From 5889b09a09d654ac85a484afe66125646736a59b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 20 Jul 2026 16:58:49 -0400 Subject: [PATCH] fix(kiroide): adopt workspace v1 hooks --- AGENTS.md | 1 + README.md | 2 + src/plugins/kiroide-command.ts | 152 ++++++++++ src/plugins/kiroide-contract.ts | 303 ++++++++++++++++++++ src/plugins/kiroide-installation.ts | 158 +++++++++++ src/plugins/kiroide-io.ts | 165 +++++++++++ src/plugins/kiroide.ts | 125 ++++----- src/plugins/registry.ts | 2 +- test-support/kiroide-test-helpers.mjs | 170 +++++++++++ test/kiroide-plugin.test.mjs | 389 ++++++++++++++++++++++++++ test/kiroide-transaction.test.mjs | 142 ++++++++++ 11 files changed, 1535 insertions(+), 74 deletions(-) create mode 100644 src/plugins/kiroide-command.ts create mode 100644 src/plugins/kiroide-contract.ts create mode 100644 src/plugins/kiroide-installation.ts create mode 100644 src/plugins/kiroide-io.ts create mode 100644 test-support/kiroide-test-helpers.mjs create mode 100644 test/kiroide-plugin.test.mjs create mode 100644 test/kiroide-transaction.test.mjs diff --git a/AGENTS.md b/AGENTS.md index 7e2b3c9..54a7416 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,6 +34,7 @@ This repository owns the published `@elydora/sdk` package, its `elydora` CLI, lo - Write Cursor hooks only to `~/.cursor/hooks.json`; keep project and enterprise sources read-only. Register `preToolUse`, `postToolUse`, and `postToolUseFailure`, preserve the complete native payload plus `conversation_id`, and emit valid JSON on successful hook execution. Commit Cursor's config, guard, audit runtime, runtime config, and private key through one rollback-capable transaction. Set a ten-second timeout plus `failClosed` on every managed handler and propagate guard exit code `2` through the host shell. - Resolve Factory Droid 0.175.0 user hooks through `~/.factory/hooks.json`, the legacy `~/.factory/hooks/hooks.json`, then the user `settings.local.json` and `settings.json` hook containers. Current hook files use a top-level `hooks` object; retain direct event maps as an explicit legacy read contract until Factory migrates them. Register exact `PreToolUse` and `PostToolUse` groups with ten-second absolute command handlers in one active source, preserve the native snake_case payload, and propagate freeze or revocation through exit code `2`. Use PowerShell's call operator with single-quoted absolute arguments and explicit `$LASTEXITCODE` propagation on Windows, and migrate the legacy double-quoted command form through exact ownership checks. Remove exact Elydora ownership from inactive user sources while preserving JSONC comments and unknown event arrays. Keep project, folder, plugin, and organization hook definitions read-only. Resolve local settings over their matching base file, then apply system, project, folder, and user `hooksDisabled` through Factory's extension-only scalar precedence; reject system-managed `allowManagedHooksOnly`. Commit user hook sources, generated runtimes, runtime config, and private key through one rollback-capable transaction, precondition every parsed source and policy file, and require `/hooks` review for the session snapshot and remote organization policy. - Resolve Qwen Code 0.20.0 user settings through explicit `QWEN_HOME`, then the initial Qwen home `.env`, then the OS home `.env`; when bootstrap discovers a redirected home, read that home's `.env` as well. Treat empty overrides as unset while preserving their process-level ownership semantics. Read system defaults, user settings, the active trusted workspace, system overrides, and trusted-folder rules with Qwen's documented precedence; write only the user source. Validate the current twenty-one event names and command, HTTP, and prompt handler schemas while preserving future event payloads. Register exact matchless `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` groups named `elydora-guard` and `elydora-audit` with ten-second millisecond timeouts. Preserve the complete native snake_case payload, propagate freeze and revocation through exit code `2`, and use PowerShell's call operator with explicit `$LASTEXITCODE` propagation on Windows. Commit user settings, generated runtimes, runtime config, and private key through one rollback-capable transaction, with snapshot preconditions for every consulted routing, policy, trust, and settings source. Require physical files, exact runtime sources, canonical private keys, matching runtime identity, an effective enabled state, `/hooks` review, and restart of active Qwen Code sessions. +- Resolve Kiro IDE 1.0 hooks through the current workspace `.kiro/hooks/elydora-audit.json`. Register exact `PreToolUse` and `PostToolUse` command entries in the v1 JSON schema with `.*` matchers, ten-second timeouts, and enabled state. Preserve Kiro's complete native payload and propagate frozen or revoked state through exit code `2`. Use the user-level `~/.kiro/hooks/elydora-audit.kiro.hook` source solely as exact Elydora legacy migration input. Commit the workspace hook file, exact legacy migration, generated runtimes, runtime config, and private key through one rollback-capable transaction. Require a physical workspace hook path, strict schemas, exact runtime sources, canonical private keys, matching runtime identity, and a complete enabled contract during status checks, then verify both entries in the Agent Hooks panel. ## Code Quality diff --git a/README.md b/README.md index 27a80f1..cde7cb8 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,8 @@ Qwen Code 0.20.0 installation writes exact, named `PreToolUse`, `PostToolUse`, a Auggie installation commits `~/.augment/settings.json`, both platform wrappers, generated runtimes, runtime config, and private key as one rollback-capable transaction. The managed `PreToolUse` guard propagates exit code `2`; `PostToolUse` preserves Auggie's complete native hook payload. System, workspace, local workspace, and alternate `--augment-cache-dir` settings remain unchanged. Run `auggie tools list` to validate the effective user configuration. +Kiro IDE 1.0 installation is workspace-scoped. Run the installer from the workspace root; it writes `.kiro/hooks/elydora-audit.json` with exact `PreToolUse` and `PostToolUse` command hooks in Kiro's v1 JSON schema. The hook file, any exact Elydora legacy migration, generated runtimes, runtime config, and private key commit through one rollback-capable transaction. Managed handlers preserve Kiro's complete native payload and propagate frozen or revoked state through exit code `2`. Open the Agent Hooks panel after installation and confirm both Elydora entries. + Kiro CLI installation covers both runtime contracts. Kiro CLI v2 uses the generated custom agent through `kiro-cli --agent elydora-audit`. Kiro CLI v3 loads the global standalone hooks when started with `kiro-cli --v3`. ## API Reference diff --git a/src/plugins/kiroide-command.ts b/src/plugins/kiroide-command.ts new file mode 100644 index 0000000..02d3187 --- /dev/null +++ b/src/plugins/kiroide-command.ts @@ -0,0 +1,152 @@ +import os from 'node:os'; +import path from 'node:path'; + +interface ParsedArgument { + readonly value: string; + readonly next: number; +} + +export interface KiroIdeRuntimeReference { + readonly agentId: string; + readonly scriptPath: string; +} + +export function sameKiroIdePath(left: string, right: string): boolean { + const normalizedLeft = path.resolve(left); + const normalizedRight = path.resolve(right); + return process.platform === 'win32' + ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase() + : normalizedLeft === normalizedRight; +} + +export function sameKiroIdeAgentId(left: string, right: string): boolean { + return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right; +} + +function quotePosix(value: string): string { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} + +function quotePowerShell(value: string): string { + return `'${value.replaceAll("'", "''")}'`; +} + +function windowsPowerShellPath(): string { + const configuredRoot = process.env.SystemRoot; + const systemRoot = configuredRoot + && path.win32.isAbsolute(configuredRoot) + && !/["%\r\n]/.test(configuredRoot) + ? configuredRoot + : 'C:\\Windows'; + return path.win32.join( + systemRoot, + 'System32', + 'WindowsPowerShell', + 'v1.0', + 'powershell.exe', + ); +} + +function windowsCommand(scriptPath: string): string { + const source = `& ${quotePowerShell(process.execPath)} ${quotePowerShell(scriptPath)}; exit $LASTEXITCODE`; + const encoded = Buffer.from(source, 'utf16le').toString('base64'); + return `"${windowsPowerShellPath()}" -NoLogo -NoProfile -NonInteractive -EncodedCommand ${encoded}`; +} + +export function buildKiroIdeCommand(scriptPath: string): string { + if (!path.isAbsolute(process.execPath) || !path.isAbsolute(scriptPath)) { + throw new Error('Kiro IDE hook commands require absolute executable and script paths'); + } + return process.platform === 'win32' + ? windowsCommand(scriptPath) + : `${quotePosix(process.execPath)} ${quotePosix(scriptPath)}`; +} + +function readPosixArgument(command: string, start: number): ParsedArgument | undefined { + if (command[start] !== "'") return undefined; + const apostrophe = `'"'"'`; + let value = ''; + for (let index = start + 1; index < command.length;) { + if (command.startsWith(apostrophe, index)) { + value += "'"; + index += apostrophe.length; + continue; + } + if (command[index] === "'") return { value, next: index + 1 }; + value += command[index]; + index += 1; + } + return undefined; +} + +function parsePosixCommand(command: string): readonly [string, string] | undefined { + const executable = readPosixArgument(command, 0); + if (!executable || command[executable.next] !== ' ') return undefined; + const script = readPosixArgument(command, executable.next + 1); + return script && script.next === command.length ? [executable.value, script.value] : undefined; +} + +function readPowerShellArgument(command: string, start: number): ParsedArgument | undefined { + if (command[start] !== "'") return undefined; + let value = ''; + for (let index = start + 1; index < command.length; index += 1) { + if (command[index] !== "'") { + value += command[index]; + continue; + } + if (command[index + 1] === "'") { + value += "'"; + index += 1; + continue; + } + return { value, next: index + 1 }; + } + return undefined; +} + +function parsePowerShellSource(source: string): readonly [string, string] | undefined { + if (!source.startsWith('& ')) return undefined; + const executable = readPowerShellArgument(source, 2); + if (!executable || source[executable.next] !== ' ') return undefined; + const script = readPowerShellArgument(source, executable.next + 1); + return script && source.slice(script.next) === '; exit $LASTEXITCODE' + ? [executable.value, script.value] + : undefined; +} + +function parseWindowsCommand(command: string): readonly [string, string] | undefined { + const match = /^"([^"\r\n]+)" -NoLogo -NoProfile -NonInteractive -EncodedCommand ([A-Za-z0-9+/]+={0,2})$/.exec(command); + if (!match + || !path.win32.isAbsolute(match[1]) + || path.win32.basename(match[1]).toLowerCase() !== 'powershell.exe') return undefined; + const buffer = Buffer.from(match[2], 'base64'); + if (buffer.length % 2 !== 0 || buffer.toString('base64') !== match[2]) return undefined; + return parsePowerShellSource(buffer.toString('utf16le')); +} + +function isNodeExecutable(filePath: string): boolean { + const basename = path.basename(filePath).toLowerCase(); + return basename === 'node' || basename === 'node.exe'; +} + +export function kiroIdeRuntimeReference( + command: string, + scriptName: string, +): KiroIdeRuntimeReference | undefined { + const parsed = process.platform === 'win32' + ? parseWindowsCommand(command) + : parsePosixCommand(command); + if (!parsed + || !path.isAbsolute(parsed[0]) + || !isNodeExecutable(parsed[0]) + || !path.isAbsolute(parsed[1]) + || path.basename(parsed[1]) !== scriptName) return undefined; + const agentDirectory = path.dirname(parsed[1]); + if (!sameKiroIdePath(path.dirname(agentDirectory), path.join(os.homedir(), '.elydora'))) { + return undefined; + } + const agentId = path.basename(agentDirectory); + return agentId && agentId !== '.' && agentId !== '..' + ? { agentId, scriptPath: parsed[1] } + : undefined; +} diff --git a/src/plugins/kiroide-contract.ts b/src/plugins/kiroide-contract.ts new file mode 100644 index 0000000..20439d7 --- /dev/null +++ b/src/plugins/kiroide-contract.ts @@ -0,0 +1,303 @@ +import os from 'node:os'; +import path from 'node:path'; +import { + buildKiroIdeCommand, + kiroIdeRuntimeReference, + sameKiroIdeAgentId, + sameKiroIdePath, + 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): KiroIdeHook { + const label = `Kiro IDE hook 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): KiroIdeDocument { + const root = parseStrictJsonObject(raw, `Kiro IDE hooks at ${filePath}`); + if (root.version !== 'v1') throw new Error(`Kiro IDE hooks version must be "v1": ${filePath}`); + if (!Array.isArray(root.hooks)) throw new Error(`Kiro IDE hooks field "hooks" must be an array: ${filePath}`); + return { + exists: true, + filePath, + root, + hooks: root.hooks.map((hook, index) => validateHook(hook, index)), + raw, + }; +} + +export function createKiroIdeDocument(filePath: string): KiroIdeDocument { + return { exists: false, filePath, root: {}, hooks: [] }; +} + +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[]): void { + for (const hook of hooks) { + if (managedSpecification(hook.name) && !ownedReference(hook)) { + throw new Error(`Kiro IDE 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 && !sameKiroIdeAgentId(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 (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) { + const reference = managedReference(hook); + if (!reference) continue; + 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); + if (!match || !path.isAbsolute(match[1]) || path.basename(match[1]) !== scriptName) return undefined; + const agentDirectory = path.dirname(match[1]); + if (!sameKiroIdePath(path.dirname(agentDirectory), path.join(os.homedir(), '.elydora'))) { + return undefined; + } + return { agentId: path.basename(agentDirectory), scriptPath: match[1] }; +} + +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 || !sameKiroIdeAgentId(guard.agentId, audit.agentId)) return undefined; + return { agentId: guard.agentId, guardPath: guard.scriptPath, auditPath: audit.scriptPath }; +} diff --git a/src/plugins/kiroide-installation.ts b/src/plugins/kiroide-installation.ts new file mode 100644 index 0000000..e9eccc4 --- /dev/null +++ b/src/plugins/kiroide-installation.ts @@ -0,0 +1,158 @@ +import path from 'node:path'; +import type { InstallConfig } from './base.js'; +import { sameKiroIdeAgentId } from './kiroide-command.js'; +import { + AGENT_KEY, + AUDIT_SCRIPT, + GUARD_SCRIPT, + type KiroIdePaths, + type RenderedKiroIdeDocument, +} from './kiroide-contract.js'; +import { + type KiroIdeSources, + type LegacyKiroIdeDocument, + requirePhysicalLegacyDirectory, +} from './kiroide-io.js'; +import { + commitManagedInstallation, + preflightManagedInstallation, + prepareManagedInstallation, + type ManagedRuntimePaths, + type PreparedManagedInstallation, + type RenameFile, +} from './managed-installation.js'; +import { + commitManagedTransaction, + prepareManagedFileChange, + type ManagedFileChange, + type PreparedManagedTransaction, +} from './managed-transaction.js'; + +const DISPLAY_NAME = 'Kiro IDE'; +const DIRECTORY_LABEL = 'Kiro IDE hooks directory'; + +export type KiroIdeRuntimePaths = ManagedRuntimePaths; +export type PreparedKiroIdeInstallation = PreparedManagedInstallation; +export type { RenameFile }; + +function hookLocation(paths: KiroIdePaths) { + return [{ directoryLabel: DIRECTORY_LABEL, filePath: paths.configPath }]; +} + +function removesLegacy(legacy: LegacyKiroIdeDocument, agentId?: string): boolean { + return legacy.contract !== undefined + && (agentId === undefined || sameKiroIdeAgentId(legacy.contract.agentId, agentId)); +} + +async function prepareLegacyRemoval( + legacy: LegacyKiroIdeDocument, + agentId?: string, +): Promise { + if (!removesLegacy(legacy, agentId)) return undefined; + if (legacy.raw === undefined) throw new Error(`Legacy Kiro IDE hook source is missing: ${legacy.filePath}`); + await requirePhysicalLegacyDirectory(legacy); + return prepareManagedFileChange({ + filePath: legacy.filePath, + label: 'legacy Kiro IDE hook', + next: undefined, + mode: 0o600, + expectedSource: legacy.raw, + verifyExpectedSource: true, + }); +} + +export async function preflightKiroIdeInstallation( + config: InstallConfig, + paths: KiroIdePaths, +): Promise { + return preflightManagedInstallation({ + agentKey: AGENT_KEY, + hookLocations: hookLocation(paths), + config, + }, GUARD_SCRIPT, AUDIT_SCRIPT); +} + +export async function prepareKiroIdeInstallation( + config: InstallConfig, + sources: KiroIdeSources, + rendered: RenderedKiroIdeDocument, +): Promise { + if (rendered.next === undefined) throw new Error('Kiro IDE installation requires a hook document'); + const prepared = await prepareManagedInstallation({ + agentKey: AGENT_KEY, + displayName: DISPLAY_NAME, + hookSources: [{ + directoryLabel: DIRECTORY_LABEL, + label: 'Kiro IDE hooks', + filePath: sources.paths.configPath, + expectedSource: sources.document.raw, + source: rendered.next, + }], + config, + auditOptions: { nativePayload: true }, + }, GUARD_SCRIPT, AUDIT_SCRIPT); + const legacyChange = await prepareLegacyRemoval(sources.legacy, config.agentId); + if (!legacyChange) return prepared; + return { + ...prepared, + transaction: { + ...prepared.transaction, + directories: [ + ...prepared.transaction.directories, + { path: path.dirname(sources.legacy.filePath), label: 'legacy Kiro IDE hooks directory' }, + ], + changes: [...prepared.transaction.changes, legacyChange], + }, + }; +} + +export async function commitKiroIdeInstallation( + prepared: PreparedKiroIdeInstallation, + renameFile?: RenameFile, +): Promise { + await commitManagedInstallation(prepared, renameFile); +} + +export async function prepareKiroIdeUninstall( + sources: KiroIdeSources, + rendered: RenderedKiroIdeDocument, + agentId?: string, +): Promise { + const changes: ManagedFileChange[] = []; + const directories = []; + if (rendered.changed) { + const configChange = await prepareManagedFileChange({ + filePath: sources.document.filePath, + label: 'Kiro IDE hooks', + next: rendered.next, + mode: 0o600, + expectedSource: sources.document.raw, + verifyExpectedSource: true, + }); + if (configChange) { + changes.push(configChange); + directories.push({ path: sources.paths.hooksDirectory, label: DIRECTORY_LABEL }); + } + } + const legacyChange = await prepareLegacyRemoval(sources.legacy, agentId); + if (legacyChange) { + changes.push(legacyChange); + directories.push({ + path: path.dirname(sources.legacy.filePath), + label: 'legacy Kiro IDE hooks directory', + }); + } + return { + displayName: DISPLAY_NAME, + operation: 'uninstall', + directories, + changes, + }; +} + +export async function commitKiroIdeUninstall( + prepared: PreparedManagedTransaction, + renameFile?: RenameFile, +): Promise { + await commitManagedTransaction(prepared, renameFile); +} diff --git a/src/plugins/kiroide-io.ts b/src/plugins/kiroide-io.ts new file mode 100644 index 0000000..d5c63e8 --- /dev/null +++ b/src/plugins/kiroide-io.ts @@ -0,0 +1,165 @@ +import os from 'node:os'; +import path from 'node:path'; +import { generateGuardScript } from './guard-template.js'; +import { generateHookScript } from './hook-template.js'; +import { sameKiroIdeAgentId, sameKiroIdePath } from './kiroide-command.js'; +import { + AGENT_KEY, + AUDIT_SCRIPT, + GUARD_SCRIPT, + createKiroIdeDocument, + legacyKiroIdeRuntimeContract, + parseKiroIdeDocument, + resolveKiroIdePaths, + type KiroIdeDocument, + type KiroIdePaths, + type KiroIdeRuntimeContract, +} from './kiroide-contract.js'; +import { inspectPhysicalDirectory, readPhysicalFile } from './managed-files.js'; +import { parseStrictJsonObject, type JsonObject } from './strict-json.js'; + +const MAX_SECRET_BYTES = 64 * 1024; +const MAX_CONFIG_BYTES = 512 * 1024; + +export interface LegacyKiroIdeDocument { + readonly exists: boolean; + readonly filePath: string; + readonly raw?: string; + readonly contract?: KiroIdeRuntimeContract; +} + +export interface KiroIdeSources { + readonly paths: KiroIdePaths; + readonly document: KiroIdeDocument; + readonly legacy: LegacyKiroIdeDocument; +} + +async function inspectWorkspace(paths: KiroIdePaths): Promise { + if (!await inspectPhysicalDirectory(paths.workspaceRoot, 'Kiro IDE workspace')) { + throw new Error(`Kiro IDE workspace is missing: ${paths.workspaceRoot}`); + } + await inspectPhysicalDirectory(paths.kiroDirectory, 'Kiro IDE configuration directory'); + await inspectPhysicalDirectory(paths.hooksDirectory, 'Kiro IDE hooks directory'); +} + +async function readDocument(paths: KiroIdePaths): Promise { + await inspectWorkspace(paths); + const snapshot = await readPhysicalFile(paths.configPath, 'Kiro IDE hooks'); + return snapshot + ? parseKiroIdeDocument(paths.configPath, snapshot.contents) + : createKiroIdeDocument(paths.configPath); +} + +async function readLegacyDocument(paths: KiroIdePaths): Promise { + const snapshot = await readPhysicalFile(paths.legacyConfigPath, 'legacy Kiro IDE hook'); + if (!snapshot) return { exists: false, filePath: paths.legacyConfigPath }; + return { + exists: true, + filePath: paths.legacyConfigPath, + raw: snapshot.contents, + contract: legacyKiroIdeRuntimeContract(snapshot.contents, paths.legacyConfigPath), + }; +} + +export async function readKiroIdeSources(): Promise { + const paths = resolveKiroIdePaths(); + const [document, legacy] = await Promise.all([ + readDocument(paths), + readLegacyDocument(paths), + ]); + return { paths, document, legacy }; +} + +export async function requirePhysicalLegacyDirectory( + legacy: LegacyKiroIdeDocument, +): Promise { + if (!legacy.exists) return; + const hooksDirectory = path.dirname(legacy.filePath); + const kiroDirectory = path.dirname(hooksDirectory); + if (!await inspectPhysicalDirectory(kiroDirectory, 'legacy Kiro IDE configuration directory')) { + throw new Error(`Legacy Kiro IDE configuration directory is missing: ${kiroDirectory}`); + } + if (!await inspectPhysicalDirectory(hooksDirectory, 'legacy Kiro IDE hooks directory')) { + throw new Error(`Legacy Kiro IDE hooks directory is missing: ${hooksDirectory}`); + } +} + +function requireString(value: unknown, field: string, configPath: string): string { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new Error(`Elydora runtime config ${field} is invalid: ${configPath}`); + } + return value; +} + +function validateRuntimeConfig( + config: JsonObject, + contract: KiroIdeRuntimeContract, + configPath: string, +): void { + const supported = new Set(['org_id', 'agent_id', 'kid', 'base_url', 'token', 'agent_name']); + const extra = Object.keys(config).find((key) => !supported.has(key)); + if (extra) throw new Error(`Elydora runtime config has unsupported field "${extra}": ${configPath}`); + requireString(config.org_id, 'org_id', configPath); + requireString(config.kid, 'kid', configPath); + const agentId = requireString(config.agent_id, 'agent_id', configPath); + if (!sameKiroIdeAgentId(agentId, contract.agentId) || config.agent_name !== AGENT_KEY) { + throw new Error(`Elydora runtime identity does not match Kiro IDE hooks: ${configPath}`); + } + if (config.token !== undefined) requireString(config.token, 'token', configPath); + const rawBaseUrl = requireString(config.base_url, 'base_url', configPath); + let baseUrl: URL; + try { + baseUrl = new URL(rawBaseUrl); + } catch (error) { + throw new Error(`Elydora runtime config base_url is invalid: ${configPath}`, { + cause: error instanceof Error ? error : new Error(String(error)), + }); + } + if (!['http:', 'https:'].includes(baseUrl.protocol) + || !baseUrl.hostname + || baseUrl.username + || baseUrl.password + || baseUrl.search + || baseUrl.hash) { + throw new Error(`Elydora runtime config base_url is invalid: ${configPath}`); + } +} + +function validatePrivateKey(contents: string, keyPath: string): void { + const bytes = Buffer.from(contents, 'base64url'); + if (bytes.length !== 32 || bytes.toString('base64url') !== contents) { + throw new Error(`Elydora private key is invalid: ${keyPath}`); + } +} + +function validContractPaths(contract: KiroIdeRuntimeContract): boolean { + const agentDirectory = path.dirname(contract.guardPath); + return sameKiroIdePath(path.dirname(agentDirectory), path.join(os.homedir(), '.elydora')) + && sameKiroIdePath(contract.guardPath, path.join(agentDirectory, GUARD_SCRIPT)) + && sameKiroIdePath(contract.auditPath, path.join(agentDirectory, AUDIT_SCRIPT)); +} + +export async function runtimeFilesExist(contract: KiroIdeRuntimeContract): Promise { + if (!validContractPaths(contract)) return false; + const runtimeRoot = path.join(os.homedir(), '.elydora'); + const agentDirectory = path.dirname(contract.guardPath); + if (!await inspectPhysicalDirectory(runtimeRoot, 'Elydora runtime directory')) return false; + if (!await inspectPhysicalDirectory(agentDirectory, 'Elydora agent runtime directory')) return false; + const configPath = path.join(agentDirectory, 'config.json'); + const keyPath = path.join(agentDirectory, 'private.key'); + const [config, key, guard, audit] = await Promise.all([ + readPhysicalFile(configPath, 'Elydora runtime config', MAX_CONFIG_BYTES), + readPhysicalFile(keyPath, 'Elydora private key', MAX_SECRET_BYTES), + readPhysicalFile(contract.guardPath, 'Elydora guard runtime'), + readPhysicalFile(contract.auditPath, 'Elydora audit runtime'), + ]); + if (!config || !key || !guard || !audit) return false; + validateRuntimeConfig( + parseStrictJsonObject(config.contents, `Elydora runtime config at ${configPath}`), + contract, + configPath, + ); + validatePrivateKey(key.contents, keyPath); + return guard.contents === generateGuardScript(AGENT_KEY, contract.agentId) + && audit.contents === generateHookScript(AGENT_KEY, contract.agentId, { nativePayload: true }); +} diff --git a/src/plugins/kiroide.ts b/src/plugins/kiroide.ts index 1fc958e..6e314b1 100644 --- a/src/plugins/kiroide.ts +++ b/src/plugins/kiroide.ts @@ -1,94 +1,73 @@ -import fsp from 'node:fs/promises'; -import path from 'node:path'; -import os from 'node:os'; import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js'; +import { + AGENT_KEY, + buildKiroIdeHook, + kiroIdeRuntimeContracts, + renderKiroIdeDocument, + requireAvailableKiroIdeHooks, + withoutManagedKiroIdeHooks, +} from './kiroide-contract.js'; +import { + commitKiroIdeInstallation, + commitKiroIdeUninstall, + preflightKiroIdeInstallation, + prepareKiroIdeInstallation, + prepareKiroIdeUninstall, +} from './kiroide-installation.js'; +import { readKiroIdeSources, runtimeFilesExist } from './kiroide-io.js'; import { SUPPORTED_AGENTS } from './registry.js'; -const AGENT_KEY = 'kiroide'; const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!; -function resolveConfigDir(): string { - // .kiro/hooks is project-relative, but for global install use home dir - return path.join(os.homedir(), entry.configDir); -} - -function resolveConfigPath(): string { - return path.join(resolveConfigDir(), entry.configFile); -} - export const kiroidePlugin: AgentPlugin = { - async install(config: InstallConfig): Promise { - const configDir = resolveConfigDir(); - await fsp.mkdir(configDir, { recursive: true }); + managesRuntime: true, - const configPath = resolveConfigPath(); - - const hookConfig = { - name: 'Elydora Audit', - description: 'Sends tool-use events to the Elydora tamper-evident audit platform', - version: '1.0.0', - hooks: { - pre_tool_use: { - command: `node "${config.guardScriptPath}"`, - timeout_ms: 5000, - }, - post_tool_use: { - command: `node "${config.hookScriptPath}"`, - timeout_ms: 5000, - }, - }, - }; - - await fsp.writeFile(configPath, JSON.stringify(hookConfig, null, 2) + '\n', 'utf-8'); + async preflightInstall(config: InstallConfig): Promise { + const sources = await readKiroIdeSources(); + requireAvailableKiroIdeHooks(sources.document.hooks); + await preflightKiroIdeInstallation(config, sources.paths); }, - async uninstall(_agentId?: string): Promise { - const configPath = resolveConfigPath(); - try { - await fsp.unlink(configPath); - } catch { - // Already removed - } + async install(config: InstallConfig): Promise { + const sources = await readKiroIdeSources(); + requireAvailableKiroIdeHooks(sources.document.hooks); + const paths = await preflightKiroIdeInstallation(config, sources.paths); + const hooks = withoutManagedKiroIdeHooks(sources.document.hooks); + const rendered = renderKiroIdeDocument(sources.document, [ + ...hooks, + buildKiroIdeHook('elydora-guard', paths.guardPath), + buildKiroIdeHook('elydora-audit', paths.auditPath), + ]); + const prepared = await prepareKiroIdeInstallation(config, sources, rendered); + await commitKiroIdeInstallation(prepared); + console.log(` Kiro IDE workspace hooks: ${sources.paths.configPath}`); + console.log(' Kiro IDE verification: confirm Elydora hooks in the Agent Hooks panel.'); + }, + + async uninstall(agentId?: string): Promise { + const sources = await readKiroIdeSources(); + const rendered = renderKiroIdeDocument( + sources.document, + withoutManagedKiroIdeHooks(sources.document.hooks, agentId), + ); + const prepared = await prepareKiroIdeUninstall(sources, rendered, agentId); + await commitKiroIdeUninstall(prepared); }, async status(): Promise { - const configPath = resolveConfigPath(); - - let hookConfigured = false; - let hookScriptPath = ''; - try { - const raw = await fsp.readFile(configPath, 'utf-8'); - const config = JSON.parse(raw); - hookConfigured = !!(config.hooks && config.hooks.pre_tool_use && config.hooks.post_tool_use); - - // Extract hook script path from post_tool_use command - if (hookConfigured && config.hooks.post_tool_use) { - const cmd = config.hooks.post_tool_use.command as string; - if (cmd && cmd.includes('elydora')) { - hookScriptPath = cmd.replace(/^node\s+"?/, '').replace(/"?\s*$/, ''); - } - } - } catch { - // Config not readable - } - - let hookScriptExists = false; - if (hookScriptPath) { - try { - await fsp.access(hookScriptPath); - hookScriptExists = true; - } catch { - // File doesn't exist - } - } - + const sources = await readKiroIdeSources(); + requireAvailableKiroIdeHooks(sources.document.hooks); + const contracts = kiroIdeRuntimeContracts(sources.document.hooks); + const hookConfigured = contracts.length > 0; + const hookScriptExists = hookConfigured + && (await Promise.all(contracts.map(runtimeFilesExist))).every(Boolean); return { installed: hookConfigured && hookScriptExists, agentName: AGENT_KEY, displayName: entry.name, hookConfigured, hookScriptExists, - configPath, + configPath: sources.paths.configPath, }; }, }; diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index a9f2f61..44d4291 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -10,7 +10,7 @@ export const SUPPORTED_AGENTS: ReadonlyMap = new Map ['cursor', { name: 'Cursor', configDir: '~/.cursor', configFile: 'hooks.json' }], ['gemini', { name: 'Gemini CLI', configDir: '~/.gemini', configFile: 'settings.json' }], ['kirocli', { name: 'Kiro CLI', configDir: '~/.kiro/hooks', configFile: 'elydora-audit.json' }], - ['kiroide', { name: 'Kiro IDE', configDir: '.kiro/hooks', configFile: 'elydora-audit.kiro.hook' }], + ['kiroide', { name: 'Kiro IDE', configDir: '.kiro/hooks', configFile: 'elydora-audit.json' }], ['opencode', { name: 'OpenCode', configDir: '.config/opencode/plugins', configFile: 'elydora-audit.mjs' }], ['copilot', { name: 'GitHub Copilot CLI', configDir: '~/.copilot/hooks', configFile: 'elydora-audit.json' }], ['letta', { name: 'Letta Code', configDir: '~/.letta', configFile: 'settings.json' }], diff --git a/test-support/kiroide-test-helpers.mjs b/test-support/kiroide-test-helpers.mjs new file mode 100644 index 0000000..8f30a1b --- /dev/null +++ b/test-support/kiroide-test-helpers.mjs @@ -0,0 +1,170 @@ +import { spawn } from 'node:child_process'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import http from 'node:http'; +import os from 'node:os'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +export const VALID_PRIVATE_KEY = Buffer.alloc(32, 11).toString('base64url'); +export const pluginModuleUrl = pathToFileURL(path.resolve('dist/plugins/kiroide.js')).href; +export const contractModuleUrl = pathToFileURL( + path.resolve('dist/plugins/kiroide-contract.js'), +).href; +export const installationModuleUrl = pathToFileURL( + path.resolve('dist/plugins/kiroide-installation.js'), +).href; +export const ioModuleUrl = pathToFileURL(path.resolve('dist/plugins/kiroide-io.js')).href; +export const registryModuleUrl = pathToFileURL(path.resolve('dist/plugins/registry.js')).href; +export const cliPath = path.resolve('dist/cli.js'); + +export function runProcess(command, args, env, cwd, input = '', shell = false) { + return new Promise((resolve, reject) => { + const childEnvironment = { ...process.env, ...env }; + for (const [name, value] of Object.entries(env)) { + if (value === undefined) delete childEnvironment[name]; + } + const child = spawn(command, args, { + cwd, + env: childEnvironment, + shell, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { stdout += chunk; }); + child.stderr.on('data', (chunk) => { stderr += chunk; }); + child.once('error', reject); + child.once('close', (code) => resolve({ code, stdout, stderr })); + child.stdin.end(input); + }); +} + +export function runNode(args, env, cwd, input = '') { + return runProcess(process.execPath, args, env, cwd, input); +} + +export function runShell(command, env, cwd, input = '') { + return runProcess(command, [], env, cwd, input, true); +} + +export function environment(fixture) { + return { HOME: fixture.homeDir, USERPROFILE: fixture.homeDir }; +} + +export function installConfig(fixture, overrides = {}) { + return { + agentName: 'kiroide', + orgId: 'org-1', + agentId: fixture.agentId, + privateKey: VALID_PRIVATE_KEY, + kid: 'kid-1', + token: 'token-1', + baseUrl: fixture.baseUrl, + guardScriptPath: fixture.guardScriptPath, + hookScriptPath: fixture.hookScriptPath, + ...overrides, + }; +} + +export async function runPlugin(fixture, method, argument) { + const source = ` + import { kiroidePlugin } from ${JSON.stringify(pluginModuleUrl)}; + const argument = JSON.parse(process.env.ELYDORA_TEST_ARGUMENT); + const result = await kiroidePlugin[process.env.ELYDORA_TEST_METHOD](argument); + if (result !== undefined) console.log(JSON.stringify(result)); + `; + return runNode( + ['--input-type=module', '--eval', source], + { + ...environment(fixture), + ELYDORA_TEST_ARGUMENT: JSON.stringify(argument), + ELYDORA_TEST_METHOD: method, + }, + fixture.projectDir, + ); +} + +async function writeOptional(filePath, value) { + if (value === undefined) return; + await mkdir(path.dirname(filePath), { recursive: true }); + const source = typeof value === 'string' ? value : `${JSON.stringify(value, null, 2)}\n`; + await writeFile(filePath, source, { mode: 0o600 }); +} + +export async function createFixture({ + agentId = 'agent-1', + baseUrl = 'http://127.0.0.1:9', + existingConfig, + existingLegacy, +} = {}) { + const rootDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-kiroide-')); + const homeDir = path.join(rootDir, "home with spaces and 'quote %KIRO%"); + const projectDir = path.join(rootDir, 'workspace with spaces'); + const hooksDir = path.join(projectDir, '.kiro', 'hooks'); + const configPath = path.join(hooksDir, 'elydora-audit.json'); + const legacyConfigPath = path.join(homeDir, '.kiro', 'hooks', 'elydora-audit.kiro.hook'); + const agentDir = path.join(homeDir, '.elydora', agentId); + const guardScriptPath = path.join(agentDir, 'guard.js'); + const hookScriptPath = path.join(agentDir, 'hook.js'); + await mkdir(projectDir, { recursive: true }); + await writeOptional(configPath, existingConfig); + await writeOptional(legacyConfigPath, existingLegacy); + const fixture = { + agentDir, + agentId, + baseUrl, + configPath, + guardScriptPath, + homeDir, + hookScriptPath, + hooksDir, + legacyConfigPath, + projectDir, + rootDir, + install(overrides = {}) { + return runPlugin(this, 'install', installConfig(this, overrides)); + }, + async close() { + await rm(rootDir, { recursive: true, force: true }); + }, + }; + return fixture; +} + +export async function readJson(filePath) { + return JSON.parse(await readFile(filePath, 'utf-8')); +} + +export function findHook(document, name) { + return document.hooks.find((hook) => hook.name === name); +} + +export async function startApiServer() { + const requests = []; + const server = http.createServer(async (request, response) => { + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + const raw = Buffer.concat(chunks).toString('utf-8'); + requests.push({ method: request.method, url: request.url, raw }); + if (request.method === 'GET') { + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end('{"agent":{"status":"active"}}'); + return; + } + response.writeHead(201, { 'Content-Type': 'application/json' }); + response.end('{"operation":{"accepted":true}}'); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + return { + baseUrl: `http://127.0.0.1:${address.port}`, + requests, + close: () => new Promise((resolve, reject) => { + server.close((error) => error ? reject(error) : resolve()); + }), + }; +} diff --git a/test/kiroide-plugin.test.mjs b/test/kiroide-plugin.test.mjs new file mode 100644 index 0000000..a479f87 --- /dev/null +++ b/test/kiroide-plugin.test.mjs @@ -0,0 +1,389 @@ +import assert from 'node:assert/strict'; +import { + lstat, + mkdir, + readFile, + readdir, + rm, + stat, + symlink, + writeFile, +} from 'node:fs/promises'; +import path from 'node:path'; +import test from 'node:test'; +import { + VALID_PRIVATE_KEY, + cliPath, + createFixture, + environment, + findHook, + installConfig, + readJson, + registryModuleUrl, + runNode, + runPlugin, + runShell, + startApiServer, +} from '../test-support/kiroide-test-helpers.mjs'; + +async function assertMissing(filePath) { + await assert.rejects(lstat(filePath), { code: 'ENOENT' }); +} + +function userHook() { + return { + name: 'workspace-context', + description: 'Load workspace context', + trigger: 'SessionStart', + action: { type: 'agent', prompt: 'Read AGENTS.md' }, + enabled: true, + }; +} + +function legacyDocument(fixture, agentId = fixture.agentId) { + const agentDirectory = path.join(fixture.homeDir, '.elydora', agentId); + return { + name: 'Elydora Audit', + description: 'Sends tool-use events to the Elydora tamper-evident audit platform', + version: '1.0.0', + hooks: { + pre_tool_use: { + command: `node "${path.join(agentDirectory, 'guard.js')}"`, + timeout_ms: 5000, + }, + post_tool_use: { + command: `node "${path.join(agentDirectory, 'hook.js')}"`, + timeout_ms: 5000, + }, + }, + }; +} + +async function writeJson(filePath, value) { + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); +} + +test('Kiro IDE uses the workspace v1 hook contract', async () => { + const { SUPPORTED_AGENTS } = await import(registryModuleUrl); + assert.deepEqual(SUPPORTED_AGENTS.get('kiroide'), { + name: 'Kiro IDE', + configDir: '.kiro/hooks', + configFile: 'elydora-audit.json', + }); + + const fixture = await createFixture({ + existingConfig: { version: 'v1', owner: 'workspace', hooks: [userHook()] }, + }); + try { + const first = await fixture.install(); + assert.equal(first.code, 0, first.stderr); + assert.match(first.stdout, /workspace hooks/); + assert.match(first.stdout, /Agent Hooks panel/); + const firstSource = await readFile(fixture.configPath, 'utf-8'); + const document = JSON.parse(firstSource); + assert.equal(document.version, 'v1'); + assert.equal(document.owner, 'workspace'); + assert.deepEqual(document.hooks[0], userHook()); + assert.deepEqual(findHook(document, 'elydora-guard'), { + name: 'elydora-guard', + description: 'Block tool use when the Elydora agent is frozen', + trigger: 'PreToolUse', + matcher: '.*', + action: { + type: 'command', + command: findHook(document, 'elydora-guard').action.command, + }, + timeout: 10, + enabled: true, + }); + assert.deepEqual(findHook(document, 'elydora-audit'), { + name: 'elydora-audit', + description: 'Record tool use in the Elydora audit trail', + trigger: 'PostToolUse', + matcher: '.*', + action: { + type: 'command', + command: findHook(document, 'elydora-audit').action.command, + }, + timeout: 10, + enabled: true, + }); + assert.deepEqual(await readJson(path.join(fixture.agentDir, 'config.json')), { + org_id: 'org-1', + agent_id: fixture.agentId, + kid: 'kid-1', + base_url: fixture.baseUrl, + token: 'token-1', + agent_name: 'kiroide', + }); + assert.equal(await readFile(path.join(fixture.agentDir, 'private.key'), 'utf-8'), VALID_PRIVATE_KEY); + + const second = await fixture.install(); + assert.equal(second.code, 0, second.stderr); + assert.equal(await readFile(fixture.configPath, 'utf-8'), firstSource); + const transactionFiles = [ + ...await readdir(fixture.hooksDir), + ...await readdir(fixture.agentDir), + ]; + assert.equal(transactionFiles.some((name) => /\.(tmp|rollback)$/.test(name)), false); + await assertMissing(path.join(fixture.homeDir, '.kiro', 'hooks', 'elydora-audit.json')); + if (process.platform !== 'win32') { + assert.equal((await stat(fixture.agentDir)).mode & 0o777, 0o700); + assert.equal((await stat(fixture.configPath)).mode & 0o777, 0o600); + assert.equal((await stat(fixture.guardScriptPath)).mode & 0o777, 0o700); + } + } finally { + await fixture.close(); + } +}); + +test('Kiro IDE commands block frozen agents and preserve the native event', async () => { + const api = await startApiServer(); + const fixture = await createFixture({ baseUrl: api.baseUrl }); + try { + assert.equal((await fixture.install()).code, 0); + const document = await readJson(fixture.configPath); + const payload = { + hook_event_name: 'preToolUse', + cwd: fixture.projectDir, + session_id: 'session-1', + tool_name: 'execute_bash', + tool_input: { command: 'npm test' }, + future_field: { retained: true }, + }; + await writeJson(path.join(fixture.agentDir, 'status-cache.json'), { + status: 'frozen', + cached_at: Date.now(), + }); + const guard = await runShell( + findHook(document, 'elydora-guard').action.command, + environment(fixture), + fixture.projectDir, + JSON.stringify(payload), + ); + assert.equal(guard.code, 2, guard.stderr); + assert.match(guard.stderr, /Tool execution blocked/); + + await rm(path.join(fixture.agentDir, 'status-cache.json')); + payload.hook_event_name = 'postToolUse'; + payload.tool_response = { success: true, result: '12 passed' }; + const audit = await runShell( + findHook(document, 'elydora-audit').action.command, + environment(fixture), + fixture.projectDir, + JSON.stringify(payload), + ); + assert.equal(audit.code, 0, audit.stderr); + const request = api.requests.find(({ method }) => method === 'POST'); + assert.ok(request); + const operation = JSON.parse(request.raw); + assert.deepEqual(operation.payload, payload); + assert.deepEqual(operation.action, { tool: 'execute_bash' }); + assert.deepEqual(operation.subject, { session_id: 'session-1' }); + } finally { + await api.close(); + await fixture.close(); + } +}); + +test('Kiro IDE status requires the exact enabled contract and physical runtime', async (t) => { + await t.test('disabled hook is inactive and reinstall repairs it', async () => { + const fixture = await createFixture(); + try { + assert.equal((await fixture.install()).code, 0); + const document = await readJson(fixture.configPath); + findHook(document, 'elydora-guard').enabled = false; + await writeJson(fixture.configPath, document); + let status = await runPlugin(fixture, 'status', null); + assert.deepEqual(JSON.parse(status.stdout), { + installed: false, + agentName: 'kiroide', + displayName: 'Kiro IDE', + hookConfigured: false, + hookScriptExists: false, + configPath: fixture.configPath, + }); + assert.equal((await fixture.install()).code, 0); + status = await runPlugin(fixture, 'status', null); + assert.equal(JSON.parse(status.stdout).installed, true); + } finally { + await fixture.close(); + } + }); + + for (const [label, target, contents, expected] of [ + ['guard runtime', 'guard', 'tampered\n', /"installed":false/], + ['runtime config', 'config', '{ malformed', /parse Elydora runtime config/i], + ['private key', 'key', 'invalid', /private key is invalid/i], + ]) { + await t.test(label, async () => { + const fixture = await createFixture(); + try { + assert.equal((await fixture.install()).code, 0); + const targets = { + guard: fixture.guardScriptPath, + config: path.join(fixture.agentDir, 'config.json'), + key: path.join(fixture.agentDir, 'private.key'), + }; + await writeFile(targets[target], contents); + const status = await runPlugin(fixture, 'status', null); + assert.match(`${status.stdout}\n${status.stderr}`, expected); + } finally { + await fixture.close(); + } + }); + } +}); + +test('Kiro IDE rejects malformed files, name collisions, and unsafe workspaces before writes', async (t) => { + await t.test('malformed hook file', async () => { + const fixture = await createFixture({ existingConfig: '{ malformed' }); + try { + const result = await fixture.install(); + assert.equal(result.code, 1); + assert.match(result.stderr, /parse Kiro IDE hooks/i); + assert.equal(await readFile(fixture.configPath, 'utf-8'), '{ malformed'); + await assertMissing(fixture.agentDir); + } finally { + await fixture.close(); + } + }); + + await t.test('managed name collision', async () => { + const existing = { + version: 'v1', + hooks: [{ + name: 'elydora-guard', + trigger: 'PreToolUse', + action: { type: 'command', command: 'user-command' }, + }], + }; + const fixture = await createFixture({ existingConfig: existing }); + try { + const result = await fixture.install(); + assert.equal(result.code, 1); + assert.match(result.stderr, /conflicts with the Elydora contract/i); + assert.deepEqual(await readJson(fixture.configPath), existing); + await assertMissing(fixture.agentDir); + } finally { + await fixture.close(); + } + }); + + await t.test('non-canonical private key', async () => { + const fixture = await createFixture(); + try { + const result = await fixture.install({ privateKey: `${VALID_PRIVATE_KEY}=` }); + assert.equal(result.code, 1); + assert.match(result.stderr, /canonical 32-byte base64url/i); + await assertMissing(fixture.agentDir); + await assertMissing(fixture.configPath); + } finally { + await fixture.close(); + } + }); + + await t.test('linked .kiro directory', async () => { + const fixture = await createFixture(); + const external = path.join(fixture.rootDir, 'external-kiro'); + const kiroDirectory = path.join(fixture.projectDir, '.kiro'); + try { + await mkdir(external); + await symlink(external, kiroDirectory, process.platform === 'win32' ? 'junction' : 'dir'); + const result = await fixture.install(); + assert.equal(result.code, 1); + assert.match(result.stderr, /not a physical directory/i); + await assertMissing(path.join(external, 'hooks', 'elydora-audit.json')); + await assertMissing(fixture.agentDir); + } finally { + await fixture.close(); + } + }); +}); + +test('Kiro IDE migrates only the matching legacy global hook', async () => { + const fixture = await createFixture(); + try { + await writeJson(fixture.legacyConfigPath, legacyDocument(fixture)); + assert.equal((await fixture.install()).code, 0); + await assertMissing(fixture.legacyConfigPath); + + await writeJson(fixture.legacyConfigPath, legacyDocument(fixture, 'agent-2')); + assert.equal((await fixture.install()).code, 0); + assert.equal((await readJson(fixture.legacyConfigPath)).version, '1.0.0'); + } finally { + await fixture.close(); + } +}); + +test('Kiro IDE uninstall removes exact ownership and preserves workspace hooks', async () => { + const fixture = await createFixture({ + existingConfig: { version: 'v1', owner: 'workspace', hooks: [userHook()] }, + }); + try { + assert.equal((await fixture.install()).code, 0); + assert.equal((await runPlugin(fixture, 'uninstall', 'agent-2')).code, 0); + assert.equal((await readJson(fixture.configPath)).hooks.length, 3); + assert.equal((await runPlugin(fixture, 'uninstall', fixture.agentId)).code, 0); + assert.deepEqual(await readJson(fixture.configPath), { + version: 'v1', + owner: 'workspace', + hooks: [userHook()], + }); + } finally { + await fixture.close(); + } + + const owned = await createFixture(); + try { + assert.equal((await owned.install()).code, 0); + assert.equal((await runPlugin(owned, 'uninstall', owned.agentId)).code, 0); + await assertMissing(owned.configPath); + } finally { + await owned.close(); + } +}); + +test('Kiro IDE CLI completes install, status, and uninstall in the current workspace', async () => { + const fixture = await createFixture(); + const privateKeyFile = path.join(fixture.rootDir, 'private.key'); + const tokenFile = path.join(fixture.rootDir, 'token'); + try { + await writeFile(privateKeyFile, VALID_PRIVATE_KEY, { mode: 0o600 }); + await writeFile(tokenFile, 'token-1', { mode: 0o600 }); + const install = await runNode([ + '--no-warnings', + cliPath, + 'install', + '--agent', 'kiroide', + '--org_id', 'org-1', + '--agent_id', fixture.agentId, + '--kid', 'kid-1', + '--private_key_file', privateKeyFile, + '--token_file', tokenFile, + '--base_url', fixture.baseUrl, + ], environment(fixture), fixture.projectDir); + assert.equal(install.code, 0, install.stderr); + assert.match(install.stdout, /Kiro IDE workspace hooks/); + const status = await runNode( + ['--no-warnings', cliPath, 'status'], + environment(fixture), + fixture.projectDir, + ); + assert.equal(status.code, 0, status.stderr); + assert.match(status.stdout, /Kiro IDE \(kiroide\) \[installed\]/); + const uninstall = await runNode([ + '--no-warnings', + cliPath, + 'uninstall', + '--agent', 'kiroide', + '--agent_id', fixture.agentId, + ], environment(fixture), fixture.projectDir); + assert.equal(uninstall.code, 0, uninstall.stderr); + await assertMissing(fixture.configPath); + await assertMissing(fixture.agentDir); + } finally { + await fixture.close(); + } +}); diff --git a/test/kiroide-transaction.test.mjs b/test/kiroide-transaction.test.mjs new file mode 100644 index 0000000..7251c16 --- /dev/null +++ b/test/kiroide-transaction.test.mjs @@ -0,0 +1,142 @@ +import assert from 'node:assert/strict'; +import { lstat, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import test from 'node:test'; +import { + contractModuleUrl, + createFixture, + environment, + installConfig, + installationModuleUrl, + ioModuleUrl, + runNode, +} from '../test-support/kiroide-test-helpers.mjs'; + +async function assertMissing(filePath) { + await assert.rejects(lstat(filePath), { code: 'ENOENT' }); +} + +function legacyDocument(fixture) { + return { + name: 'Elydora Audit', + description: 'Sends tool-use events to the Elydora tamper-evident audit platform', + version: '1.0.0', + hooks: { + pre_tool_use: { + command: `node "${fixture.guardScriptPath}"`, + timeout_ms: 5000, + }, + post_tool_use: { + command: `node "${fixture.hookScriptPath}"`, + timeout_ms: 5000, + }, + }, + }; +} + +async function writeJson(filePath, value) { + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); +} + +async function runPreparedInstallation(fixture, { failLegacy = false, raceSource } = {}) { + const source = ` + import fsp from 'node:fs/promises'; + import { + buildKiroIdeHook, + renderKiroIdeDocument, + withoutManagedKiroIdeHooks, + } from ${JSON.stringify(contractModuleUrl)}; + import { readKiroIdeSources } from ${JSON.stringify(ioModuleUrl)}; + import { + commitKiroIdeInstallation, + preflightKiroIdeInstallation, + prepareKiroIdeInstallation, + } from ${JSON.stringify(installationModuleUrl)}; + + const config = JSON.parse(process.env.ELYDORA_TEST_CONFIG); + const sources = await readKiroIdeSources(); + const paths = await preflightKiroIdeInstallation(config, sources.paths); + const rendered = renderKiroIdeDocument(sources.document, [ + ...withoutManagedKiroIdeHooks(sources.document.hooks), + buildKiroIdeHook('elydora-guard', paths.guardPath), + buildKiroIdeHook('elydora-audit', paths.auditPath), + ]); + const prepared = await prepareKiroIdeInstallation(config, sources, rendered); + if (process.env.ELYDORA_TEST_RACE_SOURCE) { + await fsp.writeFile(sources.paths.configPath, process.env.ELYDORA_TEST_RACE_SOURCE); + } + await commitKiroIdeInstallation(prepared, async (from, to) => { + if (process.env.ELYDORA_TEST_FAIL_LEGACY === 'true' + && from === sources.paths.legacyConfigPath) { + throw new Error('injected legacy rename failure'); + } + await fsp.rename(from, to); + }); + `; + return runNode( + ['--input-type=module', '--eval', source], + { + ...environment(fixture), + ELYDORA_TEST_CONFIG: JSON.stringify(installConfig(fixture)), + ELYDORA_TEST_FAIL_LEGACY: String(failLegacy), + ELYDORA_TEST_RACE_SOURCE: raceSource, + }, + fixture.projectDir, + ); +} + +async function assertNoTransactionFiles(...directories) { + const names = []; + for (const directory of directories) { + try { + names.push(...await readdir(directory)); + } catch (error) { + if (error.code !== 'ENOENT') throw error; + } + } + assert.equal(names.some((name) => /\.(tmp|rollback)$/.test(name)), false, names.join(', ')); +} + +test('Kiro IDE rolls back runtime and workspace hooks when legacy cleanup fails', async () => { + const original = { version: 'v1', owner: 'workspace', hooks: [] }; + const fixture = await createFixture({ existingConfig: original }); + try { + await writeJson(fixture.legacyConfigPath, legacyDocument(fixture)); + const result = await runPreparedInstallation(fixture, { failLegacy: true }); + assert.equal(result.code, 1); + assert.match(result.stderr, /injected legacy rename failure/); + assert.deepEqual(JSON.parse(await readFile(fixture.configPath, 'utf-8')), original); + assert.equal((await readFile(fixture.legacyConfigPath, 'utf-8')).length > 0, true); + for (const filePath of [ + fixture.guardScriptPath, + fixture.hookScriptPath, + path.join(fixture.agentDir, 'config.json'), + path.join(fixture.agentDir, 'private.key'), + ]) await assertMissing(filePath); + await assertNoTransactionFiles( + fixture.hooksDir, + fixture.agentDir, + path.dirname(fixture.legacyConfigPath), + ); + } finally { + await fixture.close(); + } +}); + +test('Kiro IDE detects workspace hook races before the first commit', async () => { + const original = { version: 'v1', hooks: [] }; + const raceSource = '{"version":"v1","hooks":[],"race":true}\n'; + const fixture = await createFixture({ existingConfig: original }); + try { + const result = await runPreparedInstallation(fixture, { raceSource }); + assert.equal(result.code, 1); + assert.match(result.stderr, /changed during Kiro IDE installation/i); + assert.equal(await readFile(fixture.configPath, 'utf-8'), raceSource); + await assertMissing(fixture.guardScriptPath); + await assertMissing(fixture.hookScriptPath); + await assertNoTransactionFiles(fixture.hooksDir, fixture.agentDir); + } finally { + await fixture.close(); + } +});