import os from 'node:os'; import path from 'node:path'; import { managedScriptReference, sameAgentId } from './common.js'; import { buildKiroIdeCommand, kiroIdeRuntimeReference, type KiroIdeRuntimeReference, } from './kiroide-command.js'; import { isObject, parseStrictJsonObject, type JsonObject } from './strict-json.js'; export const AGENT_KEY = 'kiroide'; export const AUDIT_SCRIPT = 'hook.js'; export const CONFIG_FILE = 'elydora-audit.json'; export const GUARD_SCRIPT = 'guard.js'; export const LEGACY_CONFIG_FILE = 'elydora-audit.kiro.hook'; export const HOOK_TIMEOUT_SECONDS = 10; const GUARD_NAME = 'elydora-guard'; const AUDIT_NAME = 'elydora-audit'; const GUARD_DESCRIPTION = 'Block tool use when the Elydora agent is frozen'; const AUDIT_DESCRIPTION = 'Record tool use in the Elydora audit trail'; const KIRO_TRIGGERS = new Set([ 'SessionStart', 'Stop', 'PreToolUse', 'PostToolUse', 'PreTaskExec', 'PostTaskExec', 'UserPromptSubmit', 'PostFileCreate', 'PostFileSave', 'PostFileDelete', ]); export interface KiroIdeAction extends JsonObject { readonly type: 'command' | 'agent'; readonly command?: string; readonly prompt?: string; } export interface KiroIdeHook extends JsonObject { readonly name: string; readonly description?: string; readonly trigger: string; readonly matcher?: string; readonly action: KiroIdeAction; readonly timeout?: number; readonly enabled?: boolean; } export interface KiroIdeDocument { readonly exists: boolean; readonly filePath: string; readonly root: JsonObject; readonly hooks: readonly KiroIdeHook[]; readonly raw?: string; } export interface RenderedKiroIdeDocument { readonly document: KiroIdeDocument; readonly changed: boolean; readonly next?: string; } export interface KiroIdeRuntimeContract { readonly agentId: string; readonly guardPath: string; readonly auditPath: string; } export interface KiroIdePaths { readonly workspaceRoot: string; readonly kiroDirectory: string; readonly hooksDirectory: string; readonly configPath: string; readonly legacyConfigPath: string; } export function resolveKiroIdePaths( workspaceRoot = process.cwd(), homeDirectory = os.homedir(), ): KiroIdePaths { const resolvedWorkspace = path.resolve(workspaceRoot); const kiroDirectory = path.join(resolvedWorkspace, '.kiro'); const hooksDirectory = path.join(kiroDirectory, 'hooks'); return { workspaceRoot: resolvedWorkspace, kiroDirectory, hooksDirectory, configPath: path.join(hooksDirectory, CONFIG_FILE), legacyConfigPath: path.join(homeDirectory, '.kiro', 'hooks', LEGACY_CONFIG_FILE), }; } function validateAction(value: unknown, label: string): KiroIdeAction { if (!isObject(value)) throw new Error(`${label} action must be an object`); if (value.type === 'command') { if (typeof value.command !== 'string' || !value.command) { throw new Error(`${label} command action requires a non-empty command`); } return value as KiroIdeAction; } if (value.type === 'agent') { if (typeof value.prompt !== 'string' || !value.prompt) { throw new Error(`${label} agent action requires a non-empty prompt`); } return value as KiroIdeAction; } throw new Error(`${label} action has unsupported type "${String(value.type)}"`); } function validateHook(value: unknown, index: number, documentLabel: string): KiroIdeHook { const label = `${documentLabel} hooks[${index}]`; if (!isObject(value)) throw new Error(`${label} must be an object`); if (typeof value.name !== 'string' || !value.name) throw new Error(`${label} requires a name`); if (typeof value.trigger !== 'string' || !KIRO_TRIGGERS.has(value.trigger)) { throw new Error(`${label} has unsupported trigger "${String(value.trigger)}"`); } if (value.description !== undefined && typeof value.description !== 'string') { throw new Error(`${label} description must be a string`); } if (value.matcher !== undefined && typeof value.matcher !== 'string') { throw new Error(`${label} matcher must be a string`); } if (value.timeout !== undefined && (!Number.isSafeInteger(value.timeout) || (value.timeout as number) < 0)) { throw new Error(`${label} timeout must be a non-negative integer`); } if (value.enabled !== undefined && typeof value.enabled !== 'boolean') { throw new Error(`${label} enabled must be a boolean`); } return { ...value, action: validateAction(value.action, label) } as KiroIdeHook; } export function parseKiroIdeDocument( filePath: string, raw: string, documentLabel = 'Kiro IDE hooks', ): KiroIdeDocument { const root = parseStrictJsonObject(raw, `${documentLabel} at ${filePath}`); if (root.version !== 'v1') throw new Error(`${documentLabel} version must be "v1": ${filePath}`); if (!Array.isArray(root.hooks)) throw new Error(`${documentLabel} field "hooks" must be an array: ${filePath}`); return { exists: true, filePath, root, hooks: root.hooks.map((hook, index) => validateHook(hook, index, documentLabel)), raw, }; } export function createKiroIdeDocument(filePath: string): KiroIdeDocument { return { exists: false, filePath, root: {}, hooks: [] }; } export function exactKeys(value: JsonObject, keys: readonly string[]): boolean { return Object.keys(value).sort().join('|') === [...keys].sort().join('|'); } function managedSpecification(name: string): { readonly description: string; readonly trigger: 'PreToolUse' | 'PostToolUse'; readonly scriptName: string; } | undefined { if (name === GUARD_NAME) { return { description: GUARD_DESCRIPTION, trigger: 'PreToolUse', scriptName: GUARD_SCRIPT }; } if (name === AUDIT_NAME) { return { description: AUDIT_DESCRIPTION, trigger: 'PostToolUse', scriptName: AUDIT_SCRIPT }; } return undefined; } function managedReference(hook: KiroIdeHook) { const specification = managedSpecification(hook.name); const owned = ownedReference(hook); if (!specification || !owned || !exactKeys(hook, ['name', 'description', 'trigger', 'matcher', 'action', 'timeout', 'enabled']) || hook.description !== specification.description || hook.trigger !== specification.trigger || hook.matcher !== '.*' || hook.timeout !== HOOK_TIMEOUT_SECONDS || hook.enabled !== true || !exactKeys(hook.action, ['type', 'command'])) return undefined; return owned; } function ownedReference(hook: KiroIdeHook) { const specification = managedSpecification(hook.name); if (!specification || hook.action.type !== 'command' || typeof hook.action.command !== 'string') return undefined; return kiroIdeRuntimeReference(hook.action.command, specification.scriptName); } export function requireAvailableKiroIdeHooks( hooks: readonly KiroIdeHook[], productLabel = 'Kiro IDE', ): void { for (const hook of hooks) { if (managedSpecification(hook.name) && !ownedReference(hook)) { throw new Error(`${productLabel} hook name "${hook.name}" conflicts with the Elydora contract`); } } } export function buildKiroIdeHook( name: typeof GUARD_NAME | typeof AUDIT_NAME, scriptPath: string, ): KiroIdeHook { const specification = managedSpecification(name)!; return { name, description: specification.description, trigger: specification.trigger, matcher: '.*', action: { type: 'command', command: buildKiroIdeCommand(scriptPath) }, timeout: HOOK_TIMEOUT_SECONDS, enabled: true, }; } export function withoutManagedKiroIdeHooks( hooks: readonly KiroIdeHook[], agentId?: string, ): KiroIdeHook[] { return hooks.filter((hook) => { const reference = ownedReference(hook); return !reference || (agentId !== undefined && !sameAgentId(reference.agentId, agentId)); }); } function entirelyManaged(document: KiroIdeDocument): boolean { return document.exists && exactKeys(document.root, ['version', 'hooks']) && document.hooks.length > 0 && document.hooks.every((hook) => managedReference(hook) !== undefined); } export function renderKiroIdeDocument( document: KiroIdeDocument, hooks: readonly KiroIdeHook[], ): RenderedKiroIdeDocument { if (!document.exists && hooks.length === 0) return { document, changed: false }; if (document.exists && hooks.length === document.hooks.length && hooks.every((hook, index) => hook === document.hooks[index])) { return { document, changed: false }; } if (hooks.length === 0 && entirelyManaged(document)) return { document, changed: true }; const next = `${JSON.stringify({ ...document.root, version: 'v1', hooks }, null, 2)}\n`; return { document, changed: next !== document.raw, next }; } export function kiroIdeRuntimeContracts( hooks: readonly KiroIdeHook[], ): KiroIdeRuntimeContract[] { const guards = new Map(); const audits = new Map(); for (const hook of hooks) { if (!managedSpecification(hook.name)) continue; const reference = managedReference(hook); if (!reference) return []; const key = process.platform === 'win32' ? reference.agentId.toLowerCase() : reference.agentId; const target = hook.name === GUARD_NAME ? guards : audits; const entries = target.get(key) ?? []; entries.push(reference); target.set(key, entries); } const contracts: KiroIdeRuntimeContract[] = []; for (const [key, guard] of guards) { const audit = audits.get(key); if (guard.length !== 1 || audit?.length !== 1 || !guard[0] || !audit[0]) continue; contracts.push({ agentId: guard[0].agentId, guardPath: guard[0].scriptPath, auditPath: audit[0].scriptPath, }); } return contracts; } function legacyReference(command: unknown, scriptName: string) { if (typeof command !== 'string') return undefined; const match = /^node "([^"\r\n]+)"$/.exec(command); return match ? managedScriptReference(match[1], scriptName) : undefined; } export function legacyKiroIdeRuntimeContract(raw: string, filePath: string): KiroIdeRuntimeContract | undefined { const root = parseStrictJsonObject(raw, `legacy Kiro IDE hook at ${filePath}`); if (!exactKeys(root, ['name', 'description', 'version', 'hooks']) || root.name !== 'Elydora Audit' || root.description !== 'Sends tool-use events to the Elydora tamper-evident audit platform' || root.version !== '1.0.0' || !isObject(root.hooks) || !exactKeys(root.hooks, ['pre_tool_use', 'post_tool_use'])) return undefined; const guardHook = root.hooks.pre_tool_use; const auditHook = root.hooks.post_tool_use; if (!isObject(guardHook) || !isObject(auditHook) || !exactKeys(guardHook, ['command', 'timeout_ms']) || !exactKeys(auditHook, ['command', 'timeout_ms']) || guardHook.timeout_ms !== 5000 || auditHook.timeout_ms !== 5000) return undefined; const guard = legacyReference(guardHook.command, GUARD_SCRIPT); const audit = legacyReference(auditHook.command, AUDIT_SCRIPT); if (!guard || !audit || !sameAgentId(guard.agentId, audit.agentId)) return undefined; return { agentId: guard.agentId, guardPath: guard.scriptPath, auditPath: audit.scriptPath }; }