sdks/node: mirror Elydora-Node-SDK 2.0.1

Rebuilt from the standalone 2101cb7 tree (durable transactions, Kiro IDE
adapter, hook retry with a bounded chain lock, version 2.0.1) with the
Open-Source API trims re-applied: no webhooks, members, admin events,
deep health, token rotation, or agent integration updates.

Claude-Session: https://claude.ai/code/session_01QoUjdc7HGKTsgj5GhG8HvF
This commit is contained in:
雷电芽衣 committed 2026-09-02 11:01:55 -04:00
1 parent 1656a94811
commit 98c61a0b6f
44 files changed
+6794 -1130

No files matched your search

+145 -51
View File
@@ -38,6 +38,8 @@ const KEY_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'private.key');
const CHAIN_STATE_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'chain-state.json');
const ERROR_LOG_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'error.log');
const ZERO_CHAIN_HASH = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
const MAX_CHAIN_ATTEMPTS = 5;
const SUBMIT_BUDGET_MS = 4000;
${PROTECTED_RUNTIME_READER}
@@ -165,6 +167,81 @@ function writeChainState(chainHash) {
writeProtectedJson(CHAIN_STATE_PATH, 'Chain state', { prev_chain_hash: chainHash });
}
function lockOwnerAlive(lockPath) {
let owner;
try {
owner = Number.parseInt(fs.readFileSync(lockPath, 'utf-8'), 10);
} catch (error) {
if (!hasRuntimeErrorCode(error, 'ENOENT')) throw error;
return false;
}
if (!Number.isInteger(owner) || owner <= 0) return false;
try {
process.kill(owner, 0);
return true;
} catch (error) {
if (hasRuntimeErrorCode(error, 'ESRCH')) return false;
return true;
}
}
function withChainLock(update) {
const lockPath = CHAIN_STATE_PATH + '.lock';
const deadline = performance.now() + 1000;
for (;;) {
let descriptor;
try {
descriptor = fs.openSync(lockPath, 'wx', 0o600);
fs.writeSync(descriptor, String(process.pid));
} catch (error) {
if (!hasRuntimeErrorCode(error, 'EEXIST')) throw error;
if (lockOwnerAlive(lockPath)) {
if (performance.now() > deadline) throw new Error('Chain state lock timed out: ' + lockPath);
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
continue;
}
let age = 0;
try { age = Date.now() - fs.statSync(lockPath).mtimeMs; } catch (statError) {
if (!hasRuntimeErrorCode(statError, 'ENOENT')) throw statError;
}
if (age > 5000) {
const reclaimed = lockPath + '.' + process.pid + '.stale';
try {
fs.renameSync(lockPath, reclaimed);
fs.unlinkSync(reclaimed);
} catch (reclaimError) {
if (!hasRuntimeErrorCode(reclaimError, 'ENOENT')) throw reclaimError;
}
continue;
}
if (performance.now() > deadline) throw new Error('Chain state lock timed out: ' + lockPath);
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
continue;
}
try {
return update();
} finally {
let current = null;
try {
current = fs.lstatSync(lockPath);
} catch (statError) {
if (!hasRuntimeErrorCode(statError, 'ENOENT')) throw statError;
}
const own = fs.fstatSync(descriptor);
fs.closeSync(descriptor);
if (current && current.ino === own.ino && current.dev === own.dev) fs.unlinkSync(lockPath);
}
}
}
function advanceChainState(fromHash, toHash) {
return withChainLock(() => {
if (readChainState() !== fromHash) return false;
writeChainState(toHash);
return true;
});
}
function readConfigAndKey() {
const raw = readProtectedFile(
CONFIG_PATH,
@@ -219,27 +296,43 @@ async function readHookInput() {
return value;
}
async function submitOperation(hookData, runtime) {
const toolEvent = hookData.tool_result && typeof hookData.tool_result === 'object'
? hookData.tool_result
: hookData.tool_call && typeof hookData.tool_call === 'object'
? hookData.tool_call
: {};
const toolName = hookData.tool_name || hookData.toolName || hookData.name ||
toolEvent.name || 'unknown';
const toolInput = hookData.tool_input || hookData.toolInput || hookData.input ||
hookData.parameters || toolEvent.input || {};
const sessionId = hookData.conversation_id || hookData.session_id || hookData.sessionId ||
hookData.session || hookData.taskId || 'unknown';
const previousHash = readChainState();
async function postOperation(operation, runtime, deadline) {
const remaining = deadline - performance.now();
if (remaining <= 0) throw new Error('Audit API retry budget exhausted');
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), Math.min(5000, remaining));
try {
const headers = { 'Content-Type': 'application/json', Accept: 'application/json' };
if (runtime.config.token) headers.Authorization = 'Bearer ' + runtime.config.token;
const response = await fetch(runtime.baseUrl + '/v1/operations', {
method: 'POST',
headers,
body: JSON.stringify(operation),
signal: controller.signal,
});
if (response.ok) return { accepted: true };
const responseBody = await response.text();
if (response.status === 400 && responseBody) {
let failure;
try { failure = JSON.parse(responseBody); } catch (error) {
throw new Error('Audit API returned invalid error JSON: ' + error.message);
}
if (failure.error && failure.error.code === 'PREV_HASH_MISMATCH') {
const match = String(failure.error.message || '').match(
/Expected prev_chain_hash "([A-Za-z0-9_-]{43})"/,
);
if (match) return { accepted: false, expected: match[1] };
}
}
throw new Error('Audit API returned HTTP ' + response.status);
} finally {
clearTimeout(timeout);
}
}
function buildOperation(runtime, payload, payloadHash, toolName, sessionId, previousHash) {
const operationId = uuidv7();
const issuedAt = Date.now();
const payload = NATIVE_PAYLOAD ? hookData : {
tool_name: toolName,
tool_input: toolInput,
session_id: sessionId,
};
const payloadHash = computePayloadHash(payload);
const chainHash = computeChainHash(previousHash, payloadHash, operationId, issuedAt);
const unsigned = {
op_version: '1.0',
@@ -261,41 +354,42 @@ async function submitOperation(hookData, runtime) {
runtime.privateKey,
Buffer.from(jcsCanonicalise(unsigned), 'utf-8'),
);
const operation = { ...unsigned, chain_hash: chainHash, signature };
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const headers = { 'Content-Type': 'application/json', Accept: 'application/json' };
if (runtime.config.token) headers.Authorization = 'Bearer ' + runtime.config.token;
const response = await fetch(runtime.baseUrl + '/v1/operations', {
method: 'POST',
headers,
body: JSON.stringify(operation),
signal: controller.signal,
});
if (response.ok) {
writeChainState(chainHash);
return { operation: { ...unsigned, chain_hash: chainHash, signature }, chainHash };
}
async function submitOperation(hookData, runtime) {
const toolEvent = hookData.tool_result && typeof hookData.tool_result === 'object'
? hookData.tool_result
: hookData.tool_call && typeof hookData.tool_call === 'object'
? hookData.tool_call
: {};
const toolName = hookData.tool_name || hookData.toolName || hookData.name ||
toolEvent.name || 'unknown';
const toolInput = hookData.tool_input || hookData.toolInput || hookData.input ||
hookData.parameters || toolEvent.input || {};
const sessionId = hookData.conversation_id || hookData.session_id || hookData.sessionId ||
hookData.session || hookData.taskId || 'unknown';
const payload = NATIVE_PAYLOAD ? hookData : {
tool_name: toolName,
tool_input: toolInput,
session_id: sessionId,
};
const payloadHash = computePayloadHash(payload);
const deadline = performance.now() + SUBMIT_BUDGET_MS;
let previousHash = readChainState();
for (let attempt = 1; ; attempt += 1) {
const built = buildOperation(runtime, payload, payloadHash, toolName, sessionId, previousHash);
const result = await postOperation(built.operation, runtime, deadline);
if (result.accepted) {
advanceChainState(previousHash, built.chainHash);
return;
}
const responseBody = await response.text();
if (response.status === 400 && responseBody) {
let failure;
try { failure = JSON.parse(responseBody); } catch (error) {
throw new Error('Audit API returned invalid error JSON: ' + error.message);
}
if (failure.error && failure.error.code === 'PREV_HASH_MISMATCH') {
const match = String(failure.error.message || '').match(
/Expected prev_chain_hash "([A-Za-z0-9_-]{43})"/,
);
if (match) {
writeChainState(match[1]);
logError(new Error('Chain hash resynced to server: ' + match[1]));
}
}
advanceChainState(previousHash, result.expected);
logError(new Error('Chain hash resynced to server: ' + result.expected));
if (attempt >= MAX_CHAIN_ATTEMPTS) {
throw new Error('Audit API rejected prev_chain_hash ' + attempt + ' times');
}
throw new Error('Audit API returned HTTP ' + response.status);
} finally {
clearTimeout(timeout);
previousHash = readChainState();
}
}
+416
View File
@@ -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);
}
+68
View File
@@ -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 { inspectPhysicalDirectory, readPhysicalFile } from './managed-files.js';
import { managedRuntimeFilesExist } from './managed-runtime-status.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 managedRuntimeFilesExist(contract, AGENT_KEY);
}
+73 -335
View File
@@ -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 {
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';
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)!;
type JsonObject = Record<string, unknown>;
interface HookContract {
readonly guard: string;
readonly audit: string;
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;
function validateSources(
sources: Awaited<ReturnType<typeof readKiroCliSources>>,
): void {
requireAvailableKiroCliV2Document(sources.v2);
requireAvailableKiroCliV3Hooks(sources.v3.hooks);
}
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> {
const v2Path = resolveV2Path();
const v3Path = resolveV3Path();
const v2Settings = (await readJsonFile(v2Path, 'Kiro CLI v2 agent config')) ?? {};
const v3Settings = (await readJsonFile(v3Path, 'Kiro CLI v3 hooks config')) ?? {};
const v2Hooks = hooksObject(v2Settings, 'Kiro CLI v2 agent config');
const currentV3Hooks = v3Hooks(v3Settings);
v2Hooks.preToolUse = [
...withoutV2Hooks(hookEntries(v2Hooks, 'preToolUse', 'Kiro CLI v2 agent config')),
buildV2Hook(config.guardScriptPath),
];
v2Hooks.postToolUse = [
...withoutV2Hooks(hookEntries(v2Hooks, 'postToolUse', 'Kiro CLI v2 agent config')),
buildV2Hook(config.hookScriptPath),
];
const nextV2Settings: JsonObject = {
name: V2_AGENT_NAME,
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.');
const sources = await readKiroCliSources();
validateSources(sources);
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);
await commitKiroCliInstallation(prepared);
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".');
console.log(` Kiro CLI v3 global hooks (2.13.0+): ${sources.paths.v3Path}`);
console.log(' Kiro CLI v3 activation: start the TUI with "kiro-cli --v3".');
},
async uninstall(agentId?: string): Promise<void> {
const v2Path = resolveV2Path();
const v3Path = resolveV3Path();
const v2Settings = await readJsonFile(v2Path, 'Kiro CLI v2 agent config');
const v3Settings = await readJsonFile(v3Path, 'Kiro CLI v3 hooks config');
if (v2Settings) {
const hooks = hooksObject(v2Settings, 'Kiro CLI v2 agent config');
hooks.preToolUse = withoutV2Hooks(
hookEntries(hooks, 'preToolUse', 'Kiro CLI v2 agent config'),
agentId,
);
hooks.postToolUse = withoutV2Hooks(
hookEntries(hooks, 'postToolUse', 'Kiro CLI v2 agent config'),
agentId,
);
if (isOwnedV2Config(v2Settings, hooks)) {
await removeFile(v2Path, 'Kiro CLI v2 agent config');
} 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');
}
}
const sources = await readKiroCliSources();
const v2 = renderKiroCliV2Uninstall(
sources.v2,
withoutManagedKiroCliV2Hooks(sources.v2, agentId),
agentId,
);
const v3 = renderKiroCliV3Uninstall(
sources.v3,
withoutManagedKiroCliV3Hooks(sources.v3.hooks, agentId),
agentId,
);
const prepared = await prepareKiroCliUninstall(sources, v2, v3);
await commitKiroCliUninstall(prepared);
},
async status(): Promise<PluginStatus> {
const v2Path = resolveV2Path();
const v3Path = resolveV3Path();
const v2Settings = await readJsonFile(v2Path, 'Kiro CLI v2 agent config');
const v3Settings = await readJsonFile(v3Path, 'Kiro CLI v3 hooks config');
const contracts: HookContract[] = [];
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;
const sources = await readKiroCliSources();
validateSources(sources);
const contract = commonKiroCliRuntimeContract(sources.v2, sources.v3.hooks);
const hookConfigured = contract !== undefined;
const hookScriptExists = contract !== undefined
&& await kiroCliRuntimeFilesExist(contract);
return {
installed: hookConfigured && hookScriptExists,
agentName: AGENT_KEY,
displayName: entry.name,
hookConfigured,
hookScriptExists,
configPath: contracts.at(-1)?.configPath ?? v3Path,
configPath: sources.paths.v3Path,
};
},
};
+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;
}
+316
View File
@@ -0,0 +1,316 @@
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, documentLabel: string): KiroIdeHook {
const label = `${documentLabel} hooks[${index}]`;
if (!isObject(value)) throw new Error(`${label} must be an object`);
if (typeof value.name !== 'string' || !value.name) throw new Error(`${label} requires a name`);
if (typeof value.trigger !== 'string' || !KIRO_TRIGGERS.has(value.trigger)) {
throw new Error(`${label} has unsupported trigger "${String(value.trigger)}"`);
}
if (value.description !== undefined && typeof value.description !== 'string') {
throw new Error(`${label} description must be a string`);
}
if (value.matcher !== undefined && typeof value.matcher !== 'string') {
throw new Error(`${label} matcher must be a string`);
}
if (value.timeout !== undefined
&& (!Number.isSafeInteger(value.timeout) || (value.timeout as number) < 0)) {
throw new Error(`${label} timeout must be a non-negative integer`);
}
if (value.enabled !== undefined && typeof value.enabled !== 'boolean') {
throw new Error(`${label} enabled must be a boolean`);
}
return { ...value, action: validateAction(value.action, label) } as KiroIdeHook;
}
export function parseKiroIdeDocument(
filePath: string,
raw: string,
documentLabel = 'Kiro IDE hooks',
): KiroIdeDocument {
const root = parseStrictJsonObject(raw, `${documentLabel} at ${filePath}`);
if (root.version !== 'v1') throw new Error(`${documentLabel} version must be "v1": ${filePath}`);
if (!Array.isArray(root.hooks)) throw new Error(`${documentLabel} field "hooks" must be an array: ${filePath}`);
return {
exists: true,
filePath,
root,
hooks: root.hooks.map((hook, index) => validateHook(hook, index, documentLabel)),
raw,
};
}
export function createKiroIdeDocument(filePath: string): KiroIdeDocument {
return { exists: false, filePath, root: {}, hooks: [] };
}
function exactKeys(value: JsonObject, keys: readonly string[]): boolean {
return Object.keys(value).sort().join('|') === [...keys].sort().join('|');
}
function managedSpecification(name: string): {
readonly description: string;
readonly trigger: 'PreToolUse' | 'PostToolUse';
readonly scriptName: string;
} | undefined {
if (name === GUARD_NAME) {
return { description: GUARD_DESCRIPTION, trigger: 'PreToolUse', scriptName: GUARD_SCRIPT };
}
if (name === AUDIT_NAME) {
return { description: AUDIT_DESCRIPTION, trigger: 'PostToolUse', scriptName: AUDIT_SCRIPT };
}
return undefined;
}
function managedReference(hook: KiroIdeHook) {
const specification = managedSpecification(hook.name);
const owned = ownedReference(hook);
if (!specification
|| !owned
|| !exactKeys(hook, ['name', 'description', 'trigger', 'matcher', 'action', 'timeout', 'enabled'])
|| hook.description !== specification.description
|| hook.trigger !== specification.trigger
|| hook.matcher !== '.*'
|| hook.timeout !== HOOK_TIMEOUT_SECONDS
|| hook.enabled !== true
|| !exactKeys(hook.action, ['type', 'command'])) return undefined;
return owned;
}
function ownedReference(hook: KiroIdeHook) {
const specification = managedSpecification(hook.name);
if (!specification
|| hook.action.type !== 'command'
|| typeof hook.action.command !== 'string') return undefined;
return kiroIdeRuntimeReference(hook.action.command, specification.scriptName);
}
export function requireAvailableKiroIdeHooks(
hooks: readonly KiroIdeHook[],
productLabel = 'Kiro IDE',
): void {
for (const hook of hooks) {
if (managedSpecification(hook.name) && !ownedReference(hook)) {
throw new Error(`${productLabel} hook name "${hook.name}" conflicts with the Elydora contract`);
}
}
}
export function buildKiroIdeHook(
name: typeof GUARD_NAME | typeof AUDIT_NAME,
scriptPath: string,
): KiroIdeHook {
const specification = managedSpecification(name)!;
return {
name,
description: specification.description,
trigger: specification.trigger,
matcher: '.*',
action: { type: 'command', command: buildKiroIdeCommand(scriptPath) },
timeout: HOOK_TIMEOUT_SECONDS,
enabled: true,
};
}
export function withoutManagedKiroIdeHooks(
hooks: readonly KiroIdeHook[],
agentId?: string,
): KiroIdeHook[] {
return hooks.filter((hook) => {
const reference = ownedReference(hook);
return !reference || (agentId !== undefined && !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 (document.exists
&& hooks.length === document.hooks.length
&& hooks.every((hook, index) => hook === document.hooks[index])) {
return { document, changed: false };
}
if (hooks.length === 0 && entirelyManaged(document)) return { document, changed: true };
const next = `${JSON.stringify({ ...document.root, version: 'v1', hooks }, null, 2)}\n`;
return { document, changed: next !== document.raw, next };
}
export function kiroIdeRuntimeContracts(
hooks: readonly KiroIdeHook[],
): KiroIdeRuntimeContract[] {
const guards = new Map<string, KiroIdeRuntimeReference[]>();
const audits = new Map<string, KiroIdeRuntimeReference[]>();
for (const hook of hooks) {
if (!managedSpecification(hook.name)) continue;
const reference = managedReference(hook);
if (!reference) return [];
const key = process.platform === 'win32' ? reference.agentId.toLowerCase() : reference.agentId;
const target = hook.name === GUARD_NAME ? guards : audits;
const entries = target.get(key) ?? [];
entries.push(reference);
target.set(key, entries);
}
const contracts: KiroIdeRuntimeContract[] = [];
for (const [key, guard] of guards) {
const audit = audits.get(key);
if (guard.length !== 1 || audit?.length !== 1 || !guard[0] || !audit[0]) continue;
contracts.push({
agentId: guard[0].agentId,
guardPath: guard[0].scriptPath,
auditPath: audit[0].scriptPath,
});
}
return contracts;
}
function legacyReference(command: unknown, scriptName: string) {
if (typeof command !== 'string') return undefined;
const match = /^node "([^"\r\n]+)"$/.exec(command);
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);
}
+74
View File
@@ -0,0 +1,74 @@
import path from 'node:path';
import {
createKiroIdeDocument,
legacyKiroIdeRuntimeContract,
parseKiroIdeDocument,
resolveKiroIdePaths,
type KiroIdeDocument,
type KiroIdePaths,
type KiroIdeRuntimeContract,
} from './kiroide-contract.js';
import { inspectPhysicalDirectory, readPhysicalFile } from './managed-files.js';
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}`);
}
}
+55 -73
View File
@@ -1,94 +1,76 @@
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 } from './kiroide-io.js';
import { managedRuntimeFilesExist } from './managed-runtime-status.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((contract) => (
managedRuntimeFilesExist(contract, AGENT_KEY)
)))).every(Boolean);
return {
installed: hookConfigured && hookScriptExists,
agentName: AGENT_KEY,
displayName: entry.name,
hookConfigured,
hookScriptExists,
configPath,
configPath: sources.paths.configPath,
};
},
};
+146
View File
@@ -0,0 +1,146 @@
import os from 'node:os';
import path from 'node:path';
interface ParsedArgument {
readonly value: string;
readonly next: number;
}
export interface LettaRuntimeReference {
readonly agentId: string;
readonly executablePath?: string;
readonly scriptPath: string;
}
export function sameLettaPath(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 sameLettaAgentId(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("'", "''")}'`;
}
export function buildLettaCommand(scriptPath: string): string {
if (!path.isAbsolute(process.execPath) || !path.isAbsolute(scriptPath)) {
throw new Error('Letta Code hook commands require absolute executable and script paths');
}
if (process.platform === 'win32') {
return `& ${quotePowerShell(process.execPath)} ${quotePowerShell(scriptPath)}; exit $LASTEXITCODE`;
}
return `${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);
if (!script || script.next !== command.length) return undefined;
return [executable.value, script.value];
}
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 parsePowerShellCommand(command: string): readonly [string, string] | undefined {
if (!command.startsWith('& ')) return undefined;
const executable = readPowerShellArgument(command, 2);
if (!executable || command[executable.next] !== ' ') return undefined;
const script = readPowerShellArgument(command, executable.next + 1);
if (!script || command.slice(script.next) !== '; exit $LASTEXITCODE') return undefined;
return [executable.value, script.value];
}
function legacyScriptPath(command: string): string | undefined {
if (!command.startsWith('node "') || !command.endsWith('"')) return undefined;
const scriptPath = command.slice(6, -1);
return scriptPath.includes('"') ? undefined : scriptPath;
}
function isNodeExecutable(filePath: string): boolean {
const basename = path.basename(filePath).toLowerCase();
return basename === 'node' || basename === 'node.exe';
}
function runtimeReference(
scriptPath: string,
scriptName: string,
executablePath?: string,
): LettaRuntimeReference | undefined {
if (!path.isAbsolute(scriptPath) || path.basename(scriptPath) !== scriptName) return undefined;
const agentDirectory = path.dirname(scriptPath);
if (!sameLettaPath(path.dirname(agentDirectory), path.join(os.homedir(), '.elydora'))) {
return undefined;
}
const agentId = path.basename(agentDirectory);
if (!agentId || agentId === '.' || agentId === '..') return undefined;
return { agentId, executablePath, scriptPath };
}
export function lettaRuntimeReference(
command: string,
scriptName: string,
): LettaRuntimeReference | undefined {
const parsed = process.platform === 'win32'
? parsePowerShellCommand(command)
: parsePosixCommand(command);
if (parsed
&& path.isAbsolute(parsed[0])
&& isNodeExecutable(parsed[0])) {
return runtimeReference(parsed[1], scriptName, parsed[0]);
}
return undefined;
}
export function lettaLegacyRuntimeReference(
command: string,
scriptName: string,
): LettaRuntimeReference | undefined {
const legacy = legacyScriptPath(command);
return legacy ? runtimeReference(legacy, scriptName) : undefined;
}
+183
View File
@@ -0,0 +1,183 @@
import {
applyEdits,
modify,
type FormattingOptions,
type JSONPath,
} from 'jsonc-parser';
import type { FileSnapshot } from './managed-files.js';
import {
MANAGED_EVENTS,
managedLettaRemovals,
readLettaHooks,
type LettaGroup,
type LettaHooks,
type ManagedLettaEvent,
} from './letta-contract.js';
import { parseStrictJsonObject, type JsonObject } from './strict-json.js';
export type LettaDocumentKind = 'global' | 'project' | 'project-local';
export interface LettaDocument {
readonly kind: LettaDocumentKind;
readonly filePath: string;
readonly exists: boolean;
readonly raw: string;
readonly snapshot?: FileSnapshot;
readonly root: JsonObject;
readonly hooks: LettaHooks;
readonly hasHooksContainer: boolean;
}
export interface RenderedLettaDocument {
readonly document: LettaDocument;
readonly changed: boolean;
readonly next?: string;
}
interface DocumentOptions {
readonly kind: LettaDocumentKind;
readonly exists: boolean;
readonly filePath: string;
readonly raw: string;
readonly snapshot?: FileSnapshot;
}
export function lettaSourceLabel(kind: LettaDocumentKind): string {
switch (kind) {
case 'project': return 'Letta Code project settings';
case 'project-local': return 'Letta Code project-local settings';
default: return 'Letta Code global settings';
}
}
export function parseLettaDocument(options: DocumentOptions): LettaDocument {
const label = `${lettaSourceLabel(options.kind)} at ${options.filePath}`;
const root = parseStrictJsonObject(options.raw, label);
return {
...options,
root,
hooks: readLettaHooks(root.hooks),
hasHooksContainer: Object.hasOwn(root, 'hooks'),
};
}
export function createLettaDocument(
kind: LettaDocumentKind,
filePath: string,
): LettaDocument {
return parseLettaDocument({
kind,
exists: false,
filePath,
raw: '{}\n',
});
}
function formatting(raw: string): FormattingOptions {
const indentation = /\r?\n([ \t]+)\S/.exec(raw)?.[1];
const insertSpaces = !indentation?.includes('\t');
return {
eol: raw.includes('\r\n') ? '\r\n' : '\n',
insertSpaces,
tabSize: insertSpaces ? Math.max(1, indentation?.length ?? 2) : 1,
};
}
function change(
raw: string,
jsonPath: JSONPath,
value: unknown,
isArrayInsertion = false,
): string {
return applyEdits(raw, modify(raw, jsonPath, value, {
formattingOptions: formatting(raw),
isArrayInsertion,
}));
}
function currentDocument(document: LettaDocument, raw: string): LettaDocument {
return parseLettaDocument({
kind: document.kind,
exists: document.exists,
filePath: document.filePath,
raw,
snapshot: document.snapshot,
});
}
function groupsForEvent(hooks: LettaHooks, event: ManagedLettaEvent): LettaGroup[] {
const groups = hooks[event];
return Array.isArray(groups) ? groups : [];
}
function removeManagedEntries(
document: LettaDocument,
initial: string,
agentId?: string,
): string {
let raw = initial;
const removals = managedLettaRemovals(document.hooks, agentId);
for (const event of MANAGED_EVENTS) {
const eventRemovals = removals
.filter((removal) => removal.event === event)
.sort((left, right) => right.groupIndex - left.groupIndex);
for (const removal of eventRemovals) {
const groupPath: JSONPath = ['hooks', event, removal.groupIndex];
if (removal.removeGroup) {
raw = change(raw, groupPath, undefined);
continue;
}
for (const handlerIndex of [...removal.handlerIndexes].sort(
(left, right) => right - left,
)) {
raw = change(raw, [...groupPath, 'hooks', handlerIndex], undefined);
}
}
if (eventRemovals.length > 0) {
const current = currentDocument(document, raw);
if (groupsForEvent(current.hooks, event).length === 0) {
raw = change(raw, ['hooks', event], undefined);
}
}
}
const current = currentDocument(document, raw);
if (current.hasHooksContainer && Object.keys(current.hooks).length === 0) {
raw = change(raw, ['hooks'], undefined);
}
return raw;
}
function appendGroup(
document: LettaDocument,
raw: string,
event: ManagedLettaEvent,
group: LettaGroup,
): string {
const current = currentDocument(document, raw);
const groups = groupsForEvent(current.hooks, event);
if (groups.length > 0) {
return change(raw, ['hooks', event, groups.length], group, true);
}
return change(raw, ['hooks', event], [group]);
}
export function renderLettaDocument(
document: LettaDocument,
agentId: string | undefined,
additions: ReadonlyMap<ManagedLettaEvent, LettaGroup>,
): RenderedLettaDocument {
let raw = removeManagedEntries(document, document.raw, agentId);
for (const event of MANAGED_EVENTS) {
const group = additions.get(event);
if (group) raw = appendGroup(document, raw, event, group);
}
const current = currentDocument(document, raw);
if (additions.size === 0 && Object.keys(current.root).length === 0) {
return { document, changed: true, next: undefined };
}
return { document, changed: raw !== document.raw, next: raw };
}
export function lettaDocumentLabel(document: LettaDocument): string {
return lettaSourceLabel(document.kind);
}
+281
View File
@@ -0,0 +1,281 @@
import {
buildLettaCommand,
lettaLegacyRuntimeReference,
lettaRuntimeReference,
sameLettaAgentId,
sameLettaPath,
type LettaRuntimeReference,
} from './letta-command.js';
import { isObject, type JsonObject } from './strict-json.js';
export const AGENT_KEY = 'letta';
export const GUARD_SCRIPT = 'guard.js';
export const AUDIT_SCRIPT = 'hook.js';
export const HOOK_TIMEOUT_MS = 10_000;
export const LETTA_AUDIT_OPTIONS = {
nativePayload: true,
failClosed: true,
} as const;
export const MANAGED_EVENTS = [
'PreToolUse',
'PostToolUse',
'PostToolUseFailure',
] as const;
const TOOL_EVENTS = new Set([
'PreToolUse',
'PostToolUse',
'PostToolUseFailure',
'PermissionRequest',
]);
const SIMPLE_EVENTS = new Set([
'UserPromptSubmit',
'Notification',
'Stop',
'SubagentStop',
'PreCompact',
'SessionStart',
'SessionEnd',
]);
const KNOWN_EVENTS = new Set([...TOOL_EVENTS, ...SIMPLE_EVENTS]);
export type ManagedLettaEvent = (typeof MANAGED_EVENTS)[number];
export interface LettaHandler extends JsonObject {
readonly type: 'command' | 'prompt';
readonly command?: string;
readonly prompt?: string;
readonly model?: string;
readonly timeout?: number;
readonly quiet?: boolean;
}
export interface LettaGroup extends JsonObject {
readonly matcher?: string;
readonly hooks: LettaHandler[];
}
export interface LettaHooks extends JsonObject {
readonly disabled?: boolean;
readonly [event: string]: LettaGroup[] | boolean | undefined;
}
export interface LettaRuntimeContract {
readonly agentId: string;
readonly guardPath: string;
readonly auditPath: string;
}
export interface ManagedLettaRemoval {
readonly event: ManagedLettaEvent;
readonly groupIndex: number;
readonly handlerIndexes: readonly number[];
readonly removeGroup: boolean;
}
function validateTimeout(value: unknown, label: string): void {
if (value === undefined) return;
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
throw new Error(`${label} timeout must be a non-negative finite number`);
}
}
function validateHandler(value: unknown, label: string): LettaHandler {
if (!isObject(value)) throw new Error(`${label} must be an object`);
if (value.type !== 'command' && value.type !== 'prompt') {
throw new Error(`${label} has unsupported type "${String(value.type)}"`);
}
validateTimeout(value.timeout, label);
if (value.quiet !== undefined && typeof value.quiet !== 'boolean') {
throw new Error(`${label} quiet must be a boolean`);
}
if (value.type === 'command') {
if (typeof value.command !== 'string' || value.command.length === 0) {
throw new Error(`${label} requires a non-empty command`);
}
} else {
if (typeof value.prompt !== 'string' || value.prompt.length === 0) {
throw new Error(`${label} requires a non-empty prompt`);
}
if (value.model !== undefined && typeof value.model !== 'string') {
throw new Error(`${label} model must be a string`);
}
}
return value as LettaHandler;
}
function validateGroup(
value: unknown,
event: string,
index: number,
): LettaGroup {
const label = `Letta Code settings group hooks.${event}[${index}]`;
if (!isObject(value)) throw new Error(`${label} must be an object`);
if (TOOL_EVENTS.has(event)) {
if (typeof value.matcher !== 'string') {
throw new Error(`${label} matcher must be a string`);
}
} else if (value.matcher !== undefined) {
throw new Error(`${label} matcher is unsupported for ${event}`);
}
if (!Array.isArray(value.hooks)) throw new Error(`${label} must contain a hooks array`);
return {
...value,
hooks: value.hooks.map((handler, handlerIndex) => (
validateHandler(handler, `${label}.hooks[${handlerIndex}]`)
)),
} as LettaGroup;
}
export function readLettaHooks(value: unknown): LettaHooks {
if (value === undefined) return {};
if (!isObject(value)) throw new Error('Letta Code settings field "hooks" must be an object');
if (value.disabled !== undefined && typeof value.disabled !== 'boolean') {
throw new Error('Letta Code settings field "hooks.disabled" must be a boolean');
}
const hooks: JsonObject = {};
for (const [event, groups] of Object.entries(value)) {
if (event === 'disabled') {
hooks.disabled = groups;
continue;
}
if (!KNOWN_EVENTS.has(event)) {
hooks[event] = groups;
continue;
}
if (!Array.isArray(groups)) {
throw new Error(`Letta Code settings field "hooks.${event}" must be an array`);
}
hooks[event] = groups.map((group, index) => validateGroup(group, event, index));
}
return hooks as LettaHooks;
}
export function buildLettaGroup(scriptPath: string): LettaGroup {
return {
matcher: '*',
hooks: [{
type: 'command',
command: buildLettaCommand(scriptPath),
timeout: HOOK_TIMEOUT_MS,
}],
};
}
function exactGroup(group: LettaGroup): boolean {
return Object.keys(group).sort().join('|') === 'hooks|matcher'
&& group.matcher === '*';
}
function exactCurrentReference(
handler: LettaHandler,
scriptName: string,
): LettaRuntimeReference | undefined {
return Object.keys(handler).sort().join('|') === 'command|timeout|type'
&& handler.type === 'command'
&& handler.timeout === HOOK_TIMEOUT_MS
&& typeof handler.command === 'string'
? lettaRuntimeReference(handler.command, scriptName)
: undefined;
}
function exactLegacyReference(
handler: LettaHandler,
scriptName: string,
): LettaRuntimeReference | undefined {
return Object.keys(handler).sort().join('|') === 'command|type'
&& handler.type === 'command'
&& typeof handler.command === 'string'
? lettaLegacyRuntimeReference(handler.command, scriptName)
: undefined;
}
function managedReference(
handler: LettaHandler,
scriptName: string,
): LettaRuntimeReference | undefined {
return exactCurrentReference(handler, scriptName)
?? exactLegacyReference(handler, scriptName);
}
const EVENT_CONTRACTS = [
['PreToolUse', GUARD_SCRIPT],
['PostToolUse', AUDIT_SCRIPT],
['PostToolUseFailure', AUDIT_SCRIPT],
] as const;
function eventGroups(hooks: LettaHooks, event: ManagedLettaEvent): LettaGroup[] {
const value = hooks[event];
return Array.isArray(value) ? value : [];
}
export function managedLettaRemovals(
hooks: LettaHooks,
agentId?: string,
): ManagedLettaRemoval[] {
const removals: ManagedLettaRemoval[] = [];
for (const [event, scriptName] of EVENT_CONTRACTS) {
eventGroups(hooks, event).forEach((group, groupIndex) => {
const handlerIndexes = group.hooks.flatMap((handler, handlerIndex) => {
const reference = managedReference(handler, scriptName);
return reference && (agentId === undefined || sameLettaAgentId(reference.agentId, agentId))
? [handlerIndex]
: [];
});
if (handlerIndexes.length > 0) {
removals.push({
event,
groupIndex,
handlerIndexes,
removeGroup: exactGroup(group) && handlerIndexes.length === group.hooks.length,
});
}
});
}
return removals;
}
function referencesForEvent(
groups: readonly LettaGroup[],
scriptName: string,
): Map<string, LettaRuntimeReference[]> {
const references = new Map<string, LettaRuntimeReference[]>();
for (const group of groups) {
if (!exactGroup(group)) continue;
for (const handler of group.hooks) {
const reference = exactCurrentReference(handler, scriptName);
if (!reference
|| !reference.executablePath
|| !sameLettaPath(reference.executablePath, process.execPath)) continue;
const key = process.platform === 'win32'
? reference.agentId.toLowerCase()
: reference.agentId;
references.set(key, [...(references.get(key) ?? []), reference]);
}
}
return references;
}
export function lettaRuntimeContracts(hooks: LettaHooks): LettaRuntimeContract[] {
const guards = referencesForEvent(eventGroups(hooks, 'PreToolUse'), GUARD_SCRIPT);
const posts = referencesForEvent(eventGroups(hooks, 'PostToolUse'), AUDIT_SCRIPT);
const failures = referencesForEvent(
eventGroups(hooks, 'PostToolUseFailure'),
AUDIT_SCRIPT,
);
const contracts: LettaRuntimeContract[] = [];
for (const [key, guard] of guards) {
const post = posts.get(key);
const failure = failures.get(key);
if (guard.length !== 1 || post?.length !== 1 || failure?.length !== 1) continue;
if (!sameLettaPath(post[0].scriptPath, failure[0].scriptPath)) continue;
contracts.push({
agentId: guard[0].agentId,
guardPath: guard[0].scriptPath,
auditPath: post[0].scriptPath,
});
}
return contracts;
}
+134
View File
@@ -0,0 +1,134 @@
import path from 'node:path';
import type { InstallConfig } from './base.js';
import {
AGENT_KEY,
AUDIT_SCRIPT,
GUARD_SCRIPT,
LETTA_AUDIT_OPTIONS,
} from './letta-contract.js';
import { sameLettaPath } from './letta-command.js';
import {
lettaDocumentLabel,
type RenderedLettaDocument,
} from './letta-config.js';
import {
commitManagedInstallation,
preflightManagedInstallation,
prepareManagedInstallation,
type ManagedRuntimePaths,
type PreparedManagedInstallation,
type RenameFile,
} from './managed-installation.js';
import {
commitManagedTransaction,
prepareManagedFileChange,
type PreparedManagedTransaction,
} from './managed-transaction.js';
import {
requireLettaHooksEnabled,
type LettaSources,
} from './letta-sources.js';
const DISPLAY_NAME = 'Letta Code';
const SETTINGS_DIRECTORY_LABEL = 'Letta Code global configuration directory';
const MAX_SOURCE_BYTES = 2 * 1024 * 1024;
export type LettaRuntimePaths = ManagedRuntimePaths;
export type PreparedLettaInstallation = PreparedManagedInstallation;
export type { RenameFile };
export async function preflightLettaInstallation(
config: InstallConfig,
sources: LettaSources,
): Promise<LettaRuntimePaths> {
requireLettaHooksEnabled(sources);
return preflightManagedInstallation({
agentKey: AGENT_KEY,
hookLocations: [{
directoryLabel: SETTINGS_DIRECTORY_LABEL,
filePath: sources.global.filePath,
}],
config,
}, GUARD_SCRIPT, AUDIT_SCRIPT);
}
function readOnlyPreconditions(sources: LettaSources, changedPath?: string) {
return sources.preconditions.filter((condition) => (
changedPath === undefined || !sameLettaPath(condition.filePath, changedPath)
));
}
export async function prepareLettaInstallation(
config: InstallConfig,
sources: LettaSources,
rendered: RenderedLettaDocument,
): Promise<PreparedLettaInstallation> {
await preflightLettaInstallation(config, sources);
const settingsSource = rendered.next ?? rendered.document.raw;
const prepared = await prepareManagedInstallation({
agentKey: AGENT_KEY,
displayName: DISPLAY_NAME,
hookSources: [{
directoryLabel: SETTINGS_DIRECTORY_LABEL,
label: lettaDocumentLabel(rendered.document),
filePath: rendered.document.filePath,
expectedSource: rendered.document.exists ? rendered.document.raw : undefined,
expectedSnapshot: rendered.document.snapshot,
source: settingsSource,
}],
config,
auditOptions: LETTA_AUDIT_OPTIONS,
}, GUARD_SCRIPT, AUDIT_SCRIPT);
return {
...prepared,
transaction: {
...prepared.transaction,
preconditions: readOnlyPreconditions(sources, rendered.document.filePath),
},
};
}
export async function commitLettaInstallation(
prepared: PreparedLettaInstallation,
renameFile?: RenameFile,
): Promise<void> {
await commitManagedInstallation(prepared, renameFile);
}
export async function prepareLettaUninstall(
sources: LettaSources,
rendered: RenderedLettaDocument,
): Promise<PreparedManagedTransaction> {
const change = rendered.changed
? await prepareManagedFileChange({
filePath: rendered.document.filePath,
label: lettaDocumentLabel(rendered.document),
next: rendered.next,
mode: 0o600,
maximumBytes: MAX_SOURCE_BYTES,
expectedSource: rendered.document.exists ? rendered.document.raw : undefined,
expectedSnapshot: rendered.document.snapshot,
verifyExpectedSource: true,
})
: undefined;
return {
displayName: DISPLAY_NAME,
operation: 'uninstall',
directories: [{
path: path.dirname(rendered.document.filePath),
label: SETTINGS_DIRECTORY_LABEL,
}],
changes: change ? [change] : [],
preconditions: readOnlyPreconditions(
sources,
rendered.changed ? rendered.document.filePath : undefined,
),
};
}
export async function commitLettaUninstall(
prepared: PreparedManagedTransaction,
renameFile?: RenameFile,
): Promise<void> {
await commitManagedTransaction(prepared, renameFile);
}
+165
View File
@@ -0,0 +1,165 @@
import fsp from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import {
createLettaDocument,
lettaDocumentLabel,
lettaSourceLabel,
parseLettaDocument,
type LettaDocument,
type LettaDocumentKind,
} from './letta-config.js';
import {
inspectPhysicalDirectory,
readPhysicalFile,
type FileSnapshot,
} from './managed-files.js';
const MAX_SOURCE_BYTES = 2 * 1024 * 1024;
export interface LettaSourcePrecondition {
readonly filePath: string;
readonly label: string;
readonly maximumBytes: number;
readonly original?: FileSnapshot;
}
export interface LettaDisableControl {
readonly disabled: boolean;
readonly source?: LettaDocument;
}
export interface LettaSources {
readonly homeDirectory: string;
readonly global: LettaDocument;
readonly project: LettaDocument;
readonly projectLocal: LettaDocument;
readonly projectActive: boolean;
readonly disableControl: LettaDisableControl;
readonly preconditions: readonly LettaSourcePrecondition[];
}
function comparisonPath(filePath: string): string {
const resolved = path.resolve(filePath);
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
}
function hasCode(error: unknown, code: string): boolean {
return typeof error === 'object' && error !== null && 'code' in error && error.code === code;
}
async function canonicalPath(filePath: string): Promise<string> {
try {
return await fsp.realpath(filePath);
} catch (error) {
if (hasCode(error, 'ENOENT') || hasCode(error, 'ENOTDIR')) return path.resolve(filePath);
throw new Error(`Resolve Letta Code path at ${filePath}: ${
error instanceof Error ? error.message : String(error)
}`, { cause: error instanceof Error ? error : new Error(String(error)) });
}
}
function resolveHomeDirectory(): string {
return path.resolve(process.env.HOME || os.homedir());
}
async function readDocument(
kind: LettaDocumentKind,
filePath: string,
): Promise<LettaDocument> {
const snapshot = await readPhysicalFile(filePath, lettaSourceLabel(kind), MAX_SOURCE_BYTES);
return snapshot
? parseLettaDocument({
kind,
exists: true,
filePath,
raw: snapshot.contents,
snapshot,
})
: createLettaDocument(kind, filePath);
}
function sourcePrecondition(document: LettaDocument): LettaSourcePrecondition {
return {
filePath: document.filePath,
label: lettaDocumentLabel(document),
maximumBytes: MAX_SOURCE_BYTES,
original: document.snapshot,
};
}
function deduplicatePreconditions(
values: readonly LettaSourcePrecondition[],
): LettaSourcePrecondition[] {
const result = new Map<string, LettaSourcePrecondition>();
for (const value of values) {
const key = comparisonPath(value.filePath);
if (!result.has(key)) result.set(key, value);
}
return [...result.values()];
}
function effectiveDisable(
global: LettaDocument,
project: LettaDocument,
projectLocal: LettaDocument,
projectActive: boolean,
): LettaDisableControl {
if (global.hooks.disabled === false) return { disabled: false, source: global };
if (global.hooks.disabled === true) return { disabled: true, source: global };
if (projectActive && project.hooks.disabled === true) {
return { disabled: true, source: project };
}
if (projectLocal.hooks.disabled === true) {
return { disabled: true, source: projectLocal };
}
return { disabled: false };
}
export async function readLettaSources(): Promise<LettaSources> {
const homeDirectory = resolveHomeDirectory();
const workspace = process.cwd();
const globalDirectory = path.join(homeDirectory, '.letta');
const projectDirectory = path.join(workspace, '.letta');
await inspectPhysicalDirectory(homeDirectory, 'Letta Code home directory');
await inspectPhysicalDirectory(globalDirectory, 'Letta Code global configuration directory');
if (comparisonPath(projectDirectory) !== comparisonPath(globalDirectory)) {
await inspectPhysicalDirectory(projectDirectory, 'Letta Code project configuration directory');
}
const globalPath = path.join(globalDirectory, 'settings.json');
const projectPath = path.join(projectDirectory, 'settings.json');
const projectLocalPath = path.join(projectDirectory, 'settings.local.json');
const [canonicalWorkspace, canonicalHome] = await Promise.all([
canonicalPath(workspace),
canonicalPath(homeDirectory),
]);
const projectActive = comparisonPath(canonicalWorkspace) !== comparisonPath(canonicalHome);
const global = await readDocument('global', globalPath);
const project = projectActive
? await readDocument('project', projectPath)
: createLettaDocument('project', projectPath);
const projectLocal = await readDocument('project-local', projectLocalPath);
const preconditions = deduplicatePreconditions([
sourcePrecondition(global),
...(projectActive ? [sourcePrecondition(project)] : []),
sourcePrecondition(projectLocal),
]);
return {
homeDirectory,
global,
project,
projectLocal,
projectActive,
disableControl: effectiveDisable(global, project, projectLocal, projectActive),
preconditions,
};
}
export function requireLettaHooksEnabled(sources: LettaSources): void {
if (!sources.disableControl.disabled) return;
const source = sources.disableControl.source;
const location = source
? `${lettaDocumentLabel(source)} at ${source.filePath}`
: 'effective settings';
throw new Error(`Letta Code hooks are disabled by hooks.disabled in ${location}`);
}
+63 -146
View File
@@ -1,173 +1,90 @@
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,
LETTA_AUDIT_OPTIONS,
buildLettaGroup,
lettaRuntimeContracts,
type LettaGroup,
type ManagedLettaEvent,
} from './letta-contract.js';
import { renderLettaDocument } from './letta-config.js';
import {
commitLettaInstallation,
commitLettaUninstall,
preflightLettaInstallation,
prepareLettaInstallation,
prepareLettaUninstall,
} from './letta-installation.js';
import { managedRuntimeFilesExist } from './managed-runtime-status.js';
import { readLettaSources } from './letta-sources.js';
import { SUPPORTED_AGENTS } from './registry.js';
const AGENT_KEY = 'letta';
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
function resolveConfigDir(): string {
return entry.configDir.replace(/^~/, os.homedir());
function installedGroups(guardPath: string, auditPath: string): ReadonlyMap<
ManagedLettaEvent,
LettaGroup
> {
return new Map([
['PreToolUse', buildLettaGroup(guardPath)],
['PostToolUse', buildLettaGroup(auditPath)],
['PostToolUseFailure', buildLettaGroup(auditPath)],
]);
}
function resolveConfigPath(): string {
return path.join(resolveConfigDir(), entry.configFile);
}
function buildHookCommand(scriptPath: string): string {
return `node "${scriptPath}"`;
}
function isElydoraCommand(cmd: string, agentId?: string): boolean {
if (!cmd.includes('elydora')) return false;
if (agentId) {
return cmd.includes(agentId);
async function runtimeExists(
contracts: ReturnType<typeof lettaRuntimeContracts>,
): Promise<boolean> {
for (const contract of contracts) {
if (await managedRuntimeFilesExist(contract, AGENT_KEY, {
auditOptions: LETTA_AUDIT_OPTIONS,
})) return true;
}
return true;
}
function filterElydoraEntries(arr: Array<Record<string, unknown>>, agentId?: string): Array<Record<string, unknown>> {
return arr.filter((entry) => {
if (Array.isArray(entry.hooks)) {
const cmds = entry.hooks as Array<Record<string, unknown>>;
return !cmds.some((h) => typeof h.command === 'string' && isElydoraCommand(h.command, agentId));
}
if (typeof entry.command === 'string') {
return !isElydoraCommand(entry.command, agentId);
}
return true;
});
return false;
}
export const lettaPlugin: AgentPlugin = {
managesRuntime: true,
async preflightInstall(config: InstallConfig): Promise<void> {
const sources = await readLettaSources();
await preflightLettaInstallation(config, sources);
},
async install(config: InstallConfig): Promise<void> {
const configDir = resolveConfigDir();
await fsp.mkdir(configDir, { recursive: true });
const configPath = resolveConfigPath();
let settings: Record<string, unknown> = {};
try {
const raw = await fsp.readFile(configPath, 'utf-8');
settings = JSON.parse(raw);
} catch {
// File doesn't exist or isn't valid JSON — start fresh
}
if (!settings.hooks || typeof settings.hooks !== 'object') {
settings.hooks = {};
}
const hooks = settings.hooks as Record<string, unknown>;
// --- PreToolUse (guard — freeze enforcement) ---
if (!Array.isArray(hooks.PreToolUse)) {
hooks.PreToolUse = [];
}
const preFiltered = filterElydoraEntries(hooks.PreToolUse as Array<Record<string, unknown>>);
preFiltered.push({
matcher: '*',
hooks: [{ type: 'command', command: buildHookCommand(config.guardScriptPath) }],
});
hooks.PreToolUse = preFiltered;
// --- PostToolUse (audit logging) ---
if (!Array.isArray(hooks.PostToolUse)) {
hooks.PostToolUse = [];
}
const postFiltered = filterElydoraEntries(hooks.PostToolUse as Array<Record<string, unknown>>);
postFiltered.push({
matcher: '*',
hooks: [{ type: 'command', command: buildHookCommand(config.hookScriptPath) }],
});
hooks.PostToolUse = postFiltered;
settings.hooks = hooks;
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
const sources = await readLettaSources();
const paths = await preflightLettaInstallation(config, sources);
const rendered = renderLettaDocument(
sources.global,
undefined,
installedGroups(paths.guardPath, paths.auditPath),
);
await commitLettaInstallation(
await prepareLettaInstallation(config, sources, rendered),
);
console.log(` Letta Code hooks: ${sources.global.filePath}`);
console.log(' Letta Code verification: run /hooks and restart active sessions.');
},
async uninstall(agentId?: string): Promise<void> {
const configPath = resolveConfigPath();
let settings: Record<string, unknown>;
try {
const raw = await fsp.readFile(configPath, 'utf-8');
settings = JSON.parse(raw);
} catch {
return; // Nothing to uninstall
}
const hooks = settings.hooks as Record<string, unknown> | undefined;
if (!hooks) return;
if (Array.isArray(hooks.PreToolUse)) {
hooks.PreToolUse = filterElydoraEntries(hooks.PreToolUse as Array<Record<string, unknown>>, agentId);
}
if (Array.isArray(hooks.PostToolUse)) {
hooks.PostToolUse = filterElydoraEntries(hooks.PostToolUse as Array<Record<string, unknown>>, agentId);
}
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
const sources = await readLettaSources();
const rendered = renderLettaDocument(sources.global, agentId, new Map());
if (!rendered.changed) return;
await commitLettaUninstall(await prepareLettaUninstall(sources, rendered));
},
async status(): Promise<PluginStatus> {
const configPath = resolveConfigPath();
let hookConfigured = false;
let hookScriptPath = '';
try {
const raw = await fsp.readFile(configPath, 'utf-8');
const settings = JSON.parse(raw);
const hooks = settings.hooks as Record<string, unknown> | undefined;
if (hooks) {
const checkArr = (arr: unknown) => {
if (!Array.isArray(arr)) return false;
return (arr as Array<Record<string, unknown>>).some((entry) => {
if (Array.isArray(entry.hooks)) {
return (entry.hooks as Array<Record<string, unknown>>).some(
(h) => typeof h.command === 'string' && h.command.includes('elydora'),
);
}
return typeof entry.command === 'string' && entry.command.includes('elydora');
});
};
hookConfigured = checkArr(hooks.PreToolUse) && checkArr(hooks.PostToolUse);
// Extract hook script path from PostToolUse command
if (hookConfigured && Array.isArray(hooks.PostToolUse)) {
for (const e of hooks.PostToolUse as Array<Record<string, unknown>>) {
if (Array.isArray(e.hooks)) {
for (const h of e.hooks as Array<Record<string, unknown>>) {
const cmd = h.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 readLettaSources();
const contracts = lettaRuntimeContracts(sources.global.hooks);
const hookConfigured = !sources.disableControl.disabled && contracts.length > 0;
const hookScriptExists = hookConfigured ? await runtimeExists(contracts) : false;
return {
installed: hookConfigured && hookScriptExists,
agentName: AGENT_KEY,
displayName: entry.name,
hookConfigured,
hookScriptExists,
configPath,
configPath: sources.global.filePath,
};
},
};
@@ -0,0 +1,119 @@
import os from 'node:os';
import path from 'node:path';
import { generateGuardScript, type GuardScriptOptions } from './guard-template.js';
import { generateHookScript, type HookScriptOptions } from './hook-template.js';
import { inspectPhysicalDirectory, readPhysicalFile } from './managed-files.js';
import { samePath } from './managed-installation.js';
import { parseStrictJsonObject, type JsonObject } from './strict-json.js';
const MAX_SECRET_BYTES = 64 * 1024;
const MAX_CONFIG_BYTES = 512 * 1024;
const GUARD_SCRIPT = 'guard.js';
const AUDIT_SCRIPT = 'hook.js';
export interface ManagedRuntimeContract {
readonly agentId: string;
readonly guardPath: string;
readonly auditPath: string;
}
export interface ManagedRuntimeStatusOptions {
readonly guardOptions?: GuardScriptOptions;
readonly auditOptions?: HookScriptOptions;
}
function sameAgentId(left: string, right: string): boolean {
return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right;
}
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: ManagedRuntimeContract,
configPath: string,
agentKey: 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 (!sameAgentId(agentId, contract.agentId) || config.agent_name !== agentKey) {
throw new Error(`Elydora runtime identity does not match ${agentKey} 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: ManagedRuntimeContract): boolean {
const runtimeRoot = path.join(os.homedir(), '.elydora');
const agentDirectory = path.dirname(contract.guardPath);
return samePath(path.dirname(agentDirectory), runtimeRoot)
&& sameAgentId(path.basename(agentDirectory), contract.agentId)
&& samePath(contract.guardPath, path.join(agentDirectory, GUARD_SCRIPT))
&& samePath(contract.auditPath, path.join(agentDirectory, AUDIT_SCRIPT));
}
export async function managedRuntimeFilesExist(
contract: ManagedRuntimeContract,
agentKey: string,
options: ManagedRuntimeStatusOptions = {},
): 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,
agentKey,
);
validatePrivateKey(key.contents, keyPath);
return guard.contents === generateGuardScript(agentKey, contract.agentId, options.guardOptions)
&& audit.contents === generateHookScript(
agentKey,
contract.agentId,
options.auditOptions ?? { nativePayload: true },
);
}
+405
View File
@@ -0,0 +1,405 @@
import os from 'node:os';
import path from 'node:path';
import type { HookScriptOptions } from './hook-template.js';
import { samePath } from './managed-installation.js';
import { parseStrictJsonObject } from './strict-json.js';
export const AGENT_KEY = 'opencode';
export const AUDIT_SCRIPT = 'hook.js';
export const GUARD_SCRIPT = 'guard.js';
export const PLUGIN_FILE = 'elydora-audit.js';
export const LEGACY_PLUGIN_FILE = 'elydora-audit.mjs';
export const OPENCODE_AUDIT_OPTIONS = Object.freeze({
failClosed: true,
nativePayload: true,
}) satisfies HookScriptOptions;
const METADATA_MARKER = '// @elydora-opencode-plugin ';
const METADATA_VERSION = 1;
const MAX_RUNTIME_STDERR_BYTES = 64 * 1024;
const GUARD_TIMEOUT_MILLISECONDS = 5_000;
const AUDIT_TIMEOUT_MILLISECONDS = 7_000;
export interface OpenCodePaths {
readonly homeDirectory: string;
readonly configRoot: string;
readonly configDirectory: string;
readonly pluginsDirectory: string;
readonly pluginPath: string;
readonly legacyPluginPath: string;
}
export interface OpenCodeRuntimeContract {
readonly agentId: string;
readonly executablePath: string;
readonly guardPath: string;
readonly auditPath: string;
}
export interface OpenCodeLegacyContract {
readonly agentId: string;
readonly guardPath: string;
readonly auditPath: string;
}
interface OpenCodePluginMetadata {
readonly version: 1;
readonly agentName: 'opencode';
readonly agentId: string;
readonly executablePath: string;
readonly guardPath: string;
readonly auditPath: string;
}
export function sameOpenCodeAgentId(left: string, right: string): boolean {
return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right;
}
export function resolveOpenCodePaths(
homeDirectory = os.homedir(),
xdgConfigHome = process.env.XDG_CONFIG_HOME,
): OpenCodePaths {
const resolvedHome = path.resolve(homeDirectory);
const configRoot = xdgConfigHome || path.join(resolvedHome, '.config');
if (!path.isAbsolute(configRoot)) {
throw new Error('XDG_CONFIG_HOME must be an absolute path for OpenCode integration');
}
const configDirectory = path.join(configRoot, 'opencode');
const pluginsDirectory = path.join(configDirectory, 'plugins');
return {
homeDirectory: resolvedHome,
configRoot: path.normalize(configRoot),
configDirectory,
pluginsDirectory,
pluginPath: path.join(pluginsDirectory, PLUGIN_FILE),
legacyPluginPath: path.join(pluginsDirectory, LEGACY_PLUGIN_FILE),
};
}
function validAgentSegment(agentId: string): boolean {
return Boolean(agentId)
&& agentId !== '.'
&& agentId !== '..'
&& path.basename(agentId) === agentId;
}
function isNodeExecutable(filePath: string): boolean {
const basename = path.basename(filePath).toLowerCase();
return basename === 'node' || basename === 'node.exe';
}
function validateRuntimePaths(metadata: OpenCodePluginMetadata): void {
if (!validAgentSegment(metadata.agentId)) {
throw new Error('OpenCode plugin metadata contains an invalid agentId');
}
if (!path.isAbsolute(metadata.executablePath) || !isNodeExecutable(metadata.executablePath)) {
throw new Error('OpenCode plugin metadata executablePath must reference Node.js');
}
const agentDirectory = path.join(os.homedir(), '.elydora', metadata.agentId);
if (!samePath(metadata.guardPath, path.join(agentDirectory, GUARD_SCRIPT))
|| !samePath(metadata.auditPath, path.join(agentDirectory, AUDIT_SCRIPT))) {
throw new Error('OpenCode plugin metadata references an unexpected runtime path');
}
}
function validateMetadata(value: unknown): OpenCodePluginMetadata {
const object = value as Record<string, unknown>;
if (!object || typeof object !== 'object' || Array.isArray(object)) {
throw new Error('OpenCode plugin metadata must be an object');
}
if (Object.keys(object).sort().join(',') !== 'agentId,agentName,auditPath,executablePath,guardPath,version') {
throw new Error('OpenCode plugin metadata contains an unexpected field set');
}
if (object.version !== METADATA_VERSION) throw new Error('OpenCode plugin metadata version must be 1');
if (object.agentName !== AGENT_KEY) throw new Error('OpenCode plugin metadata agentName must be opencode');
for (const field of ['agentId', 'executablePath', 'guardPath', 'auditPath']) {
if (typeof object[field] !== 'string' || object[field].length === 0) {
throw new Error(`OpenCode plugin metadata ${field} must be a non-empty string`);
}
}
const metadata = object as unknown as OpenCodePluginMetadata;
validateRuntimePaths(metadata);
return metadata;
}
function encodeMetadata(metadata: OpenCodePluginMetadata): string {
return Buffer.from(JSON.stringify(metadata), 'utf-8').toString('base64url');
}
export function buildOpenCodeMetadata(
agentId: string,
executablePath: string,
guardPath: string,
auditPath: string,
): OpenCodeRuntimeContract {
return validateMetadata({
version: METADATA_VERSION,
agentName: AGENT_KEY,
agentId,
executablePath,
guardPath,
auditPath,
});
}
function normalizeGeneratedSource(source: string): string {
return source.replaceAll('\r\n', '\n');
}
export function buildOpenCodePlugin(contract: OpenCodeRuntimeContract): string {
const metadata = validateMetadata({
version: METADATA_VERSION,
agentName: AGENT_KEY,
...contract,
});
return normalizeGeneratedSource(`// Elydora Audit Plugin for OpenCode
${METADATA_MARKER}${encodeMetadata(metadata)}
// Generated by @elydora/sdk. DO NOT EDIT.
import { spawn, spawnSync } from 'node:child_process';
const RUNTIME_EXECUTABLE = ${JSON.stringify(metadata.executablePath)};
const GUARD_SCRIPT_PATH = ${JSON.stringify(metadata.guardPath)};
const AUDIT_SCRIPT_PATH = ${JSON.stringify(metadata.auditPath)};
const MAX_STDERR_BYTES = ${MAX_RUNTIME_STDERR_BYTES};
const GUARD_TIMEOUT_MS = ${GUARD_TIMEOUT_MILLISECONDS};
const AUDIT_TIMEOUT_MS = ${AUDIT_TIMEOUT_MILLISECONDS};
function eventPayload(eventName, input, output) {
return JSON.stringify({
hook_event_name: eventName,
tool_name: input?.tool || 'unknown',
tool_input: eventName === 'tool.execute.before' ? output?.args : input?.args,
session_id: input?.sessionID || 'unknown',
call_id: input?.callID || 'unknown',
input,
output,
});
}
function runGuard(input, output) {
let payload;
try {
payload = eventPayload('tool.execute.before', input, output);
} catch (error) {
throw new Error('Elydora guard input serialization failed: ' + error.message);
}
const result = spawnSync(RUNTIME_EXECUTABLE, [GUARD_SCRIPT_PATH], {
input: payload,
encoding: 'utf-8',
maxBuffer: MAX_STDERR_BYTES,
timeout: GUARD_TIMEOUT_MS,
windowsHide: true,
});
if (result.error) throw new Error('Elydora guard failed: ' + result.error.message);
const stderr = String(result.stderr || '').trim();
if (result.status !== 0) {
const detail = stderr || (result.signal
? 'Guard terminated by signal ' + result.signal
: 'Guard exited with status ' + result.status);
throw new Error((result.status === 2 ? '' : 'Elydora guard failed: ') + detail);
}
if (stderr) console.error(stderr);
}
function runAudit(input, output) {
let payload;
try {
payload = eventPayload('tool.execute.after', input, output);
} catch (error) {
console.error('[elydora] OpenCode audit input serialization failed:', error.message);
return Promise.resolve();
}
return new Promise((resolve) => {
const child = spawn(RUNTIME_EXECUTABLE, [AUDIT_SCRIPT_PATH], {
stdio: ['pipe', 'ignore', 'pipe'],
windowsHide: true,
});
const stderr = [];
let stderrBytes = 0;
let truncated = false;
let settled = false;
let timer;
const report = (message) => {
console.error('[elydora] OpenCode audit runtime failed:', message);
};
const complete = (failure) => {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
if (failure) report(failure);
resolve();
};
const stderrText = () => {
const detail = Buffer.concat(stderr).toString('utf-8').trim();
return truncated ? detail + '\\n[stderr truncated]' : detail;
};
child.stderr.on('data', (chunk) => {
const value = Buffer.from(chunk);
const remaining = MAX_STDERR_BYTES - stderrBytes;
if (remaining <= 0) {
truncated = true;
return;
}
stderr.push(value.subarray(0, remaining));
stderrBytes += Math.min(value.length, remaining);
if (value.length > remaining) truncated = true;
});
child.once('error', (error) => complete(error.message));
child.stdin.once('error', (error) => {
child.kill();
complete('failed to write hook input: ' + error.message);
});
child.once('close', (code, signal) => {
const detail = stderrText();
if (code !== 0 || signal) {
complete(detail || (signal
? 'runtime terminated by signal ' + signal
: 'runtime exited with code ' + code));
return;
}
if (detail) report(detail);
complete();
});
timer = setTimeout(() => {
child.kill();
complete('runtime timed out after ' + AUDIT_TIMEOUT_MS + 'ms');
}, AUDIT_TIMEOUT_MS);
try {
child.stdin.end(payload);
} catch (error) {
child.kill();
complete('failed to write hook input: ' + error.message);
}
});
}
export const ElydoraAuditPlugin = async () => ({
'tool.execute.before': async (input, output) => {
runGuard(input, output);
},
'tool.execute.after': async (input, output) => {
await runAudit(input, output);
},
});
`);
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export function parseOpenCodePlugin(
filePath: string,
source: string,
): OpenCodeRuntimeContract | undefined {
const markerLine = source.split(/\r?\n/, 3)[1];
if (!markerLine?.startsWith(METADATA_MARKER)) return undefined;
let metadata: OpenCodePluginMetadata;
try {
const encoded = markerLine.slice(METADATA_MARKER.length);
const decoded = Buffer.from(encoded, 'base64url').toString('utf-8');
if (Buffer.from(decoded, 'utf-8').toString('base64url') !== encoded) {
throw new Error('metadata encoding is not canonical base64url');
}
metadata = validateMetadata(parseStrictJsonObject(decoded, 'OpenCode plugin metadata'));
} catch (error) {
throw new Error(`Failed to parse Elydora OpenCode plugin metadata at ${filePath}: ${errorMessage(error)}`, {
cause: error instanceof Error ? error : new Error(String(error)),
});
}
if (source !== buildOpenCodePlugin(metadata)) {
throw new Error(`Elydora OpenCode plugin at ${filePath} does not match the managed template`);
}
return metadata;
}
export function buildLegacyOpenCodePlugin(auditPath: string, guardPath: string): string {
return normalizeGeneratedSource(`// Elydora Audit Plugin for OpenCode
// Generated by @elydora/sdk — DO NOT EDIT
import { spawn, spawnSync } from 'node:child_process';
const HOOK_SCRIPT_PATH = ${JSON.stringify(auditPath)};
const GUARD_SCRIPT_PATH = ${JSON.stringify(guardPath)};
export const ElydoraAuditPlugin = async (ctx) => {
return {
"tool.execute.before": async () => {
// Guard — blocks tool if agent is frozen
let result;
try {
result = spawnSync('node', [GUARD_SCRIPT_PATH], {
timeout: 5000,
stdio: ['pipe', 'ignore', 'pipe'],
});
} catch (error) {
throw new Error('Elydora guard failed: ' + error.message);
}
if (result.error) {
throw new Error('Elydora guard failed: ' + result.error.message);
}
if (result.status !== 0) {
const message = result.stderr?.toString().trim() || 'Guard exited with status ' + result.status;
const prefix = result.status === 2 ? '' : 'Elydora guard failed: ';
throw new Error(prefix + message);
}
},
"tool.execute.after": async (input, output) => {
const data = JSON.stringify({
tool_name: input?.tool || 'unknown',
tool_input: input?.args || output?.args || {},
session_id: input?.sessionID || ctx?.project?.name || 'unknown',
});
const child = spawn('node', [HOOK_SCRIPT_PATH], {
detached: true,
stdio: ['pipe', 'ignore', 'ignore'],
});
child.on('error', (error) => {
console.error('[elydora] audit hook failed:', error.message);
});
child.stdin.on('error', (error) => {
console.error('[elydora] audit input failed:', error.message);
});
child.stdin.end(data);
child.unref();
},
};
};
`);
}
function legacyRuntimeContract(
guardPath: string,
auditPath: string,
): OpenCodeLegacyContract | undefined {
if (!path.isAbsolute(guardPath) || !path.isAbsolute(auditPath)) return undefined;
const agentDirectory = path.dirname(guardPath);
if (!samePath(path.dirname(agentDirectory), path.join(os.homedir(), '.elydora'))
|| !samePath(auditPath, path.join(agentDirectory, AUDIT_SCRIPT))
|| !samePath(guardPath, path.join(agentDirectory, GUARD_SCRIPT))) return undefined;
const agentId = path.basename(agentDirectory);
return validAgentSegment(agentId) ? { agentId, guardPath, auditPath } : undefined;
}
export function parseLegacyOpenCodePlugin(source: string): OpenCodeLegacyContract | undefined {
const normalized = normalizeGeneratedSource(source);
const auditMatch = normalized.match(/^const HOOK_SCRIPT_PATH = ("(?:\\.|[^"\\])*");$/m);
const guardMatch = normalized.match(/^const GUARD_SCRIPT_PATH = ("(?:\\.|[^"\\])*");$/m);
if (!auditMatch || !guardMatch) return undefined;
let auditPath: unknown;
let guardPath: unknown;
try {
auditPath = JSON.parse(auditMatch[1]);
guardPath = JSON.parse(guardMatch[1]);
} catch {
return undefined;
}
if (typeof auditPath !== 'string' || typeof guardPath !== 'string'
|| normalized !== buildLegacyOpenCodePlugin(auditPath, guardPath)) return undefined;
return legacyRuntimeContract(guardPath, auditPath);
}
@@ -0,0 +1,132 @@
import path from 'node:path';
import type { InstallConfig } from './base.js';
import {
AGENT_KEY,
AUDIT_SCRIPT,
GUARD_SCRIPT,
OPENCODE_AUDIT_OPTIONS,
buildOpenCodeMetadata,
buildOpenCodePlugin,
sameOpenCodeAgentId,
} from './opencode-contract.js';
import type { OpenCodePluginFile, OpenCodeSources } from './opencode-io.js';
import {
commitManagedInstallation,
preflightManagedInstallation,
prepareManagedInstallation,
type ManagedHookSource,
type ManagedRuntimePaths,
type PreparedManagedInstallation,
type RenameFile,
} from './managed-installation.js';
import {
commitManagedTransaction,
prepareManagedFileChange,
type ManagedFileChange,
type PreparedManagedTransaction,
} from './managed-transaction.js';
const DISPLAY_NAME = 'OpenCode';
const DIRECTORY_LABEL = 'OpenCode plugins directory';
export type OpenCodeRuntimePaths = ManagedRuntimePaths;
export type PreparedOpenCodeInstallation = PreparedManagedInstallation;
export type { RenameFile };
export async function preflightOpenCodeInstallation(
config: InstallConfig,
sources: OpenCodeSources,
): Promise<OpenCodeRuntimePaths> {
return preflightManagedInstallation({
agentKey: AGENT_KEY,
hookLocations: [{
directoryLabel: DIRECTORY_LABEL,
filePath: sources.paths.pluginPath,
}],
config,
}, GUARD_SCRIPT, AUDIT_SCRIPT);
}
export async function prepareOpenCodeInstallation(
config: InstallConfig,
sources: OpenCodeSources,
): Promise<PreparedOpenCodeInstallation> {
const paths = await preflightOpenCodeInstallation(config, sources);
const metadata = buildOpenCodeMetadata(
config.agentId,
process.execPath,
paths.guardPath,
paths.auditPath,
);
const hookSources: ManagedHookSource[] = [{
directoryLabel: DIRECTORY_LABEL,
label: 'OpenCode plugin',
filePath: sources.current.filePath,
expectedSource: sources.current.source,
expectedSnapshot: sources.current.snapshot,
source: buildOpenCodePlugin(metadata),
}];
if (sources.legacy.contract) {
hookSources.push({
directoryLabel: DIRECTORY_LABEL,
label: 'legacy OpenCode plugin',
filePath: sources.legacy.filePath,
expectedSource: sources.legacy.source,
expectedSnapshot: sources.legacy.snapshot,
});
}
return prepareManagedInstallation({
agentKey: AGENT_KEY,
displayName: DISPLAY_NAME,
hookSources,
config,
auditOptions: OPENCODE_AUDIT_OPTIONS,
}, GUARD_SCRIPT, AUDIT_SCRIPT);
}
export async function commitOpenCodeInstallation(
prepared: PreparedOpenCodeInstallation,
renameFile?: RenameFile,
): Promise<void> {
await commitManagedInstallation(prepared, renameFile);
}
function ownedByAgent<TContract extends { readonly agentId: string }>(
file: OpenCodePluginFile<TContract>,
agentId?: string,
): boolean {
return file.contract !== undefined
&& (agentId === undefined || sameOpenCodeAgentId(file.contract.agentId, agentId));
}
export async function prepareOpenCodeUninstall(
sources: OpenCodeSources,
agentId?: string,
): Promise<PreparedManagedTransaction> {
const owned = [sources.current, sources.legacy].filter((file) => ownedByAgent(file, agentId));
const changes = await Promise.all(owned.map((file) => prepareManagedFileChange({
filePath: file.filePath,
label: file.filePath === sources.paths.pluginPath ? 'OpenCode plugin' : 'legacy OpenCode plugin',
next: undefined,
mode: 0o600,
expectedSource: file.source,
expectedSnapshot: file.snapshot,
verifyExpectedSource: true,
})));
return {
displayName: DISPLAY_NAME,
operation: 'uninstall',
directories: owned.map((file) => ({
path: path.dirname(file.filePath),
label: DIRECTORY_LABEL,
})),
changes: changes.filter((change): change is ManagedFileChange => change !== undefined),
};
}
export async function commitOpenCodeUninstall(
prepared: PreparedManagedTransaction,
renameFile?: RenameFile,
): Promise<void> {
await commitManagedTransaction(prepared, renameFile);
}
+98
View File
@@ -0,0 +1,98 @@
import fsp from 'node:fs/promises';
import {
AGENT_KEY,
OPENCODE_AUDIT_OPTIONS,
parseLegacyOpenCodePlugin,
parseOpenCodePlugin,
resolveOpenCodePaths,
type OpenCodeLegacyContract,
type OpenCodePaths,
type OpenCodeRuntimeContract,
} from './opencode-contract.js';
import { inspectPhysicalDirectory, readPhysicalFile, type FileSnapshot } from './managed-files.js';
import { managedRuntimeFilesExist } from './managed-runtime-status.js';
const MAX_PLUGIN_BYTES = 512 * 1024;
export interface OpenCodePluginFile<TContract> {
readonly exists: boolean;
readonly filePath: string;
readonly source?: string;
readonly snapshot?: FileSnapshot;
readonly contract?: TContract;
}
export interface OpenCodeSources {
readonly paths: OpenCodePaths;
readonly current: OpenCodePluginFile<OpenCodeRuntimeContract>;
readonly legacy: OpenCodePluginFile<OpenCodeLegacyContract>;
}
async function inspectOpenCodeDirectories(paths: OpenCodePaths): Promise<void> {
await inspectPhysicalDirectory(paths.homeDirectory, 'OpenCode home directory');
await inspectPhysicalDirectory(paths.configRoot, 'OpenCode XDG config directory');
await inspectPhysicalDirectory(paths.configDirectory, 'OpenCode config directory');
await inspectPhysicalDirectory(paths.pluginsDirectory, 'OpenCode plugins directory');
}
async function readPluginFile<TContract>(
filePath: string,
label: string,
parse: (filePath: string, source: string) => TContract | undefined,
): Promise<OpenCodePluginFile<TContract>> {
const snapshot = await readPhysicalFile(filePath, label, MAX_PLUGIN_BYTES);
if (!snapshot) return { exists: false, filePath };
return {
exists: true,
filePath,
source: snapshot.contents,
snapshot,
contract: parse(filePath, snapshot.contents),
};
}
export async function readOpenCodeSources(): Promise<OpenCodeSources> {
const paths = resolveOpenCodePaths();
await inspectOpenCodeDirectories(paths);
const [current, legacy] = await Promise.all([
readPluginFile(paths.pluginPath, 'OpenCode plugin', parseOpenCodePlugin),
readPluginFile(
paths.legacyPluginPath,
'legacy OpenCode plugin',
(_filePath, source) => parseLegacyOpenCodePlugin(source),
),
]);
return { paths, current, legacy };
}
export function requireAvailableOpenCodePlugin(
file: OpenCodePluginFile<OpenCodeRuntimeContract>,
): void {
if (file.exists && !file.contract) {
throw new Error(`OpenCode plugin path is owned by another integration: ${file.filePath}`);
}
}
function hasCode(error: unknown, code: string): boolean {
return typeof error === 'object' && error !== null && 'code' in error && error.code === code;
}
async function executableExists(filePath: string): Promise<boolean> {
try {
return (await fsp.stat(filePath)).isFile();
} catch (error) {
if (hasCode(error, 'ENOENT') || hasCode(error, 'ENOTDIR')) return false;
throw new Error(`Inspect OpenCode Node.js runtime at ${filePath}: ${
error instanceof Error ? error.message : String(error)
}`, { cause: error instanceof Error ? error : new Error(String(error)) });
}
}
export async function openCodeRuntimeFilesExist(
contract: OpenCodeRuntimeContract,
): Promise<boolean> {
return await executableExists(contract.executablePath)
&& await managedRuntimeFilesExist(contract, AGENT_KEY, {
auditOptions: OPENCODE_AUDIT_OPTIONS,
});
}
+40 -111
View File
@@ -1,134 +1,63 @@
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 } from './opencode-contract.js';
import {
commitOpenCodeInstallation,
commitOpenCodeUninstall,
preflightOpenCodeInstallation,
prepareOpenCodeInstallation,
prepareOpenCodeUninstall,
} from './opencode-installation.js';
import {
openCodeRuntimeFilesExist,
readOpenCodeSources,
requireAvailableOpenCodePlugin,
} from './opencode-io.js';
import { SUPPORTED_AGENTS } from './registry.js';
const AGENT_KEY = 'opencode';
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
function resolveConfigDir(): string {
return path.join(os.homedir(), entry.configDir);
}
function resolveConfigPath(): string {
return path.join(resolveConfigDir(), entry.configFile);
}
function generatePluginScript(hookScriptPath: string, guardScriptPath: string): string {
return `// Elydora Audit Plugin for OpenCode
// Generated by @elydora/sdk — DO NOT EDIT
import { spawn, spawnSync } from 'node:child_process';
const HOOK_SCRIPT_PATH = ${JSON.stringify(hookScriptPath)};
const GUARD_SCRIPT_PATH = ${JSON.stringify(guardScriptPath)};
export const ElydoraAuditPlugin = async (ctx) => {
return {
"tool.execute.before": async () => {
// Guard — blocks tool if agent is frozen
let result;
try {
result = spawnSync('node', [GUARD_SCRIPT_PATH], {
timeout: 5000,
stdio: ['pipe', 'ignore', 'pipe'],
});
} catch (error) {
throw new Error('Elydora guard failed: ' + error.message);
}
if (result.error) {
throw new Error('Elydora guard failed: ' + result.error.message);
}
if (result.status !== 0) {
const message = result.stderr?.toString().trim() || 'Guard exited with status ' + result.status;
const prefix = result.status === 2 ? '' : 'Elydora guard failed: ';
throw new Error(prefix + message);
}
},
"tool.execute.after": async (input, output) => {
const data = JSON.stringify({
tool_name: input?.tool || 'unknown',
tool_input: input?.args || output?.args || {},
session_id: input?.sessionID || ctx?.project?.name || 'unknown',
});
const child = spawn('node', [HOOK_SCRIPT_PATH], {
detached: true,
stdio: ['pipe', 'ignore', 'ignore'],
});
child.on('error', (error) => {
console.error('[elydora] audit hook failed:', error.message);
});
child.stdin.on('error', (error) => {
console.error('[elydora] audit input failed:', error.message);
});
child.stdin.end(data);
child.unref();
},
};
};
`;
function validateSources(sources: Awaited<ReturnType<typeof readOpenCodeSources>>): void {
requireAvailableOpenCodePlugin(sources.current);
}
export const opencodePlugin: AgentPlugin = {
async install(config: InstallConfig): Promise<void> {
const configDir = resolveConfigDir();
await fsp.mkdir(configDir, { recursive: true });
managesRuntime: true,
const configPath = resolveConfigPath();
const script = generatePluginScript(config.hookScriptPath, config.guardScriptPath);
await fsp.writeFile(configPath, script, 'utf-8');
async preflightInstall(config: InstallConfig): Promise<void> {
const sources = await readOpenCodeSources();
validateSources(sources);
await preflightOpenCodeInstallation(config, sources);
},
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 readOpenCodeSources();
validateSources(sources);
const prepared = await prepareOpenCodeInstallation(config, sources);
await commitOpenCodeInstallation(prepared);
console.log(` OpenCode global plugin: ${sources.paths.pluginPath}`);
console.log(' OpenCode activation: restart active OpenCode sessions.');
},
async uninstall(agentId?: string): Promise<void> {
const sources = await readOpenCodeSources();
const prepared = await prepareOpenCodeUninstall(sources, agentId);
await commitOpenCodeUninstall(prepared);
},
async status(): Promise<PluginStatus> {
const configPath = resolveConfigPath();
let hookConfigured = false;
let hookScriptPath = '';
let guardScriptPath = '';
try {
const raw = await fsp.readFile(configPath, 'utf-8');
const hookMatch = raw.match(/const HOOK_SCRIPT_PATH = ("(?:\\.|[^"\\])*");/);
const guardMatch = raw.match(/const GUARD_SCRIPT_PATH = ("(?:\\.|[^"\\])*");/);
if (hookMatch && guardMatch) {
hookScriptPath = JSON.parse(hookMatch[1]);
guardScriptPath = JSON.parse(guardMatch[1]);
hookConfigured = true;
}
} catch {
// File doesn't exist
}
let hookScriptExists = false;
if (hookScriptPath && guardScriptPath) {
try {
await fsp.access(hookScriptPath);
await fsp.access(guardScriptPath);
hookScriptExists = true;
} catch {
// File doesn't exist
}
}
const sources = await readOpenCodeSources();
validateSources(sources);
const contract = sources.legacy.contract ? undefined : sources.current.contract;
const hookConfigured = contract !== undefined;
const hookScriptExists = contract !== undefined
&& await openCodeRuntimeFilesExist(contract);
return {
installed: hookConfigured && hookScriptExists,
agentName: AGENT_KEY,
displayName: entry.name,
hookConfigured,
hookScriptExists,
configPath,
configPath: sources.paths.pluginPath,
};
},
};
+4 -4
View File
@@ -7,14 +7,14 @@ export interface AgentRegistryEntry {
export const SUPPORTED_AGENTS: ReadonlyMap<string, AgentRegistryEntry> = new Map([
['augment', { name: 'Augment Code CLI', configDir: '~/.augment', configFile: 'settings.json' }],
['claudecode', { name: 'Claude Code', configDir: '~/.claude', configFile: 'settings.json' }],
['cursor', { name: 'Cursor', configDir: '~/.cursor', configFile: 'hooks.json' }],
['cursor', { name: 'Cursor CLI', 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' }],
['opencode', { name: 'OpenCode', configDir: '.config/opencode/plugins', configFile: 'elydora-audit.mjs' }],
['kiroide', { name: 'Kiro IDE', configDir: '.kiro/hooks', configFile: 'elydora-audit.json' }],
['opencode', { name: 'OpenCode', configDir: '.config/opencode/plugins', configFile: 'elydora-audit.js' }],
['copilot', { name: 'GitHub Copilot CLI', configDir: '~/.copilot/hooks', configFile: 'elydora-audit.json' }],
['letta', { name: 'Letta Code', configDir: '~/.letta', configFile: 'settings.json' }],
['codex', { name: 'OpenAI Codex', configDir: '~/.codex', configFile: 'hooks.json' }],
['codex', { name: 'Codex CLI', configDir: '~/.codex', configFile: 'hooks.json' }],
['cline', { name: 'Cline', configDir: '~/.cline/hooks', configFile: 'PreToolUse.mjs' }],
['droid', { name: 'Factory Droid', configDir: '~/.factory', configFile: 'hooks.json' }],
['kimi', { name: 'Kimi Code', configDir: '~/.kimi-code', configFile: 'config.toml' }],