import os from 'node:os'; import path from 'node:path'; import { managedScriptReference, sameAgentId, samePath } from './common.js'; import { buildKiroIdeCommand, kiroIdeRuntimeReference, type KiroIdeRuntimeReference, } from './kiroide-command.js'; import { buildKiroIdeHook, exactKeys, kiroIdeRuntimeContracts, renderKiroIdeDocument, type KiroIdeDocument, type KiroIdeHook, type RenderedKiroIdeDocument, type KiroIdeRuntimeContract, } from './kiroide-contract.js'; import { isNodeExecutable, parseLegacyWindowsCommand } from './shell-command.js'; import { isObject, parseStrictJsonObject, type JsonObject } from './strict-json.js'; export const AGENT_KEY = 'kirocli'; export const AUDIT_SCRIPT = 'hook.js'; export const GUARD_SCRIPT = 'guard.js'; export const V2_AGENT_NAME = 'elydora-audit'; export const V3_CONFIG_FILE = 'elydora-audit.json'; export const HOOK_TIMEOUT_MILLISECONDS = 10_000; const V2_DESCRIPTION = 'Kiro CLI with Elydora audit and freeze enforcement'; const V3_GUARD_NAME = 'elydora-guard'; const V3_AUDIT_NAME = 'elydora-audit'; const V2_EVENTS = new Set([ 'agentSpawn', 'userPromptSubmit', 'preToolUse', 'postToolUse', 'stop', ]); export interface KiroCliPaths { readonly homeDirectory: string; readonly kiroDirectory: string; readonly agentsDirectory: string; readonly hooksDirectory: string; readonly v2Path: string; readonly v3Path: string; } export interface KiroCliV2Hook extends JsonObject { readonly command: string; readonly matcher?: string; readonly timeout_ms?: number; readonly cache_ttl_seconds?: number; } export interface KiroCliV2Document { readonly exists: boolean; readonly filePath: string; readonly root: JsonObject; readonly hooks: JsonObject; readonly raw?: string; } export interface RenderedKiroCliV2Document { readonly document: KiroCliV2Document; readonly changed: boolean; readonly next?: string; } export function resolveKiroCliPaths(homeDirectory = os.homedir()): KiroCliPaths { const resolvedHome = path.resolve(homeDirectory); const kiroDirectory = path.join(resolvedHome, '.kiro'); const agentsDirectory = path.join(kiroDirectory, 'agents'); const hooksDirectory = path.join(kiroDirectory, 'hooks'); return { homeDirectory: resolvedHome, kiroDirectory, agentsDirectory, hooksDirectory, v2Path: path.join(agentsDirectory, `${V2_AGENT_NAME}.json`), v3Path: path.join(hooksDirectory, V3_CONFIG_FILE), }; } function validateOptionalInteger(value: unknown, field: string, label: string): void { if (value !== undefined && (!Number.isSafeInteger(value) || (value as number) < 0)) { throw new Error(`${label} ${field} must be a non-negative integer`); } } function validateV2Hook(value: unknown, event: string, index: number): KiroCliV2Hook { const label = `Kiro CLI v2 agent hooks.${event}[${index}]`; if (!isObject(value)) throw new Error(`${label} must be an object`); if (typeof value.command !== 'string' || !value.command) { throw new Error(`${label} command must be a non-empty string`); } if (value.matcher !== undefined) { if (!['preToolUse', 'postToolUse'].includes(event)) { throw new Error(`${label} matcher is valid only for tool events`); } if (typeof value.matcher !== 'string' || !value.matcher) { throw new Error(`${label} matcher must be a non-empty string`); } } validateOptionalInteger(value.timeout_ms, 'timeout_ms', label); validateOptionalInteger(value.cache_ttl_seconds, 'cache_ttl_seconds', label); return value as KiroCliV2Hook; } function validateV2Hooks(value: unknown, filePath: string): JsonObject { if (value === undefined) return {}; if (!isObject(value)) { throw new Error(`Kiro CLI v2 agent field "hooks" must be an object: ${filePath}`); } const hooks: JsonObject = { ...value }; for (const [event, entries] of Object.entries(value)) { if (!V2_EVENTS.has(event)) { throw new Error(`Kiro CLI v2 agent has unsupported hook event "${event}": ${filePath}`); } if (!Array.isArray(entries)) { throw new Error(`Kiro CLI v2 agent hooks.${event} must be an array: ${filePath}`); } hooks[event] = entries.map((entry, index) => validateV2Hook(entry, event, index)); } return hooks; } function validateOptionalV2Fields(root: JsonObject, filePath: string): void { for (const field of ['name', 'description', 'prompt', 'model', 'keyboardShortcut', 'welcomeMessage']) { if (root[field] !== undefined && typeof root[field] !== 'string') { throw new Error(`Kiro CLI v2 agent field "${field}" must be a string: ${filePath}`); } } for (const field of ['tools', 'allowedTools', 'resources']) { const value = root[field]; if (value !== undefined && (!Array.isArray(value) || !value.every((entry) => typeof entry === 'string'))) { throw new Error(`Kiro CLI v2 agent field "${field}" must be an array of strings: ${filePath}`); } } if (root.includeMcpJson !== undefined && typeof root.includeMcpJson !== 'boolean') { throw new Error(`Kiro CLI v2 agent field "includeMcpJson" must be a boolean: ${filePath}`); } } export function parseKiroCliV2Document(filePath: string, raw: string): KiroCliV2Document { const root = parseStrictJsonObject(raw, `Kiro CLI v2 agent at ${filePath}`); validateOptionalV2Fields(root, filePath); return { exists: true, filePath, root, hooks: validateV2Hooks(root.hooks, filePath), raw, }; } export function createKiroCliV2Document(filePath: string): KiroCliV2Document { return { exists: false, filePath, root: {}, hooks: {} }; } function eventHooks(document: KiroCliV2Document, event: string): readonly KiroCliV2Hook[] { return (document.hooks[event] as readonly KiroCliV2Hook[] | undefined) ?? []; } function legacyWindowsReference( command: unknown, scriptName: string, ): KiroIdeRuntimeReference | undefined { if (typeof command !== 'string') return undefined; const parsed = parseLegacyWindowsCommand(command); if (!parsed || !path.isAbsolute(parsed[0]) || !isNodeExecutable(parsed[0])) return undefined; return managedScriptReference(parsed[1], scriptName); } function ownedReference(command: unknown, scriptName: string): KiroIdeRuntimeReference | undefined { return typeof command === 'string' ? kiroIdeRuntimeReference(command, scriptName) ?? legacyWindowsReference(command, scriptName) : undefined; } function v2OwnedReference(hook: KiroCliV2Hook, event: string) { if (event === 'preToolUse') return ownedReference(hook.command, GUARD_SCRIPT); if (event === 'postToolUse') return ownedReference(hook.command, AUDIT_SCRIPT); return undefined; } function v2ManagedReference(hook: KiroCliV2Hook, event: string) { const reference = v2OwnedReference(hook, event); if (!reference || !exactKeys(hook, ['matcher', 'command', 'timeout_ms']) || hook.matcher !== '*' || hook.timeout_ms !== HOOK_TIMEOUT_MILLISECONDS) return undefined; return reference; } function managedMetadata(root: JsonObject): boolean { return root.name === V2_AGENT_NAME && root.description === V2_DESCRIPTION && Array.isArray(root.tools) && root.tools.length === 1 && root.tools[0] === '*' && root.includeMcpJson === true; } function hasOwnedV2Hooks(document: KiroCliV2Document, agentId?: string): boolean { return ['preToolUse', 'postToolUse'].some((event) => eventHooks(document, event).some((hook) => { const reference = v2OwnedReference(hook, event); return reference !== undefined && (agentId === undefined || sameAgentId(reference.agentId, agentId)); })); } export function requireAvailableKiroCliV2Document(document: KiroCliV2Document): void { if (document.exists && !managedMetadata(document.root) && !hasOwnedV2Hooks(document)) { throw new Error(`Kiro CLI v2 agent path conflicts with the Elydora contract: ${document.filePath}`); } } export function withoutManagedKiroCliV2Hooks( document: KiroCliV2Document, agentId?: string, ): JsonObject { const next = { ...document.hooks }; for (const event of ['preToolUse', 'postToolUse']) { const remaining = eventHooks(document, event).filter((hook) => { const reference = v2OwnedReference(hook, event); return !reference || (agentId !== undefined && !sameAgentId(reference.agentId, agentId)); }); if (remaining.length > 0) next[event] = remaining; else delete next[event]; } return next; } function buildV2Hook(scriptPath: string): KiroCliV2Hook { return { matcher: '*', command: buildKiroIdeCommand(scriptPath), timeout_ms: HOOK_TIMEOUT_MILLISECONDS, }; } export function buildKiroCliV2Hooks( document: KiroCliV2Document, guardPath: string, auditPath: string, ): JsonObject { const hooks = withoutManagedKiroCliV2Hooks(document); hooks.preToolUse = [...eventHooks({ ...document, hooks }, 'preToolUse'), buildV2Hook(guardPath)]; hooks.postToolUse = [...eventHooks({ ...document, hooks }, 'postToolUse'), buildV2Hook(auditPath)]; return hooks; } export function renderKiroCliV2Installation( document: KiroCliV2Document, hooks: JsonObject, ): RenderedKiroCliV2Document { const next = `${JSON.stringify({ ...document.root, name: V2_AGENT_NAME, description: V2_DESCRIPTION, tools: ['*'], includeMcpJson: true, hooks, }, null, 2)}\n`; return { document, changed: next !== document.raw, next }; } function entirelyManagedV2Document(document: KiroCliV2Document, agentId?: string): boolean { if (!document.exists || !managedMetadata(document.root) || !exactKeys(document.root, ['name', 'description', 'tools', 'includeMcpJson', 'hooks']) || !exactKeys(document.hooks, ['preToolUse', 'postToolUse'])) return false; for (const event of ['preToolUse', 'postToolUse']) { const hooks = eventHooks(document, event); if (hooks.length !== 1) return false; const reference = v2OwnedReference(hooks[0], event); if (!reference || (agentId !== undefined && !sameAgentId(reference.agentId, agentId))) { return false; } } return true; } export function renderKiroCliV2Uninstall( document: KiroCliV2Document, hooks: JsonObject, agentId?: string, ): RenderedKiroCliV2Document { if (!hasOwnedV2Hooks(document, agentId)) return { document, changed: false }; if (entirelyManagedV2Document(document, agentId) && Object.keys(hooks).length === 0) { return { document, changed: true }; } const next = `${JSON.stringify({ ...document.root, hooks }, null, 2)}\n`; return { document, changed: next !== document.raw, next }; } export function kiroCliV2RuntimeContracts( document: KiroCliV2Document, ): KiroIdeRuntimeContract[] { for (const event of ['preToolUse', 'postToolUse']) { if (eventHooks(document, event).some((hook) => ( v2OwnedReference(hook, event) !== undefined && v2ManagedReference(hook, event) === undefined ))) return []; } const guards = eventHooks(document, 'preToolUse') .map((hook) => v2ManagedReference(hook, 'preToolUse')) .filter((value): value is KiroIdeRuntimeReference => value !== undefined); const audits = eventHooks(document, 'postToolUse') .map((hook) => v2ManagedReference(hook, 'postToolUse')) .filter((value): value is KiroIdeRuntimeReference => value !== undefined); if (guards.length !== 1 || audits.length !== 1 || !sameAgentId(guards[0].agentId, audits[0].agentId)) return []; return [{ agentId: guards[0].agentId, guardPath: guards[0].scriptPath, auditPath: audits[0].scriptPath }]; } function v3Specification(name: string) { if (name === V3_GUARD_NAME) return { scriptName: GUARD_SCRIPT }; if (name === V3_AUDIT_NAME) return { scriptName: AUDIT_SCRIPT }; return undefined; } function v3OwnedReference(hook: KiroIdeHook) { const specification = v3Specification(hook.name); return specification && hook.action.type === 'command' ? ownedReference(hook.action.command, specification.scriptName) : undefined; } export function requireAvailableKiroCliV3Hooks(hooks: readonly KiroIdeHook[]): void { for (const hook of hooks) { if (v3Specification(hook.name) && !v3OwnedReference(hook)) { throw new Error(`Kiro CLI v3 hook name "${hook.name}" conflicts with the Elydora contract`); } } } export function withoutManagedKiroCliV3Hooks( hooks: readonly KiroIdeHook[], agentId?: string, ): KiroIdeHook[] { return hooks.filter((hook) => { const reference = v3OwnedReference(hook); return !reference || (agentId !== undefined && !sameAgentId(reference.agentId, agentId)); }); } function hasOwnedV3Hooks(hooks: readonly KiroIdeHook[], agentId?: string): boolean { return hooks.some((hook) => { const reference = v3OwnedReference(hook); return reference !== undefined && (agentId === undefined || sameAgentId(reference.agentId, agentId)); }); } function entirelyManagedV3Document(document: KiroIdeDocument, agentId?: string): boolean { return document.exists && exactKeys(document.root, ['version', 'hooks']) && document.hooks.length > 0 && document.hooks.every((hook) => { const reference = v3OwnedReference(hook); return reference !== undefined && (agentId === undefined || sameAgentId(reference.agentId, agentId)); }); } export function renderKiroCliV3Uninstall( document: KiroIdeDocument, hooks: readonly KiroIdeHook[], agentId?: string, ): RenderedKiroIdeDocument { if (!hasOwnedV3Hooks(document.hooks, agentId)) return { document, changed: false }; if (hooks.length === 0 && entirelyManagedV3Document(document, agentId)) { return { document, changed: true }; } return renderKiroIdeDocument(document, hooks); } export function buildKiroCliV3Hooks( hooks: readonly KiroIdeHook[], guardPath: string, auditPath: string, ): KiroIdeHook[] { return [ ...withoutManagedKiroCliV3Hooks(hooks), buildKiroIdeHook(V3_GUARD_NAME, guardPath), buildKiroIdeHook(V3_AUDIT_NAME, auditPath), ]; } export function commonKiroCliRuntimeContract( v2Document: KiroCliV2Document, v3Hooks: readonly KiroIdeHook[], ): KiroIdeRuntimeContract | undefined { const v2 = kiroCliV2RuntimeContracts(v2Document); const v3 = kiroIdeRuntimeContracts(v3Hooks); if (v2.length !== 1 || v3.length !== 1 || !sameAgentId(v2[0].agentId, v3[0].agentId) || !samePath(v2[0].guardPath, v3[0].guardPath) || !samePath(v2[0].auditPath, v3[0].auditPath)) return undefined; return v2[0]; }