fix(kiroide): adopt workspace v1 hooks

This commit is contained in:
ZacharyZhang-NY committed 2026-07-20 16:58:49 -04:00
1 parent 3cfd0a5187
commit 5889b09a09
11 files changed
+1535 -74

No files matched your search

+152
View File
@@ -0,0 +1,152 @@
import os from 'node:os';
import path from 'node:path';
interface ParsedArgument {
readonly value: string;
readonly next: number;
}
export interface KiroIdeRuntimeReference {
readonly agentId: string;
readonly scriptPath: string;
}
export function sameKiroIdePath(left: string, right: string): boolean {
const normalizedLeft = path.resolve(left);
const normalizedRight = path.resolve(right);
return process.platform === 'win32'
? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase()
: normalizedLeft === normalizedRight;
}
export function sameKiroIdeAgentId(left: string, right: string): boolean {
return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right;
}
function quotePosix(value: string): string {
return `'${value.replaceAll("'", `'"'"'`)}'`;
}
function quotePowerShell(value: string): string {
return `'${value.replaceAll("'", "''")}'`;
}
function windowsPowerShellPath(): string {
const configuredRoot = process.env.SystemRoot;
const systemRoot = configuredRoot
&& path.win32.isAbsolute(configuredRoot)
&& !/["%\r\n]/.test(configuredRoot)
? configuredRoot
: 'C:\\Windows';
return path.win32.join(
systemRoot,
'System32',
'WindowsPowerShell',
'v1.0',
'powershell.exe',
);
}
function windowsCommand(scriptPath: string): string {
const source = `& ${quotePowerShell(process.execPath)} ${quotePowerShell(scriptPath)}; exit $LASTEXITCODE`;
const encoded = Buffer.from(source, 'utf16le').toString('base64');
return `"${windowsPowerShellPath()}" -NoLogo -NoProfile -NonInteractive -EncodedCommand ${encoded}`;
}
export function buildKiroIdeCommand(scriptPath: string): string {
if (!path.isAbsolute(process.execPath) || !path.isAbsolute(scriptPath)) {
throw new Error('Kiro IDE hook commands require absolute executable and script paths');
}
return process.platform === 'win32'
? windowsCommand(scriptPath)
: `${quotePosix(process.execPath)} ${quotePosix(scriptPath)}`;
}
function readPosixArgument(command: string, start: number): ParsedArgument | undefined {
if (command[start] !== "'") return undefined;
const apostrophe = `'"'"'`;
let value = '';
for (let index = start + 1; index < command.length;) {
if (command.startsWith(apostrophe, index)) {
value += "'";
index += apostrophe.length;
continue;
}
if (command[index] === "'") return { value, next: index + 1 };
value += command[index];
index += 1;
}
return undefined;
}
function parsePosixCommand(command: string): readonly [string, string] | undefined {
const executable = readPosixArgument(command, 0);
if (!executable || command[executable.next] !== ' ') return undefined;
const script = readPosixArgument(command, executable.next + 1);
return script && script.next === command.length ? [executable.value, script.value] : undefined;
}
function readPowerShellArgument(command: string, start: number): ParsedArgument | undefined {
if (command[start] !== "'") return undefined;
let value = '';
for (let index = start + 1; index < command.length; index += 1) {
if (command[index] !== "'") {
value += command[index];
continue;
}
if (command[index + 1] === "'") {
value += "'";
index += 1;
continue;
}
return { value, next: index + 1 };
}
return undefined;
}
function parsePowerShellSource(source: string): readonly [string, string] | undefined {
if (!source.startsWith('& ')) return undefined;
const executable = readPowerShellArgument(source, 2);
if (!executable || source[executable.next] !== ' ') return undefined;
const script = readPowerShellArgument(source, executable.next + 1);
return script && source.slice(script.next) === '; exit $LASTEXITCODE'
? [executable.value, script.value]
: undefined;
}
function parseWindowsCommand(command: string): readonly [string, string] | undefined {
const match = /^"([^"\r\n]+)" -NoLogo -NoProfile -NonInteractive -EncodedCommand ([A-Za-z0-9+/]+={0,2})$/.exec(command);
if (!match
|| !path.win32.isAbsolute(match[1])
|| path.win32.basename(match[1]).toLowerCase() !== 'powershell.exe') return undefined;
const buffer = Buffer.from(match[2], 'base64');
if (buffer.length % 2 !== 0 || buffer.toString('base64') !== match[2]) return undefined;
return parsePowerShellSource(buffer.toString('utf16le'));
}
function isNodeExecutable(filePath: string): boolean {
const basename = path.basename(filePath).toLowerCase();
return basename === 'node' || basename === 'node.exe';
}
export function kiroIdeRuntimeReference(
command: string,
scriptName: string,
): KiroIdeRuntimeReference | undefined {
const parsed = process.platform === 'win32'
? parseWindowsCommand(command)
: parsePosixCommand(command);
if (!parsed
|| !path.isAbsolute(parsed[0])
|| !isNodeExecutable(parsed[0])
|| !path.isAbsolute(parsed[1])
|| path.basename(parsed[1]) !== scriptName) return undefined;
const agentDirectory = path.dirname(parsed[1]);
if (!sameKiroIdePath(path.dirname(agentDirectory), path.join(os.homedir(), '.elydora'))) {
return undefined;
}
const agentId = path.basename(agentDirectory);
return agentId && agentId !== '.' && agentId !== '..'
? { agentId, scriptPath: parsed[1] }
: undefined;
}
+303
View File
@@ -0,0 +1,303 @@
import os from 'node:os';
import path from 'node:path';
import {
buildKiroIdeCommand,
kiroIdeRuntimeReference,
sameKiroIdeAgentId,
sameKiroIdePath,
type KiroIdeRuntimeReference,
} from './kiroide-command.js';
import { isObject, parseStrictJsonObject, type JsonObject } from './strict-json.js';
export const AGENT_KEY = 'kiroide';
export const AUDIT_SCRIPT = 'hook.js';
export const CONFIG_FILE = 'elydora-audit.json';
export const GUARD_SCRIPT = 'guard.js';
export const LEGACY_CONFIG_FILE = 'elydora-audit.kiro.hook';
export const HOOK_TIMEOUT_SECONDS = 10;
const GUARD_NAME = 'elydora-guard';
const AUDIT_NAME = 'elydora-audit';
const GUARD_DESCRIPTION = 'Block tool use when the Elydora agent is frozen';
const AUDIT_DESCRIPTION = 'Record tool use in the Elydora audit trail';
const KIRO_TRIGGERS = new Set([
'SessionStart',
'Stop',
'PreToolUse',
'PostToolUse',
'PreTaskExec',
'PostTaskExec',
'UserPromptSubmit',
'PostFileCreate',
'PostFileSave',
'PostFileDelete',
]);
export interface KiroIdeAction extends JsonObject {
readonly type: 'command' | 'agent';
readonly command?: string;
readonly prompt?: string;
}
export interface KiroIdeHook extends JsonObject {
readonly name: string;
readonly description?: string;
readonly trigger: string;
readonly matcher?: string;
readonly action: KiroIdeAction;
readonly timeout?: number;
readonly enabled?: boolean;
}
export interface KiroIdeDocument {
readonly exists: boolean;
readonly filePath: string;
readonly root: JsonObject;
readonly hooks: readonly KiroIdeHook[];
readonly raw?: string;
}
export interface RenderedKiroIdeDocument {
readonly document: KiroIdeDocument;
readonly changed: boolean;
readonly next?: string;
}
export interface KiroIdeRuntimeContract {
readonly agentId: string;
readonly guardPath: string;
readonly auditPath: string;
}
export interface KiroIdePaths {
readonly workspaceRoot: string;
readonly kiroDirectory: string;
readonly hooksDirectory: string;
readonly configPath: string;
readonly legacyConfigPath: string;
}
export function resolveKiroIdePaths(
workspaceRoot = process.cwd(),
homeDirectory = os.homedir(),
): KiroIdePaths {
const resolvedWorkspace = path.resolve(workspaceRoot);
const kiroDirectory = path.join(resolvedWorkspace, '.kiro');
const hooksDirectory = path.join(kiroDirectory, 'hooks');
return {
workspaceRoot: resolvedWorkspace,
kiroDirectory,
hooksDirectory,
configPath: path.join(hooksDirectory, CONFIG_FILE),
legacyConfigPath: path.join(homeDirectory, '.kiro', 'hooks', LEGACY_CONFIG_FILE),
};
}
function validateAction(value: unknown, label: string): KiroIdeAction {
if (!isObject(value)) throw new Error(`${label} action must be an object`);
if (value.type === 'command') {
if (typeof value.command !== 'string' || !value.command) {
throw new Error(`${label} command action requires a non-empty command`);
}
return value as KiroIdeAction;
}
if (value.type === 'agent') {
if (typeof value.prompt !== 'string' || !value.prompt) {
throw new Error(`${label} agent action requires a non-empty prompt`);
}
return value as KiroIdeAction;
}
throw new Error(`${label} action has unsupported type "${String(value.type)}"`);
}
function validateHook(value: unknown, index: number): KiroIdeHook {
const label = `Kiro IDE hook hooks[${index}]`;
if (!isObject(value)) throw new Error(`${label} must be an object`);
if (typeof value.name !== 'string' || !value.name) throw new Error(`${label} requires a name`);
if (typeof value.trigger !== 'string' || !KIRO_TRIGGERS.has(value.trigger)) {
throw new Error(`${label} has unsupported trigger "${String(value.trigger)}"`);
}
if (value.description !== undefined && typeof value.description !== 'string') {
throw new Error(`${label} description must be a string`);
}
if (value.matcher !== undefined && typeof value.matcher !== 'string') {
throw new Error(`${label} matcher must be a string`);
}
if (value.timeout !== undefined
&& (!Number.isSafeInteger(value.timeout) || (value.timeout as number) < 0)) {
throw new Error(`${label} timeout must be a non-negative integer`);
}
if (value.enabled !== undefined && typeof value.enabled !== 'boolean') {
throw new Error(`${label} enabled must be a boolean`);
}
return { ...value, action: validateAction(value.action, label) } as KiroIdeHook;
}
export function parseKiroIdeDocument(filePath: string, raw: string): KiroIdeDocument {
const root = parseStrictJsonObject(raw, `Kiro IDE hooks at ${filePath}`);
if (root.version !== 'v1') throw new Error(`Kiro IDE hooks version must be "v1": ${filePath}`);
if (!Array.isArray(root.hooks)) throw new Error(`Kiro IDE hooks field "hooks" must be an array: ${filePath}`);
return {
exists: true,
filePath,
root,
hooks: root.hooks.map((hook, index) => validateHook(hook, index)),
raw,
};
}
export function createKiroIdeDocument(filePath: string): KiroIdeDocument {
return { exists: false, filePath, root: {}, hooks: [] };
}
function exactKeys(value: JsonObject, keys: readonly string[]): boolean {
return Object.keys(value).sort().join('|') === [...keys].sort().join('|');
}
function managedSpecification(name: string): {
readonly description: string;
readonly trigger: 'PreToolUse' | 'PostToolUse';
readonly scriptName: string;
} | undefined {
if (name === GUARD_NAME) {
return { description: GUARD_DESCRIPTION, trigger: 'PreToolUse', scriptName: GUARD_SCRIPT };
}
if (name === AUDIT_NAME) {
return { description: AUDIT_DESCRIPTION, trigger: 'PostToolUse', scriptName: AUDIT_SCRIPT };
}
return undefined;
}
function managedReference(hook: KiroIdeHook) {
const specification = managedSpecification(hook.name);
const owned = ownedReference(hook);
if (!specification
|| !owned
|| !exactKeys(hook, ['name', 'description', 'trigger', 'matcher', 'action', 'timeout', 'enabled'])
|| hook.description !== specification.description
|| hook.trigger !== specification.trigger
|| hook.matcher !== '.*'
|| hook.timeout !== HOOK_TIMEOUT_SECONDS
|| hook.enabled !== true
|| !exactKeys(hook.action, ['type', 'command'])) return undefined;
return owned;
}
function ownedReference(hook: KiroIdeHook) {
const specification = managedSpecification(hook.name);
if (!specification
|| hook.action.type !== 'command'
|| typeof hook.action.command !== 'string') return undefined;
return kiroIdeRuntimeReference(hook.action.command, specification.scriptName);
}
export function requireAvailableKiroIdeHooks(hooks: readonly KiroIdeHook[]): void {
for (const hook of hooks) {
if (managedSpecification(hook.name) && !ownedReference(hook)) {
throw new Error(`Kiro IDE hook name "${hook.name}" conflicts with the Elydora contract`);
}
}
}
export function buildKiroIdeHook(
name: typeof GUARD_NAME | typeof AUDIT_NAME,
scriptPath: string,
): KiroIdeHook {
const specification = managedSpecification(name)!;
return {
name,
description: specification.description,
trigger: specification.trigger,
matcher: '.*',
action: { type: 'command', command: buildKiroIdeCommand(scriptPath) },
timeout: HOOK_TIMEOUT_SECONDS,
enabled: true,
};
}
export function withoutManagedKiroIdeHooks(
hooks: readonly KiroIdeHook[],
agentId?: string,
): KiroIdeHook[] {
return hooks.filter((hook) => {
const reference = ownedReference(hook);
return !reference || (agentId !== undefined && !sameKiroIdeAgentId(reference.agentId, agentId));
});
}
function entirelyManaged(document: KiroIdeDocument): boolean {
return document.exists
&& exactKeys(document.root, ['version', 'hooks'])
&& document.hooks.length > 0
&& document.hooks.every((hook) => managedReference(hook) !== undefined);
}
export function renderKiroIdeDocument(
document: KiroIdeDocument,
hooks: readonly KiroIdeHook[],
): RenderedKiroIdeDocument {
if (!document.exists && hooks.length === 0) return { document, changed: false };
if (hooks.length === 0 && entirelyManaged(document)) return { document, changed: true };
const next = `${JSON.stringify({ ...document.root, version: 'v1', hooks }, null, 2)}\n`;
return { document, changed: next !== document.raw, next };
}
export function kiroIdeRuntimeContracts(
hooks: readonly KiroIdeHook[],
): KiroIdeRuntimeContract[] {
const guards = new Map<string, KiroIdeRuntimeReference[]>();
const audits = new Map<string, KiroIdeRuntimeReference[]>();
for (const hook of hooks) {
const reference = managedReference(hook);
if (!reference) continue;
const key = process.platform === 'win32' ? reference.agentId.toLowerCase() : reference.agentId;
const target = hook.name === GUARD_NAME ? guards : audits;
const entries = target.get(key) ?? [];
entries.push(reference);
target.set(key, entries);
}
const contracts: KiroIdeRuntimeContract[] = [];
for (const [key, guard] of guards) {
const audit = audits.get(key);
if (guard.length !== 1 || audit?.length !== 1 || !guard[0] || !audit[0]) continue;
contracts.push({
agentId: guard[0].agentId,
guardPath: guard[0].scriptPath,
auditPath: audit[0].scriptPath,
});
}
return contracts;
}
function legacyReference(command: unknown, scriptName: string) {
if (typeof command !== 'string') return undefined;
const match = /^node "([^"\r\n]+)"$/.exec(command);
if (!match || !path.isAbsolute(match[1]) || path.basename(match[1]) !== scriptName) return undefined;
const agentDirectory = path.dirname(match[1]);
if (!sameKiroIdePath(path.dirname(agentDirectory), path.join(os.homedir(), '.elydora'))) {
return undefined;
}
return { agentId: path.basename(agentDirectory), scriptPath: match[1] };
}
export function legacyKiroIdeRuntimeContract(raw: string, filePath: string): KiroIdeRuntimeContract | undefined {
const root = parseStrictJsonObject(raw, `legacy Kiro IDE hook at ${filePath}`);
if (!exactKeys(root, ['name', 'description', 'version', 'hooks'])
|| root.name !== 'Elydora Audit'
|| root.description !== 'Sends tool-use events to the Elydora tamper-evident audit platform'
|| root.version !== '1.0.0'
|| !isObject(root.hooks)
|| !exactKeys(root.hooks, ['pre_tool_use', 'post_tool_use'])) return undefined;
const guardHook = root.hooks.pre_tool_use;
const auditHook = root.hooks.post_tool_use;
if (!isObject(guardHook)
|| !isObject(auditHook)
|| !exactKeys(guardHook, ['command', 'timeout_ms'])
|| !exactKeys(auditHook, ['command', 'timeout_ms'])
|| guardHook.timeout_ms !== 5000
|| auditHook.timeout_ms !== 5000) return undefined;
const guard = legacyReference(guardHook.command, GUARD_SCRIPT);
const audit = legacyReference(auditHook.command, AUDIT_SCRIPT);
if (!guard || !audit || !sameKiroIdeAgentId(guard.agentId, audit.agentId)) return undefined;
return { agentId: guard.agentId, guardPath: guard.scriptPath, auditPath: audit.scriptPath };
}
+158
View File
@@ -0,0 +1,158 @@
import path from 'node:path';
import type { InstallConfig } from './base.js';
import { sameKiroIdeAgentId } from './kiroide-command.js';
import {
AGENT_KEY,
AUDIT_SCRIPT,
GUARD_SCRIPT,
type KiroIdePaths,
type RenderedKiroIdeDocument,
} from './kiroide-contract.js';
import {
type KiroIdeSources,
type LegacyKiroIdeDocument,
requirePhysicalLegacyDirectory,
} from './kiroide-io.js';
import {
commitManagedInstallation,
preflightManagedInstallation,
prepareManagedInstallation,
type ManagedRuntimePaths,
type PreparedManagedInstallation,
type RenameFile,
} from './managed-installation.js';
import {
commitManagedTransaction,
prepareManagedFileChange,
type ManagedFileChange,
type PreparedManagedTransaction,
} from './managed-transaction.js';
const DISPLAY_NAME = 'Kiro IDE';
const DIRECTORY_LABEL = 'Kiro IDE hooks directory';
export type KiroIdeRuntimePaths = ManagedRuntimePaths;
export type PreparedKiroIdeInstallation = PreparedManagedInstallation;
export type { RenameFile };
function hookLocation(paths: KiroIdePaths) {
return [{ directoryLabel: DIRECTORY_LABEL, filePath: paths.configPath }];
}
function removesLegacy(legacy: LegacyKiroIdeDocument, agentId?: string): boolean {
return legacy.contract !== undefined
&& (agentId === undefined || sameKiroIdeAgentId(legacy.contract.agentId, agentId));
}
async function prepareLegacyRemoval(
legacy: LegacyKiroIdeDocument,
agentId?: string,
): Promise<ManagedFileChange | undefined> {
if (!removesLegacy(legacy, agentId)) return undefined;
if (legacy.raw === undefined) throw new Error(`Legacy Kiro IDE hook source is missing: ${legacy.filePath}`);
await requirePhysicalLegacyDirectory(legacy);
return prepareManagedFileChange({
filePath: legacy.filePath,
label: 'legacy Kiro IDE hook',
next: undefined,
mode: 0o600,
expectedSource: legacy.raw,
verifyExpectedSource: true,
});
}
export async function preflightKiroIdeInstallation(
config: InstallConfig,
paths: KiroIdePaths,
): Promise<KiroIdeRuntimePaths> {
return preflightManagedInstallation({
agentKey: AGENT_KEY,
hookLocations: hookLocation(paths),
config,
}, GUARD_SCRIPT, AUDIT_SCRIPT);
}
export async function prepareKiroIdeInstallation(
config: InstallConfig,
sources: KiroIdeSources,
rendered: RenderedKiroIdeDocument,
): Promise<PreparedKiroIdeInstallation> {
if (rendered.next === undefined) throw new Error('Kiro IDE installation requires a hook document');
const prepared = await prepareManagedInstallation({
agentKey: AGENT_KEY,
displayName: DISPLAY_NAME,
hookSources: [{
directoryLabel: DIRECTORY_LABEL,
label: 'Kiro IDE hooks',
filePath: sources.paths.configPath,
expectedSource: sources.document.raw,
source: rendered.next,
}],
config,
auditOptions: { nativePayload: true },
}, GUARD_SCRIPT, AUDIT_SCRIPT);
const legacyChange = await prepareLegacyRemoval(sources.legacy, config.agentId);
if (!legacyChange) return prepared;
return {
...prepared,
transaction: {
...prepared.transaction,
directories: [
...prepared.transaction.directories,
{ path: path.dirname(sources.legacy.filePath), label: 'legacy Kiro IDE hooks directory' },
],
changes: [...prepared.transaction.changes, legacyChange],
},
};
}
export async function commitKiroIdeInstallation(
prepared: PreparedKiroIdeInstallation,
renameFile?: RenameFile,
): Promise<void> {
await commitManagedInstallation(prepared, renameFile);
}
export async function prepareKiroIdeUninstall(
sources: KiroIdeSources,
rendered: RenderedKiroIdeDocument,
agentId?: string,
): Promise<PreparedManagedTransaction> {
const changes: ManagedFileChange[] = [];
const directories = [];
if (rendered.changed) {
const configChange = await prepareManagedFileChange({
filePath: sources.document.filePath,
label: 'Kiro IDE hooks',
next: rendered.next,
mode: 0o600,
expectedSource: sources.document.raw,
verifyExpectedSource: true,
});
if (configChange) {
changes.push(configChange);
directories.push({ path: sources.paths.hooksDirectory, label: DIRECTORY_LABEL });
}
}
const legacyChange = await prepareLegacyRemoval(sources.legacy, agentId);
if (legacyChange) {
changes.push(legacyChange);
directories.push({
path: path.dirname(sources.legacy.filePath),
label: 'legacy Kiro IDE hooks directory',
});
}
return {
displayName: DISPLAY_NAME,
operation: 'uninstall',
directories,
changes,
};
}
export async function commitKiroIdeUninstall(
prepared: PreparedManagedTransaction,
renameFile?: RenameFile,
): Promise<void> {
await commitManagedTransaction(prepared, renameFile);
}
+165
View File
@@ -0,0 +1,165 @@
import os from 'node:os';
import path from 'node:path';
import { generateGuardScript } from './guard-template.js';
import { generateHookScript } from './hook-template.js';
import { sameKiroIdeAgentId, sameKiroIdePath } from './kiroide-command.js';
import {
AGENT_KEY,
AUDIT_SCRIPT,
GUARD_SCRIPT,
createKiroIdeDocument,
legacyKiroIdeRuntimeContract,
parseKiroIdeDocument,
resolveKiroIdePaths,
type KiroIdeDocument,
type KiroIdePaths,
type KiroIdeRuntimeContract,
} from './kiroide-contract.js';
import { inspectPhysicalDirectory, readPhysicalFile } from './managed-files.js';
import { parseStrictJsonObject, type JsonObject } from './strict-json.js';
const MAX_SECRET_BYTES = 64 * 1024;
const MAX_CONFIG_BYTES = 512 * 1024;
export interface LegacyKiroIdeDocument {
readonly exists: boolean;
readonly filePath: string;
readonly raw?: string;
readonly contract?: KiroIdeRuntimeContract;
}
export interface KiroIdeSources {
readonly paths: KiroIdePaths;
readonly document: KiroIdeDocument;
readonly legacy: LegacyKiroIdeDocument;
}
async function inspectWorkspace(paths: KiroIdePaths): Promise<void> {
if (!await inspectPhysicalDirectory(paths.workspaceRoot, 'Kiro IDE workspace')) {
throw new Error(`Kiro IDE workspace is missing: ${paths.workspaceRoot}`);
}
await inspectPhysicalDirectory(paths.kiroDirectory, 'Kiro IDE configuration directory');
await inspectPhysicalDirectory(paths.hooksDirectory, 'Kiro IDE hooks directory');
}
async function readDocument(paths: KiroIdePaths): Promise<KiroIdeDocument> {
await inspectWorkspace(paths);
const snapshot = await readPhysicalFile(paths.configPath, 'Kiro IDE hooks');
return snapshot
? parseKiroIdeDocument(paths.configPath, snapshot.contents)
: createKiroIdeDocument(paths.configPath);
}
async function readLegacyDocument(paths: KiroIdePaths): Promise<LegacyKiroIdeDocument> {
const snapshot = await readPhysicalFile(paths.legacyConfigPath, 'legacy Kiro IDE hook');
if (!snapshot) return { exists: false, filePath: paths.legacyConfigPath };
return {
exists: true,
filePath: paths.legacyConfigPath,
raw: snapshot.contents,
contract: legacyKiroIdeRuntimeContract(snapshot.contents, paths.legacyConfigPath),
};
}
export async function readKiroIdeSources(): Promise<KiroIdeSources> {
const paths = resolveKiroIdePaths();
const [document, legacy] = await Promise.all([
readDocument(paths),
readLegacyDocument(paths),
]);
return { paths, document, legacy };
}
export async function requirePhysicalLegacyDirectory(
legacy: LegacyKiroIdeDocument,
): Promise<void> {
if (!legacy.exists) return;
const hooksDirectory = path.dirname(legacy.filePath);
const kiroDirectory = path.dirname(hooksDirectory);
if (!await inspectPhysicalDirectory(kiroDirectory, 'legacy Kiro IDE configuration directory')) {
throw new Error(`Legacy Kiro IDE configuration directory is missing: ${kiroDirectory}`);
}
if (!await inspectPhysicalDirectory(hooksDirectory, 'legacy Kiro IDE hooks directory')) {
throw new Error(`Legacy Kiro IDE hooks directory is missing: ${hooksDirectory}`);
}
}
function requireString(value: unknown, field: string, configPath: string): string {
if (typeof value !== 'string' || value.trim().length === 0) {
throw new Error(`Elydora runtime config ${field} is invalid: ${configPath}`);
}
return value;
}
function validateRuntimeConfig(
config: JsonObject,
contract: KiroIdeRuntimeContract,
configPath: string,
): void {
const supported = new Set(['org_id', 'agent_id', 'kid', 'base_url', 'token', 'agent_name']);
const extra = Object.keys(config).find((key) => !supported.has(key));
if (extra) throw new Error(`Elydora runtime config has unsupported field "${extra}": ${configPath}`);
requireString(config.org_id, 'org_id', configPath);
requireString(config.kid, 'kid', configPath);
const agentId = requireString(config.agent_id, 'agent_id', configPath);
if (!sameKiroIdeAgentId(agentId, contract.agentId) || config.agent_name !== AGENT_KEY) {
throw new Error(`Elydora runtime identity does not match Kiro IDE hooks: ${configPath}`);
}
if (config.token !== undefined) requireString(config.token, 'token', configPath);
const rawBaseUrl = requireString(config.base_url, 'base_url', configPath);
let baseUrl: URL;
try {
baseUrl = new URL(rawBaseUrl);
} catch (error) {
throw new Error(`Elydora runtime config base_url is invalid: ${configPath}`, {
cause: error instanceof Error ? error : new Error(String(error)),
});
}
if (!['http:', 'https:'].includes(baseUrl.protocol)
|| !baseUrl.hostname
|| baseUrl.username
|| baseUrl.password
|| baseUrl.search
|| baseUrl.hash) {
throw new Error(`Elydora runtime config base_url is invalid: ${configPath}`);
}
}
function validatePrivateKey(contents: string, keyPath: string): void {
const bytes = Buffer.from(contents, 'base64url');
if (bytes.length !== 32 || bytes.toString('base64url') !== contents) {
throw new Error(`Elydora private key is invalid: ${keyPath}`);
}
}
function validContractPaths(contract: KiroIdeRuntimeContract): boolean {
const agentDirectory = path.dirname(contract.guardPath);
return sameKiroIdePath(path.dirname(agentDirectory), path.join(os.homedir(), '.elydora'))
&& sameKiroIdePath(contract.guardPath, path.join(agentDirectory, GUARD_SCRIPT))
&& sameKiroIdePath(contract.auditPath, path.join(agentDirectory, AUDIT_SCRIPT));
}
export async function runtimeFilesExist(contract: KiroIdeRuntimeContract): Promise<boolean> {
if (!validContractPaths(contract)) return false;
const runtimeRoot = path.join(os.homedir(), '.elydora');
const agentDirectory = path.dirname(contract.guardPath);
if (!await inspectPhysicalDirectory(runtimeRoot, 'Elydora runtime directory')) return false;
if (!await inspectPhysicalDirectory(agentDirectory, 'Elydora agent runtime directory')) return false;
const configPath = path.join(agentDirectory, 'config.json');
const keyPath = path.join(agentDirectory, 'private.key');
const [config, key, guard, audit] = await Promise.all([
readPhysicalFile(configPath, 'Elydora runtime config', MAX_CONFIG_BYTES),
readPhysicalFile(keyPath, 'Elydora private key', MAX_SECRET_BYTES),
readPhysicalFile(contract.guardPath, 'Elydora guard runtime'),
readPhysicalFile(contract.auditPath, 'Elydora audit runtime'),
]);
if (!config || !key || !guard || !audit) return false;
validateRuntimeConfig(
parseStrictJsonObject(config.contents, `Elydora runtime config at ${configPath}`),
contract,
configPath,
);
validatePrivateKey(key.contents, keyPath);
return guard.contents === generateGuardScript(AGENT_KEY, contract.agentId)
&& audit.contents === generateHookScript(AGENT_KEY, contract.agentId, { nativePayload: true });
}
+52 -73
View File
@@ -1,94 +1,73 @@
import fsp from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
import {
AGENT_KEY,
buildKiroIdeHook,
kiroIdeRuntimeContracts,
renderKiroIdeDocument,
requireAvailableKiroIdeHooks,
withoutManagedKiroIdeHooks,
} from './kiroide-contract.js';
import {
commitKiroIdeInstallation,
commitKiroIdeUninstall,
preflightKiroIdeInstallation,
prepareKiroIdeInstallation,
prepareKiroIdeUninstall,
} from './kiroide-installation.js';
import { readKiroIdeSources, runtimeFilesExist } from './kiroide-io.js';
import { SUPPORTED_AGENTS } from './registry.js';
const AGENT_KEY = 'kiroide';
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
function resolveConfigDir(): string {
// .kiro/hooks is project-relative, but for global install use home dir
return path.join(os.homedir(), entry.configDir);
}
function resolveConfigPath(): string {
return path.join(resolveConfigDir(), entry.configFile);
}
export const kiroidePlugin: AgentPlugin = {
async install(config: InstallConfig): Promise<void> {
const configDir = resolveConfigDir();
await fsp.mkdir(configDir, { recursive: true });
managesRuntime: true,
const configPath = resolveConfigPath();
const hookConfig = {
name: 'Elydora Audit',
description: 'Sends tool-use events to the Elydora tamper-evident audit platform',
version: '1.0.0',
hooks: {
pre_tool_use: {
command: `node "${config.guardScriptPath}"`,
timeout_ms: 5000,
},
post_tool_use: {
command: `node "${config.hookScriptPath}"`,
timeout_ms: 5000,
},
},
};
await fsp.writeFile(configPath, JSON.stringify(hookConfig, null, 2) + '\n', 'utf-8');
async preflightInstall(config: InstallConfig): Promise<void> {
const sources = await readKiroIdeSources();
requireAvailableKiroIdeHooks(sources.document.hooks);
await preflightKiroIdeInstallation(config, sources.paths);
},
async uninstall(_agentId?: string): Promise<void> {
const configPath = resolveConfigPath();
try {
await fsp.unlink(configPath);
} catch {
// Already removed
}
async install(config: InstallConfig): Promise<void> {
const sources = await readKiroIdeSources();
requireAvailableKiroIdeHooks(sources.document.hooks);
const paths = await preflightKiroIdeInstallation(config, sources.paths);
const hooks = withoutManagedKiroIdeHooks(sources.document.hooks);
const rendered = renderKiroIdeDocument(sources.document, [
...hooks,
buildKiroIdeHook('elydora-guard', paths.guardPath),
buildKiroIdeHook('elydora-audit', paths.auditPath),
]);
const prepared = await prepareKiroIdeInstallation(config, sources, rendered);
await commitKiroIdeInstallation(prepared);
console.log(` Kiro IDE workspace hooks: ${sources.paths.configPath}`);
console.log(' Kiro IDE verification: confirm Elydora hooks in the Agent Hooks panel.');
},
async uninstall(agentId?: string): Promise<void> {
const sources = await readKiroIdeSources();
const rendered = renderKiroIdeDocument(
sources.document,
withoutManagedKiroIdeHooks(sources.document.hooks, agentId),
);
const prepared = await prepareKiroIdeUninstall(sources, rendered, agentId);
await commitKiroIdeUninstall(prepared);
},
async status(): Promise<PluginStatus> {
const configPath = resolveConfigPath();
let hookConfigured = false;
let hookScriptPath = '';
try {
const raw = await fsp.readFile(configPath, 'utf-8');
const config = JSON.parse(raw);
hookConfigured = !!(config.hooks && config.hooks.pre_tool_use && config.hooks.post_tool_use);
// Extract hook script path from post_tool_use command
if (hookConfigured && config.hooks.post_tool_use) {
const cmd = config.hooks.post_tool_use.command as string;
if (cmd && cmd.includes('elydora')) {
hookScriptPath = cmd.replace(/^node\s+"?/, '').replace(/"?\s*$/, '');
}
}
} catch {
// Config not readable
}
let hookScriptExists = false;
if (hookScriptPath) {
try {
await fsp.access(hookScriptPath);
hookScriptExists = true;
} catch {
// File doesn't exist
}
}
const sources = await readKiroIdeSources();
requireAvailableKiroIdeHooks(sources.document.hooks);
const contracts = kiroIdeRuntimeContracts(sources.document.hooks);
const hookConfigured = contracts.length > 0;
const hookScriptExists = hookConfigured
&& (await Promise.all(contracts.map(runtimeFilesExist))).every(Boolean);
return {
installed: hookConfigured && hookScriptExists,
agentName: AGENT_KEY,
displayName: entry.name,
hookConfigured,
hookScriptExists,
configPath,
configPath: sources.paths.configPath,
};
},
};
+1 -1
View File
@@ -10,7 +10,7 @@ export const SUPPORTED_AGENTS: ReadonlyMap<string, AgentRegistryEntry> = new Map
['cursor', { name: 'Cursor', configDir: '~/.cursor', configFile: 'hooks.json' }],
['gemini', { name: 'Gemini CLI', configDir: '~/.gemini', configFile: 'settings.json' }],
['kirocli', { name: 'Kiro CLI', configDir: '~/.kiro/hooks', configFile: 'elydora-audit.json' }],
['kiroide', { name: 'Kiro IDE', configDir: '.kiro/hooks', configFile: 'elydora-audit.kiro.hook' }],
['kiroide', { name: 'Kiro IDE', configDir: '.kiro/hooks', configFile: 'elydora-audit.json' }],
['opencode', { name: 'OpenCode', configDir: '.config/opencode/plugins', configFile: 'elydora-audit.mjs' }],
['copilot', { name: 'GitHub Copilot CLI', configDir: '~/.copilot/hooks', configFile: 'elydora-audit.json' }],
['letta', { name: 'Letta Code', configDir: '~/.letta', configFile: 'settings.json' }],