diff --git a/AGENTS.md b/AGENTS.md index 8fd8ddd..2802d34 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,12 +21,12 @@ This repository owns the published `@elydora/sdk` package, its `elydora` CLI, lo - Quote the current Node executable and generated script paths for the host shell. - Forward official hook JSON from STDIN without reshaping provider fields. - Use the provider's documented blocking mechanism. Command-hook providers that define exit code `2` must receive exit code `2` from the freeze guard. -- Report installation as healthy only when a complete hook contract references both generated runtime scripts and both scripts exist. +- Report installation as healthy only when a complete hook contract references every required event and both generated runtime scripts, runtime config, and private key exist as physical files. - Model stable, legacy, and early-access hook generations as explicit contracts. Keep their activation requirements visible in CLI output and README guidance. - Keep Grok Build writes inside its native global `$GROK_HOME/hooks/*.json` contract. Treat Claude Code and Cursor compatibility files plus project `.grok/hooks` as read-only integration sources. - Write Auggie hooks only to `~/.augment/settings.json`; keep system and workspace settings read-only. Generate `.cmd` wrappers on Windows and `.sh` wrappers on Unix because Auggie dispatches supported script paths, and express hook timeouts in milliseconds. - Write Cline hooks only to `$CLINE_DIR/hooks` with `~/.cline/hooks` as the default; keep Documents and workspace hook roots read-only. Translate guard exit code `2` into Cline's JSON stdout cancellation control and preserve official hook input byte-for-byte. -- Write Cursor hooks only to `~/.cursor/hooks.json`; keep project and enterprise sources read-only. Preserve native `tool_name`, `tool_input`, and `conversation_id` input, set a ten-second timeout plus `failClosed` on both managed handlers, and propagate guard exit code `2` through the host shell. +- Write Cursor hooks only to `~/.cursor/hooks.json`; keep project and enterprise sources read-only. Register `preToolUse`, `postToolUse`, and `postToolUseFailure`, preserve the complete native payload plus `conversation_id`, and emit valid JSON on successful hook execution. Commit Cursor's config, guard, audit runtime, runtime config, and private key through one rollback-capable transaction. Set a ten-second timeout plus `failClosed` on every managed handler and propagate guard exit code `2` through the host shell. - Preserve Factory Droid's scope-root `hooks.json` precedence, legacy nested hook source, and per-event `settings.json` fallback. Scope-root and legacy files store event keys at the document root; settings stores the same map under `hooks`. Edit only effective user sources; keep project and organization sources read-only. Preserve JSONC comments through syntax-tree edits and require `/hooks` review after external changes. - Resolve Qwen Code user settings through explicit `QWEN_HOME`, then user-level `.qwen/.env`, then `~/.env`, with `~/.qwen/settings.json` as the default. Keep workspace settings read-only. Preserve JSON comments, reject trailing commas and duplicate keys before writes, express timeouts in milliseconds, propagate native exit code `2` through PowerShell, and require `/hooks` review. diff --git a/README.md b/README.md index 6601f86..82b9f4f 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ The CLI reads the private key and optional API token through hidden terminal pro Codex performs a one-time trust review for user hooks. Run `/hooks` in Codex after installation and trust the Elydora `PreToolUse` and `PostToolUse` definitions. -Cursor installation writes native `preToolUse` and `postToolUse` handlers to `~/.cursor/hooks.json` for both the CLI and IDE. Project and enterprise hook sources remain unchanged. Managed commands use the current Node.js executable, preserve Cursor's JSON input, propagate freeze exit code `2`, and enable `failClosed` with a ten-second timeout. +Cursor installation writes native `preToolUse`, `postToolUse`, and `postToolUseFailure` handlers to `~/.cursor/hooks.json` for both the CLI and IDE. Project and enterprise hook sources remain unchanged. The installation commits Cursor's hook file, generated runtimes, runtime config, and private key as one rollback-capable update. Managed commands use the current Node.js executable, preserve the complete Cursor JSON payload, return valid JSON, propagate freeze and revocation exit code `2`, and enable `failClosed` with a ten-second timeout. Cline installation writes `PreToolUse.mjs` and `PostToolUse.mjs` to `$CLINE_DIR/hooks` (default `~/.cline/hooks`). Elydora leaves the Documents and workspace hook roots unchanged. The guard translates a frozen agent into Cline's JSON stdout cancellation control. diff --git a/src/cli.ts b/src/cli.ts index a0633bf..2caae9c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -188,41 +188,10 @@ async function cmdInstall(args: string[]): Promise { console.log(`Verifying private key... Public key: ${publicKey.slice(0, 12)}...`); - // Create ~/.elydora/{agentId}/ directory - await ensurePrivateDirectory(ELYDORA_DIR); - await ensurePrivateDirectory(agentDir); - - // Write agent config const agentConfigPath = path.join(agentDir, 'config.json'); - const agentConfig = { - org_id: orgId, - agent_id: agentId, - kid, - base_url: baseUrl, - ...(token ? { token } : {}), - agent_name: agentName, - }; - await writePrivateFile(agentConfigPath, JSON.stringify(agentConfig, null, 2) + '\n'); - console.log(` Agent config: ${agentConfigPath}`); - - // Write private key with owner-restricted access const keyPath = path.join(agentDir, 'private.key'); - await writePrivateFile(keyPath, privateKey); - console.log(` Private key: ${keyPath}`); - - // Generate and write hook script (PostToolUse — audit logging) const hookScriptPath = path.join(agentDir, 'hook.js'); - const hookScript = generateHookScript(agentName, agentId); - await fsp.writeFile(hookScriptPath, hookScript, { encoding: 'utf-8', mode: 0o755 }); - console.log(` Hook script: ${hookScriptPath}`); - - // Generate and write guard script (PreToolUse — freeze enforcement) const guardScriptPath = path.join(agentDir, 'guard.js'); - const guardScript = generateGuardScript(agentName, agentId); - await fsp.writeFile(guardScriptPath, guardScript, { encoding: 'utf-8', mode: 0o755 }); - console.log(` Guard script: ${guardScriptPath}`); - - // Install agent-specific config hook const plugin = PLUGINS.get(agentName)!; const installConfig: InstallConfig = { agentName, @@ -235,8 +204,32 @@ async function cmdInstall(args: string[]): Promise { hookScriptPath, guardScriptPath, }; + await plugin.preflightInstall?.(installConfig); + + if (!plugin.managesRuntime) { + await ensurePrivateDirectory(ELYDORA_DIR); + await ensurePrivateDirectory(agentDir); + const agentConfig = { + org_id: orgId, + agent_id: agentId, + kid, + base_url: baseUrl, + ...(token ? { token } : {}), + agent_name: agentName, + }; + await writePrivateFile(agentConfigPath, JSON.stringify(agentConfig, null, 2) + '\n'); + await writePrivateFile(keyPath, privateKey); + const hookScript = generateHookScript(agentName, agentId); + await fsp.writeFile(hookScriptPath, hookScript, { encoding: 'utf-8', mode: 0o755 }); + const guardScript = generateGuardScript(agentName, agentId); + await fsp.writeFile(guardScriptPath, guardScript, { encoding: 'utf-8', mode: 0o755 }); + } await plugin.install(installConfig); + console.log(` Agent config: ${agentConfigPath}`); + console.log(` Private key: ${keyPath}`); + console.log(` Hook script: ${hookScriptPath}`); + console.log(` Guard script: ${guardScriptPath}`); const entry = SUPPORTED_AGENTS.get(agentName)!; console.log(`\nElydora audit hook installed for ${entry.name}.`); diff --git a/src/plugins/base.ts b/src/plugins/base.ts index 7e64532..7126ee3 100644 --- a/src/plugins/base.ts +++ b/src/plugins/base.ts @@ -20,6 +20,8 @@ export interface PluginStatus { } export interface AgentPlugin { + readonly managesRuntime?: boolean; + preflightInstall?(config: InstallConfig): Promise; install(config: InstallConfig): Promise; uninstall(agentId?: string): Promise; status(): Promise; diff --git a/src/plugins/cursor-contract.ts b/src/plugins/cursor-contract.ts index 7e151ab..d5fa40e 100644 --- a/src/plugins/cursor-contract.ts +++ b/src/plugins/cursor-contract.ts @@ -193,7 +193,7 @@ function rejectDuplicateKeys(node: Node | undefined, label: string, location: st } } -function parseJsonObject(raw: string, label: string): JsonObject { +export function parseStrictJsonObject(raw: string, label: string): JsonObject { const errors: ParseError[] = []; const value: unknown = parse(raw, errors, { allowTrailingComma: false, @@ -233,13 +233,17 @@ function readHooks(value: unknown, label: string): CursorHooks { export function parseDocument(filePath: string, raw: string): CursorDocument { const label = `Cursor user hooks at ${filePath}`; - const root = parseJsonObject(raw, label); - if (root.version !== 1) throw new Error(`${label} must declare version 1`); + const root = parseStrictJsonObject(raw, label); + const hooks = readHooks(root.hooks, label); + const hasVersion = Object.prototype.hasOwnProperty.call(root, 'version'); + if (root.version !== 1 && (hasVersion || !containsManagedHook(hooks))) { + throw new Error(`${label} must declare version 1`); + } return { exists: true, filePath, root, - hooks: readHooks(root.hooks, label), + hooks, raw, }; } @@ -255,6 +259,7 @@ export function removeManagedHooks(hooks: CursorHooks, agentId?: string): Cursor for (const [event, scriptName] of [ ['preToolUse', GUARD_SCRIPT], ['postToolUse', AUDIT_SCRIPT], + ['postToolUseFailure', AUDIT_SCRIPT], ] as const) { const handlers = (next[event] ?? []).filter((handler) => { const managedId = managedAgentId(handler, scriptName); @@ -277,7 +282,7 @@ function entirelyManaged(document: CursorDocument): boolean { for (const [event, handlers] of events) { const scriptName = event === 'preToolUse' ? GUARD_SCRIPT - : event === 'postToolUse' + : event === 'postToolUse' || event === 'postToolUseFailure' ? AUDIT_SCRIPT : undefined; if (!scriptName || handlers.length === 0) return false; @@ -315,12 +320,21 @@ function managedIds(handlers: JsonObject[], scriptName: string): Map managedAgentId(handler, GUARD_SCRIPT)), + ...(hooks.postToolUse ?? []).map((handler) => managedAgentId(handler, AUDIT_SCRIPT)), + ...(hooks.postToolUseFailure ?? []).map((handler) => managedAgentId(handler, AUDIT_SCRIPT)), + ].some((agentId) => agentId !== undefined); +} + export function runtimeContracts(hooks: CursorHooks): RuntimeContract[] { const guards = managedIds(hooks.preToolUse ?? [], GUARD_SCRIPT); const audits = managedIds(hooks.postToolUse ?? [], AUDIT_SCRIPT); + const failures = managedIds(hooks.postToolUseFailure ?? [], AUDIT_SCRIPT); const root = path.join(os.homedir(), '.elydora'); return [...guards] - .filter(([key]) => audits.has(key)) + .filter(([key]) => audits.has(key) && failures.has(key)) .map(([, agentId]) => ({ agentId, guardPath: path.join(root, agentId, GUARD_SCRIPT), diff --git a/src/plugins/cursor-installation.ts b/src/plugins/cursor-installation.ts new file mode 100644 index 0000000..742b20c --- /dev/null +++ b/src/plugins/cursor-installation.ts @@ -0,0 +1,448 @@ +import { randomUUID } from 'node:crypto'; +import { constants } from 'node:fs'; +import fsp from 'node:fs/promises'; +import type { FileHandle } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { ensurePrivateDirectory, resolvePrivateChildDirectory } from '../runtime-paths.js'; +import type { InstallConfig } from './base.js'; +import { + AGENT_KEY, + AUDIT_SCRIPT, + GUARD_SCRIPT, + type JsonObject, + type RenderedDocument, + parseStrictJsonObject, + samePath, +} from './cursor-contract.js'; +import { generateGuardScript } from './guard-template.js'; +import { generateHookScript } from './hook-template.js'; +import { cursorConfigPath } from './cursor-io.js'; + +const MAX_SECRET_BYTES = 64 * 1024; +const MAX_CONFIG_BYTES = 512 * 1024; +const MAX_SOURCE_BYTES = 2 * 1024 * 1024; + +interface FileSnapshot { + readonly contents: string; + readonly device: bigint | number; + readonly inode: bigint | number; + readonly mode: number; +} + +interface FileChange { + readonly filePath: string; + readonly label: string; + readonly next: string; + readonly mode: number; + readonly original?: FileSnapshot; +} + +interface StagedChange { + readonly change: FileChange; + readonly temporaryPath: string; + readonly rollbackPath?: string; + committed: boolean; +} + +export interface CursorRuntimePaths { + readonly runtimeRoot: string; + readonly agentDirectory: string; + readonly configPath: string; + readonly keyPath: string; + readonly guardPath: string; + readonly auditPath: string; +} + +export interface PreparedCursorInstallation { + readonly paths: CursorRuntimePaths; + readonly changes: readonly FileChange[]; +} + +export type RenameFile = (source: string, destination: string) => Promise; + +function asError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function hasCode(error: unknown, code: string): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === code; +} + +async function readPhysicalFile( + filePath: string, + label: string, + maximumBytes = MAX_SOURCE_BYTES, +): Promise { + let before; + try { + before = await fsp.lstat(filePath, { bigint: true }); + } catch (error) { + if (hasCode(error, 'ENOENT')) return undefined; + throw new Error(`Inspect ${label} at ${filePath}: ${errorMessage(error)}`, { + cause: asError(error), + }); + } + if (!before.isFile() || before.isSymbolicLink()) { + throw new Error(`${label} path is not a physical file: ${filePath}`); + } + if (before.size > BigInt(maximumBytes)) { + throw new Error(`${label} exceeds ${maximumBytes} bytes: ${filePath}`); + } + let flags = constants.O_RDONLY; + if (process.platform !== 'win32' && typeof constants.O_NOFOLLOW === 'number') { + flags |= constants.O_NOFOLLOW; + } + let handle: FileHandle | undefined; + try { + handle = await fsp.open(filePath, flags); + const after = await handle.stat({ bigint: true }); + if (!after.isFile() || before.dev !== after.dev || before.ino !== after.ino) { + throw new Error(`${label} changed while opening: ${filePath}`); + } + const contents = await handle.readFile('utf-8'); + if (Buffer.byteLength(contents) > maximumBytes) { + throw new Error(`${label} exceeds ${maximumBytes} bytes: ${filePath}`); + } + return { + contents, + device: after.dev, + inode: after.ino, + mode: Number(after.mode & 0o777n), + }; + } finally { + await handle?.close(); + } +} + +async function inspectDirectory(directory: string, label: string): Promise { + let metadata; + try { + metadata = await fsp.lstat(directory); + } catch (error) { + if (hasCode(error, 'ENOENT')) return false; + throw new Error(`Inspect ${label} at ${directory}: ${errorMessage(error)}`, { + cause: asError(error), + }); + } + if (!metadata.isDirectory() || metadata.isSymbolicLink()) { + throw new Error(`${label} is not a physical directory: ${directory}`); + } + return true; +} + +function validateInstallConfig(config: InstallConfig): void { + for (const [field, value] of [ + ['orgId', config.orgId], + ['agentId', config.agentId], + ['kid', config.kid], + ['privateKey', config.privateKey], + ] as const) { + if (!value) throw new Error(`${field} is required`); + } + if (config.agentName !== AGENT_KEY) { + throw new Error(`Cursor installation requires agentName ${AGENT_KEY}`); + } + const seed = Buffer.from(config.privateKey, 'base64url'); + if (seed.length !== 32 || seed.toString('base64url') !== config.privateKey) { + throw new Error('privateKey must be a canonical 32-byte base64url value'); + } + const baseUrl = new URL(config.baseUrl); + if (!['http:', 'https:'].includes(baseUrl.protocol)) { + throw new Error('baseUrl must use HTTP or HTTPS'); + } +} + +export function cursorRuntimePaths(config: InstallConfig): CursorRuntimePaths { + validateInstallConfig(config); + const runtimeRoot = path.join(os.homedir(), '.elydora'); + const agentDirectory = resolvePrivateChildDirectory(runtimeRoot, config.agentId); + const guardPath = path.join(agentDirectory, GUARD_SCRIPT); + const auditPath = path.join(agentDirectory, AUDIT_SCRIPT); + if (!samePath(config.guardScriptPath, guardPath)) { + throw new Error(`Elydora guard runtime must use the managed agent directory: ${guardPath}`); + } + if (!samePath(config.hookScriptPath, auditPath)) { + throw new Error(`Elydora audit runtime must use the managed agent directory: ${auditPath}`); + } + return { + runtimeRoot, + agentDirectory, + configPath: path.join(agentDirectory, 'config.json'), + keyPath: path.join(agentDirectory, 'private.key'), + guardPath, + auditPath, + }; +} + +function sameAgentId(left: unknown, right: string): boolean { + return typeof left === 'string' && (process.platform === 'win32' + ? left.toLowerCase() === right.toLowerCase() + : left === right); +} + +async function validateRuntimeIdentity(paths: CursorRuntimePaths): Promise { + const runtimeRootExists = await inspectDirectory(paths.runtimeRoot, 'Elydora runtime directory'); + if (!runtimeRootExists) return; + const agentDirectoryExists = await inspectDirectory( + paths.agentDirectory, + 'Elydora agent runtime directory', + ); + if (!agentDirectoryExists) return; + const config = await readPhysicalFile(paths.configPath, 'Elydora runtime config', MAX_CONFIG_BYTES); + const artifacts = await Promise.all([ + readPhysicalFile(paths.keyPath, 'Elydora private key', MAX_SECRET_BYTES), + readPhysicalFile(paths.guardPath, 'Elydora guard runtime'), + readPhysicalFile(paths.auditPath, 'Elydora audit runtime'), + readPhysicalFile(path.join(paths.agentDirectory, 'chain-state.json'), 'Elydora chain state'), + readPhysicalFile(path.join(paths.agentDirectory, 'status-cache.json'), 'Elydora status cache'), + ]); + if (!config) { + if (artifacts.some(Boolean)) { + throw new Error( + `Elydora runtime identity cannot be verified without config.json: ${paths.agentDirectory}`, + ); + } + return; + } + const value = parseStrictJsonObject(config.contents, `Elydora runtime config at ${paths.configPath}`); + if (value.agent_name !== AGENT_KEY || !sameAgentId(value.agent_id, path.basename(paths.agentDirectory))) { + throw new Error( + `Elydora runtime config identity does not match Cursor agent ${path.basename(paths.agentDirectory)}: ${paths.configPath}`, + ); + } +} + +export async function preflightCursorInstallation(config: InstallConfig): Promise { + const paths = cursorRuntimePaths(config); + await inspectDirectory(path.dirname(cursorConfigPath()), 'Cursor hooks directory'); + await validateRuntimeIdentity(paths); + return paths; +} + +function runtimeConfig(config: InstallConfig): string { + const value: JsonObject = { + org_id: config.orgId, + agent_id: config.agentId, + kid: config.kid, + base_url: config.baseUrl, + ...(config.token ? { token: config.token } : {}), + agent_name: AGENT_KEY, + }; + const encoded = `${JSON.stringify(value, null, 2)}\n`; + if (Buffer.byteLength(encoded) > MAX_CONFIG_BYTES) { + throw new Error(`Elydora runtime config exceeds ${MAX_CONFIG_BYTES} bytes`); + } + return encoded; +} + +async function prepareChange( + filePath: string, + label: string, + next: string, + mode: number, + maximumBytes = MAX_SOURCE_BYTES, +): Promise { + return { + filePath, + label, + next, + mode, + original: await readPhysicalFile(filePath, label, maximumBytes), + }; +} + +export async function prepareCursorInstallation( + config: InstallConfig, + rendered: RenderedDocument, +): Promise { + const paths = await preflightCursorInstallation(config); + if (!rendered.changed && rendered.document.raw === undefined) { + throw new Error('Cursor hook installation did not produce a configuration document'); + } + const hooksConfig = rendered.next ?? rendered.document.raw; + if (hooksConfig === undefined) throw new Error('Cursor hook configuration is missing'); + const changes = await Promise.all([ + prepareChange( + paths.guardPath, + 'Elydora guard runtime', + generateGuardScript(AGENT_KEY, config.agentId, { + failClosed: true, + successOutput: '{"permission":"allow"}\n', + }), + 0o700, + ), + prepareChange(paths.configPath, 'Elydora runtime config', runtimeConfig(config), 0o600, MAX_CONFIG_BYTES), + prepareChange(paths.keyPath, 'Elydora private key', config.privateKey, 0o600, MAX_SECRET_BYTES), + prepareChange( + paths.auditPath, + 'Elydora audit runtime', + generateHookScript(AGENT_KEY, config.agentId, { + failClosed: true, + nativePayload: true, + successOutput: '{}\n', + }), + 0o700, + ), + prepareChange( + rendered.document.filePath, + 'Cursor user hooks', + hooksConfig, + 0o600, + MAX_SOURCE_BYTES, + ), + ]); + const hooksSource = changes.at(-1)!.original?.contents; + if (hooksSource !== rendered.document.raw) { + throw new Error( + `Cursor user hooks changed before installation: ${rendered.document.filePath}`, + ); + } + await validateRuntimeIdentity(paths); + return { paths, changes }; +} + +async function unlinkOptional(filePath: string): Promise { + try { + await fsp.unlink(filePath); + } catch (error) { + if (!hasCode(error, 'ENOENT')) throw error; + } +} + +async function assertUnchanged(change: FileChange): Promise { + const current = await readPhysicalFile(change.filePath, change.label); + if ((!current && change.original) + || (current && !change.original) + || (current && change.original && ( + current.contents !== change.original.contents + || current.device !== change.original.device + || current.inode !== change.original.inode + ))) { + throw new Error(`${change.label} changed during Cursor installation: ${change.filePath}`); + } +} + +async function writeExclusive( + filePath: string, + contents: string, + mode: number, + label: string, +): Promise { + let handle: FileHandle | undefined; + try { + handle = await fsp.open(filePath, 'wx', mode); + await handle.writeFile(contents, 'utf-8'); + await handle.sync(); + await handle.close(); + handle = undefined; + if (process.platform !== 'win32') await fsp.chmod(filePath, mode); + } catch (error) { + const failures = [asError(error)]; + if (handle) { + try { await handle.close(); } catch (closeError) { failures.push(asError(closeError)); } + } + try { await unlinkOptional(filePath); } catch (cleanupError) { + failures.push(asError(cleanupError)); + } + if (failures.length > 1) throw new AggregateError(failures, `Stage ${label}`); + throw new Error(`Stage ${label}: ${errorMessage(error)}`, { cause: failures[0] }); + } +} + +async function stage(change: FileChange): Promise { + await assertUnchanged(change); + const token = randomUUID(); + const directory = path.dirname(change.filePath); + const temporaryPath = path.join(directory, `.${path.basename(change.filePath)}.${token}.tmp`); + const rollbackPath = change.original + ? path.join(directory, `.${path.basename(change.filePath)}.${token}.rollback`) + : undefined; + try { + await writeExclusive(temporaryPath, change.next, change.mode, change.label); + if (rollbackPath && change.original) { + await writeExclusive( + rollbackPath, + change.original.contents, + change.original.mode, + `${change.label} rollback`, + ); + } + return { change, temporaryPath, rollbackPath, committed: false }; + } catch (error) { + const failures = [asError(error)]; + for (const item of [temporaryPath, rollbackPath]) { + if (!item) continue; + try { await unlinkOptional(item); } catch (cleanupError) { + failures.push(asError(cleanupError)); + } + } + if (failures.length > 1) throw new AggregateError(failures, `Stage ${change.label}`); + throw error; + } +} + +async function commit(staged: StagedChange, renameFile: RenameFile): Promise { + await assertUnchanged(staged.change); + await renameFile(staged.temporaryPath, staged.change.filePath); + staged.committed = true; +} + +async function rollback(staged: StagedChange, renameFile: RenameFile): Promise { + if (!staged.committed) return; + if (!staged.change.original) { + await unlinkOptional(staged.change.filePath); + return; + } + if (!staged.rollbackPath) throw new Error(`Missing rollback data for ${staged.change.label}`); + await renameFile(staged.rollbackPath, staged.change.filePath); +} + +async function cleanup(staged: StagedChange): Promise { + await Promise.all( + [staged.temporaryPath, staged.rollbackPath].filter(Boolean).map((item) => unlinkOptional(item!)), + ); +} + +async function ensureCursorDirectory(directory: string): Promise { + await fsp.mkdir(directory, { recursive: true, mode: 0o700 }); + await inspectDirectory(directory, 'Cursor hooks directory'); +} + +export async function commitCursorInstallation( + prepared: PreparedCursorInstallation, + renameFile: RenameFile = fsp.rename, +): Promise { + await ensurePrivateDirectory(prepared.paths.runtimeRoot); + await ensurePrivateDirectory(prepared.paths.agentDirectory); + await ensureCursorDirectory(path.dirname(prepared.changes.at(-1)!.filePath)); + const staged: StagedChange[] = []; + try { + for (const change of prepared.changes) staged.push(await stage(change)); + for (const item of staged) await commit(item, renameFile); + } catch (error) { + const failures = [asError(error)]; + for (const item of [...staged].reverse()) { + try { await rollback(item, renameFile); } catch (rollbackError) { + failures.push(asError(rollbackError)); + } + } + for (const item of staged) { + try { await cleanup(item); } catch (cleanupError) { + failures.push(asError(cleanupError)); + } + } + throw new AggregateError(failures, `Install Cursor hooks: ${errorMessage(error)}`); + } + const failures: Error[] = []; + for (const item of staged) { + try { await cleanup(item); } catch (error) { failures.push(asError(error)); } + } + if (failures.length > 0) { + throw new AggregateError(failures, 'Clean Cursor installation transaction files'); + } +} diff --git a/src/plugins/cursor-io.ts b/src/plugins/cursor-io.ts index 71b8a8e..84bfe89 100644 --- a/src/plugins/cursor-io.ts +++ b/src/plugins/cursor-io.ts @@ -13,6 +13,7 @@ import { createDocument, isObject, parseDocument, + parseStrictJsonObject, } from './cursor-contract.js'; function errorMessage(error: unknown): string { @@ -204,18 +205,7 @@ export async function requireRuntime(filePath: string, label: string): Promise { const raw = await readOptionalPhysical(filePath, 'Elydora runtime config'); if (raw === undefined) return undefined; - let value: unknown; - try { - value = JSON.parse(raw); - } catch (error) { - throw new Error(`Failed to parse Elydora runtime config at ${filePath}: ${errorMessage(error)}`, { - cause: asError(error), - }); - } - if (!isObject(value)) { - throw new Error(`Elydora runtime config at ${filePath} must contain a JSON object`); - } - return value; + return parseStrictJsonObject(raw, `Elydora runtime config at ${filePath}`); } function sameAgentId(left: unknown, right: string): boolean { @@ -236,6 +226,10 @@ export async function runtimeFilesExist(contracts: RuntimeContract[]): Promise { + await readDocument(); + await preflightCursorInstallation(config); + }, + async install(config: InstallConfig): Promise { - if (!config.agentId) throw new Error('agentId is required'); const document = await readDocument(); - const runtimeRoot = path.join(os.homedir(), '.elydora'); - const agentDirectory = resolvePrivateChildDirectory(runtimeRoot, config.agentId); - const expectedGuard = path.join(agentDirectory, GUARD_SCRIPT); - const expectedAudit = path.join(agentDirectory, AUDIT_SCRIPT); - if (!samePath(config.guardScriptPath, expectedGuard)) { - throw new Error(`Elydora guard runtime must use the managed agent directory: ${expectedGuard}`); - } - if (!samePath(config.hookScriptPath, expectedAudit)) { - throw new Error(`Elydora audit runtime must use the managed agent directory: ${expectedAudit}`); - } - await requireRuntime(expectedGuard, 'Elydora guard runtime'); - await requireRuntime(expectedAudit, 'Elydora audit runtime'); + const paths = await preflightCursorInstallation(config); const hooks = removeManagedHooks(document.hooks); - hooks.preToolUse = [...(hooks.preToolUse ?? []), buildHandler(expectedGuard)]; - hooks.postToolUse = [...(hooks.postToolUse ?? []), buildHandler(expectedAudit)]; - await writeDocument(renderDocument(document, hooks)); + hooks.preToolUse = [...(hooks.preToolUse ?? []), buildHandler(paths.guardPath)]; + hooks.postToolUse = [...(hooks.postToolUse ?? []), buildHandler(paths.auditPath)]; + hooks.postToolUseFailure = [ + ...(hooks.postToolUseFailure ?? []), + buildHandler(paths.auditPath), + ]; + const prepared = await prepareCursorInstallation( + config, + renderDocument(document, hooks), + ); + await commitCursorInstallation(prepared); console.log(` Cursor hooks: ${document.filePath}`); }, diff --git a/src/plugins/guard-template.ts b/src/plugins/guard-template.ts new file mode 100644 index 0000000..17f6fce --- /dev/null +++ b/src/plugins/guard-template.ts @@ -0,0 +1,190 @@ +import { PROTECTED_RUNTIME_READER } from './runtime-reader-template.js'; + +export interface GuardScriptOptions { + readonly failClosed?: boolean; + readonly successOutput?: string; +} + +export function generateGuardScript( + agentName: string, + agentId: string, + options: GuardScriptOptions = {}, +): string { + const failClosed = options.failClosed ?? false; + const successOutput = options.successOutput ?? ''; + return `#!/usr/bin/env node +'use strict'; + +// Elydora agent guard for ${agentName}. Generated by @elydora/sdk. +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); + +const AGENT_NAME = ${JSON.stringify(agentName)}; +const AGENT_ID = ${JSON.stringify(agentId)}; +const SUCCESS_OUTPUT = ${JSON.stringify(successOutput)}; +const FAIL_CLOSED = ${JSON.stringify(failClosed)}; +const ELYDORA_DIR = path.join(os.homedir(), '.elydora'); +const CONFIG_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'config.json'); +const STATUS_CACHE_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'status-cache.json'); +const CACHE_TTL_MS = 60000; + +${PROTECTED_RUNTIME_READER} + +function validateStatus(value, source) { + if (!['active', 'frozen', 'revoked'].includes(value)) { + throw new Error(source + ' contains an invalid agent status'); + } + return value; +} + +function blockAgent(status) { + const state = status === 'frozen' ? 'frozen' : 'revoked'; + const message = 'Agent "' + AGENT_NAME + '" is ' + state + ' in Elydora.'; + if (SUCCESS_OUTPUT) { + process.stdout.write(JSON.stringify({ + permission: 'deny', + userMessage: message, + agentMessage: message + ' Tool execution is blocked.', + }) + '\\n'); + } + process.stderr.write('[Elydora guard] ' + message + ' Tool execution blocked.\\n'); + process.exitCode = 2; +} + +function readStatusCache() { + let raw; + try { + raw = fs.readFileSync(STATUS_CACHE_PATH, 'utf-8'); + } catch (error) { + if (error.code === 'ENOENT') return undefined; + throw new Error('Failed to read status cache: ' + error.message); + } + let cache; + try { + cache = JSON.parse(raw); + } catch (error) { + throw new Error('Status cache is invalid JSON: ' + error.message); + } + if (!cache || typeof cache !== 'object' || !Number.isFinite(cache.cached_at)) { + throw new Error('Status cache has an invalid shape'); + } + const age = Date.now() - cache.cached_at; + if (age < 0) throw new Error('Status cache timestamp is in the future'); + return { status: validateStatus(cache.status, 'Status cache'), age }; +} + +function writeStatusCache(status) { + const temporaryPath = STATUS_CACHE_PATH + '.' + process.pid + '.tmp'; + try { + fs.writeFileSync(temporaryPath, JSON.stringify({ + status, + cached_at: Date.now(), + }), { encoding: 'utf-8', mode: 0o600, flag: 'wx' }); + try { + fs.renameSync(temporaryPath, STATUS_CACHE_PATH); + } catch (error) { + if (process.platform !== 'win32' || !['EEXIST', 'EPERM'].includes(error.code)) throw error; + try { fs.unlinkSync(STATUS_CACHE_PATH); } catch (unlinkError) { + if (unlinkError.code !== 'ENOENT') throw unlinkError; + } + fs.renameSync(temporaryPath, STATUS_CACHE_PATH); + } + } finally { + try { fs.unlinkSync(temporaryPath); } catch (error) { + if (error.code !== 'ENOENT') throw error; + } + } +} + +async function readInput() { + const chunks = []; + for await (const chunk of process.stdin) chunks.push(chunk); + const raw = Buffer.concat(chunks).toString('utf-8').trim(); + if (!raw) { + if (FAIL_CLOSED) throw new Error('Hook input is empty'); + return; + } + try { + JSON.parse(raw); + } catch (error) { + if (FAIL_CLOSED) throw new Error('Hook input is invalid JSON: ' + error.message); + } +} + +function readConfig() { + const raw = readProtectedFile( + CONFIG_PATH, + 'Agent config', + MAX_PROTECTED_CONFIG_BYTES, + ).toString('utf-8'); + let config; + try { + config = JSON.parse(raw); + } catch (error) { + throw new Error('Agent config is invalid JSON: ' + error.message); + } + if (!config || typeof config !== 'object') throw new Error('Agent config must be an object'); + if (config.agent_id !== AGENT_ID + || (config.agent_name !== undefined && config.agent_name !== AGENT_NAME)) { + throw new Error('Agent config identity does not match the generated guard'); + } + return config; +} + +async function main() { + await readInput(); + let config; + try { + config = readConfig(); + } catch (error) { + if (FAIL_CLOSED) throw new Error('Failed to read agent config: ' + error.message); + process.stderr.write('[Elydora guard] Failed to read agent config: ' + error.message + '\\n'); + return; + } + + try { + const cache = readStatusCache(); + if (cache && cache.age < CACHE_TTL_MS) { + if (cache.status !== 'active') blockAgent(cache.status); + return; + } + } catch (error) { + if (FAIL_CLOSED) throw error; + process.stderr.write('[Elydora guard] ' + error.message + '\\n'); + } + + const baseUrl = String(config.base_url || 'https://api.elydora.com').replace(/\\\/+$/, ''); + const token = config.token || ''; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 3000); + try { + const headers = { Accept: 'application/json' }; + if (token) headers.Authorization = 'Bearer ' + token; + const response = await fetch(baseUrl + '/v1/agents/' + encodeURIComponent(AGENT_ID), { + headers, + signal: controller.signal, + }); + if (!response.ok) throw new Error('Agent status API returned HTTP ' + response.status); + const data = await response.json(); + const status = validateStatus(data && data.agent && data.agent.status, 'Agent status API'); + writeStatusCache(status); + if (status !== 'active') blockAgent(status); + } catch (error) { + if (FAIL_CLOSED) throw new Error('Failed to resolve agent status: ' + error.message); + process.stderr.write('[Elydora guard] Failed to resolve agent status: ' + error.message + '\\n'); + } finally { + clearTimeout(timeout); + } +} + +main().then(() => { + if ((!process.exitCode || process.exitCode === 0) && SUCCESS_OUTPUT) { + process.stdout.write(SUCCESS_OUTPUT); + } +}).catch((error) => { + process.stderr.write('[Elydora guard] ' + (error.stack || error.message || String(error)) + '\\n'); + process.exitCode = 1; +}); +`; +} diff --git a/src/plugins/hook-template.ts b/src/plugins/hook-template.ts index 475c7b7..5e53447 100644 --- a/src/plugins/hook-template.ts +++ b/src/plugins/hook-template.ts @@ -1,49 +1,59 @@ -/** - * Generates a self-contained Node.js hook script that embeds all crypto logic - * inline so it has zero runtime dependency on the SDK. - */ +import { PROTECTED_RUNTIME_READER } from './runtime-reader-template.js'; -export function generateHookScript(agentName: string, agentId: string): string { +export { generateGuardScript } from './guard-template.js'; +export type { GuardScriptOptions } from './guard-template.js'; + +export interface HookScriptOptions { + readonly failClosed?: boolean; + readonly nativePayload?: boolean; + readonly successOutput?: string; +} + +/** Generates a self-contained Node.js audit hook with no SDK runtime dependency. */ +export function generateHookScript( + agentName: string, + agentId: string, + options: HookScriptOptions = {}, +): string { + const failClosed = options.failClosed ?? false; + const nativePayload = options.nativePayload ?? false; + const successOutput = options.successOutput ?? ''; return `#!/usr/bin/env node 'use strict'; -// --------------------------------------------------------------------------- -// Self-contained Elydora audit hook for: ${agentName} -// Generated by @elydora/sdk — DO NOT EDIT -// --------------------------------------------------------------------------- - +// Elydora audit hook for ${agentName}. Generated by @elydora/sdk. const crypto = require('node:crypto'); const fs = require('node:fs'); const path = require('node:path'); const os = require('node:os'); + const AGENT_NAME = ${JSON.stringify(agentName)}; const AGENT_ID = ${JSON.stringify(agentId)}; +const SUCCESS_OUTPUT = ${JSON.stringify(successOutput)}; +const FAIL_CLOSED = ${JSON.stringify(failClosed)}; +const NATIVE_PAYLOAD = ${JSON.stringify(nativePayload)}; const ELYDORA_DIR = path.join(os.homedir(), '.elydora'); const CONFIG_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'config.json'); 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'; -// --------------------------------------------------------------------------- -// Embedded crypto (from @elydora/sdk src/crypto.ts + src/utils.ts) -// --------------------------------------------------------------------------- +${PROTECTED_RUNTIME_READER} function base64urlEncode(data) { - const buf = Buffer.isBuffer(data) ? data : Buffer.from(data); - return buf.toString('base64url'); + const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data); + return buffer.toString('base64url'); } function jcsCanonicalise(value) { if (value === null || value === undefined) return 'null'; if (typeof value === 'boolean' || typeof value === 'number') return JSON.stringify(value); if (typeof value === 'string') return JSON.stringify(value); - if (Array.isArray(value)) { - return '[' + value.map(v => jcsCanonicalise(v)).join(',') + ']'; - } + if (Array.isArray(value)) return '[' + value.map(jcsCanonicalise).join(',') + ']'; if (typeof value === 'object') { - const keys = Object.keys(value).sort(); const pairs = []; - for (const key of keys) { + for (const key of Object.keys(value).sort()) { if (value[key] !== undefined) { pairs.push(JSON.stringify(key) + ':' + jcsCanonicalise(value[key])); } @@ -62,9 +72,8 @@ function computePayloadHash(payload) { return sha256Base64url(jcsCanonicalise(payload)); } -function computeChainHash(prevChainHash, payloadHash, operationId, issuedAt) { - const input = prevChainHash + '|' + payloadHash + '|' + operationId + '|' + issuedAt; - return sha256Base64url(input); +function computeChainHash(previous, payload, operationId, issuedAt) { + return sha256Base64url(previous + '|' + payload + '|' + operationId + '|' + issuedAt); } const PKCS8_ED25519_PREFIX = Buffer.from([ @@ -72,15 +81,25 @@ const PKCS8_ED25519_PREFIX = Buffer.from([ 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20, ]); -function signEd25519(privateKeyBase64url, data) { - const seed = Buffer.from(privateKeyBase64url, 'base64url'); - const pkcs8 = Buffer.concat([PKCS8_ED25519_PREFIX, seed]); - const keyObject = crypto.createPrivateKey({ key: pkcs8, format: 'der', type: 'pkcs8' }); - const signature = crypto.sign(null, data, keyObject); - return base64urlEncode(signature); +function parsePrivateKey(raw) { + let value = raw.toString('utf-8'); + if (value.endsWith('\\r\\n')) value = value.slice(0, -2); + else if (value.endsWith('\\n')) value = value.slice(0, -1); + if (!value || value.includes('\\r') || value.includes('\\n') || value.includes('\\0')) { + throw new Error('Private key file must contain exactly one line'); + } + const seed = Buffer.from(value, 'base64url'); + if (seed.length !== 32 || base64urlEncode(seed) !== value) { + throw new Error('Private key must be a canonical 32-byte base64url value'); + } + return seed; } -const ZERO_CHAIN_HASH = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; +function signEd25519(seed, data) { + const pkcs8 = Buffer.concat([PKCS8_ED25519_PREFIX, seed]); + const key = crypto.createPrivateKey({ key: pkcs8, format: 'der', type: 'pkcs8' }); + return base64urlEncode(crypto.sign(null, data, key)); +} function uuidv7() { const now = Date.now(); @@ -103,308 +122,217 @@ function uuidv7() { bytes[14] = random[8]; bytes[15] = random[9]; const hex = bytes.toString('hex'); - return ( - hex.slice(0, 8) + '-' + - hex.slice(8, 12) + '-' + - hex.slice(12, 16) + '-' + - hex.slice(16, 20) + '-' + - hex.slice(20, 32) - ); + return hex.slice(0, 8) + '-' + hex.slice(8, 12) + '-' + hex.slice(12, 16) + '-' + + hex.slice(16, 20) + '-' + hex.slice(20); } -function generateNonce() { - return base64urlEncode(crypto.randomBytes(16)); +function logError(error) { + try { + const message = new Date().toISOString() + ' [' + AGENT_NAME + '] ' + + (error.stack || error.message || String(error)) + '\\n'; + fs.appendFileSync(ERROR_LOG_PATH, message, { encoding: 'utf-8', mode: 0o600 }); + } catch (logFailure) { + process.stderr.write('[Elydora audit] Failed to write error log: ' + logFailure.message + '\\n'); + } } -// --------------------------------------------------------------------------- -// Chain state persistence (atomic writes) -// --------------------------------------------------------------------------- - function readChainState() { + let raw; try { - const raw = fs.readFileSync(CHAIN_STATE_PATH, 'utf-8'); - const state = JSON.parse(raw); - return state.prev_chain_hash || ZERO_CHAIN_HASH; - } catch { - return ZERO_CHAIN_HASH; + raw = fs.readFileSync(CHAIN_STATE_PATH, 'utf-8'); + } catch (error) { + if (error.code === 'ENOENT') return ZERO_CHAIN_HASH; + throw new Error('Failed to read chain state: ' + error.message); } + let state; + try { + state = JSON.parse(raw); + } catch (error) { + throw new Error('Chain state is invalid JSON: ' + error.message); + } + if (!state || typeof state.prev_chain_hash !== 'string' || + !/^[A-Za-z0-9_-]{43}$/.test(state.prev_chain_hash)) { + throw new Error('Chain state contains an invalid prev_chain_hash'); + } + return state.prev_chain_hash; } function writeChainState(chainHash) { - const tmpPath = CHAIN_STATE_PATH + '.tmp'; - fs.writeFileSync(tmpPath, JSON.stringify({ prev_chain_hash: chainHash }), 'utf-8'); + const temporaryPath = CHAIN_STATE_PATH + '.' + process.pid + '.tmp'; + let descriptor; try { - fs.renameSync(tmpPath, CHAIN_STATE_PATH); - } catch (err) { - if (err.code === 'EPERM' || err.code === 'EEXIST') { - try { fs.unlinkSync(CHAIN_STATE_PATH); } catch { /* ignore */ } - fs.renameSync(tmpPath, CHAIN_STATE_PATH); - } else { - throw err; - } - } -} - -// --------------------------------------------------------------------------- -// Error logging -// --------------------------------------------------------------------------- - -function logError(err) { - try { - const ts = new Date().toISOString(); - const msg = ts + ' [' + AGENT_NAME + '] ' + (err.stack || err.message || String(err)) + '\\n'; - fs.appendFileSync(ERROR_LOG_PATH, msg, 'utf-8'); - } catch { - // Silently ignore logging failures - } -} - -// --------------------------------------------------------------------------- -// Main hook logic -// --------------------------------------------------------------------------- - -async function main() { - try { - // Read JSON from stdin - const chunks = []; - for await (const chunk of process.stdin) { - chunks.push(chunk); - } - const rawInput = Buffer.concat(chunks).toString('utf-8').trim(); - if (!rawInput) return; - - let hookData; + descriptor = fs.openSync(temporaryPath, 'wx', 0o600); + fs.writeFileSync(descriptor, JSON.stringify({ prev_chain_hash: chainHash }), 'utf-8'); + fs.fsyncSync(descriptor); + fs.closeSync(descriptor); + descriptor = undefined; try { - hookData = JSON.parse(rawInput); - } catch { - return; - } - - // Extract provider-native tool fields without reshaping the hook input. - const clineToolEvent = - 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 || clineToolEvent.name || 'unknown'; - const toolInput = - hookData.tool_input || hookData.toolInput || hookData.input || hookData.parameters || - clineToolEvent.input || {}; - const sessionId = - hookData.session_id || hookData.sessionId || hookData.session || hookData.taskId || 'unknown'; - - // Read agent config and private key - let config; - let privateKey; - try { - config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); - privateKey = fs.readFileSync(KEY_PATH, 'utf-8').trim(); - } catch (err) { - logError(new Error('Failed to read agent config/key: ' + err.message)); - return; - } - - const orgId = config.org_id; - const agentId = config.agent_id; - const kid = config.kid; - const baseUrl = (config.base_url || 'https://api.elydora.com').replace(/\\/+$/, ''); - const token = config.token || ''; - - // Read chain state - const prevChainHash = readChainState(); - - // Build EOR - const operationId = uuidv7(); - const issuedAt = Date.now(); - const nonce = generateNonce(); - - const payload = { - tool_name: toolName, - tool_input: toolInput, - session_id: sessionId, - }; - - const payloadHash = computePayloadHash(payload); - const chainHash = computeChainHash(prevChainHash, payloadHash, operationId, issuedAt); - - const eorWithoutSig = { - op_version: '1.0', - operation_id: operationId, - org_id: orgId, - agent_id: agentId, - issued_at: issuedAt, - ttl_ms: 30000, - nonce: nonce, - operation_type: 'ai.tool_use', - subject: { session_id: sessionId }, - action: { tool: toolName }, - payload: payload, - payload_hash: payloadHash, - prev_chain_hash: prevChainHash, - agent_pubkey_kid: kid, - }; - - const canonical = jcsCanonicalise(eorWithoutSig); - const signature = signEd25519(privateKey, Buffer.from(canonical, 'utf-8')); - - const eor = Object.assign({}, eorWithoutSig, { chain_hash: chainHash, signature: signature }); - - // POST to /v1/operations (5s timeout) - // Only update local chain state on success to prevent desync - const url = baseUrl + '/v1/operations'; - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 5000); - - try { - const headers = { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - }; - if (token) { - headers['Authorization'] = 'Bearer ' + token; + fs.renameSync(temporaryPath, CHAIN_STATE_PATH); + } catch (error) { + if (process.platform !== 'win32' || !['EEXIST', 'EPERM'].includes(error.code)) throw error; + try { fs.unlinkSync(CHAIN_STATE_PATH); } catch (unlinkError) { + if (unlinkError.code !== 'ENOENT') throw unlinkError; } - - const res = await fetch(url, { - method: 'POST', - headers: headers, - body: JSON.stringify(eor), - signal: controller.signal, - }); - - if (res.ok) { - // Server accepted — safe to advance local chain state - writeChainState(chainHash); - } else if (res.status === 400) { - // Possible chain hash mismatch — try to resync from server response - try { - const errBody = await res.json(); - if (errBody.error && errBody.error.code === 'PREV_HASH_MISMATCH') { - // Extract the expected hash from the error message - const match = (errBody.error.message || '').match(/Expected prev_chain_hash "([^"]+)"/); - if (match) { - writeChainState(match[1]); - logError(new Error('Chain hash resynced to server: ' + match[1])); - } - } - } catch { /* ignore parse errors */ } - } - } catch { - // Network error — don't advance chain state, will retry with same prev_chain_hash - } finally { - clearTimeout(timeout); + fs.renameSync(temporaryPath, CHAIN_STATE_PATH); + } + } finally { + if (descriptor !== undefined) fs.closeSync(descriptor); + try { fs.unlinkSync(temporaryPath); } catch (error) { + if (error.code !== 'ENOENT') throw error; } - } catch (err) { - logError(err); } } -main(); -`; -} - -/** - * Generates a lightweight PreToolUse guard script that checks if the agent - * is frozen and blocks tool execution if so. - * - * - Caches the agent status locally (60s TTL) to avoid hitting the API on every tool call - * - Fails open: if the API is unreachable, tool execution is allowed - * - If frozen: exits with code 2, the blocking code shared by supported hook CLIs - */ -export function generateGuardScript(agentName: string, agentId: string): string { - return `#!/usr/bin/env node -'use strict'; - -// --------------------------------------------------------------------------- -// Elydora agent guard (PreToolUse) for: ${agentName} -// Generated by @elydora/sdk — DO NOT EDIT -// --------------------------------------------------------------------------- - -const fs = require('node:fs'); -const path = require('node:path'); -const os = require('node:os'); - -const AGENT_NAME = ${JSON.stringify(agentName)}; -const AGENT_ID = ${JSON.stringify(agentId)}; -const ELYDORA_DIR = path.join(os.homedir(), '.elydora'); -const CONFIG_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'config.json'); -const STATUS_CACHE_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'status-cache.json'); -const CACHE_TTL_MS = 60000; // 60 seconds - -// Discard stdin immediately so the process can exit cleanly -process.stdin.destroy(); - -async function main() { - // Read agent config +function readConfigAndKey() { + const raw = readProtectedFile( + CONFIG_PATH, + 'Agent config', + MAX_PROTECTED_CONFIG_BYTES, + ).toString('utf-8'); let config; try { - config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); - } catch { - return; // Can't read config — fail-open (allow) + config = JSON.parse(raw); + } catch (error) { + throw new Error('Agent config is invalid JSON: ' + error.message); } - - // Check cached status - try { - const raw = fs.readFileSync(STATUS_CACHE_PATH, 'utf-8'); - const cache = JSON.parse(raw); - const age = Date.now() - (cache.cached_at || 0); - - if (age < CACHE_TTL_MS) { - if (cache.status === 'frozen') { - process.stderr.write('Agent "' + AGENT_NAME + '" is frozen by Elydora. Tool execution blocked.\\n'); - process.exitCode = 2; - } - return; // Cache is fresh — use cached result - } - } catch { - // No cache or invalid — need to check API + if (!config || typeof config !== 'object') throw new Error('Agent config must be an object'); + if (config.agent_name !== AGENT_NAME || config.agent_id !== AGENT_ID) { + throw new Error('Agent config identity does not match the generated audit hook'); } - - // Fetch agent status from API - const baseUrl = (config.base_url || 'https://api.elydora.com').replace(/\\/+$/, ''); - const token = config.token || ''; - const agentId = config.agent_id; - - try { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 3000); - - const headers = { 'Accept': 'application/json' }; - if (token) { - headers['Authorization'] = 'Bearer ' + token; + for (const field of ['org_id', 'agent_id', 'kid']) { + if (typeof config[field] !== 'string' || !config[field]) { + throw new Error('Agent config is missing ' + field); } + } + if (config.token !== undefined && typeof config.token !== 'string') { + throw new Error('Agent config token must be a string'); + } + const baseUrl = config.base_url || 'https://api.elydora.com'; + if (typeof baseUrl !== 'string') throw new Error('Agent config base_url must be a string'); + const parsedBaseUrl = new URL(baseUrl); + if (!['http:', 'https:'].includes(parsedBaseUrl.protocol)) { + throw new Error('Agent config base_url must use HTTP or HTTPS'); + } + return { + config, + baseUrl: baseUrl.replace(/\\\/+$/, ''), + privateKey: parsePrivateKey(readProtectedFile(KEY_PATH, 'Private key')), + }; +} - const res = await fetch(baseUrl + '/v1/agents/' + encodeURIComponent(agentId), { - headers: headers, +async function readHookInput() { + const chunks = []; + for await (const chunk of process.stdin) chunks.push(chunk); + const raw = Buffer.concat(chunks).toString('utf-8').trim(); + if (!raw) throw new Error('Hook input is empty'); + let value; + try { + value = JSON.parse(raw); + } catch (error) { + throw new Error('Hook input is invalid JSON: ' + error.message); + } + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Hook input must contain a JSON object'); + } + 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(); + 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', + operation_id: operationId, + org_id: runtime.config.org_id, + agent_id: runtime.config.agent_id, + issued_at: issuedAt, + ttl_ms: 30000, + nonce: base64urlEncode(crypto.randomBytes(16)), + operation_type: 'ai.tool_use', + subject: { session_id: sessionId }, + action: { tool: toolName }, + payload, + payload_hash: payloadHash, + prev_chain_hash: previousHash, + agent_pubkey_kid: runtime.config.kid, + }; + const signature = signEd25519( + 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, }); - clearTimeout(timeout); - - if (res.ok) { - const data = await res.json(); - const agentStatus = (data.agent && data.agent.status) || 'active'; - - // Update cache - try { - fs.writeFileSync(STATUS_CACHE_PATH, JSON.stringify({ - status: agentStatus, - cached_at: Date.now(), - }), 'utf-8'); - } catch { - // Ignore cache write failures + if (response.ok) { + writeChainState(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 (agentStatus === 'frozen') { - process.stderr.write('Agent "' + AGENT_NAME + '" is frozen by Elydora. Tool execution blocked.\\n'); - process.exitCode = 2; + 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])); + } } } - } catch { - // API unreachable — fail-open (allow) + throw new Error('Audit API returned HTTP ' + response.status); + } finally { + clearTimeout(timeout); } } -main(); +async function main() { + try { + const hookData = await readHookInput(); + const runtime = readConfigAndKey(); + await submitOperation(hookData, runtime); + } catch (error) { + logError(error); + if (FAIL_CLOSED) throw error; + } +} + +main().then(() => { + if (SUCCESS_OUTPUT) process.stdout.write(SUCCESS_OUTPUT); +}).catch((error) => { + process.stderr.write('[Elydora audit] ' + (error.stack || error.message || String(error)) + '\\n'); + process.exitCode = 1; +}); `; } diff --git a/src/plugins/runtime-reader-template.ts b/src/plugins/runtime-reader-template.ts new file mode 100644 index 0000000..c44785d --- /dev/null +++ b/src/plugins/runtime-reader-template.ts @@ -0,0 +1,44 @@ +export const PROTECTED_RUNTIME_READER: string = `const MAX_PROTECTED_SECRET_BYTES = 64 * 1024; +const MAX_PROTECTED_CONFIG_BYTES = 512 * 1024; + +function readProtectedFile(filePath, label, maxBytes = MAX_PROTECTED_SECRET_BYTES) { + const before = fs.lstatSync(filePath); + if (!before.isFile() || before.isSymbolicLink()) { + throw new Error(label + ' path is not a physical file: ' + filePath); + } + if (before.size > maxBytes) { + throw new Error(label + ' exceeds the size limit: ' + filePath); + } + if (process.platform !== 'win32' && (before.mode & 0o077) !== 0) { + throw new Error(label + ' permissions are too broad: ' + filePath); + } + + let flags = fs.constants.O_RDONLY; + if (process.platform !== 'win32' && typeof fs.constants.O_NOFOLLOW === 'number') { + flags |= fs.constants.O_NOFOLLOW; + } + const descriptor = fs.openSync(filePath, flags); + try { + const after = fs.fstatSync(descriptor); + if (!after.isFile()) { + throw new Error(label + ' path is not a physical file: ' + filePath); + } + if (after.size > maxBytes) { + throw new Error(label + ' exceeds the size limit: ' + filePath); + } + if (process.platform !== 'win32' && (after.mode & 0o077) !== 0) { + throw new Error(label + ' permissions are too broad: ' + filePath); + } + if (before.dev !== after.dev || before.ino !== after.ino) { + throw new Error(label + ' changed while opening: ' + filePath); + } + const raw = fs.readFileSync(descriptor); + if (raw.length > maxBytes) { + throw new Error(label + ' exceeds the size limit: ' + filePath); + } + return raw; + } finally { + fs.closeSync(descriptor); + } +} +`; diff --git a/test-support/cursor-test-helpers.mjs b/test-support/cursor-test-helpers.mjs new file mode 100644 index 0000000..7d968be --- /dev/null +++ b/test-support/cursor-test-helpers.mjs @@ -0,0 +1,178 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import http from 'node:http'; +import os from 'node:os'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +export const VALID_PRIVATE_KEY = Buffer.alloc(32).toString('base64url'); +export const pluginModuleUrl = pathToFileURL(path.resolve('dist/plugins/cursor.js')).href; +export const registryModuleUrl = pathToFileURL(path.resolve('dist/plugins/registry.js')).href; +export const ioModuleUrl = pathToFileURL(path.resolve('dist/plugins/cursor-io.js')).href; +export const contractModuleUrl = pathToFileURL(path.resolve('dist/plugins/cursor-contract.js')).href; +export const installationModuleUrl = pathToFileURL( + path.resolve('dist/plugins/cursor-installation.js'), +).href; + +export function runNode(args, env, cwd, input = '') { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, args, { + cwd, + env: { ...process.env, ...env }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { stdout += chunk; }); + child.stderr.on('data', (chunk) => { stderr += chunk; }); + child.once('error', reject); + child.once('close', (code) => resolve({ code, stdout, stderr })); + child.stdin.end(input); + }); +} + +export function runHook(handler, input, fixture, environment = {}) { + const executable = process.platform === 'win32' ? 'powershell.exe' : '/bin/sh'; + const args = process.platform === 'win32' + ? ['-NoProfile', '-NonInteractive', '-Command', handler.command] + : ['-c', handler.command]; + return new Promise((resolve, reject) => { + const child = spawn(executable, args, { + env: { + ...process.env, + HOME: fixture.homeDir, + USERPROFILE: fixture.homeDir, + ...environment, + }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { stdout += chunk; }); + child.stderr.on('data', (chunk) => { stderr += chunk; }); + child.once('error', reject); + child.once('close', (code) => resolve({ code, stdout, stderr })); + child.stdin.end(input); + }); +} + +export async function writeJson(filePath, value) { + await mkdir(path.dirname(filePath), { recursive: true }); + const contents = typeof value === 'string' ? value : `${JSON.stringify(value, null, 2)}\n`; + await writeFile(filePath, contents, { encoding: 'utf-8', mode: 0o600 }); +} + +export function installConfig(fixture, overrides = {}) { + return { + agentName: 'cursor', + orgId: 'org-1', + agentId: 'agent-1', + privateKey: VALID_PRIVATE_KEY, + kid: 'kid-1', + token: 'token-1', + baseUrl: fixture.baseUrl, + guardScriptPath: fixture.guardScriptPath, + hookScriptPath: fixture.hookScriptPath, + ...overrides, + }; +} + +export async function runPlugin(fixture, method, argument) { + const source = ` + import { cursorPlugin } from ${JSON.stringify(pluginModuleUrl)}; + const argument = JSON.parse(process.env.ELYDORA_TEST_ARGUMENT); + const result = await cursorPlugin[process.env.ELYDORA_TEST_METHOD](argument); + if (result !== undefined) console.log(JSON.stringify(result)); + `; + return runNode( + ['--input-type=module', '--eval', source], + { + HOME: fixture.homeDir, + USERPROFILE: fixture.homeDir, + ELYDORA_TEST_ARGUMENT: JSON.stringify(argument), + ELYDORA_TEST_METHOD: method, + }, + fixture.projectDir, + ); +} + +export async function createFixture({ existingConfig, baseUrl = 'http://127.0.0.1:9' } = {}) { + const rootDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-cursor-')); + const homeDir = path.join(rootDir, "home with spaces and 'quote"); + const projectDir = path.join(rootDir, 'project with spaces'); + const configPath = path.join(homeDir, '.cursor', 'hooks.json'); + const agentDir = path.join(homeDir, '.elydora', 'agent-1'); + const guardScriptPath = path.join(agentDir, 'guard.js'); + const hookScriptPath = path.join(agentDir, 'hook.js'); + await mkdir(projectDir, { recursive: true }); + if (existingConfig !== undefined) await writeJson(configPath, existingConfig); + return { + agentDir, + baseUrl, + configPath, + guardScriptPath, + homeDir, + hookScriptPath, + projectDir, + rootDir, + install(overrides = {}) { + return runPlugin(this, 'install', installConfig(this, overrides)); + }, + async close() { + await rm(rootDir, { recursive: true, force: true }); + }, + }; +} + +export function managedHandler(config, event, scriptName) { + return config.hooks?.[event]?.find((handler) => handler.command?.includes(scriptName)); +} + +export function assertNativeHandler(handler) { + assert.deepEqual(Object.keys(handler).sort(), ['command', 'failClosed', 'timeout']); + assert.equal(handler.failClosed, true); + assert.equal(handler.timeout, 10); + assert.match(handler.command, /node(?:\.exe)?/i); + if (process.platform === 'win32') { + assert.match(handler.command, /^& /); + assert.match(handler.command, /; exit \$LASTEXITCODE$/); + } else { + assert.match(handler.command, /^'/); + } +} + +export function legacyHandler(scriptPath) { + return { command: `node "${scriptPath}"` }; +} + +export async function startApiServer({ status = 'active', operationStatus = 201 } = {}) { + const requests = []; + const server = http.createServer(async (request, response) => { + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + const raw = Buffer.concat(chunks).toString('utf-8'); + requests.push({ method: request.method, url: request.url, raw }); + if (request.method === 'GET') { + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify({ agent: { status } })); + return; + } + response.writeHead(operationStatus, { 'Content-Type': 'application/json' }); + response.end(operationStatus >= 200 && operationStatus < 300 + ? JSON.stringify({ operation: { accepted: true } }) + : JSON.stringify({ error: { code: 'UPSTREAM_FAILURE', message: 'failed' } })); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + return { + baseUrl: `http://127.0.0.1:${address.port}`, + requests, + close: () => new Promise((resolve, reject) => { + server.close((error) => error ? reject(error) : resolve()); + }), + }; +} diff --git a/test/cursor-plugin.test.mjs b/test/cursor-plugin.test.mjs index e25fd03..cbd9ce7 100644 --- a/test/cursor-plugin.test.mjs +++ b/test/cursor-plugin.test.mjs @@ -1,9 +1,7 @@ import assert from 'node:assert/strict'; -import { spawn } from 'node:child_process'; import { lstat, mkdir, - mkdtemp, readFile, readdir, rm, @@ -11,140 +9,18 @@ import { symlink, writeFile, } from 'node:fs/promises'; -import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; -import { pathToFileURL } from 'node:url'; - -const pluginModuleUrl = pathToFileURL(path.resolve('dist/plugins/cursor.js')).href; -const registryModuleUrl = pathToFileURL(path.resolve('dist/plugins/registry.js')).href; -const ioModuleUrl = pathToFileURL(path.resolve('dist/plugins/cursor-io.js')).href; - -function runNode(args, env, cwd, input = '') { - return new Promise((resolve, reject) => { - const child = spawn(process.execPath, args, { - cwd, - env: { ...process.env, ...env }, - stdio: ['pipe', 'pipe', 'pipe'], - }); - let stdout = ''; - let stderr = ''; - child.stdout.on('data', (chunk) => { stdout += chunk; }); - child.stderr.on('data', (chunk) => { stderr += chunk; }); - child.once('error', reject); - child.once('close', (code) => resolve({ code, stdout, stderr })); - child.stdin.end(input); - }); -} - -function runHook(handler, input, environment = {}) { - const executable = process.platform === 'win32' ? 'powershell.exe' : '/bin/sh'; - const args = process.platform === 'win32' - ? ['-NoProfile', '-NonInteractive', '-Command', handler.command] - : ['-c', handler.command]; - return new Promise((resolve, reject) => { - const child = spawn(executable, args, { - env: { ...process.env, ...environment }, - stdio: ['pipe', 'pipe', 'pipe'], - }); - let stdout = ''; - let stderr = ''; - child.stdout.on('data', (chunk) => { stdout += chunk; }); - child.stderr.on('data', (chunk) => { stderr += chunk; }); - child.once('error', reject); - child.once('close', (code) => resolve({ code, stdout, stderr })); - child.stdin.end(input); - }); -} - -async function writeJson(filePath, value) { - await mkdir(path.dirname(filePath), { recursive: true }); - await writeFile(filePath, typeof value === 'string' ? value : `${JSON.stringify(value, null, 2)}\n`); -} - -async function runPlugin(fixture, method, argument) { - const source = ` - import { cursorPlugin } from ${JSON.stringify(pluginModuleUrl)}; - const argument = JSON.parse(process.env.ELYDORA_TEST_ARGUMENT); - const result = await cursorPlugin[process.env.ELYDORA_TEST_METHOD](argument); - if (result !== undefined) console.log(JSON.stringify(result)); - `; - return runNode( - ['--input-type=module', '--eval', source], - { - HOME: fixture.homeDir, - USERPROFILE: fixture.homeDir, - ELYDORA_TEST_ARGUMENT: JSON.stringify(argument), - ELYDORA_TEST_METHOD: method, - }, - fixture.projectDir, - ); -} - -async function createFixture({ existingConfig, createRuntimes = true, hookSource } = {}) { - const rootDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-cursor-')); - const homeDir = path.join(rootDir, "home with spaces and 'quote"); - const projectDir = path.join(rootDir, 'project with spaces'); - const configPath = path.join(homeDir, '.cursor', 'hooks.json'); - const agentDir = path.join(homeDir, '.elydora', 'agent-1'); - const guardScriptPath = path.join(agentDir, 'guard.js'); - const hookScriptPath = path.join(agentDir, 'hook.js'); - await mkdir(projectDir, { recursive: true }); - if (createRuntimes) { - await mkdir(agentDir, { recursive: true }); - await writeFile( - guardScriptPath, - "process.stdin.resume(); process.stdin.once('end', () => { process.stderr.write('Agent is frozen by Elydora.'); process.exit(2); });\n", - ); - await writeFile(hookScriptPath, hookSource ?? 'process.stdin.resume();\n'); - await writeFile(path.join(agentDir, 'config.json'), JSON.stringify({ - agent_id: 'agent-1', - agent_name: 'cursor', - })); - } - if (existingConfig !== undefined) await writeJson(configPath, existingConfig); - return { - agentDir, - configPath, - guardScriptPath, - homeDir, - hookScriptPath, - projectDir, - rootDir, - async install() { - return runPlugin(this, 'install', { - agentName: 'cursor', - agentId: 'agent-1', - guardScriptPath, - hookScriptPath, - }); - }, - async close() { - await rm(rootDir, { recursive: true, force: true }); - }, - }; -} - -function managedHandler(config, event, scriptName) { - return config.hooks?.[event]?.find((handler) => handler.command?.includes(scriptName)); -} - -function assertNativeHandler(handler) { - assert.deepEqual(Object.keys(handler).sort(), ['command', 'failClosed', 'timeout']); - assert.equal(handler.failClosed, true); - assert.equal(handler.timeout, 10); - assert.match(handler.command, /node(?:\.exe)?/i); - if (process.platform === 'win32') { - assert.match(handler.command, /^& /); - assert.match(handler.command, /; exit \$LASTEXITCODE$/); - } else { - assert.match(handler.command, /^'/); - } -} - -function legacyHandler(scriptPath) { - return { command: `node "${scriptPath}"` }; -} +import { + assertNativeHandler, + createFixture, + installConfig, + legacyHandler, + managedHandler, + registryModuleUrl, + runPlugin, + writeJson, +} from '../test-support/cursor-test-helpers.mjs'; test('Cursor is registered with the native user hook file', async () => { const { SUPPORTED_AGENTS } = await import(registryModuleUrl); @@ -155,11 +31,10 @@ test('Cursor is registered with the native user hook file', async () => { }); }); -test('Cursor install preserves user hooks, migrates legacy entries, and is idempotent', async () => { +test('Cursor install preserves hooks, migrates legacy entries, and is idempotent', async () => { const fixture = await createFixture(); try { await writeJson(fixture.configPath, { - version: 1, description: 'user-owned', hooks: { sessionStart: [{ command: 'user-session' }], @@ -168,9 +43,11 @@ test('Cursor install preserves user hooks, migrates legacy entries, and is idemp legacyHandler(fixture.guardScriptPath), ], postToolUse: [legacyHandler(fixture.hookScriptPath)], + postToolUseFailure: [legacyHandler(fixture.hookScriptPath)], }, }); - assert.equal((await fixture.install()).code, 0); + const first = await fixture.install(); + assert.equal(first.code, 0, first.stderr); const second = await fixture.install(); assert.equal(second.code, 0, second.stderr); @@ -181,63 +58,16 @@ test('Cursor install preserves user hooks, migrates legacy entries, and is idemp assert.deepEqual(config.hooks.preToolUse[0], { command: 'user-pre' }); assert.equal(config.hooks.preToolUse.length, 2); assert.equal(config.hooks.postToolUse.length, 1); + assert.equal(config.hooks.postToolUseFailure.length, 1); assertNativeHandler(managedHandler(config, 'preToolUse', 'guard.js')); assertNativeHandler(managedHandler(config, 'postToolUse', 'hook.js')); + assertNativeHandler(managedHandler(config, 'postToolUseFailure', 'hook.js')); } finally { await fixture.close(); } }); -test('Cursor hooks block freezes and forward official input byte-for-byte', async () => { - const capturePath = path.join(os.tmpdir(), `elydora-cursor-event-${process.pid}-${Date.now()}.json`); - const fixture = await createFixture({ - hookSource: ` - const fs = require('node:fs'); - const chunks = []; - process.stdin.on('data', (chunk) => chunks.push(chunk)); - process.stdin.on('end', () => fs.writeFileSync(process.env.ELYDORA_CAPTURE, Buffer.concat(chunks))); - `, - }); - try { - const install = await fixture.install(); - assert.equal(install.code, 0, install.stderr); - const config = JSON.parse(await readFile(fixture.configPath, 'utf-8')); - const guard = managedHandler(config, 'preToolUse', 'guard.js'); - const audit = managedHandler(config, 'postToolUse', 'hook.js'); - const prePayload = `${JSON.stringify({ - conversation_id: 'conversation-1', - generation_id: 'generation-1', - hook_event_name: 'preToolUse', - tool_name: 'Shell', - tool_input: { command: 'Get-ChildItem', working_directory: fixture.projectDir }, - tool_use_id: 'call-1', - cwd: fixture.projectDir, - })}\n`; - const guardResult = await runHook(guard, prePayload); - assert.equal(guardResult.code, 2, guardResult.stderr); - assert.match(guardResult.stderr, /Agent is frozen by Elydora/); - - const postPayload = `${JSON.stringify({ - conversation_id: 'conversation-1', - generation_id: 'generation-1', - hook_event_name: 'postToolUse', - tool_name: 'Shell', - tool_input: { command: 'Get-ChildItem' }, - tool_output: '{"exitCode":0,"stdout":"ok"}', - tool_use_id: 'call-1', - cwd: fixture.projectDir, - duration: 42, - })}\n`; - const auditResult = await runHook(audit, postPayload, { ELYDORA_CAPTURE: capturePath }); - assert.equal(auditResult.code, 0, auditResult.stderr); - assert.equal(await readFile(capturePath, 'utf-8'), postPayload); - } finally { - await fixture.close(); - await rm(capturePath, { force: true }); - } -}); - -test('Cursor status requires an exact pair, matching identity, and physical runtimes', async () => { +test('Cursor status requires three handlers, matching identity, and every runtime secret', async () => { const fixture = await createFixture(); try { assert.equal((await fixture.install()).code, 0); @@ -245,25 +75,27 @@ test('Cursor status requires an exact pair, matching identity, and physical runt assert.equal(status.code, 0, status.stderr); assert.equal(JSON.parse(status.stdout).installed, true); - const config = JSON.parse(await readFile(fixture.configPath, 'utf-8')); - config.hooks.preToolUse[0].failClosed = false; - await writeJson(fixture.configPath, config); + const hooks = JSON.parse(await readFile(fixture.configPath, 'utf-8')); + delete hooks.hooks.postToolUseFailure; + await writeJson(fixture.configPath, hooks); status = await runPlugin(fixture, 'status', null); assert.equal(JSON.parse(status.stdout).hookConfigured, false); assert.equal((await fixture.install()).code, 0); - const auditSource = await readFile(fixture.hookScriptPath); - await rm(fixture.hookScriptPath); - status = await runPlugin(fixture, 'status', null); - assert.equal(JSON.parse(status.stdout).installed, false); - await writeFile(fixture.hookScriptPath, auditSource); - - const runtimeConfigPath = path.join(fixture.agentDir, 'config.json'); - await writeJson(runtimeConfigPath, { agent_id: 'another-agent', agent_name: 'cursor' }); + const keyPath = path.join(fixture.agentDir, 'private.key'); + await rm(keyPath); status = await runPlugin(fixture, 'status', null); assert.equal(JSON.parse(status.stdout).installed, false); - await writeFile(runtimeConfigPath, '{ malformed'); + await writeFile(keyPath, 'restored', { mode: 0o600 }); + await writeJson(path.join(fixture.agentDir, 'config.json'), { + agent_id: 'another-agent', + agent_name: 'cursor', + }); + status = await runPlugin(fixture, 'status', null); + assert.equal(JSON.parse(status.stdout).installed, false); + + await writeFile(path.join(fixture.agentDir, 'config.json'), '{ malformed'); status = await runPlugin(fixture, 'status', null); assert.equal(status.code, 1); assert.match(status.stderr, /parse Elydora runtime config/i); @@ -279,14 +111,16 @@ test('Cursor uninstall removes exact ownership and preserves user entries', asyn try { assert.equal((await fixture.install()).code, 0); const config = JSON.parse(await readFile(fixture.configPath, 'utf-8')); - const otherGuard = structuredClone(managedHandler(config, 'preToolUse', 'guard.js')); - const otherAudit = structuredClone(managedHandler(config, 'postToolUse', 'hook.js')); - otherGuard.command = otherGuard.command.replaceAll('agent-1', 'agent-10'); - otherAudit.command = otherAudit.command.replaceAll('agent-1', 'agent-10'); - config.hooks.preToolUse.push(otherGuard, { - command: 'echo elydora agent-1 guard.js', timeout: 10, failClosed: true, - }); - config.hooks.postToolUse.push(otherAudit); + for (const [event, script] of [ + ['preToolUse', 'guard.js'], + ['postToolUse', 'hook.js'], + ['postToolUseFailure', 'hook.js'], + ]) { + const other = structuredClone(managedHandler(config, event, script)); + other.command = other.command.replaceAll('agent-1', 'agent-10'); + config.hooks[event].push(other); + } + config.hooks.preToolUse.push({ command: 'user-pre' }); await writeJson(fixture.configPath, config); const result = await runPlugin(fixture, 'uninstall', 'agent-1'); @@ -295,17 +129,19 @@ test('Cursor uninstall removes exact ownership and preserves user entries', asyn assert.deepEqual(remaining.hooks.sessionStart, [{ command: 'keep' }]); assert.equal(remaining.hooks.preToolUse.length, 2); assert.equal(remaining.hooks.postToolUse.length, 1); + assert.equal(remaining.hooks.postToolUseFailure.length, 1); assert.match(remaining.hooks.preToolUse[0].command, /agent-10/); } finally { await fixture.close(); } }); -test('Cursor rejects malformed, duplicate, and invalid configs before writes', async () => { +test('Cursor rejects malformed, duplicate, versionless user, and invalid configs before writes', async () => { const invalidConfigs = [ '{ malformed', '[]\n', '{"hooks":{}}\n', + '{"hooks":{"preToolUse":[{"command":"user"}]}}\n', '{"version":2,"hooks":{}}\n', '{"version":1,"hooks":null}\n', '{"version":1,"hooks":{"preToolUse":null}}\n', @@ -320,54 +156,20 @@ test('Cursor rejects malformed, duplicate, and invalid configs before writes', a const result = await fixture.install(); assert.equal(result.code, 1, `accepted ${existingConfig}`); assert.equal(await readFile(fixture.configPath, 'utf-8'), before); + await assert.rejects(lstat(path.join(fixture.homeDir, '.elydora')), { code: 'ENOENT' }); } finally { await fixture.close(); } } }); -test('Cursor rejects missing and symbolic-link files before config writes', async (t) => { - const missing = await createFixture({ createRuntimes: false }); - try { - const result = await missing.install(); - assert.equal(result.code, 1); - assert.match(result.stderr, /runtime is missing/i); - await assert.rejects(readFile(missing.configPath), { code: 'ENOENT' }); - } finally { - await missing.close(); - } - - const linkedRuntime = await createFixture(); - try { - const target = path.join(linkedRuntime.homeDir, 'guard-target.js'); - await writeFile(target, 'process.exit(2);\n'); - await rm(linkedRuntime.guardScriptPath); - try { - await symlink(target, linkedRuntime.guardScriptPath); - } catch (error) { - if (error?.code === 'EPERM') t.skip(`symbolic links unavailable: ${error.message}`); - throw error; - } - const result = await linkedRuntime.install(); - assert.equal(result.code, 1); - assert.match(result.stderr, /physical file/i); - await assert.rejects(readFile(linkedRuntime.configPath), { code: 'ENOENT' }); - } finally { - await linkedRuntime.close(); - } -}); - -test('Cursor confines both runtimes to the managed agent directory', async () => { +test('Cursor confines runtime scripts to the managed agent directory', async () => { for (const field of ['guardScriptPath', 'hookScriptPath']) { const fixture = await createFixture(); try { - const result = await runPlugin(fixture, 'install', { - agentName: 'cursor', - agentId: 'agent-1', - guardScriptPath: fixture.guardScriptPath, - hookScriptPath: fixture.hookScriptPath, + const result = await runPlugin(fixture, 'install', installConfig(fixture, { [field]: path.join(fixture.homeDir, `unmanaged-${field}.js`), - }); + })); assert.equal(result.code, 1); assert.match(result.stderr, /managed agent directory/i); await assert.rejects(readFile(fixture.configPath), { code: 'ENOENT' }); @@ -377,17 +179,21 @@ test('Cursor confines both runtimes to the managed agent directory', async () => } }); -test('Cursor rejects symbolic-link config and preserves its target', async (t) => { +test('Cursor rejects a symbolic-link config and preserves its target', async (t) => { const fixture = await createFixture(); try { const target = path.join(fixture.homeDir, 'cursor-hooks-target.json'); const original = '{"version":1,"hooks":{}}\n'; + await mkdir(fixture.homeDir, { recursive: true }); await writeFile(target, original); await mkdir(path.dirname(fixture.configPath), { recursive: true }); try { await symlink(target, fixture.configPath); } catch (error) { - if (error?.code === 'EPERM') t.skip(`symbolic links unavailable: ${error.message}`); + if (error?.code === 'EPERM') { + t.skip(`symbolic links unavailable: ${error.message}`); + return; + } throw error; } const result = await fixture.install(); @@ -401,12 +207,13 @@ test('Cursor rejects symbolic-link config and preserves its target', async (t) = }); test('Cursor status rejects symbolic-link runtime files', async (t) => { - for (const name of ['config', 'guard', 'audit']) { + for (const name of ['config', 'key', 'guard', 'audit']) { const fixture = await createFixture(); try { assert.equal((await fixture.install()).code, 0); const filePath = { config: path.join(fixture.agentDir, 'config.json'), + key: path.join(fixture.agentDir, 'private.key'), guard: fixture.guardScriptPath, audit: fixture.hookScriptPath, }[name]; @@ -417,7 +224,10 @@ test('Cursor status rejects symbolic-link runtime files', async (t) => { try { await symlink(target, filePath); } catch (error) { - if (error?.code === 'EPERM') t.skip(`symbolic links unavailable: ${error.message}`); + if (error?.code === 'EPERM') { + t.skip(`symbolic links unavailable: ${error.message}`); + return; + } throw error; } const status = await runPlugin(fixture, 'status', null); @@ -429,7 +239,7 @@ test('Cursor status rejects symbolic-link runtime files', async (t) => { } }); -test('Cursor removes an entirely managed config and leaves absent config absent', async () => { +test('Cursor removes an entirely managed config and leaves an absent config absent', async () => { const fixture = await createFixture(); try { assert.equal((await fixture.install()).code, 0); @@ -443,48 +253,29 @@ test('Cursor removes an entirely managed config and leaves absent config absent' } }); -test('Cursor atomic writes are private and leave no staging files', async () => { +test('Cursor installation writes private files and leaves no transaction files', async () => { const fixture = await createFixture(); try { - assert.equal((await fixture.install()).code, 0); - const files = await readdir(path.dirname(fixture.configPath)); - assert.equal(files.some((name) => name.endsWith('.tmp') || name.endsWith('.rollback')), false); + const result = await fixture.install(); + assert.equal(result.code, 0, result.stderr); + const names = [ + ...await readdir(fixture.agentDir), + ...await readdir(path.dirname(fixture.configPath)), + ]; + assert.equal(names.some((name) => /\.(tmp|rollback)$/.test(name)), false); if (process.platform !== 'win32') { - assert.equal((await stat(fixture.configPath)).mode & 0o777, 0o600); + for (const filePath of [ + fixture.configPath, + path.join(fixture.agentDir, 'config.json'), + path.join(fixture.agentDir, 'private.key'), + ]) { + assert.equal((await stat(filePath)).mode & 0o777, 0o600); + } + for (const filePath of [fixture.guardScriptPath, fixture.hookScriptPath]) { + assert.equal((await stat(filePath)).mode & 0o777, 0o700); + } } } finally { await fixture.close(); } }); - -test('Cursor atomic write detects a concurrent source change', async () => { - const fixture = await createFixture({ - existingConfig: { version: 1, hooks: { sessionStart: [{ command: 'original' }] } }, - }); - const concurrent = '{"version":1,"hooks":{"sessionStart":[{"command":"concurrent"}]}}\n'; - try { - const source = ` - import { writeFile } from 'node:fs/promises'; - import { readDocument, writeDocument } from ${JSON.stringify(ioModuleUrl)}; - const document = await readDocument(); - await writeFile(document.filePath, process.env.ELYDORA_CONCURRENT, 'utf-8'); - await writeDocument({ - document, - changed: true, - next: '{"version":1,"hooks":{}}\\n', - }); - `; - const result = await runNode( - ['--input-type=module', '--eval', source], - { HOME: fixture.homeDir, USERPROFILE: fixture.homeDir, ELYDORA_CONCURRENT: concurrent }, - fixture.projectDir, - ); - assert.equal(result.code, 1); - assert.match(result.stderr, /changed during update/i); - assert.equal(await readFile(fixture.configPath, 'utf-8'), concurrent); - const files = await readdir(path.dirname(fixture.configPath)); - assert.equal(files.some((name) => name.endsWith('.tmp')), false); - } finally { - await fixture.close(); - } -}); diff --git a/test/cursor-runtime.test.mjs b/test/cursor-runtime.test.mjs new file mode 100644 index 0000000..07b9094 --- /dev/null +++ b/test/cursor-runtime.test.mjs @@ -0,0 +1,176 @@ +import assert from 'node:assert/strict'; +import { readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import test from 'node:test'; +import { + createFixture, + managedHandler, + runHook, + startApiServer, + writeJson, +} from '../test-support/cursor-test-helpers.mjs'; + +test('Cursor guard and both audit events use native JSON contracts', async () => { + const api = await startApiServer(); + const fixture = await createFixture({ baseUrl: api.baseUrl }); + try { + const install = await fixture.install(); + assert.equal(install.code, 0, install.stderr); + const config = JSON.parse(await readFile(fixture.configPath, 'utf-8')); + const guard = managedHandler(config, 'preToolUse', 'guard.js'); + const successAudit = managedHandler(config, 'postToolUse', 'hook.js'); + const failureAudit = managedHandler(config, 'postToolUseFailure', 'hook.js'); + + const prePayload = { + conversation_id: 'conversation-1', + generation_id: 'generation-1', + hook_event_name: 'preToolUse', + tool_name: 'Shell', + tool_input: { command: 'Get-ChildItem', working_directory: fixture.projectDir }, + tool_use_id: 'call-1', + cwd: fixture.projectDir, + }; + const guardResult = await runHook(guard, `${JSON.stringify(prePayload)}\n`, fixture); + assert.equal(guardResult.code, 0, guardResult.stderr); + assert.deepEqual(JSON.parse(guardResult.stdout), { permission: 'allow' }); + + const postPayload = { + conversation_id: 'conversation-1', + generation_id: 'generation-1', + hook_event_name: 'postToolUse', + tool_name: 'Shell', + tool_input: { command: 'Get-ChildItem' }, + tool_output: '{"exitCode":0,"stdout":"ok"}', + tool_use_id: 'call-1', + cwd: fixture.projectDir, + duration: 42, + }; + const postResult = await runHook(successAudit, `${JSON.stringify(postPayload)}\n`, fixture); + assert.equal(postResult.code, 0, postResult.stderr); + assert.deepEqual(JSON.parse(postResult.stdout), {}); + + const failurePayload = { + conversation_id: 'conversation-1', + generation_id: 'generation-1', + hook_event_name: 'postToolUseFailure', + tool_name: 'Shell', + tool_input: { command: 'exit 7' }, + tool_error: { message: 'Process exited with code 7', exit_code: 7 }, + tool_use_id: 'call-2', + cwd: fixture.projectDir, + duration: 12, + }; + const failureResult = await runHook( + failureAudit, + `${JSON.stringify(failurePayload)}\n`, + fixture, + ); + assert.equal(failureResult.code, 0, failureResult.stderr); + assert.deepEqual(JSON.parse(failureResult.stdout), {}); + + const operations = api.requests + .filter((request) => request.method === 'POST') + .map((request) => JSON.parse(request.raw)); + assert.equal(operations.length, 2); + assert.deepEqual(operations[0].payload, postPayload); + assert.deepEqual(operations[1].payload, failurePayload); + assert.equal(operations[0].subject.session_id, 'conversation-1'); + assert.equal(operations[1].action.tool, 'Shell'); + } finally { + await fixture.close(); + await api.close(); + } +}); + +test('Cursor guard emits a structured deny for frozen and revoked agents', async () => { + const fixture = await createFixture(); + try { + assert.equal((await fixture.install()).code, 0); + const config = JSON.parse(await readFile(fixture.configPath, 'utf-8')); + const guard = managedHandler(config, 'preToolUse', 'guard.js'); + for (const status of ['frozen', 'revoked']) { + await writeJson(path.join(fixture.agentDir, 'status-cache.json'), { + status, + cached_at: Date.now(), + }); + const result = await runHook(guard, '{}\n', fixture); + assert.equal(result.code, 2, result.stderr); + const response = JSON.parse(result.stdout); + assert.equal(response.permission, 'deny'); + assert.match(response.agentMessage, new RegExp(status)); + } + } finally { + await fixture.close(); + } +}); + +test('Cursor strict runtimes surface malformed input, config, key, cache, and API errors', async () => { + const api = await startApiServer({ operationStatus: 500 }); + const fixture = await createFixture({ baseUrl: api.baseUrl }); + try { + assert.equal((await fixture.install()).code, 0); + const hooks = JSON.parse(await readFile(fixture.configPath, 'utf-8')); + const guard = managedHandler(hooks, 'preToolUse', 'guard.js'); + const audit = managedHandler(hooks, 'postToolUse', 'hook.js'); + const runtimeConfigPath = path.join(fixture.agentDir, 'config.json'); + const runtimeConfig = await readFile(runtimeConfigPath, 'utf-8'); + const privateKeyPath = path.join(fixture.agentDir, 'private.key'); + const privateKey = await readFile(privateKeyPath, 'utf-8'); + + let result = await runHook(audit, '{ malformed', fixture); + assert.equal(result.code, 1); + assert.equal(result.stdout, ''); + assert.match(result.stderr, /invalid JSON/i); + + await writeFile(runtimeConfigPath, '{ malformed', { mode: 0o600 }); + result = await runHook(guard, '{}\n', fixture); + assert.equal(result.code, 1); + assert.match(result.stderr, /agent config/i); + await writeFile(runtimeConfigPath, runtimeConfig, { mode: 0o600 }); + + await writeFile(privateKeyPath, 'invalid-key', { mode: 0o600 }); + result = await runHook(audit, '{}\n', fixture); + assert.equal(result.code, 1); + assert.match(result.stderr, /private key/i); + await writeFile(privateKeyPath, privateKey, { mode: 0o600 }); + + await writeFile(path.join(fixture.agentDir, 'status-cache.json'), '{ malformed', { + mode: 0o600, + }); + result = await runHook(guard, '{}\n', fixture); + assert.equal(result.code, 1); + assert.match(result.stderr, /status cache/i); + + await writeFile(path.join(fixture.agentDir, 'status-cache.json'), JSON.stringify({ + status: 'active', + cached_at: 0, + }), { mode: 0o600 }); + result = await runHook(audit, '{}\n', fixture); + assert.equal(result.code, 1); + assert.match(result.stderr, /HTTP 500/i); + assert.match(await readFile(path.join(fixture.agentDir, 'error.log'), 'utf-8'), /HTTP 500/i); + } finally { + await fixture.close(); + await api.close(); + } +}); + +test('Cursor guard fails closed on invalid status API data', async () => { + const api = await startApiServer({ status: 'paused' }); + const fixture = await createFixture({ baseUrl: api.baseUrl }); + try { + assert.equal((await fixture.install()).code, 0); + const hooks = JSON.parse(await readFile(fixture.configPath, 'utf-8')); + const result = await runHook( + managedHandler(hooks, 'preToolUse', 'guard.js'), + '{}\n', + fixture, + ); + assert.equal(result.code, 1); + assert.equal(result.stdout, ''); + assert.match(result.stderr, /invalid agent status/i); + } finally { + await fixture.close(); + await api.close(); + } +}); diff --git a/test/cursor-transaction.test.mjs b/test/cursor-transaction.test.mjs new file mode 100644 index 0000000..ed82192 --- /dev/null +++ b/test/cursor-transaction.test.mjs @@ -0,0 +1,262 @@ +import assert from 'node:assert/strict'; +import { + lstat, + mkdir, + readFile, + readdir, + symlink, + writeFile, +} from 'node:fs/promises'; +import path from 'node:path'; +import test from 'node:test'; +import { + VALID_PRIVATE_KEY, + createFixture, + installationModuleUrl, + installConfig, + ioModuleUrl, + runNode, + writeJson, +} from '../test-support/cursor-test-helpers.mjs'; + +const managedFileNames = ['guard.js', 'config.json', 'private.key', 'hook.js']; + +async function snapshotInstallation(fixture) { + const entries = await Promise.all(managedFileNames.map(async (name) => [ + path.join(fixture.agentDir, name), + await readFile(path.join(fixture.agentDir, name), 'utf-8'), + ])); + entries.push([fixture.configPath, await readFile(fixture.configPath, 'utf-8')]); + return new Map(entries); +} + +async function assertSnapshot(snapshot) { + for (const [filePath, expected] of snapshot) { + assert.equal(await readFile(filePath, 'utf-8'), expected, filePath); + } +} + +async function assertNoTransactionFiles(fixture) { + const names = [ + ...await readdir(fixture.agentDir), + ...await readdir(path.dirname(fixture.configPath)), + ]; + assert.equal(names.some((name) => /\.(tmp|rollback)$/.test(name)), false); +} + +test('Cursor rolls back all five files when a transaction commit fails', async () => { + const fixture = await createFixture(); + try { + assert.equal((await fixture.install()).code, 0); + const before = await snapshotInstallation(fixture); + const source = ` + import { rename } from 'node:fs/promises'; + import { readDocument } from ${JSON.stringify(ioModuleUrl)}; + import { + commitCursorInstallation, + prepareCursorInstallation, + } from ${JSON.stringify(installationModuleUrl)}; + const config = JSON.parse(process.env.ELYDORA_INSTALL_CONFIG); + const document = await readDocument(); + const prepared = await prepareCursorInstallation(config, { document, changed: false }); + let calls = 0; + await commitCursorInstallation(prepared, async (from, to) => { + calls += 1; + if (calls === 3) throw new Error('injected rename failure'); + await rename(from, to); + }); + `; + const result = await runNode( + ['--input-type=module', '--eval', source], + { + HOME: fixture.homeDir, + USERPROFILE: fixture.homeDir, + ELYDORA_INSTALL_CONFIG: JSON.stringify(installConfig(fixture, { + orgId: 'org-updated', + token: 'token-updated', + })), + }, + fixture.projectDir, + ); + assert.equal(result.code, 1); + assert.match(result.stderr, /injected rename failure/i); + await assertSnapshot(before); + await assertNoTransactionFiles(fixture); + } finally { + await fixture.close(); + } +}); + +test('Cursor detects concurrent hook config changes before committing runtime updates', async () => { + const fixture = await createFixture({ + existingConfig: { version: 1, hooks: { sessionStart: [{ command: 'original' }] } }, + }); + try { + assert.equal((await fixture.install()).code, 0); + const before = await snapshotInstallation(fixture); + const concurrent = '{"version":1,"hooks":{"sessionStart":[{"command":"concurrent"}]}}\n'; + const source = ` + import { writeFile } from 'node:fs/promises'; + import { readDocument } from ${JSON.stringify(ioModuleUrl)}; + import { + commitCursorInstallation, + prepareCursorInstallation, + } from ${JSON.stringify(installationModuleUrl)}; + const config = JSON.parse(process.env.ELYDORA_INSTALL_CONFIG); + const document = await readDocument(); + const prepared = await prepareCursorInstallation(config, { document, changed: false }); + await writeFile(document.filePath, process.env.ELYDORA_CONCURRENT, 'utf-8'); + await commitCursorInstallation(prepared); + `; + const result = await runNode( + ['--input-type=module', '--eval', source], + { + HOME: fixture.homeDir, + USERPROFILE: fixture.homeDir, + ELYDORA_CONCURRENT: concurrent, + ELYDORA_INSTALL_CONFIG: JSON.stringify(installConfig(fixture, { orgId: 'org-updated' })), + }, + fixture.projectDir, + ); + assert.equal(result.code, 1); + assert.match(result.stderr, /changed during Cursor installation/i); + for (const [filePath, expected] of before) { + if (filePath !== fixture.configPath) assert.equal(await readFile(filePath, 'utf-8'), expected); + } + assert.equal(await readFile(fixture.configPath, 'utf-8'), concurrent); + await assertNoTransactionFiles(fixture); + } finally { + await fixture.close(); + } +}); + +test('Cursor rejects a stale hook document before staging an installation', async () => { + const fixture = await createFixture(); + try { + assert.equal((await fixture.install()).code, 0); + const before = await snapshotInstallation(fixture); + const concurrent = '{"version":1,"hooks":{"sessionStart":[{"command":"new"}]}}\n'; + const source = ` + import { writeFile } from 'node:fs/promises'; + import { readDocument } from ${JSON.stringify(ioModuleUrl)}; + import { prepareCursorInstallation } from ${JSON.stringify(installationModuleUrl)}; + const document = await readDocument(); + await writeFile(document.filePath, process.env.ELYDORA_CONCURRENT, 'utf-8'); + await prepareCursorInstallation( + JSON.parse(process.env.ELYDORA_INSTALL_CONFIG), + { document, changed: false }, + ); + `; + const result = await runNode( + ['--input-type=module', '--eval', source], + { + HOME: fixture.homeDir, + USERPROFILE: fixture.homeDir, + ELYDORA_CONCURRENT: concurrent, + ELYDORA_INSTALL_CONFIG: JSON.stringify(installConfig(fixture)), + }, + fixture.projectDir, + ); + assert.equal(result.code, 1); + assert.match(result.stderr, /changed before installation/i); + for (const [filePath, expected] of before) { + if (filePath !== fixture.configPath) assert.equal(await readFile(filePath, 'utf-8'), expected); + } + assert.equal(await readFile(fixture.configPath, 'utf-8'), concurrent); + await assertNoTransactionFiles(fixture); + } finally { + await fixture.close(); + } +}); + +test('Cursor rejects orphaned runtime artifacts before writing hook config', async () => { + const fixture = await createFixture(); + try { + await mkdir(fixture.agentDir, { recursive: true }); + await writeFile(fixture.guardScriptPath, 'orphaned guard\n'); + const result = await fixture.install(); + assert.equal(result.code, 1); + assert.match(result.stderr, /identity cannot be verified/i); + assert.equal(await readFile(fixture.guardScriptPath, 'utf-8'), 'orphaned guard\n'); + await assert.rejects(readFile(fixture.configPath), { code: 'ENOENT' }); + } finally { + await fixture.close(); + } +}); + +test('Cursor rejects a mismatched runtime identity before changing files', async () => { + const fixture = await createFixture(); + try { + await writeJson(path.join(fixture.agentDir, 'config.json'), { + agent_name: 'cursor', + agent_id: 'another-agent', + }); + const before = await readFile(path.join(fixture.agentDir, 'config.json'), 'utf-8'); + const result = await fixture.install(); + assert.equal(result.code, 1); + assert.match(result.stderr, /identity does not match/i); + assert.equal(await readFile(path.join(fixture.agentDir, 'config.json'), 'utf-8'), before); + await assert.rejects(readFile(fixture.configPath), { code: 'ENOENT' }); + } finally { + await fixture.close(); + } +}); + +test('Cursor rejects symbolic-link config and runtime directories before file writes', async (t) => { + for (const kind of ['cursor', 'runtime']) { + const fixture = await createFixture(); + try { + const target = path.join(fixture.rootDir, `${kind}-target`); + await mkdir(target, { recursive: true }); + const link = kind === 'cursor' + ? path.join(fixture.homeDir, '.cursor') + : path.join(fixture.homeDir, '.elydora'); + await mkdir(fixture.homeDir, { recursive: true }); + try { + await symlink(target, link, 'junction'); + } catch (error) { + if (error?.code === 'EPERM') { + t.skip(`symbolic links unavailable: ${error.message}`); + return; + } + throw error; + } + const result = await fixture.install(); + assert.equal(result.code, 1); + assert.match(result.stderr, /physical directory/i); + assert.deepEqual(await readdir(target), []); + const untouched = kind === 'cursor' + ? path.join(fixture.homeDir, '.elydora') + : path.join(fixture.homeDir, '.cursor', 'hooks.json'); + await assert.rejects(lstat(untouched), { code: 'ENOENT' }); + } finally { + await fixture.close(); + } + } +}); + +test('Cursor CLI preflight rejects malformed hooks before creating runtime files', async () => { + const fixture = await createFixture({ existingConfig: '{ malformed' }); + try { + const privateKeyPath = path.join(fixture.rootDir, 'private.key'); + await writeFile(privateKeyPath, VALID_PRIVATE_KEY, { mode: 0o600 }); + const result = await runNode([ + path.resolve('dist/cli.js'), + 'install', + '--agent', 'cursor', + '--org_id', 'org-1', + '--agent_id', 'agent-1', + '--private_key_file', privateKeyPath, + '--kid', 'kid-1', + '--base_url', fixture.baseUrl, + ], { + HOME: fixture.homeDir, + USERPROFILE: fixture.homeDir, + }, fixture.projectDir); + assert.equal(result.code, 1); + assert.match(result.stderr, /Cursor user hooks/i); + await assert.rejects(lstat(path.join(fixture.homeDir, '.elydora')), { code: 'ENOENT' }); + } finally { + await fixture.close(); + } +});