fix(kirocli): harden v2 and v3 hooks
This commit is contained in:
1 parent
5889b09a09
commit
3f43f4aad5
14 files changed
+1452
-583
No files matched your search
@@ -35,6 +35,7 @@ This repository owns the published `@elydora/sdk` package, its `elydora` CLI, lo
|
|||||||
- 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 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 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.
|
- 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.
|
||||||
|
- Resolve Kiro CLI v2 through the global `~/.kiro/agents/elydora-audit.json` custom agent and CLI 2.13.0+ v3 through `~/.kiro/hooks/elydora-audit.json`. Register exact lower-camel `preToolUse` and `postToolUse` v2 handlers with `*` matchers and ten-second millisecond timeouts; register exact PascalCase `PreToolUse` and `PostToolUse` v3 handlers in Kiro's v1 schema with `.*` matchers and ten-second timeouts. Preserve the complete native payload, propagate frozen or revoked state through exit code `2`, and use encoded PowerShell commands on Windows. Commit both hook sources, generated runtimes, runtime config, and private key through one rollback-capable transaction. Reject path and reserved-name collisions, migrate only exact Elydora command ownership, and require physical sources, strict schemas, canonical private keys, exact runtime sources, matching identity, and a common complete v2/v3 contract during status checks. Validate v2 with `kiro-cli agent validate --path` and activate v3 with `kiro-cli --v3`.
|
||||||
|
|
||||||
## Code Quality
|
## Code Quality
|
||||||
|
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ Auggie installation commits `~/.augment/settings.json`, both platform wrappers,
|
|||||||
|
|
||||||
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 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`.
|
Kiro CLI installation commits the global v2 custom agent at `~/.kiro/agents/elydora-audit.json`, the CLI 2.13.0+ v3 global hooks at `~/.kiro/hooks/elydora-audit.json`, generated runtimes, runtime config, and private key through one rollback-capable transaction. The v2 agent uses exact lower-camel `preToolUse` and `postToolUse` handlers with ten-second millisecond timeouts. The v3 source uses Kiro's v1 schema with exact PascalCase `PreToolUse` and `PostToolUse` command hooks. Both contracts preserve the complete native event payload and propagate frozen or revoked state through exit code `2`. Validate v2 with `kiro-cli agent validate --path ~/.kiro/agents/elydora-audit.json` and start it with `kiro-cli --agent elydora-audit`; start the v3 TUI with `kiro-cli --v3`.
|
||||||
|
|
||||||
## API Reference
|
## API Reference
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,416 @@
|
|||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import {
|
||||||
|
buildKiroIdeCommand,
|
||||||
|
kiroIdeRuntimeReference,
|
||||||
|
sameKiroIdeAgentId,
|
||||||
|
sameKiroIdePath,
|
||||||
|
type KiroIdeRuntimeReference,
|
||||||
|
} from './kiroide-command.js';
|
||||||
|
import {
|
||||||
|
buildKiroIdeHook,
|
||||||
|
kiroIdeRuntimeContracts,
|
||||||
|
renderKiroIdeDocument,
|
||||||
|
type KiroIdeDocument,
|
||||||
|
type KiroIdeHook,
|
||||||
|
type RenderedKiroIdeDocument,
|
||||||
|
type KiroIdeRuntimeContract,
|
||||||
|
} from './kiroide-contract.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 exactKeys(value: JsonObject, keys: readonly string[]): boolean {
|
||||||
|
return Object.keys(value).sort().join('|') === [...keys].sort().join('|');
|
||||||
|
}
|
||||||
|
|
||||||
|
function legacyWindowsReference(
|
||||||
|
command: unknown,
|
||||||
|
scriptName: string,
|
||||||
|
): KiroIdeRuntimeReference | undefined {
|
||||||
|
if (typeof command !== 'string') return undefined;
|
||||||
|
const match = /^"([^"\r\n]+)" "([^"\r\n]+)"$/.exec(command);
|
||||||
|
if (!match
|
||||||
|
|| !path.isAbsolute(match[1])
|
||||||
|
|| !['node', 'node.exe'].includes(path.basename(match[1]).toLowerCase())
|
||||||
|
|| !path.isAbsolute(match[2])
|
||||||
|
|| path.basename(match[2]) !== scriptName) return undefined;
|
||||||
|
const agentDirectory = path.dirname(match[2]);
|
||||||
|
if (!sameKiroIdePath(path.dirname(agentDirectory), path.join(os.homedir(), '.elydora'))) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const agentId = path.basename(agentDirectory);
|
||||||
|
return agentId ? { agentId, scriptPath: match[2] } : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 || sameKiroIdeAgentId(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 && !sameKiroIdeAgentId(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 && !sameKiroIdeAgentId(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
|
||||||
|
|| !sameKiroIdeAgentId(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 && !sameKiroIdeAgentId(reference.agentId, agentId));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasOwnedV3Hooks(hooks: readonly KiroIdeHook[], agentId?: string): boolean {
|
||||||
|
return hooks.some((hook) => {
|
||||||
|
const reference = v3OwnedReference(hook);
|
||||||
|
return reference !== undefined
|
||||||
|
&& (agentId === undefined || sameKiroIdeAgentId(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 || sameKiroIdeAgentId(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
|
||||||
|
|| !sameKiroIdeAgentId(v2[0].agentId, v3[0].agentId)
|
||||||
|
|| !sameKiroIdePath(v2[0].guardPath, v3[0].guardPath)
|
||||||
|
|| !sameKiroIdePath(v2[0].auditPath, v3[0].auditPath)) return undefined;
|
||||||
|
return v2[0];
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
import type { InstallConfig } from './base.js';
|
||||||
|
import {
|
||||||
|
AGENT_KEY,
|
||||||
|
AUDIT_SCRIPT,
|
||||||
|
GUARD_SCRIPT,
|
||||||
|
type RenderedKiroCliV2Document,
|
||||||
|
} from './kirocli-contract.js';
|
||||||
|
import type { KiroCliSources } from './kirocli-io.js';
|
||||||
|
import type { RenderedKiroIdeDocument } from './kiroide-contract.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 CLI';
|
||||||
|
const V2_DIRECTORY_LABEL = 'Kiro CLI agents directory';
|
||||||
|
const V3_DIRECTORY_LABEL = 'Kiro CLI global hooks directory';
|
||||||
|
|
||||||
|
export type KiroCliRuntimePaths = ManagedRuntimePaths;
|
||||||
|
export type PreparedKiroCliInstallation = PreparedManagedInstallation;
|
||||||
|
export type { RenameFile };
|
||||||
|
|
||||||
|
function hookLocations(sources: KiroCliSources) {
|
||||||
|
return [
|
||||||
|
{ directoryLabel: V2_DIRECTORY_LABEL, filePath: sources.paths.v2Path },
|
||||||
|
{ directoryLabel: V3_DIRECTORY_LABEL, filePath: sources.paths.v3Path },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function preflightKiroCliInstallation(
|
||||||
|
config: InstallConfig,
|
||||||
|
sources: KiroCliSources,
|
||||||
|
): Promise<KiroCliRuntimePaths> {
|
||||||
|
return preflightManagedInstallation({
|
||||||
|
agentKey: AGENT_KEY,
|
||||||
|
hookLocations: hookLocations(sources),
|
||||||
|
config,
|
||||||
|
}, GUARD_SCRIPT, AUDIT_SCRIPT);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function prepareKiroCliInstallation(
|
||||||
|
config: InstallConfig,
|
||||||
|
sources: KiroCliSources,
|
||||||
|
v2: RenderedKiroCliV2Document,
|
||||||
|
v3: RenderedKiroIdeDocument,
|
||||||
|
): Promise<PreparedKiroCliInstallation> {
|
||||||
|
if (v2.next === undefined || v3.next === undefined) {
|
||||||
|
throw new Error('Kiro CLI installation requires both v2 and v3 hook documents');
|
||||||
|
}
|
||||||
|
return prepareManagedInstallation({
|
||||||
|
agentKey: AGENT_KEY,
|
||||||
|
displayName: DISPLAY_NAME,
|
||||||
|
hookSources: [
|
||||||
|
{
|
||||||
|
directoryLabel: V2_DIRECTORY_LABEL,
|
||||||
|
label: 'Kiro CLI v2 agent config',
|
||||||
|
filePath: sources.paths.v2Path,
|
||||||
|
expectedSource: sources.v2.raw,
|
||||||
|
source: v2.next,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
directoryLabel: V3_DIRECTORY_LABEL,
|
||||||
|
label: 'Kiro CLI v3 global hooks',
|
||||||
|
filePath: sources.paths.v3Path,
|
||||||
|
expectedSource: sources.v3.raw,
|
||||||
|
source: v3.next,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
config,
|
||||||
|
auditOptions: { nativePayload: true },
|
||||||
|
}, GUARD_SCRIPT, AUDIT_SCRIPT);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function commitKiroCliInstallation(
|
||||||
|
prepared: PreparedKiroCliInstallation,
|
||||||
|
renameFile?: RenameFile,
|
||||||
|
): Promise<void> {
|
||||||
|
await commitManagedInstallation(prepared, renameFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function prepareKiroCliUninstall(
|
||||||
|
sources: KiroCliSources,
|
||||||
|
v2: RenderedKiroCliV2Document,
|
||||||
|
v3: RenderedKiroIdeDocument,
|
||||||
|
): Promise<PreparedManagedTransaction> {
|
||||||
|
const changes: ManagedFileChange[] = [];
|
||||||
|
const directories = [];
|
||||||
|
for (const item of [
|
||||||
|
{ rendered: v2, label: 'Kiro CLI v2 agent config', directoryLabel: V2_DIRECTORY_LABEL },
|
||||||
|
{ rendered: v3, label: 'Kiro CLI v3 global hooks', directoryLabel: V3_DIRECTORY_LABEL },
|
||||||
|
]) {
|
||||||
|
if (!item.rendered.changed) continue;
|
||||||
|
const change = await prepareManagedFileChange({
|
||||||
|
filePath: item.rendered.document.filePath,
|
||||||
|
label: item.label,
|
||||||
|
next: item.rendered.next,
|
||||||
|
mode: 0o600,
|
||||||
|
expectedSource: item.rendered.document.raw,
|
||||||
|
verifyExpectedSource: true,
|
||||||
|
});
|
||||||
|
if (!change) continue;
|
||||||
|
changes.push(change);
|
||||||
|
directories.push({
|
||||||
|
path: path.dirname(item.rendered.document.filePath),
|
||||||
|
label: item.directoryLabel,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { displayName: DISPLAY_NAME, operation: 'uninstall', directories, changes };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function commitKiroCliUninstall(
|
||||||
|
prepared: PreparedManagedTransaction,
|
||||||
|
renameFile?: RenameFile,
|
||||||
|
): Promise<void> {
|
||||||
|
await commitManagedTransaction(prepared, renameFile);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import {
|
||||||
|
AGENT_KEY,
|
||||||
|
createKiroCliV2Document,
|
||||||
|
parseKiroCliV2Document,
|
||||||
|
resolveKiroCliPaths,
|
||||||
|
type KiroCliPaths,
|
||||||
|
type KiroCliV2Document,
|
||||||
|
} from './kirocli-contract.js';
|
||||||
|
import {
|
||||||
|
createKiroIdeDocument,
|
||||||
|
parseKiroIdeDocument,
|
||||||
|
type KiroIdeDocument,
|
||||||
|
type KiroIdeRuntimeContract,
|
||||||
|
} from './kiroide-contract.js';
|
||||||
|
import { runtimeFilesExist } from './kiroide-io.js';
|
||||||
|
import { inspectPhysicalDirectory, readPhysicalFile } from './managed-files.js';
|
||||||
|
|
||||||
|
const MAX_HOOK_BYTES = 512 * 1024;
|
||||||
|
|
||||||
|
export interface KiroCliSources {
|
||||||
|
readonly paths: KiroCliPaths;
|
||||||
|
readonly v2: KiroCliV2Document;
|
||||||
|
readonly v3: KiroIdeDocument;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function inspectKiroCliDirectories(paths: KiroCliPaths): Promise<void> {
|
||||||
|
await inspectPhysicalDirectory(paths.homeDirectory, 'Kiro CLI home directory');
|
||||||
|
await inspectPhysicalDirectory(paths.kiroDirectory, 'Kiro CLI configuration directory');
|
||||||
|
await Promise.all([
|
||||||
|
inspectPhysicalDirectory(paths.agentsDirectory, 'Kiro CLI agents directory'),
|
||||||
|
inspectPhysicalDirectory(paths.hooksDirectory, 'Kiro CLI hooks directory'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readV2Document(paths: KiroCliPaths): Promise<KiroCliV2Document> {
|
||||||
|
const snapshot = await readPhysicalFile(
|
||||||
|
paths.v2Path,
|
||||||
|
'Kiro CLI v2 agent config',
|
||||||
|
MAX_HOOK_BYTES,
|
||||||
|
);
|
||||||
|
return snapshot
|
||||||
|
? parseKiroCliV2Document(paths.v2Path, snapshot.contents)
|
||||||
|
: createKiroCliV2Document(paths.v2Path);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readV3Document(paths: KiroCliPaths): Promise<KiroIdeDocument> {
|
||||||
|
const snapshot = await readPhysicalFile(
|
||||||
|
paths.v3Path,
|
||||||
|
'Kiro CLI v3 global hooks',
|
||||||
|
MAX_HOOK_BYTES,
|
||||||
|
);
|
||||||
|
return snapshot
|
||||||
|
? parseKiroIdeDocument(paths.v3Path, snapshot.contents, 'Kiro CLI v3 global hooks')
|
||||||
|
: createKiroIdeDocument(paths.v3Path);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readKiroCliSources(): Promise<KiroCliSources> {
|
||||||
|
const paths = resolveKiroCliPaths();
|
||||||
|
await inspectKiroCliDirectories(paths);
|
||||||
|
const [v2, v3] = await Promise.all([readV2Document(paths), readV3Document(paths)]);
|
||||||
|
return { paths, v2, v3 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function kiroCliRuntimeFilesExist(
|
||||||
|
contract: KiroIdeRuntimeContract,
|
||||||
|
): Promise<boolean> {
|
||||||
|
return runtimeFilesExist(contract, AGENT_KEY);
|
||||||
|
}
|
||||||
+69
-331
@@ -1,359 +1,97 @@
|
|||||||
import fsp from 'node:fs/promises';
|
|
||||||
import os from 'node:os';
|
|
||||||
import path from 'node:path';
|
|
||||||
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
|
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
|
||||||
|
import {
|
||||||
|
AGENT_KEY,
|
||||||
|
buildKiroCliV2Hooks,
|
||||||
|
buildKiroCliV3Hooks,
|
||||||
|
commonKiroCliRuntimeContract,
|
||||||
|
renderKiroCliV2Installation,
|
||||||
|
renderKiroCliV2Uninstall,
|
||||||
|
renderKiroCliV3Uninstall,
|
||||||
|
requireAvailableKiroCliV2Document,
|
||||||
|
requireAvailableKiroCliV3Hooks,
|
||||||
|
withoutManagedKiroCliV2Hooks,
|
||||||
|
withoutManagedKiroCliV3Hooks,
|
||||||
|
} from './kirocli-contract.js';
|
||||||
|
import {
|
||||||
|
commitKiroCliInstallation,
|
||||||
|
commitKiroCliUninstall,
|
||||||
|
preflightKiroCliInstallation,
|
||||||
|
prepareKiroCliInstallation,
|
||||||
|
prepareKiroCliUninstall,
|
||||||
|
} from './kirocli-installation.js';
|
||||||
|
import { kiroCliRuntimeFilesExist, readKiroCliSources } from './kirocli-io.js';
|
||||||
|
import { renderKiroIdeDocument } from './kiroide-contract.js';
|
||||||
import { SUPPORTED_AGENTS } from './registry.js';
|
import { SUPPORTED_AGENTS } from './registry.js';
|
||||||
|
|
||||||
const AGENT_KEY = 'kirocli';
|
|
||||||
const V2_AGENT_NAME = 'elydora-audit';
|
|
||||||
const V2_DESCRIPTION = 'Kiro CLI with Elydora audit and freeze enforcement';
|
|
||||||
const V3_GUARD_NAME = 'elydora-guard';
|
|
||||||
const V3_AUDIT_NAME = 'elydora-audit';
|
|
||||||
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
|
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
|
||||||
|
|
||||||
type JsonObject = Record<string, unknown>;
|
function validateSources(
|
||||||
|
sources: Awaited<ReturnType<typeof readKiroCliSources>>,
|
||||||
interface HookContract {
|
): void {
|
||||||
readonly guard: string;
|
requireAvailableKiroCliV2Document(sources.v2);
|
||||||
readonly audit: string;
|
requireAvailableKiroCliV3Hooks(sources.v3.hooks);
|
||||||
readonly configPath: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isObject(value: unknown): value is JsonObject {
|
|
||||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveV2Path(): string {
|
|
||||||
return path.join(os.homedir(), '.kiro', 'agents', `${V2_AGENT_NAME}.json`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveV3Path(): string {
|
|
||||||
const configDir = entry.configDir.replace(/^~/, os.homedir());
|
|
||||||
return path.join(configDir, entry.configFile);
|
|
||||||
}
|
|
||||||
|
|
||||||
function quotePosix(value: string): string {
|
|
||||||
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function quoteWindows(value: string): string {
|
|
||||||
return `"${value.replaceAll('"', '\\"')}"`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildCommand(scriptPath: string): string {
|
|
||||||
const quote = process.platform === 'win32' ? quoteWindows : quotePosix;
|
|
||||||
return `${quote(process.execPath)} ${quote(scriptPath)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readJsonFile(configPath: string, label: string): Promise<JsonObject | undefined> {
|
|
||||||
let raw: string;
|
|
||||||
try {
|
|
||||||
raw = await fsp.readFile(configPath, 'utf-8');
|
|
||||||
} catch (error) {
|
|
||||||
if (isObject(error) && error.code === 'ENOENT') return undefined;
|
|
||||||
throw new Error(`Read ${label}: ${error instanceof Error ? error.message : String(error)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
let value: unknown;
|
|
||||||
try {
|
|
||||||
value = JSON.parse(raw);
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(`Failed to parse ${label}: ${error instanceof Error ? error.message : String(error)}`);
|
|
||||||
}
|
|
||||||
if (!isObject(value)) throw new Error(`${label} must contain a JSON object`);
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function writeJsonFile(configPath: string, value: JsonObject, label: string): Promise<void> {
|
|
||||||
await fsp.mkdir(path.dirname(configPath), { recursive: true });
|
|
||||||
const tempPath = `${configPath}.${process.pid}.${Date.now()}.tmp`;
|
|
||||||
try {
|
|
||||||
await fsp.writeFile(tempPath, JSON.stringify(value, null, 2) + '\n', {
|
|
||||||
encoding: 'utf-8',
|
|
||||||
mode: 0o600,
|
|
||||||
});
|
|
||||||
await fsp.rename(tempPath, configPath);
|
|
||||||
} catch (error) {
|
|
||||||
await fsp.unlink(tempPath).catch(() => undefined);
|
|
||||||
throw new Error(`Write ${label}: ${error instanceof Error ? error.message : String(error)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeFile(configPath: string, label: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
await fsp.unlink(configPath);
|
|
||||||
} catch (error) {
|
|
||||||
if (isObject(error) && error.code === 'ENOENT') return;
|
|
||||||
throw new Error(`Remove ${label}: ${error instanceof Error ? error.message : String(error)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function hooksObject(settings: JsonObject, label: string): JsonObject {
|
|
||||||
if (settings.hooks === undefined) return {};
|
|
||||||
if (!isObject(settings.hooks)) throw new Error(`${label} field "hooks" must be an object`);
|
|
||||||
return { ...settings.hooks };
|
|
||||||
}
|
|
||||||
|
|
||||||
function hookEntries(hooks: JsonObject, event: string, label: string): JsonObject[] {
|
|
||||||
const value = hooks[event];
|
|
||||||
if (value === undefined) return [];
|
|
||||||
if (!Array.isArray(value) || !value.every(isObject)) {
|
|
||||||
throw new Error(`${label} field "hooks.${event}" must be an array of objects`);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isManagedCommand(command: unknown, scriptName: string, agentId?: string): command is string {
|
|
||||||
if (typeof command !== 'string') return false;
|
|
||||||
const normalized = command.toLowerCase();
|
|
||||||
if (!normalized.includes('.elydora') || !normalized.includes(scriptName)) return false;
|
|
||||||
return !agentId || command.includes(agentId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function withoutV2Hooks(entries: JsonObject[], agentId?: string): JsonObject[] {
|
|
||||||
return entries.filter(
|
|
||||||
(hook) => !isManagedCommand(hook.command, 'guard.js', agentId)
|
|
||||||
&& !isManagedCommand(hook.command, 'hook.js', agentId),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildV2Hook(scriptPath: string): JsonObject {
|
|
||||||
return {
|
|
||||||
matcher: '*',
|
|
||||||
command: buildCommand(scriptPath),
|
|
||||||
timeout_ms: 5000,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function isOwnedV2Config(settings: JsonObject, hooks: JsonObject): boolean {
|
|
||||||
const configKeys = new Set(['name', 'description', 'tools', 'includeMcpJson', 'hooks']);
|
|
||||||
const hookKeys = new Set(['preToolUse', 'postToolUse']);
|
|
||||||
return Object.keys(settings).every((key) => configKeys.has(key))
|
|
||||||
&& settings.name === V2_AGENT_NAME
|
|
||||||
&& settings.description === V2_DESCRIPTION
|
|
||||||
&& Array.isArray(settings.tools)
|
|
||||||
&& settings.tools.length === 1
|
|
||||||
&& settings.tools[0] === '*'
|
|
||||||
&& settings.includeMcpJson === true
|
|
||||||
&& Object.keys(hooks).every((key) => hookKeys.has(key))
|
|
||||||
&& Object.values(hooks).every((value) => Array.isArray(value) && value.length === 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function v3Hooks(settings: JsonObject): JsonObject[] {
|
|
||||||
if (settings.version !== undefined && settings.version !== 'v1') {
|
|
||||||
throw new Error('Kiro CLI v3 hooks config field "version" must be "v1"');
|
|
||||||
}
|
|
||||||
if (settings.hooks === undefined) return [];
|
|
||||||
if (!Array.isArray(settings.hooks) || !settings.hooks.every(isObject)) {
|
|
||||||
throw new Error('Kiro CLI v3 hooks config field "hooks" must be an array of objects');
|
|
||||||
}
|
|
||||||
return settings.hooks;
|
|
||||||
}
|
|
||||||
|
|
||||||
function v3ActionCommand(hook: JsonObject): unknown {
|
|
||||||
return isObject(hook.action) ? hook.action.command : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isManagedV3Hook(hook: JsonObject, agentId?: string): boolean {
|
|
||||||
if (hook.name === V3_GUARD_NAME) {
|
|
||||||
return isManagedCommand(v3ActionCommand(hook), 'guard.js', agentId);
|
|
||||||
}
|
|
||||||
if (hook.name === V3_AUDIT_NAME) {
|
|
||||||
return isManagedCommand(v3ActionCommand(hook), 'hook.js', agentId);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildV3Hook(
|
|
||||||
name: string,
|
|
||||||
description: string,
|
|
||||||
trigger: 'PreToolUse' | 'PostToolUse',
|
|
||||||
scriptPath: string,
|
|
||||||
): JsonObject {
|
|
||||||
return {
|
|
||||||
name,
|
|
||||||
description,
|
|
||||||
trigger,
|
|
||||||
matcher: '.*',
|
|
||||||
action: { type: 'command', command: buildCommand(scriptPath) },
|
|
||||||
timeout: 5,
|
|
||||||
enabled: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function findV2Command(entries: JsonObject[], scriptName: string): string | undefined {
|
|
||||||
return entries.find((hook) => isManagedCommand(hook.command, scriptName))?.command as string | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function findV3Command(entries: JsonObject[], name: string, scriptName: string): string | undefined {
|
|
||||||
const hook = entries.find(
|
|
||||||
(candidate) => candidate.name === name && isManagedCommand(v3ActionCommand(candidate), scriptName),
|
|
||||||
);
|
|
||||||
return hook ? v3ActionCommand(hook) as string : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runtimeScriptsExist(contracts: HookContract[]): Promise<boolean> {
|
|
||||||
const root = path.join(os.homedir(), '.elydora');
|
|
||||||
let entries: Array<{ isDirectory(): boolean; name: string }>;
|
|
||||||
try {
|
|
||||||
entries = await fsp.readdir(root, { withFileTypes: true });
|
|
||||||
} catch (error) {
|
|
||||||
if (isObject(error) && error.code === 'ENOENT') return false;
|
|
||||||
throw new Error(`Read Elydora runtime directory: ${error instanceof Error ? error.message : String(error)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const directory of entries) {
|
|
||||||
if (!directory.isDirectory()) continue;
|
|
||||||
const agentDir = path.join(root, directory.name);
|
|
||||||
const guardPath = path.join(agentDir, 'guard.js');
|
|
||||||
const hookPath = path.join(agentDir, 'hook.js');
|
|
||||||
const referencesScripts = contracts.some(
|
|
||||||
({ guard, audit }) => guard.includes(guardPath) && audit.includes(hookPath),
|
|
||||||
);
|
|
||||||
if (!referencesScripts) continue;
|
|
||||||
|
|
||||||
const configPath = path.join(agentDir, 'config.json');
|
|
||||||
const config = await readJsonFile(configPath, `Elydora runtime config at ${configPath}`);
|
|
||||||
if (!config || config.agent_name !== AGENT_KEY) continue;
|
|
||||||
|
|
||||||
for (const [scriptPath, label] of [
|
|
||||||
[guardPath, 'Elydora guard runtime'],
|
|
||||||
[hookPath, 'Elydora audit runtime'],
|
|
||||||
] as const) {
|
|
||||||
try {
|
|
||||||
await fsp.access(scriptPath);
|
|
||||||
} catch (error) {
|
|
||||||
if (isObject(error) && error.code === 'ENOENT') return false;
|
|
||||||
throw new Error(`${label} at ${scriptPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const kirocliPlugin: AgentPlugin = {
|
export const kirocliPlugin: AgentPlugin = {
|
||||||
|
managesRuntime: true,
|
||||||
|
|
||||||
|
async preflightInstall(config: InstallConfig): Promise<void> {
|
||||||
|
const sources = await readKiroCliSources();
|
||||||
|
validateSources(sources);
|
||||||
|
await preflightKiroCliInstallation(config, sources);
|
||||||
|
},
|
||||||
|
|
||||||
async install(config: InstallConfig): Promise<void> {
|
async install(config: InstallConfig): Promise<void> {
|
||||||
const v2Path = resolveV2Path();
|
const sources = await readKiroCliSources();
|
||||||
const v3Path = resolveV3Path();
|
validateSources(sources);
|
||||||
const v2Settings = (await readJsonFile(v2Path, 'Kiro CLI v2 agent config')) ?? {};
|
const paths = await preflightKiroCliInstallation(config, sources);
|
||||||
const v3Settings = (await readJsonFile(v3Path, 'Kiro CLI v3 hooks config')) ?? {};
|
const v2 = renderKiroCliV2Installation(
|
||||||
const v2Hooks = hooksObject(v2Settings, 'Kiro CLI v2 agent config');
|
sources.v2,
|
||||||
const currentV3Hooks = v3Hooks(v3Settings);
|
buildKiroCliV2Hooks(sources.v2, paths.guardPath, paths.auditPath),
|
||||||
|
);
|
||||||
v2Hooks.preToolUse = [
|
const v3 = renderKiroIdeDocument(
|
||||||
...withoutV2Hooks(hookEntries(v2Hooks, 'preToolUse', 'Kiro CLI v2 agent config')),
|
sources.v3,
|
||||||
buildV2Hook(config.guardScriptPath),
|
buildKiroCliV3Hooks(sources.v3.hooks, paths.guardPath, paths.auditPath),
|
||||||
];
|
);
|
||||||
v2Hooks.postToolUse = [
|
const prepared = await prepareKiroCliInstallation(config, sources, v2, v3);
|
||||||
...withoutV2Hooks(hookEntries(v2Hooks, 'postToolUse', 'Kiro CLI v2 agent config')),
|
await commitKiroCliInstallation(prepared);
|
||||||
buildV2Hook(config.hookScriptPath),
|
console.log(` Kiro CLI v2 agent: ${sources.paths.v2Path}`);
|
||||||
];
|
console.log(' Kiro CLI v2 verification: run "kiro-cli agent validate --path <agent-file>".');
|
||||||
|
console.log(' Kiro CLI v2 activation: start with "kiro-cli --agent elydora-audit".');
|
||||||
const nextV2Settings: JsonObject = {
|
console.log(` Kiro CLI v3 global hooks (2.13.0+): ${sources.paths.v3Path}`);
|
||||||
name: V2_AGENT_NAME,
|
console.log(' Kiro CLI v3 activation: start the TUI with "kiro-cli --v3".');
|
||||||
description: V2_DESCRIPTION,
|
|
||||||
tools: ['*'],
|
|
||||||
includeMcpJson: true,
|
|
||||||
...v2Settings,
|
|
||||||
hooks: v2Hooks,
|
|
||||||
};
|
|
||||||
const nextV3Settings: JsonObject = {
|
|
||||||
...v3Settings,
|
|
||||||
version: 'v1',
|
|
||||||
hooks: [
|
|
||||||
...currentV3Hooks.filter((hook) => !isManagedV3Hook(hook)),
|
|
||||||
buildV3Hook(
|
|
||||||
V3_GUARD_NAME,
|
|
||||||
'Block tool use when the Elydora agent is frozen',
|
|
||||||
'PreToolUse',
|
|
||||||
config.guardScriptPath,
|
|
||||||
),
|
|
||||||
buildV3Hook(
|
|
||||||
V3_AUDIT_NAME,
|
|
||||||
'Record tool use in the Elydora audit trail',
|
|
||||||
'PostToolUse',
|
|
||||||
config.hookScriptPath,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
await writeJsonFile(v2Path, nextV2Settings, 'Kiro CLI v2 agent config');
|
|
||||||
await writeJsonFile(v3Path, nextV3Settings, 'Kiro CLI v3 hooks config');
|
|
||||||
console.log(' Kiro CLI v2: start with "kiro-cli --agent elydora-audit".');
|
|
||||||
console.log(' Kiro CLI v3: start with "kiro-cli --v3"; global hooks load automatically.');
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async uninstall(agentId?: string): Promise<void> {
|
async uninstall(agentId?: string): Promise<void> {
|
||||||
const v2Path = resolveV2Path();
|
const sources = await readKiroCliSources();
|
||||||
const v3Path = resolveV3Path();
|
const v2 = renderKiroCliV2Uninstall(
|
||||||
const v2Settings = await readJsonFile(v2Path, 'Kiro CLI v2 agent config');
|
sources.v2,
|
||||||
const v3Settings = await readJsonFile(v3Path, 'Kiro CLI v3 hooks config');
|
withoutManagedKiroCliV2Hooks(sources.v2, agentId),
|
||||||
|
|
||||||
if (v2Settings) {
|
|
||||||
const hooks = hooksObject(v2Settings, 'Kiro CLI v2 agent config');
|
|
||||||
hooks.preToolUse = withoutV2Hooks(
|
|
||||||
hookEntries(hooks, 'preToolUse', 'Kiro CLI v2 agent config'),
|
|
||||||
agentId,
|
agentId,
|
||||||
);
|
);
|
||||||
hooks.postToolUse = withoutV2Hooks(
|
const v3 = renderKiroCliV3Uninstall(
|
||||||
hookEntries(hooks, 'postToolUse', 'Kiro CLI v2 agent config'),
|
sources.v3,
|
||||||
|
withoutManagedKiroCliV3Hooks(sources.v3.hooks, agentId),
|
||||||
agentId,
|
agentId,
|
||||||
);
|
);
|
||||||
if (isOwnedV2Config(v2Settings, hooks)) {
|
const prepared = await prepareKiroCliUninstall(sources, v2, v3);
|
||||||
await removeFile(v2Path, 'Kiro CLI v2 agent config');
|
await commitKiroCliUninstall(prepared);
|
||||||
} else {
|
|
||||||
await writeJsonFile(v2Path, { ...v2Settings, hooks }, 'Kiro CLI v2 agent config');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (v3Settings) {
|
|
||||||
const hooks = v3Hooks(v3Settings).filter((hook) => !isManagedV3Hook(hook, agentId));
|
|
||||||
const owned = Object.keys(v3Settings).every((key) => key === 'version' || key === 'hooks');
|
|
||||||
if (owned && hooks.length === 0) {
|
|
||||||
await removeFile(v3Path, 'Kiro CLI v3 hooks config');
|
|
||||||
} else {
|
|
||||||
await writeJsonFile(v3Path, { ...v3Settings, hooks }, 'Kiro CLI v3 hooks config');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async status(): Promise<PluginStatus> {
|
async status(): Promise<PluginStatus> {
|
||||||
const v2Path = resolveV2Path();
|
const sources = await readKiroCliSources();
|
||||||
const v3Path = resolveV3Path();
|
validateSources(sources);
|
||||||
const v2Settings = await readJsonFile(v2Path, 'Kiro CLI v2 agent config');
|
const contract = commonKiroCliRuntimeContract(sources.v2, sources.v3.hooks);
|
||||||
const v3Settings = await readJsonFile(v3Path, 'Kiro CLI v3 hooks config');
|
const hookConfigured = contract !== undefined;
|
||||||
const contracts: HookContract[] = [];
|
const hookScriptExists = contract !== undefined
|
||||||
|
&& await kiroCliRuntimeFilesExist(contract);
|
||||||
if (v2Settings) {
|
|
||||||
const hooks = hooksObject(v2Settings, 'Kiro CLI v2 agent config');
|
|
||||||
const guard = findV2Command(
|
|
||||||
hookEntries(hooks, 'preToolUse', 'Kiro CLI v2 agent config'),
|
|
||||||
'guard.js',
|
|
||||||
);
|
|
||||||
const audit = findV2Command(
|
|
||||||
hookEntries(hooks, 'postToolUse', 'Kiro CLI v2 agent config'),
|
|
||||||
'hook.js',
|
|
||||||
);
|
|
||||||
if (guard && audit) contracts.push({ guard, audit, configPath: v2Path });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (v3Settings) {
|
|
||||||
const hooks = v3Hooks(v3Settings);
|
|
||||||
const guard = findV3Command(hooks, V3_GUARD_NAME, 'guard.js');
|
|
||||||
const audit = findV3Command(hooks, V3_AUDIT_NAME, 'hook.js');
|
|
||||||
if (guard && audit) contracts.push({ guard, audit, configPath: v3Path });
|
|
||||||
}
|
|
||||||
|
|
||||||
const hookConfigured = contracts.length > 0;
|
|
||||||
const hookScriptExists = hookConfigured ? await runtimeScriptsExist(contracts) : false;
|
|
||||||
return {
|
return {
|
||||||
installed: hookConfigured && hookScriptExists,
|
installed: hookConfigured && hookScriptExists,
|
||||||
agentName: AGENT_KEY,
|
agentName: AGENT_KEY,
|
||||||
displayName: entry.name,
|
displayName: entry.name,
|
||||||
hookConfigured,
|
hookConfigured,
|
||||||
hookScriptExists,
|
hookScriptExists,
|
||||||
configPath: contracts.at(-1)?.configPath ?? v3Path,
|
configPath: sources.paths.v3Path,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -110,8 +110,8 @@ function validateAction(value: unknown, label: string): KiroIdeAction {
|
|||||||
throw new Error(`${label} action has unsupported type "${String(value.type)}"`);
|
throw new Error(`${label} action has unsupported type "${String(value.type)}"`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateHook(value: unknown, index: number): KiroIdeHook {
|
function validateHook(value: unknown, index: number, documentLabel: string): KiroIdeHook {
|
||||||
const label = `Kiro IDE hook hooks[${index}]`;
|
const label = `${documentLabel} hooks[${index}]`;
|
||||||
if (!isObject(value)) throw new Error(`${label} must be an object`);
|
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.name !== 'string' || !value.name) throw new Error(`${label} requires a name`);
|
||||||
if (typeof value.trigger !== 'string' || !KIRO_TRIGGERS.has(value.trigger)) {
|
if (typeof value.trigger !== 'string' || !KIRO_TRIGGERS.has(value.trigger)) {
|
||||||
@@ -133,15 +133,19 @@ function validateHook(value: unknown, index: number): KiroIdeHook {
|
|||||||
return { ...value, action: validateAction(value.action, label) } as KiroIdeHook;
|
return { ...value, action: validateAction(value.action, label) } as KiroIdeHook;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseKiroIdeDocument(filePath: string, raw: string): KiroIdeDocument {
|
export function parseKiroIdeDocument(
|
||||||
const root = parseStrictJsonObject(raw, `Kiro IDE hooks at ${filePath}`);
|
filePath: string,
|
||||||
if (root.version !== 'v1') throw new Error(`Kiro IDE hooks version must be "v1": ${filePath}`);
|
raw: string,
|
||||||
if (!Array.isArray(root.hooks)) throw new Error(`Kiro IDE hooks field "hooks" must be an array: ${filePath}`);
|
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 {
|
return {
|
||||||
exists: true,
|
exists: true,
|
||||||
filePath,
|
filePath,
|
||||||
root,
|
root,
|
||||||
hooks: root.hooks.map((hook, index) => validateHook(hook, index)),
|
hooks: root.hooks.map((hook, index) => validateHook(hook, index, documentLabel)),
|
||||||
raw,
|
raw,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -191,10 +195,13 @@ function ownedReference(hook: KiroIdeHook) {
|
|||||||
return kiroIdeRuntimeReference(hook.action.command, specification.scriptName);
|
return kiroIdeRuntimeReference(hook.action.command, specification.scriptName);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function requireAvailableKiroIdeHooks(hooks: readonly KiroIdeHook[]): void {
|
export function requireAvailableKiroIdeHooks(
|
||||||
|
hooks: readonly KiroIdeHook[],
|
||||||
|
productLabel = 'Kiro IDE',
|
||||||
|
): void {
|
||||||
for (const hook of hooks) {
|
for (const hook of hooks) {
|
||||||
if (managedSpecification(hook.name) && !ownedReference(hook)) {
|
if (managedSpecification(hook.name) && !ownedReference(hook)) {
|
||||||
throw new Error(`Kiro IDE hook name "${hook.name}" conflicts with the Elydora contract`);
|
throw new Error(`${productLabel} hook name "${hook.name}" conflicts with the Elydora contract`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -237,6 +244,11 @@ export function renderKiroIdeDocument(
|
|||||||
hooks: readonly KiroIdeHook[],
|
hooks: readonly KiroIdeHook[],
|
||||||
): RenderedKiroIdeDocument {
|
): RenderedKiroIdeDocument {
|
||||||
if (!document.exists && hooks.length === 0) return { document, changed: false };
|
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 };
|
if (hooks.length === 0 && entirelyManaged(document)) return { document, changed: true };
|
||||||
const next = `${JSON.stringify({ ...document.root, version: 'v1', hooks }, null, 2)}\n`;
|
const next = `${JSON.stringify({ ...document.root, version: 'v1', hooks }, null, 2)}\n`;
|
||||||
return { document, changed: next !== document.raw, next };
|
return { document, changed: next !== document.raw, next };
|
||||||
@@ -248,8 +260,9 @@ export function kiroIdeRuntimeContracts(
|
|||||||
const guards = new Map<string, KiroIdeRuntimeReference[]>();
|
const guards = new Map<string, KiroIdeRuntimeReference[]>();
|
||||||
const audits = new Map<string, KiroIdeRuntimeReference[]>();
|
const audits = new Map<string, KiroIdeRuntimeReference[]>();
|
||||||
for (const hook of hooks) {
|
for (const hook of hooks) {
|
||||||
|
if (!managedSpecification(hook.name)) continue;
|
||||||
const reference = managedReference(hook);
|
const reference = managedReference(hook);
|
||||||
if (!reference) continue;
|
if (!reference) return [];
|
||||||
const key = process.platform === 'win32' ? reference.agentId.toLowerCase() : reference.agentId;
|
const key = process.platform === 'win32' ? reference.agentId.toLowerCase() : reference.agentId;
|
||||||
const target = hook.name === GUARD_NAME ? guards : audits;
|
const target = hook.name === GUARD_NAME ? guards : audits;
|
||||||
const entries = target.get(key) ?? [];
|
const entries = target.get(key) ?? [];
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ function validateRuntimeConfig(
|
|||||||
config: JsonObject,
|
config: JsonObject,
|
||||||
contract: KiroIdeRuntimeContract,
|
contract: KiroIdeRuntimeContract,
|
||||||
configPath: string,
|
configPath: string,
|
||||||
|
agentKey: string,
|
||||||
): void {
|
): void {
|
||||||
const supported = new Set(['org_id', 'agent_id', 'kid', 'base_url', 'token', 'agent_name']);
|
const supported = new Set(['org_id', 'agent_id', 'kid', 'base_url', 'token', 'agent_name']);
|
||||||
const extra = Object.keys(config).find((key) => !supported.has(key));
|
const extra = Object.keys(config).find((key) => !supported.has(key));
|
||||||
@@ -102,8 +103,8 @@ function validateRuntimeConfig(
|
|||||||
requireString(config.org_id, 'org_id', configPath);
|
requireString(config.org_id, 'org_id', configPath);
|
||||||
requireString(config.kid, 'kid', configPath);
|
requireString(config.kid, 'kid', configPath);
|
||||||
const agentId = requireString(config.agent_id, 'agent_id', configPath);
|
const agentId = requireString(config.agent_id, 'agent_id', configPath);
|
||||||
if (!sameKiroIdeAgentId(agentId, contract.agentId) || config.agent_name !== AGENT_KEY) {
|
if (!sameKiroIdeAgentId(agentId, contract.agentId) || config.agent_name !== agentKey) {
|
||||||
throw new Error(`Elydora runtime identity does not match Kiro IDE hooks: ${configPath}`);
|
throw new Error(`Elydora runtime identity does not match ${agentKey} hooks: ${configPath}`);
|
||||||
}
|
}
|
||||||
if (config.token !== undefined) requireString(config.token, 'token', configPath);
|
if (config.token !== undefined) requireString(config.token, 'token', configPath);
|
||||||
const rawBaseUrl = requireString(config.base_url, 'base_url', configPath);
|
const rawBaseUrl = requireString(config.base_url, 'base_url', configPath);
|
||||||
@@ -139,7 +140,10 @@ function validContractPaths(contract: KiroIdeRuntimeContract): boolean {
|
|||||||
&& sameKiroIdePath(contract.auditPath, path.join(agentDirectory, AUDIT_SCRIPT));
|
&& sameKiroIdePath(contract.auditPath, path.join(agentDirectory, AUDIT_SCRIPT));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runtimeFilesExist(contract: KiroIdeRuntimeContract): Promise<boolean> {
|
export async function runtimeFilesExist(
|
||||||
|
contract: KiroIdeRuntimeContract,
|
||||||
|
agentKey = AGENT_KEY,
|
||||||
|
): Promise<boolean> {
|
||||||
if (!validContractPaths(contract)) return false;
|
if (!validContractPaths(contract)) return false;
|
||||||
const runtimeRoot = path.join(os.homedir(), '.elydora');
|
const runtimeRoot = path.join(os.homedir(), '.elydora');
|
||||||
const agentDirectory = path.dirname(contract.guardPath);
|
const agentDirectory = path.dirname(contract.guardPath);
|
||||||
@@ -158,8 +162,9 @@ export async function runtimeFilesExist(contract: KiroIdeRuntimeContract): Promi
|
|||||||
parseStrictJsonObject(config.contents, `Elydora runtime config at ${configPath}`),
|
parseStrictJsonObject(config.contents, `Elydora runtime config at ${configPath}`),
|
||||||
contract,
|
contract,
|
||||||
configPath,
|
configPath,
|
||||||
|
agentKey,
|
||||||
);
|
);
|
||||||
validatePrivateKey(key.contents, keyPath);
|
validatePrivateKey(key.contents, keyPath);
|
||||||
return guard.contents === generateGuardScript(AGENT_KEY, contract.agentId)
|
return guard.contents === generateGuardScript(agentKey, contract.agentId)
|
||||||
&& audit.contents === generateHookScript(AGENT_KEY, contract.agentId, { nativePayload: true });
|
&& audit.contents === generateHookScript(agentKey, contract.agentId, { nativePayload: true });
|
||||||
}
|
}
|
||||||
@@ -60,7 +60,7 @@ export const kiroidePlugin: AgentPlugin = {
|
|||||||
const contracts = kiroIdeRuntimeContracts(sources.document.hooks);
|
const contracts = kiroIdeRuntimeContracts(sources.document.hooks);
|
||||||
const hookConfigured = contracts.length > 0;
|
const hookConfigured = contracts.length > 0;
|
||||||
const hookScriptExists = hookConfigured
|
const hookScriptExists = hookConfigured
|
||||||
&& (await Promise.all(contracts.map(runtimeFilesExist))).every(Boolean);
|
&& (await Promise.all(contracts.map((contract) => runtimeFilesExist(contract)))).every(Boolean);
|
||||||
return {
|
return {
|
||||||
installed: hookConfigured && hookScriptExists,
|
installed: hookConfigured && hookScriptExists,
|
||||||
agentName: AGENT_KEY,
|
agentName: AGENT_KEY,
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
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, 17).toString('base64url');
|
||||||
|
export const pluginModuleUrl = pathToFileURL(path.resolve('dist/plugins/kirocli.js')).href;
|
||||||
|
export const contractModuleUrl = pathToFileURL(
|
||||||
|
path.resolve('dist/plugins/kirocli-contract.js'),
|
||||||
|
).href;
|
||||||
|
export const installationModuleUrl = pathToFileURL(
|
||||||
|
path.resolve('dist/plugins/kirocli-installation.js'),
|
||||||
|
).href;
|
||||||
|
export const ioModuleUrl = pathToFileURL(path.resolve('dist/plugins/kirocli-io.js')).href;
|
||||||
|
export const kiroV1ModuleUrl = pathToFileURL(
|
||||||
|
path.resolve('dist/plugins/kiroide-contract.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: 'kirocli',
|
||||||
|
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 { kirocliPlugin } from ${JSON.stringify(pluginModuleUrl)};
|
||||||
|
const argument = JSON.parse(process.env.ELYDORA_TEST_ARGUMENT);
|
||||||
|
const result = await kirocliPlugin[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 function managedV2Document(overrides = {}) {
|
||||||
|
return {
|
||||||
|
name: 'elydora-audit',
|
||||||
|
description: 'Kiro CLI with Elydora audit and freeze enforcement',
|
||||||
|
tools: ['*'],
|
||||||
|
includeMcpJson: true,
|
||||||
|
hooks: {},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createFixture({
|
||||||
|
agentId = 'agent-1',
|
||||||
|
baseUrl = 'http://127.0.0.1:9',
|
||||||
|
existingV2,
|
||||||
|
existingV3,
|
||||||
|
} = {}) {
|
||||||
|
const rootDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-kirocli-'));
|
||||||
|
const homeDir = path.join(rootDir, "home with spaces and 'quote %KIRO%");
|
||||||
|
const projectDir = path.join(rootDir, 'workspace with spaces');
|
||||||
|
const v2Path = path.join(homeDir, '.kiro', 'agents', 'elydora-audit.json');
|
||||||
|
const v3Path = path.join(homeDir, '.kiro', 'hooks', 'elydora-audit.json');
|
||||||
|
const agentDir = path.join(homeDir, '.elydora', agentId);
|
||||||
|
const guardScriptPath = path.join(agentDir, 'guard.js');
|
||||||
|
const hookScriptPath = path.join(agentDir, 'hook.js');
|
||||||
|
await mkdir(homeDir, { recursive: true });
|
||||||
|
await mkdir(projectDir, { recursive: true });
|
||||||
|
await writeOptional(v2Path, existingV2);
|
||||||
|
await writeOptional(v3Path, existingV3);
|
||||||
|
return {
|
||||||
|
agentDir,
|
||||||
|
agentId,
|
||||||
|
baseUrl,
|
||||||
|
guardScriptPath,
|
||||||
|
homeDir,
|
||||||
|
hookScriptPath,
|
||||||
|
projectDir,
|
||||||
|
rootDir,
|
||||||
|
v2Path,
|
||||||
|
v3Path,
|
||||||
|
install(overrides = {}) {
|
||||||
|
return runPlugin(this, 'install', installConfig(this, overrides));
|
||||||
|
},
|
||||||
|
async close() {
|
||||||
|
await rm(rootDir, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readJson(filePath) {
|
||||||
|
return JSON.parse(await readFile(filePath, 'utf-8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findV3Hook(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());
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
createFixture,
|
||||||
|
runProcess,
|
||||||
|
} from '../test-support/kirocli-test-helpers.mjs';
|
||||||
|
|
||||||
|
test('official Kiro CLI 2.13 validates the installed v2 agent contract', {
|
||||||
|
skip: process.env.ELYDORA_KIRO_CLI_BINARY
|
||||||
|
? false
|
||||||
|
: 'set ELYDORA_KIRO_CLI_BINARY to the official Kiro CLI executable',
|
||||||
|
}, async () => {
|
||||||
|
const fixture = await createFixture();
|
||||||
|
try {
|
||||||
|
const install = await fixture.install();
|
||||||
|
assert.equal(install.code, 0, install.stderr);
|
||||||
|
const binary = process.env.ELYDORA_KIRO_CLI_BINARY;
|
||||||
|
const version = await runProcess(binary, ['--version'], {}, fixture.projectDir);
|
||||||
|
assert.equal(version.code, 0, version.stderr);
|
||||||
|
assert.match(version.stdout, /\b2\.13\.\d+\b/);
|
||||||
|
const validation = await runProcess(
|
||||||
|
binary,
|
||||||
|
['agent', 'validate', '--path', fixture.v2Path],
|
||||||
|
{},
|
||||||
|
fixture.projectDir,
|
||||||
|
);
|
||||||
|
assert.equal(validation.code, 0, validation.stderr);
|
||||||
|
} finally {
|
||||||
|
await fixture.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
+369
-235
@@ -1,314 +1,448 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { spawn } from 'node:child_process';
|
import {
|
||||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
lstat,
|
||||||
import os from 'node:os';
|
mkdir,
|
||||||
|
readFile,
|
||||||
|
readdir,
|
||||||
|
rm,
|
||||||
|
stat,
|
||||||
|
symlink,
|
||||||
|
writeFile,
|
||||||
|
} from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { pathToFileURL } from 'node:url';
|
import {
|
||||||
|
VALID_PRIVATE_KEY,
|
||||||
|
cliPath,
|
||||||
|
createFixture,
|
||||||
|
environment,
|
||||||
|
findV3Hook,
|
||||||
|
managedV2Document,
|
||||||
|
readJson,
|
||||||
|
registryModuleUrl,
|
||||||
|
runNode,
|
||||||
|
runPlugin,
|
||||||
|
runShell,
|
||||||
|
startApiServer,
|
||||||
|
} from '../test-support/kirocli-test-helpers.mjs';
|
||||||
|
|
||||||
const pluginModuleUrl = pathToFileURL(path.resolve('dist/plugins/kirocli.js')).href;
|
async function assertMissing(filePath) {
|
||||||
const registryModuleUrl = pathToFileURL(path.resolve('dist/plugins/registry.js')).href;
|
await assert.rejects(lstat(filePath), { code: 'ENOENT' });
|
||||||
|
|
||||||
function runNode(args, env, input = '') {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const child = spawn(process.execPath, args, {
|
|
||||||
env: { ...process.env, ...env },
|
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
|
||||||
});
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function runCommand(command, input = '') {
|
async function writeJson(filePath, value) {
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const child = spawn(command, { shell: true, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runPlugin(homeDir, method, argument) {
|
|
||||||
const script = `
|
|
||||||
import { kirocliPlugin } from ${JSON.stringify(pluginModuleUrl)};
|
|
||||||
const argument = JSON.parse(process.env.ELYDORA_TEST_ARGUMENT);
|
|
||||||
const result = await kirocliPlugin[process.env.ELYDORA_TEST_METHOD](argument);
|
|
||||||
if (result !== undefined) console.log(JSON.stringify(result));
|
|
||||||
`;
|
|
||||||
return runNode(
|
|
||||||
['--input-type=module', '--eval', script],
|
|
||||||
{
|
|
||||||
HOME: homeDir,
|
|
||||||
USERPROFILE: homeDir,
|
|
||||||
ELYDORA_TEST_ARGUMENT: JSON.stringify(argument),
|
|
||||||
ELYDORA_TEST_METHOD: method,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function writeJsonOrText(filePath, value) {
|
|
||||||
await mkdir(path.dirname(filePath), { recursive: true });
|
await mkdir(path.dirname(filePath), { recursive: true });
|
||||||
await writeFile(filePath, typeof value === 'string' ? value : JSON.stringify(value, null, 2));
|
await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createFixture({ existingV2, existingV3, guardSource, hookSource } = {}) {
|
function userV3Hook() {
|
||||||
const homeDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-kirocli-'));
|
|
||||||
const agentDir = path.join(homeDir, '.elydora', 'agent-1');
|
|
||||||
const v2Path = path.join(homeDir, '.kiro', 'agents', 'elydora-audit.json');
|
|
||||||
const v3Path = path.join(homeDir, '.kiro', 'hooks', 'elydora-audit.json');
|
|
||||||
await mkdir(agentDir, { recursive: true });
|
|
||||||
|
|
||||||
const guardScriptPath = path.join(agentDir, 'guard.js');
|
|
||||||
const hookScriptPath = path.join(agentDir, 'hook.js');
|
|
||||||
const runtimeConfigPath = path.join(agentDir, 'config.json');
|
|
||||||
await writeFile(
|
|
||||||
guardScriptPath,
|
|
||||||
guardSource ?? "process.stderr.write('Agent is frozen by Elydora.'); process.exit(2);\n",
|
|
||||||
);
|
|
||||||
await writeFile(hookScriptPath, hookSource ?? 'process.exit(0);\n');
|
|
||||||
await writeFile(runtimeConfigPath, JSON.stringify({
|
|
||||||
agent_id: 'agent-1',
|
|
||||||
agent_name: 'kirocli',
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (existingV2 !== undefined) await writeJsonOrText(v2Path, existingV2);
|
|
||||||
if (existingV3 !== undefined) await writeJsonOrText(v3Path, existingV3);
|
|
||||||
|
|
||||||
const config = {
|
|
||||||
agentName: 'kirocli',
|
|
||||||
agentId: 'agent-1',
|
|
||||||
baseUrl: 'https://api.elydora.com',
|
|
||||||
guardScriptPath,
|
|
||||||
hookScriptPath,
|
|
||||||
};
|
|
||||||
const installResult = await runPlugin(homeDir, 'install', config);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
config,
|
name: 'global-context',
|
||||||
guardScriptPath,
|
trigger: 'SessionStart',
|
||||||
homeDir,
|
action: { type: 'agent', prompt: 'Read AGENTS.md' },
|
||||||
hookScriptPath,
|
enabled: true,
|
||||||
installResult,
|
};
|
||||||
runtimeConfigPath,
|
}
|
||||||
v2Path,
|
|
||||||
v3Path,
|
function legacyCommand(scriptPath) {
|
||||||
async close() {
|
if (process.platform === 'win32') return `"${process.execPath}" "${scriptPath}"`;
|
||||||
await rm(homeDir, { recursive: true, force: true });
|
const quote = (value) => `'${value.replaceAll("'", `'"'"'`)}'`;
|
||||||
|
return `${quote(process.execPath)} ${quote(scriptPath)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function legacyDocuments(fixture) {
|
||||||
|
const guard = legacyCommand(fixture.guardScriptPath);
|
||||||
|
const audit = legacyCommand(fixture.hookScriptPath);
|
||||||
|
return {
|
||||||
|
v2: managedV2Document({
|
||||||
|
hooks: {
|
||||||
|
preToolUse: [{ matcher: '*', command: guard, timeout_ms: 5000 }],
|
||||||
|
postToolUse: [{ matcher: '*', command: audit, timeout_ms: 5000 }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
v3: {
|
||||||
|
version: 'v1',
|
||||||
|
hooks: [
|
||||||
|
{
|
||||||
|
name: 'elydora-guard',
|
||||||
|
description: 'Block tool use when the Elydora agent is frozen',
|
||||||
|
trigger: 'PreToolUse',
|
||||||
|
matcher: '.*',
|
||||||
|
action: { type: 'command', command: guard },
|
||||||
|
timeout: 5,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'elydora-audit',
|
||||||
|
description: 'Record tool use in the Elydora audit trail',
|
||||||
|
trigger: 'PostToolUse',
|
||||||
|
matcher: '.*',
|
||||||
|
action: { type: 'command', command: audit },
|
||||||
|
timeout: 5,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function findV3Hook(config, name) {
|
test('Kiro CLI installs exact v2 and 2.13 v3 global contracts atomically', async () => {
|
||||||
return config.hooks.find((hook) => hook.name === name);
|
|
||||||
}
|
|
||||||
|
|
||||||
test('Kiro CLI registry points at the v3 global hook contract', async () => {
|
|
||||||
const { SUPPORTED_AGENTS } = await import(registryModuleUrl);
|
const { SUPPORTED_AGENTS } = await import(registryModuleUrl);
|
||||||
assert.deepEqual(SUPPORTED_AGENTS.get('kirocli'), {
|
assert.deepEqual(SUPPORTED_AGENTS.get('kirocli'), {
|
||||||
name: 'Kiro CLI',
|
name: 'Kiro CLI',
|
||||||
configDir: '~/.kiro/hooks',
|
configDir: '~/.kiro/hooks',
|
||||||
configFile: 'elydora-audit.json',
|
configFile: 'elydora-audit.json',
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
test('Kiro CLI install preserves user hooks and writes idempotent v2 and v3 contracts', async () => {
|
|
||||||
const fixture = await createFixture({
|
const fixture = await createFixture({
|
||||||
existingV2: {
|
existingV2: managedV2Document({
|
||||||
description: 'User Kiro agent',
|
owner: 'user',
|
||||||
tools: ['read'],
|
|
||||||
hooks: {
|
hooks: {
|
||||||
agentSpawn: [{ command: 'existing-spawn' }],
|
agentSpawn: [{ command: 'existing-spawn' }],
|
||||||
preToolUse: [{ matcher: 'read', command: 'existing-v2' }],
|
preToolUse: [{ matcher: 'read', command: 'existing-v2' }],
|
||||||
},
|
},
|
||||||
},
|
}),
|
||||||
existingV3: {
|
existingV3: { version: 'v1', owner: 'user', hooks: [userV3Hook()] },
|
||||||
version: 'v1',
|
|
||||||
hooks: [{
|
|
||||||
name: 'existing-v3',
|
|
||||||
trigger: 'SessionStart',
|
|
||||||
action: { type: 'command', command: 'existing-command' },
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr);
|
const first = await fixture.install();
|
||||||
assert.match(fixture.installResult.stdout, /--agent elydora-audit/);
|
assert.equal(first.code, 0, first.stderr);
|
||||||
assert.match(fixture.installResult.stdout, /--v3/);
|
assert.match(first.stdout, /2\.13\.0\+/);
|
||||||
const secondInstall = await runPlugin(fixture.homeDir, 'install', fixture.config);
|
assert.match(first.stdout, /agent validate/);
|
||||||
assert.equal(secondInstall.code, 0, secondInstall.stderr);
|
assert.match(first.stdout, /--agent elydora-audit/);
|
||||||
|
const firstV2Source = await readFile(fixture.v2Path, 'utf-8');
|
||||||
const v2 = JSON.parse(await readFile(fixture.v2Path, 'utf-8'));
|
const firstV3Source = await readFile(fixture.v3Path, 'utf-8');
|
||||||
assert.equal(v2.description, 'User Kiro agent');
|
const v2 = JSON.parse(firstV2Source);
|
||||||
assert.deepEqual(v2.tools, ['read']);
|
assert.equal(v2.owner, 'user');
|
||||||
assert.equal(v2.hooks.agentSpawn[0].command, 'existing-spawn');
|
assert.deepEqual(v2.hooks.agentSpawn, [{ command: 'existing-spawn' }]);
|
||||||
assert.equal(v2.hooks.preToolUse.length, 2);
|
assert.equal(v2.hooks.preToolUse.length, 2);
|
||||||
assert.equal(v2.hooks.postToolUse.length, 1);
|
assert.deepEqual(v2.hooks.preToolUse[1], {
|
||||||
assert.equal(v2.hooks.preToolUse[1].matcher, '*');
|
matcher: '*',
|
||||||
assert.equal(typeof v2.hooks.preToolUse[1].command, 'string');
|
command: v2.hooks.preToolUse[1].command,
|
||||||
|
timeout_ms: 10_000,
|
||||||
const v3 = JSON.parse(await readFile(fixture.v3Path, 'utf-8'));
|
});
|
||||||
assert.equal(v3.version, 'v1');
|
assert.deepEqual(v2.hooks.postToolUse[0], {
|
||||||
assert.equal(v3.hooks.length, 3);
|
matcher: '*',
|
||||||
assert.equal(v3.hooks[0].name, 'existing-v3');
|
command: v2.hooks.postToolUse[0].command,
|
||||||
|
timeout_ms: 10_000,
|
||||||
|
});
|
||||||
|
const v3 = JSON.parse(firstV3Source);
|
||||||
|
assert.equal(v3.owner, 'user');
|
||||||
|
assert.deepEqual(v3.hooks[0], userV3Hook());
|
||||||
assert.deepEqual(findV3Hook(v3, 'elydora-guard'), {
|
assert.deepEqual(findV3Hook(v3, 'elydora-guard'), {
|
||||||
name: 'elydora-guard',
|
name: 'elydora-guard',
|
||||||
description: 'Block tool use when the Elydora agent is frozen',
|
description: 'Block tool use when the Elydora agent is frozen',
|
||||||
trigger: 'PreToolUse',
|
trigger: 'PreToolUse',
|
||||||
matcher: '.*',
|
matcher: '.*',
|
||||||
action: { type: 'command', command: findV3Hook(v3, 'elydora-guard').action.command },
|
action: { type: 'command', command: findV3Hook(v3, 'elydora-guard').action.command },
|
||||||
timeout: 5,
|
timeout: 10,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
});
|
});
|
||||||
assert.equal(findV3Hook(v3, 'elydora-audit').trigger, 'PostToolUse');
|
assert.equal(findV3Hook(v3, 'elydora-audit').trigger, 'PostToolUse');
|
||||||
|
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: 'kirocli',
|
||||||
|
});
|
||||||
|
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.v2Path, 'utf-8'), firstV2Source);
|
||||||
|
assert.equal(await readFile(fixture.v3Path, 'utf-8'), firstV3Source);
|
||||||
|
const names = [
|
||||||
|
...await readdir(path.dirname(fixture.v2Path)),
|
||||||
|
...await readdir(path.dirname(fixture.v3Path)),
|
||||||
|
...await readdir(fixture.agentDir),
|
||||||
|
];
|
||||||
|
assert.equal(names.some((name) => /\.(tmp|rollback)$/.test(name)), false);
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
assert.equal((await stat(fixture.v2Path)).mode & 0o777, 0o600);
|
||||||
|
assert.equal((await stat(fixture.guardScriptPath)).mode & 0o777, 0o700);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
await fixture.close();
|
await fixture.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Kiro CLI hook commands enforce freezes and forward the official event payload', async () => {
|
test('Kiro CLI commands block frozen agents and preserve native events', async () => {
|
||||||
const capturePath = path.join(os.tmpdir(), `elydora-kiro-event-${process.pid}-${Date.now()}.json`);
|
const api = await startApiServer();
|
||||||
const fixture = await createFixture({
|
const fixture = await createFixture({ baseUrl: api.baseUrl });
|
||||||
hookSource: `
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const chunks = [];
|
|
||||||
process.stdin.on('data', (chunk) => chunks.push(chunk));
|
|
||||||
process.stdin.on('end', () => fs.writeFileSync(${JSON.stringify(capturePath)}, Buffer.concat(chunks)));
|
|
||||||
`,
|
|
||||||
});
|
|
||||||
try {
|
try {
|
||||||
assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr);
|
assert.equal((await fixture.install()).code, 0);
|
||||||
const v3 = JSON.parse(await readFile(fixture.v3Path, 'utf-8'));
|
const v2 = await readJson(fixture.v2Path);
|
||||||
|
const v3 = await readJson(fixture.v3Path);
|
||||||
|
assert.equal(v2.hooks.preToolUse[0].command, findV3Hook(v3, 'elydora-guard').action.command);
|
||||||
const payload = {
|
const payload = {
|
||||||
hook_event_name: 'PreToolUse',
|
hook_event_name: 'preToolUse',
|
||||||
cwd: fixture.homeDir,
|
cwd: fixture.projectDir,
|
||||||
session_id: 'session-1',
|
session_id: 'session-1',
|
||||||
tool_name: 'execute_bash',
|
tool_name: 'execute_bash',
|
||||||
tool_input: { command: 'echo test' },
|
tool_input: { command: 'npm test' },
|
||||||
|
future_field: { retained: true },
|
||||||
};
|
};
|
||||||
const guardResult = await runCommand(findV3Hook(v3, 'elydora-guard').action.command, JSON.stringify(payload));
|
await writeJson(path.join(fixture.agentDir, 'status-cache.json'), {
|
||||||
assert.equal(guardResult.code, 2);
|
status: 'frozen',
|
||||||
assert.match(guardResult.stderr, /Agent is frozen by Elydora/);
|
cached_at: Date.now(),
|
||||||
|
});
|
||||||
payload.hook_event_name = 'PostToolUse';
|
const guard = await runShell(
|
||||||
payload.tool_response = { success: true, result: 'test' };
|
findV3Hook(v3, 'elydora-guard').action.command,
|
||||||
const auditResult = await runCommand(findV3Hook(v3, 'elydora-audit').action.command, JSON.stringify(payload));
|
environment(fixture),
|
||||||
assert.equal(auditResult.code, 0, auditResult.stderr);
|
fixture.projectDir,
|
||||||
assert.deepEqual(JSON.parse(await readFile(capturePath, 'utf-8')), payload);
|
JSON.stringify(payload),
|
||||||
|
);
|
||||||
|
assert.equal(guard.code, 2, guard.stderr);
|
||||||
|
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(
|
||||||
|
findV3Hook(v3, 'elydora-audit').action.command,
|
||||||
|
environment(fixture),
|
||||||
|
fixture.projectDir,
|
||||||
|
JSON.stringify(payload),
|
||||||
|
);
|
||||||
|
assert.equal(audit.code, 0, audit.stderr);
|
||||||
|
const operation = JSON.parse(api.requests.find(({ method }) => method === 'POST').raw);
|
||||||
|
assert.deepEqual(operation.payload, payload);
|
||||||
|
assert.deepEqual(operation.action, { tool: 'execute_bash' });
|
||||||
|
assert.deepEqual(operation.subject, { session_id: 'session-1' });
|
||||||
} finally {
|
} finally {
|
||||||
|
await api.close();
|
||||||
await fixture.close();
|
await fixture.close();
|
||||||
await rm(capturePath, { force: true });
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Kiro CLI status accepts either contract and requires both runtime scripts', async () => {
|
test('Kiro CLI status requires both exact contracts and physical runtime identity', async (t) => {
|
||||||
|
await t.test('missing home is an empty installation', async () => {
|
||||||
const fixture = await createFixture();
|
const fixture = await createFixture();
|
||||||
try {
|
try {
|
||||||
assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr);
|
await rm(fixture.homeDir, { recursive: true });
|
||||||
const status = await runPlugin(fixture.homeDir, 'status', null);
|
const status = await runPlugin(fixture, 'status', null);
|
||||||
assert.equal(status.code, 0, status.stderr);
|
assert.equal(status.code, 0, status.stderr);
|
||||||
assert.equal(JSON.parse(status.stdout).installed, true);
|
assert.equal(JSON.parse(status.stdout).installed, false);
|
||||||
|
|
||||||
await rm(fixture.v3Path);
|
|
||||||
const v2Only = await runPlugin(fixture.homeDir, 'status', null);
|
|
||||||
const v2Status = JSON.parse(v2Only.stdout);
|
|
||||||
assert.equal(v2Status.installed, true);
|
|
||||||
assert.equal(v2Status.configPath, fixture.v2Path);
|
|
||||||
|
|
||||||
const reinstall = await runPlugin(fixture.homeDir, 'install', fixture.config);
|
|
||||||
assert.equal(reinstall.code, 0, reinstall.stderr);
|
|
||||||
await rm(fixture.v2Path);
|
|
||||||
const v3Only = await runPlugin(fixture.homeDir, 'status', null);
|
|
||||||
const v3Status = JSON.parse(v3Only.stdout);
|
|
||||||
assert.equal(v3Status.installed, true);
|
|
||||||
assert.equal(v3Status.configPath, fixture.v3Path);
|
|
||||||
|
|
||||||
await rm(fixture.guardScriptPath);
|
|
||||||
const degraded = await runPlugin(fixture.homeDir, 'status', null);
|
|
||||||
assert.equal(JSON.parse(degraded.stdout).installed, false);
|
|
||||||
} finally {
|
} finally {
|
||||||
await fixture.close();
|
await fixture.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
await t.test('missing or disabled contract is degraded and reinstall repairs it', async () => {
|
||||||
test('Kiro CLI status surfaces malformed referenced runtime metadata', async () => {
|
|
||||||
const fixture = await createFixture();
|
const fixture = await createFixture();
|
||||||
try {
|
try {
|
||||||
assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr);
|
assert.equal((await fixture.install()).code, 0);
|
||||||
await writeFile(fixture.runtimeConfigPath, '{ malformed');
|
let status = await runPlugin(fixture, 'status', null);
|
||||||
const status = await runPlugin(fixture.homeDir, 'status', null);
|
assert.equal(JSON.parse(status.stdout).installed, true);
|
||||||
assert.equal(status.code, 1);
|
const v3 = await readJson(fixture.v3Path);
|
||||||
assert.match(status.stderr, /parse Elydora runtime config/i);
|
findV3Hook(v3, 'elydora-guard').enabled = false;
|
||||||
|
await writeJson(fixture.v3Path, v3);
|
||||||
|
status = await runPlugin(fixture, 'status', null);
|
||||||
|
assert.equal(JSON.parse(status.stdout).installed, false);
|
||||||
|
assert.equal((await fixture.install()).code, 0);
|
||||||
|
await rm(fixture.v2Path);
|
||||||
|
status = await runPlugin(fixture, 'status', null);
|
||||||
|
assert.equal(JSON.parse(status.stdout).hookConfigured, false);
|
||||||
|
} finally {
|
||||||
|
await fixture.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await t.test('recognized altered hooks degrade status', async () => {
|
||||||
|
const fixture = await createFixture();
|
||||||
|
try {
|
||||||
|
assert.equal((await fixture.install()).code, 0);
|
||||||
|
const v2 = await readJson(fixture.v2Path);
|
||||||
|
v2.hooks.preToolUse.push({ ...v2.hooks.preToolUse[0], timeout_ms: 5000 });
|
||||||
|
await writeJson(fixture.v2Path, v2);
|
||||||
|
let status = await runPlugin(fixture, 'status', null);
|
||||||
|
assert.equal(JSON.parse(status.stdout).hookConfigured, false);
|
||||||
|
|
||||||
|
assert.equal((await fixture.install()).code, 0);
|
||||||
|
const v3 = await readJson(fixture.v3Path);
|
||||||
|
v3.hooks.push({ ...findV3Hook(v3, 'elydora-guard'), enabled: false });
|
||||||
|
await writeJson(fixture.v3Path, v3);
|
||||||
|
status = await runPlugin(fixture, 'status', null);
|
||||||
|
assert.equal(JSON.parse(status.stdout).hookConfigured, false);
|
||||||
|
} 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],
|
||||||
|
['runtime identity', 'config', JSON.stringify({
|
||||||
|
org_id: 'org-1',
|
||||||
|
agent_id: 'agent-1',
|
||||||
|
kid: 'kid-1',
|
||||||
|
base_url: 'http://127.0.0.1:9',
|
||||||
|
token: 'token-1',
|
||||||
|
agent_name: 'kiroide',
|
||||||
|
}), /runtime identity/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}${status.stderr}`, expected);
|
||||||
|
} finally {
|
||||||
|
await fixture.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Kiro CLI rejects malformed, colliding, invalid, and linked inputs before writes', async (t) => {
|
||||||
|
for (const [label, options, pattern] of [
|
||||||
|
['duplicate v2', { existingV2: '{"name":"elydora-audit","name":"other"}' }, /duplicate/i],
|
||||||
|
['v2 path collision', { existingV2: { name: 'user-agent', hooks: {} } }, /v2 agent path conflicts/i],
|
||||||
|
['v3 name collision', { existingV3: { version: 'v1', hooks: [{ name: 'elydora-guard', trigger: 'PreToolUse', action: { type: 'command', command: 'user-command' } }] } }, /hook name.*conflicts/i],
|
||||||
|
]) {
|
||||||
|
await t.test(label, async () => {
|
||||||
|
const fixture = await createFixture(options);
|
||||||
|
try {
|
||||||
|
const result = await fixture.install();
|
||||||
|
assert.equal(result.code, 1);
|
||||||
|
assert.match(result.stderr, pattern);
|
||||||
|
await assertMissing(fixture.guardScriptPath);
|
||||||
|
} finally {
|
||||||
|
await fixture.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await t.test('non-canonical private key', async () => {
|
||||||
|
const fixture = await createFixture();
|
||||||
|
try {
|
||||||
|
const result = await fixture.install({ privateKey: 'invalid' });
|
||||||
|
assert.equal(result.code, 1);
|
||||||
|
assert.match(result.stderr, /canonical 32-byte/);
|
||||||
|
await assertMissing(fixture.v2Path);
|
||||||
|
await assertMissing(fixture.v3Path);
|
||||||
|
} finally {
|
||||||
|
await fixture.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await t.test('linked Kiro directory', async () => {
|
||||||
|
const fixture = await createFixture();
|
||||||
|
const external = path.join(fixture.rootDir, 'external');
|
||||||
|
try {
|
||||||
|
await mkdir(external);
|
||||||
|
await symlink(external, path.join(fixture.homeDir, '.kiro'), 'junction');
|
||||||
|
const result = await fixture.install();
|
||||||
|
assert.equal(result.code, 1);
|
||||||
|
assert.match(result.stderr, /not a physical directory|symbolic link/i);
|
||||||
|
await assertMissing(path.join(external, 'agents', 'elydora-audit.json'));
|
||||||
|
} finally {
|
||||||
|
await fixture.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Kiro CLI migrates exact legacy commands and repairs both schemas', async () => {
|
||||||
|
const fixture = await createFixture();
|
||||||
|
try {
|
||||||
|
const legacy = legacyDocuments(fixture);
|
||||||
|
await writeJson(fixture.v2Path, legacy.v2);
|
||||||
|
await writeJson(fixture.v3Path, legacy.v3);
|
||||||
|
const result = await fixture.install();
|
||||||
|
assert.equal(result.code, 0, result.stderr);
|
||||||
|
const v2 = await readJson(fixture.v2Path);
|
||||||
|
const v3 = await readJson(fixture.v3Path);
|
||||||
|
assert.equal(v2.hooks.preToolUse.length, 1);
|
||||||
|
assert.equal(v2.hooks.preToolUse[0].timeout_ms, 10_000);
|
||||||
|
assert.equal(findV3Hook(v3, 'elydora-guard').timeout, 10);
|
||||||
|
assert.equal(findV3Hook(v3, 'elydora-guard').action.command, v2.hooks.preToolUse[0].command);
|
||||||
} finally {
|
} finally {
|
||||||
await fixture.close();
|
await fixture.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Kiro CLI uninstall preserves unrelated v2 and v3 hooks', async () => {
|
test('Kiro CLI uninstall preserves user hooks and removes fully owned files', async (t) => {
|
||||||
|
await t.test('mixed files', async () => {
|
||||||
const fixture = await createFixture({
|
const fixture = await createFixture({
|
||||||
existingV2: { hooks: { preToolUse: [{ matcher: 'read', command: 'existing-v2' }] } },
|
existingV2: managedV2Document({ hooks: { agentSpawn: [{ command: 'existing-spawn' }] } }),
|
||||||
existingV3: {
|
existingV3: { version: 'v1', hooks: [userV3Hook()] },
|
||||||
version: 'v1',
|
|
||||||
hooks: [{
|
|
||||||
name: 'existing-v3',
|
|
||||||
trigger: 'SessionStart',
|
|
||||||
action: { type: 'command', command: 'existing-command' },
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr);
|
assert.equal((await fixture.install()).code, 0);
|
||||||
const result = await runPlugin(fixture.homeDir, 'uninstall', 'agent-1');
|
const result = await runPlugin(fixture, 'uninstall', fixture.agentId);
|
||||||
assert.equal(result.code, 0, result.stderr);
|
assert.equal(result.code, 0, result.stderr);
|
||||||
const v2 = JSON.parse(await readFile(fixture.v2Path, 'utf-8'));
|
const v2 = await readJson(fixture.v2Path);
|
||||||
assert.deepEqual(v2.hooks.preToolUse, [{ matcher: 'read', command: 'existing-v2' }]);
|
assert.deepEqual(v2.hooks, { agentSpawn: [{ command: 'existing-spawn' }] });
|
||||||
assert.deepEqual(v2.hooks.postToolUse, []);
|
const v3 = await readJson(fixture.v3Path);
|
||||||
const v3 = JSON.parse(await readFile(fixture.v3Path, 'utf-8'));
|
assert.deepEqual(v3.hooks, [userV3Hook()]);
|
||||||
assert.equal(v3.hooks.length, 1);
|
|
||||||
assert.equal(v3.hooks[0].name, 'existing-v3');
|
|
||||||
} finally {
|
} finally {
|
||||||
await fixture.close();
|
await fixture.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
await t.test('fully owned files', async () => {
|
||||||
test('Kiro CLI uninstall removes configs owned entirely by Elydora', async () => {
|
|
||||||
const fixture = await createFixture();
|
const fixture = await createFixture();
|
||||||
try {
|
try {
|
||||||
assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr);
|
assert.equal((await fixture.install()).code, 0);
|
||||||
const result = await runPlugin(fixture.homeDir, 'uninstall', 'agent-1');
|
const result = await runPlugin(fixture, 'uninstall', fixture.agentId);
|
||||||
assert.equal(result.code, 0, result.stderr);
|
assert.equal(result.code, 0, result.stderr);
|
||||||
await assert.rejects(readFile(fixture.v2Path), { code: 'ENOENT' });
|
await assertMissing(fixture.v2Path);
|
||||||
await assert.rejects(readFile(fixture.v3Path), { code: 'ENOENT' });
|
await assertMissing(fixture.v3Path);
|
||||||
|
} finally {
|
||||||
|
await fixture.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await t.test('legacy files', async () => {
|
||||||
|
const fixture = await createFixture();
|
||||||
|
try {
|
||||||
|
const legacy = legacyDocuments(fixture);
|
||||||
|
await writeJson(fixture.v2Path, legacy.v2);
|
||||||
|
await writeJson(fixture.v3Path, legacy.v3);
|
||||||
|
const result = await runPlugin(fixture, 'uninstall', fixture.agentId);
|
||||||
|
assert.equal(result.code, 0, result.stderr);
|
||||||
|
await assertMissing(fixture.v2Path);
|
||||||
|
await assertMissing(fixture.v3Path);
|
||||||
|
} finally {
|
||||||
|
await fixture.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await t.test('unrelated v3 source bytes', async () => {
|
||||||
|
const source = '{ "version": "v1", "hooks": [{ "name": "user", "trigger": "SessionStart", "action": { "type": "command", "command": "echo user" } }] }\n';
|
||||||
|
const fixture = await createFixture({ existingV3: source });
|
||||||
|
try {
|
||||||
|
const result = await runPlugin(fixture, 'uninstall', fixture.agentId);
|
||||||
|
assert.equal(result.code, 0, result.stderr);
|
||||||
|
assert.equal(await readFile(fixture.v3Path, 'utf-8'), source);
|
||||||
|
} finally {
|
||||||
|
await fixture.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Kiro CLI command completes install, status, and uninstall end to end', async () => {
|
||||||
|
const fixture = await createFixture();
|
||||||
|
const privateKeyFile = path.join(fixture.projectDir, 'private-key.txt');
|
||||||
|
const tokenFile = path.join(fixture.projectDir, 'token.txt');
|
||||||
|
try {
|
||||||
|
await writeFile(privateKeyFile, `${VALID_PRIVATE_KEY}\n`, { mode: 0o600 });
|
||||||
|
await writeFile(tokenFile, 'token-1\n', { mode: 0o600 });
|
||||||
|
const install = await runNode([
|
||||||
|
cliPath, 'install', '--agent', 'kirocli', '--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);
|
||||||
|
const status = await runNode([cliPath, 'status'], environment(fixture), fixture.projectDir);
|
||||||
|
assert.equal(status.code, 0, status.stderr);
|
||||||
|
assert.match(status.stdout, /Kiro CLI \(kirocli\) \[installed\]/);
|
||||||
|
const uninstall = await runNode([
|
||||||
|
cliPath, 'uninstall', '--agent', 'kirocli', '--agent_id', fixture.agentId,
|
||||||
|
], environment(fixture), fixture.projectDir);
|
||||||
|
assert.equal(uninstall.code, 0, uninstall.stderr);
|
||||||
|
await assertMissing(fixture.v2Path);
|
||||||
|
await assertMissing(fixture.v3Path);
|
||||||
|
await assertMissing(fixture.agentDir);
|
||||||
} finally {
|
} finally {
|
||||||
await fixture.close();
|
await fixture.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Kiro CLI install preserves malformed configs for recovery', async () => {
|
|
||||||
const fixture = await createFixture({ existingV3: '{ malformed' });
|
|
||||||
try {
|
|
||||||
assert.equal(fixture.installResult.code, 1);
|
|
||||||
assert.match(fixture.installResult.stderr, /parse Kiro CLI v3 hooks config/i);
|
|
||||||
assert.equal(await readFile(fixture.v3Path, 'utf-8'), '{ malformed');
|
|
||||||
} finally {
|
|
||||||
await fixture.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
const v2Fixture = await createFixture({ existingV2: '{ malformed' });
|
|
||||||
try {
|
|
||||||
assert.equal(v2Fixture.installResult.code, 1);
|
|
||||||
assert.match(v2Fixture.installResult.stderr, /parse Kiro CLI v2 agent config/i);
|
|
||||||
assert.equal(await readFile(v2Fixture.v2Path, 'utf-8'), '{ malformed');
|
|
||||||
} finally {
|
|
||||||
await v2Fixture.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { lstat, readFile, readdir } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
contractModuleUrl,
|
||||||
|
createFixture,
|
||||||
|
environment,
|
||||||
|
installConfig,
|
||||||
|
installationModuleUrl,
|
||||||
|
ioModuleUrl,
|
||||||
|
kiroV1ModuleUrl,
|
||||||
|
managedV2Document,
|
||||||
|
runNode,
|
||||||
|
} from '../test-support/kirocli-test-helpers.mjs';
|
||||||
|
|
||||||
|
async function assertMissing(filePath) {
|
||||||
|
await assert.rejects(lstat(filePath), { code: 'ENOENT' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runPreparedInstallation(fixture, { failV3 = false, raceV2 } = {}) {
|
||||||
|
const source = `
|
||||||
|
import fsp from 'node:fs/promises';
|
||||||
|
import {
|
||||||
|
buildKiroCliV2Hooks,
|
||||||
|
buildKiroCliV3Hooks,
|
||||||
|
renderKiroCliV2Installation,
|
||||||
|
} from ${JSON.stringify(contractModuleUrl)};
|
||||||
|
import {
|
||||||
|
commitKiroCliInstallation,
|
||||||
|
preflightKiroCliInstallation,
|
||||||
|
prepareKiroCliInstallation,
|
||||||
|
} from ${JSON.stringify(installationModuleUrl)};
|
||||||
|
import { readKiroCliSources } from ${JSON.stringify(ioModuleUrl)};
|
||||||
|
import { renderKiroIdeDocument } from ${JSON.stringify(kiroV1ModuleUrl)};
|
||||||
|
|
||||||
|
const config = JSON.parse(process.env.ELYDORA_TEST_CONFIG);
|
||||||
|
const sources = await readKiroCliSources();
|
||||||
|
const paths = await preflightKiroCliInstallation(config, sources);
|
||||||
|
const v2 = renderKiroCliV2Installation(
|
||||||
|
sources.v2,
|
||||||
|
buildKiroCliV2Hooks(sources.v2, paths.guardPath, paths.auditPath),
|
||||||
|
);
|
||||||
|
const v3 = renderKiroIdeDocument(
|
||||||
|
sources.v3,
|
||||||
|
buildKiroCliV3Hooks(sources.v3.hooks, paths.guardPath, paths.auditPath),
|
||||||
|
);
|
||||||
|
const prepared = await prepareKiroCliInstallation(config, sources, v2, v3);
|
||||||
|
if (process.env.ELYDORA_TEST_RACE_V2) {
|
||||||
|
await fsp.writeFile(sources.paths.v2Path, process.env.ELYDORA_TEST_RACE_V2);
|
||||||
|
}
|
||||||
|
let failed = false;
|
||||||
|
await commitKiroCliInstallation(prepared, async (from, to) => {
|
||||||
|
if (!failed
|
||||||
|
&& process.env.ELYDORA_TEST_FAIL_V3 === 'true'
|
||||||
|
&& to === sources.paths.v3Path
|
||||||
|
&& from.endsWith('.tmp')) {
|
||||||
|
failed = true;
|
||||||
|
throw new Error('injected Kiro CLI v3 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_V3: String(failV3),
|
||||||
|
ELYDORA_TEST_RACE_V2: raceV2,
|
||||||
|
},
|
||||||
|
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 CLI rolls back v2, v3, and runtime files after a late commit failure', async () => {
|
||||||
|
const originalV2 = managedV2Document({ owner: 'v2', hooks: {} });
|
||||||
|
const originalV3 = { version: 'v1', owner: 'v3', hooks: [] };
|
||||||
|
const fixture = await createFixture({ existingV2: originalV2, existingV3: originalV3 });
|
||||||
|
try {
|
||||||
|
const result = await runPreparedInstallation(fixture, { failV3: true });
|
||||||
|
assert.equal(result.code, 1);
|
||||||
|
assert.match(result.stderr, /injected Kiro CLI v3 rename failure/);
|
||||||
|
assert.deepEqual(JSON.parse(await readFile(fixture.v2Path, 'utf-8')), originalV2);
|
||||||
|
assert.deepEqual(JSON.parse(await readFile(fixture.v3Path, 'utf-8')), originalV3);
|
||||||
|
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(
|
||||||
|
path.dirname(fixture.v2Path),
|
||||||
|
path.dirname(fixture.v3Path),
|
||||||
|
fixture.agentDir,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await fixture.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Kiro CLI detects concurrent source changes before the first commit', async () => {
|
||||||
|
const originalV2 = managedV2Document({ hooks: {} });
|
||||||
|
const originalV3 = { version: 'v1', hooks: [] };
|
||||||
|
const raceV2 = '{"name":"elydora-audit","race":true,"hooks":{}}\n';
|
||||||
|
const fixture = await createFixture({ existingV2: originalV2, existingV3: originalV3 });
|
||||||
|
try {
|
||||||
|
const result = await runPreparedInstallation(fixture, { raceV2 });
|
||||||
|
assert.equal(result.code, 1);
|
||||||
|
assert.match(result.stderr, /changed during Kiro CLI installation/i);
|
||||||
|
assert.equal(await readFile(fixture.v2Path, 'utf-8'), raceV2);
|
||||||
|
assert.deepEqual(JSON.parse(await readFile(fixture.v3Path, 'utf-8')), originalV3);
|
||||||
|
await assertMissing(fixture.guardScriptPath);
|
||||||
|
await assertMissing(fixture.hookScriptPath);
|
||||||
|
await assertNoTransactionFiles(path.dirname(fixture.v2Path), fixture.agentDir);
|
||||||
|
} finally {
|
||||||
|
await fixture.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -212,6 +212,20 @@ test('Kiro IDE status requires the exact enabled contract and physical runtime',
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await t.test('recognized altered hook degrades status', async () => {
|
||||||
|
const fixture = await createFixture();
|
||||||
|
try {
|
||||||
|
assert.equal((await fixture.install()).code, 0);
|
||||||
|
const document = await readJson(fixture.configPath);
|
||||||
|
document.hooks.push({ ...findHook(document, 'elydora-guard'), enabled: false });
|
||||||
|
await writeJson(fixture.configPath, document);
|
||||||
|
const status = await runPlugin(fixture, 'status', null);
|
||||||
|
assert.equal(JSON.parse(status.stdout).hookConfigured, false);
|
||||||
|
} finally {
|
||||||
|
await fixture.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
for (const [label, target, contents, expected] of [
|
for (const [label, target, contents, expected] of [
|
||||||
['guard runtime', 'guard', 'tampered\n', /"installed":false/],
|
['guard runtime', 'guard', 'tampered\n', /"installed":false/],
|
||||||
['runtime config', 'config', '{ malformed', /parse Elydora runtime config/i],
|
['runtime config', 'config', '{ malformed', /parse Elydora runtime config/i],
|
||||||
@@ -343,6 +357,15 @@ test('Kiro IDE uninstall removes exact ownership and preserves workspace hooks',
|
|||||||
} finally {
|
} finally {
|
||||||
await owned.close();
|
await owned.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const source = '{ "version": "v1", "hooks": [{ "name": "user", "trigger": "SessionStart", "action": { "type": "command", "command": "echo user" } }] }\n';
|
||||||
|
const unrelated = await createFixture({ existingConfig: source });
|
||||||
|
try {
|
||||||
|
assert.equal((await runPlugin(unrelated, 'uninstall', unrelated.agentId)).code, 0);
|
||||||
|
assert.equal(await readFile(unrelated.configPath, 'utf-8'), source);
|
||||||
|
} finally {
|
||||||
|
await unrelated.close();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Kiro IDE CLI completes install, status, and uninstall in the current workspace', async () => {
|
test('Kiro IDE CLI completes install, status, and uninstall in the current workspace', async () => {
|
||||||
|
|||||||
Reference in new issue
Block a user