fix(kiroide): adopt workspace v1 hooks
This commit is contained in:
1 parent
3cfd0a5187
commit
5889b09a09
11 files changed
+1535
-74
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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' }],
|
||||
|
||||
Reference in new issue
Block a user