From 6d36f557b1941a934176448aef41989b009c95ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 19 Jul 2026 23:28:01 -0400 Subject: [PATCH] fix: harden Cursor hooks --- AGENTS.md | 1 + README.md | 2 + src/plugins/cursor-contract.ts | 329 ++++++++++++++++++++++ src/plugins/cursor-io.ts | 243 ++++++++++++++++ src/plugins/cursor.ts | 168 +++-------- src/plugins/registry.ts | 2 +- test/cursor-plugin.test.mjs | 490 +++++++++++++++++++++++++++++++++ 7 files changed, 1109 insertions(+), 126 deletions(-) create mode 100644 src/plugins/cursor-contract.ts create mode 100644 src/plugins/cursor-io.ts create mode 100644 test/cursor-plugin.test.mjs diff --git a/AGENTS.md b/AGENTS.md index ace956c..8fd8ddd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,7 @@ This repository owns the published `@elydora/sdk` package, its `elydora` CLI, lo - 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. - 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 0849ed2..6601f86 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,8 @@ 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. + 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. Factory Droid installation preserves the active user source across `~/.factory/hooks.json`, the `hooks` field in `~/.factory/settings.json`, and the legacy `~/.factory/hooks/hooks.json` path. Hook files store events at the document root; settings stores the same event map under `hooks`. Project and organization sources remain unchanged. Droid snapshots hooks for each session, so run `/hooks` after installation to review and apply the external change. diff --git a/src/plugins/cursor-contract.ts b/src/plugins/cursor-contract.ts new file mode 100644 index 0000000..7e151ab --- /dev/null +++ b/src/plugins/cursor-contract.ts @@ -0,0 +1,329 @@ +import os from 'node:os'; +import path from 'node:path'; +import { + parse, + parseTree, + printParseErrorCode, + type Node, + type ParseError, +} from 'jsonc-parser'; + +export const AGENT_KEY = 'cursor'; +export const CONFIG_FILE = 'hooks.json'; +export const GUARD_SCRIPT = 'guard.js'; +export const AUDIT_SCRIPT = 'hook.js'; +export const HOOK_TIMEOUT_SECONDS = 10; + +export type JsonObject = Record; +export type CursorHooks = Record; + +export interface CursorDocument { + readonly exists: boolean; + readonly filePath: string; + readonly root: JsonObject; + readonly hooks: CursorHooks; + readonly raw?: string; +} + +export interface RenderedDocument { + readonly document: CursorDocument; + readonly changed: boolean; + readonly next?: string; +} + +export interface RuntimeContract { + readonly agentId: string; + readonly guardPath: string; + readonly auditPath: string; +} + +interface ParsedArgument { + readonly value: string; + readonly next: number; +} + +export function isObject(value: unknown): value is JsonObject { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function samePath(left: string, right: string): boolean { + const normalizedLeft = path.resolve(left); + const normalizedRight = path.resolve(right); + return process.platform === 'win32' + ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase() + : normalizedLeft === normalizedRight; +} + +function sameAgentId(left: string, right: string): boolean { + return process.platform === 'win32' + ? left.toLowerCase() === right.toLowerCase() + : left === right; +} + +function quotePosix(value: string): string { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} + +function quotePowerShell(value: string): string { + return `'${value.replaceAll("'", "''")}'`; +} + +export function buildHandler(scriptPath: string): JsonObject { + const command = process.platform === 'win32' + ? `& ${quotePowerShell(process.execPath)} ${quotePowerShell(scriptPath)}; exit $LASTEXITCODE` + : `${quotePosix(process.execPath)} ${quotePosix(scriptPath)}`; + return { + command, + timeout: HOOK_TIMEOUT_SECONDS, + failClosed: true, + }; +} + +function readPosixArgument(command: string, start: number): ParsedArgument | undefined { + if (command[start] !== "'") return undefined; + const apostrophe = `'"'"'`; + let value = ''; + for (let index = start + 1; index < command.length;) { + if (command.startsWith(apostrophe, index)) { + value += "'"; + index += apostrophe.length; + continue; + } + if (command[index] === "'") return { value, next: index + 1 }; + value += command[index]; + index += 1; + } + return undefined; +} + +function parsePosixCommand(command: string): readonly [string, string] | undefined { + const executable = readPosixArgument(command, 0); + if (!executable || command[executable.next] !== ' ') return undefined; + const script = readPosixArgument(command, executable.next + 1); + if (!script || script.next !== command.length) return undefined; + return [executable.value, script.value]; +} + +function readPowerShellArgument(command: string, start: number): ParsedArgument | undefined { + if (command[start] !== "'") return undefined; + let value = ''; + for (let index = start + 1; index < command.length; index += 1) { + if (command[index] !== "'") { + value += command[index]; + continue; + } + if (command[index + 1] === "'") { + value += "'"; + index += 1; + continue; + } + return { value, next: index + 1 }; + } + return undefined; +} + +function parsePowerShellCommand(command: string): readonly [string, string] | undefined { + if (!command.startsWith('& ')) return undefined; + const executable = readPowerShellArgument(command, 2); + if (!executable || command[executable.next] !== ' ') return undefined; + const script = readPowerShellArgument(command, executable.next + 1); + if (!script || command.slice(script.next) !== '; exit $LASTEXITCODE') return undefined; + return [executable.value, script.value]; +} + +function parseLegacyCommand(command: string): readonly [string, string] | undefined { + const match = /^(node(?:\.exe)?)\s+"([^"\r\n]+)"$/i.exec(command); + return match ? [match[1], match[2]] : undefined; +} + +function isNodeExecutable(filePath: string): boolean { + const basename = path.basename(filePath); + return basename === 'node' || basename.toLowerCase() === 'node.exe'; +} + +function managedScriptPath(handler: JsonObject): string | undefined { + const keys = Object.keys(handler); + if (keys.length === 3 + && typeof handler.command === 'string' + && handler.timeout === HOOK_TIMEOUT_SECONDS + && handler.failClosed === true) { + const parsed = process.platform === 'win32' + ? parsePowerShellCommand(handler.command) + : parsePosixCommand(handler.command); + if (parsed + && path.isAbsolute(parsed[0]) + && path.isAbsolute(parsed[1]) + && isNodeExecutable(parsed[0])) return parsed[1]; + } + if (keys.length !== 1 || typeof handler.command !== 'string') return undefined; + const legacy = parseLegacyCommand(handler.command); + return legacy && isNodeExecutable(legacy[0]) && path.isAbsolute(legacy[1]) + ? legacy[1] + : undefined; +} + +function managedAgentId(handler: JsonObject, scriptName: string): string | undefined { + const scriptPath = managedScriptPath(handler); + if (!scriptPath || path.basename(scriptPath) !== scriptName) return undefined; + const agentDirectory = path.dirname(scriptPath); + if (!samePath(path.dirname(agentDirectory), path.join(os.homedir(), '.elydora'))) { + return undefined; + } + const agentId = path.basename(agentDirectory); + return agentId && agentId !== '.' && agentId !== '..' ? agentId : undefined; +} + +function rejectDuplicateKeys(node: Node | undefined, label: string, location: string[] = []): void { + if (!node) return; + if (node.type === 'object') { + const keys = new Set(); + for (const property of node.children ?? []) { + const key = String(property.children?.[0]?.value); + const childLocation = [...location, key]; + if (keys.has(key)) { + throw new Error(`${label} contains duplicate field "${childLocation.join('.')}"`); + } + keys.add(key); + rejectDuplicateKeys(property.children?.[1], label, childLocation); + } + return; + } + if (node.type === 'array') { + for (const child of node.children ?? []) rejectDuplicateKeys(child, label, location); + } +} + +function parseJsonObject(raw: string, label: string): JsonObject { + const errors: ParseError[] = []; + const value: unknown = parse(raw, errors, { + allowTrailingComma: false, + disallowComments: true, + }); + if (errors.length > 0) { + const details = errors + .map((error) => `${printParseErrorCode(error.error)} at offset ${error.offset}`) + .join(', '); + throw new Error(`Failed to parse ${label}: ${details}`); + } + if (!isObject(value)) throw new Error(`${label} must contain a JSON object`); + rejectDuplicateKeys(parseTree(raw, [], { + allowTrailingComma: false, + disallowComments: true, + }), label); + return value; +} + +function readHooks(value: unknown, label: string): CursorHooks { + if (value === undefined) return {}; + if (!isObject(value)) throw new Error(`${label} field "hooks" must be an object`); + const hooks: CursorHooks = {}; + for (const [event, candidate] of Object.entries(value)) { + if (!Array.isArray(candidate)) { + throw new Error(`${label} field "hooks.${event}" must be an array`); + } + hooks[event] = candidate.map((handler, index) => { + if (!isObject(handler)) { + throw new Error(`${label} handler hooks.${event}[${index}] must be an object`); + } + return handler; + }); + } + return hooks; +} + +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`); + return { + exists: true, + filePath, + root, + hooks: readHooks(root.hooks, label), + raw, + }; +} + +export function createDocument(filePath: string): CursorDocument { + return { exists: false, filePath, root: {}, hooks: {} }; +} + +export function removeManagedHooks(hooks: CursorHooks, agentId?: string): CursorHooks { + const next: CursorHooks = Object.fromEntries( + Object.entries(hooks).map(([event, handlers]) => [event, [...handlers]]), + ); + for (const [event, scriptName] of [ + ['preToolUse', GUARD_SCRIPT], + ['postToolUse', AUDIT_SCRIPT], + ] as const) { + const handlers = (next[event] ?? []).filter((handler) => { + const managedId = managedAgentId(handler, scriptName); + return !managedId || (agentId !== undefined && !sameAgentId(managedId, agentId)); + }); + if (handlers.length > 0) next[event] = handlers; + else delete next[event]; + } + return next; +} + +function entirelyManaged(document: CursorDocument): boolean { + if (!document.exists + || !Object.keys(document.root).every((key) => key === 'version' || key === 'hooks')) { + return false; + } + const events = Object.entries(document.hooks); + if (events.length === 0) return false; + let handlerCount = 0; + for (const [event, handlers] of events) { + const scriptName = event === 'preToolUse' + ? GUARD_SCRIPT + : event === 'postToolUse' + ? AUDIT_SCRIPT + : undefined; + if (!scriptName || handlers.length === 0) return false; + handlerCount += handlers.length; + if (handlers.some((handler) => !managedAgentId(handler, scriptName))) return false; + } + return handlerCount > 0; +} + +export function renderDocument( + document: CursorDocument, + hooks: CursorHooks, +): RenderedDocument { + if (!document.exists && Object.keys(hooks).length === 0) { + return { document, changed: false }; + } + if (Object.keys(hooks).length === 0 && entirelyManaged(document)) { + return { document, changed: true }; + } + const root: JsonObject = { ...document.root, version: 1 }; + if (Object.keys(hooks).length > 0) root.hooks = hooks; + else delete root.hooks; + const next = `${JSON.stringify(root, null, 2)}\n`; + return { document, changed: next !== document.raw, next }; +} + +function managedIds(handlers: JsonObject[], scriptName: string): Map { + const result = new Map(); + for (const handler of handlers) { + const agentId = managedAgentId(handler, scriptName); + if (!agentId) continue; + const key = process.platform === 'win32' ? agentId.toLowerCase() : agentId; + result.set(key, agentId); + } + return result; +} + +export function runtimeContracts(hooks: CursorHooks): RuntimeContract[] { + const guards = managedIds(hooks.preToolUse ?? [], GUARD_SCRIPT); + const audits = managedIds(hooks.postToolUse ?? [], AUDIT_SCRIPT); + const root = path.join(os.homedir(), '.elydora'); + return [...guards] + .filter(([key]) => audits.has(key)) + .map(([, agentId]) => ({ + agentId, + guardPath: path.join(root, agentId, GUARD_SCRIPT), + auditPath: path.join(root, agentId, AUDIT_SCRIPT), + })); +} diff --git a/src/plugins/cursor-io.ts b/src/plugins/cursor-io.ts new file mode 100644 index 0000000..71b8a8e --- /dev/null +++ b/src/plugins/cursor-io.ts @@ -0,0 +1,243 @@ +import { randomUUID } from 'node:crypto'; +import fsp from 'node:fs/promises'; +import type { FileHandle } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { + AGENT_KEY, + CONFIG_FILE, + type CursorDocument, + type JsonObject, + type RenderedDocument, + type RuntimeContract, + createDocument, + isObject, + parseDocument, +} from './cursor-contract.js'; + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function asError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} + +function hasErrorCode(error: unknown, code: string): boolean { + return isObject(error) && error.code === code; +} + +export function cursorConfigPath(): string { + return path.join(os.homedir(), '.cursor', CONFIG_FILE); +} + +async function readOptionalPhysical(filePath: string, label: string): Promise { + let metadata; + try { + metadata = await fsp.lstat(filePath); + } catch (error) { + if (hasErrorCode(error, 'ENOENT') || hasErrorCode(error, 'ENOTDIR')) return undefined; + throw new Error(`Inspect ${label} at ${filePath}: ${errorMessage(error)}`, { + cause: asError(error), + }); + } + if (!metadata.isFile() || metadata.isSymbolicLink()) { + throw new Error(`${label} path is not a physical file: ${filePath}`); + } + try { + return await fsp.readFile(filePath, 'utf-8'); + } catch (error) { + throw new Error(`Read ${label} at ${filePath}: ${errorMessage(error)}`, { + cause: asError(error), + }); + } +} + +export async function readDocument(): Promise { + const filePath = cursorConfigPath(); + const raw = await readOptionalPhysical(filePath, 'Cursor user hooks'); + return raw === undefined ? createDocument(filePath) : parseDocument(filePath, raw); +} + +async function ensurePhysicalDirectory(directory: string): Promise { + try { + await fsp.mkdir(directory, { recursive: true, mode: 0o700 }); + } catch (error) { + throw new Error(`Create Cursor hooks directory at ${directory}: ${errorMessage(error)}`, { + cause: asError(error), + }); + } + let metadata; + try { + metadata = await fsp.lstat(directory); + } catch (error) { + throw new Error(`Inspect Cursor hooks directory at ${directory}: ${errorMessage(error)}`, { + cause: asError(error), + }); + } + if (!metadata.isDirectory() || metadata.isSymbolicLink()) { + throw new Error(`Cursor hooks directory is not a physical directory: ${directory}`); + } +} + +async function unlinkOptional(filePath: string): Promise { + try { + await fsp.unlink(filePath); + } catch (error) { + if (!hasErrorCode(error, 'ENOENT')) throw error; + } +} + +async function writeExclusive(filePath: string, contents: string): Promise { + let handle: FileHandle | undefined; + try { + handle = await fsp.open(filePath, 'wx', 0o600); + await handle.writeFile(contents, 'utf-8'); + await handle.sync(); + await handle.close(); + handle = undefined; + } 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 Cursor user hooks'); + } + throw new Error(`Stage Cursor user hooks: ${errorMessage(error)}`, { cause: failures[0] }); + } +} + +async function assertUnchanged(document: CursorDocument): Promise { + const current = await readOptionalPhysical(document.filePath, 'Cursor user hooks'); + if (current !== document.raw) { + throw new Error(`Cursor user hooks changed during update: ${document.filePath}`); + } +} + +export async function writeDocument(rendered: RenderedDocument): Promise { + if (!rendered.changed) return; + await assertUnchanged(rendered.document); + if (rendered.next === undefined) { + try { + await fsp.unlink(rendered.document.filePath); + } catch (error) { + if (!hasErrorCode(error, 'ENOENT')) { + throw new Error( + `Remove Cursor user hooks at ${rendered.document.filePath}: ${errorMessage(error)}`, + { cause: asError(error) }, + ); + } + } + return; + } + + const directory = path.dirname(rendered.document.filePath); + await ensurePhysicalDirectory(directory); + const temporaryPath = path.join( + directory, + `.${path.basename(rendered.document.filePath)}.${randomUUID()}.tmp`, + ); + let committed = false; + let failure: Error | undefined; + try { + await writeExclusive(temporaryPath, rendered.next); + await assertUnchanged(rendered.document); + await fsp.rename(temporaryPath, rendered.document.filePath); + committed = true; + } catch (error) { + failure = new Error( + `Write Cursor user hooks at ${rendered.document.filePath}: ${errorMessage(error)}`, + { cause: asError(error) }, + ); + } + let cleanupFailure: Error | undefined; + if (!committed) { + try { + await unlinkOptional(temporaryPath); + } catch (error) { + cleanupFailure = new Error( + `Clean Cursor hook staging file at ${temporaryPath}: ${errorMessage(error)}`, + { cause: asError(error) }, + ); + } + } + if (failure && cleanupFailure) { + throw new AggregateError([failure, cleanupFailure], 'Write Cursor user hooks'); + } + if (failure) throw failure; + if (cleanupFailure) throw cleanupFailure; +} + +export async function physicalFileExists(filePath: string, label: string): Promise { + let metadata; + try { + metadata = await fsp.lstat(filePath); + } catch (error) { + if (hasErrorCode(error, 'ENOENT') || hasErrorCode(error, 'ENOTDIR')) return false; + throw new Error(`Inspect ${label} at ${filePath}: ${errorMessage(error)}`, { + cause: asError(error), + }); + } + if (!metadata.isFile() || metadata.isSymbolicLink()) { + throw new Error(`${label} path is not a physical file: ${filePath}`); + } + return true; +} + +export async function requireRuntime(filePath: string, label: string): Promise { + if (!filePath) throw new Error(`${label} path is required`); + if (!await physicalFileExists(filePath, label)) { + throw new Error(`${label} is missing: ${filePath}`); + } +} + +async function readRuntimeConfig(filePath: 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; +} + +function sameAgentId(left: unknown, right: string): boolean { + if (typeof left !== 'string') return false; + return process.platform === 'win32' + ? left.toLowerCase() === right.toLowerCase() + : left === right; +} + +export async function runtimeFilesExist(contracts: RuntimeContract[]): Promise { + for (const contract of contracts) { + const runtimeConfig = await readRuntimeConfig( + path.join(path.dirname(contract.guardPath), 'config.json'), + ); + if (!runtimeConfig + || runtimeConfig.agent_name !== AGENT_KEY + || !sameAgentId(runtimeConfig.agent_id, contract.agentId)) continue; + const files = await Promise.all([ + physicalFileExists(contract.guardPath, 'Elydora guard runtime'), + physicalFileExists(contract.auditPath, 'Elydora audit runtime'), + ]); + if (files.every(Boolean)) return true; + } + return false; +} diff --git a/src/plugins/cursor.ts b/src/plugins/cursor.ts index eb02da7..6697817 100644 --- a/src/plugins/cursor.ts +++ b/src/plugins/cursor.ts @@ -1,151 +1,69 @@ -import fsp from 'node:fs/promises'; -import path from 'node:path'; import os from 'node:os'; +import path from 'node:path'; +import { resolvePrivateChildDirectory } from '../runtime-paths.js'; import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js'; +import { + AGENT_KEY, + AUDIT_SCRIPT, + GUARD_SCRIPT, + buildHandler, + removeManagedHooks, + renderDocument, + runtimeContracts, + samePath, +} from './cursor-contract.js'; +import { + readDocument, + requireRuntime, + runtimeFilesExist, + writeDocument, +} from './cursor-io.js'; import { SUPPORTED_AGENTS } from './registry.js'; -const AGENT_KEY = 'cursor'; const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!; -function resolveConfigDir(): string { - // .cursor is project-relative, but for global install use home dir - return path.join(os.homedir(), entry.configDir); -} - -function resolveConfigPath(): string { - return path.join(resolveConfigDir(), entry.configFile); -} - -function buildHookCommand(scriptPath: string): string { - return `node "${scriptPath}"`; -} - -function isElydoraCommand(cmd: string, agentId?: string): boolean { - if (!cmd.includes('elydora')) return false; - if (agentId) { - return cmd.includes(agentId); - } - return true; -} - -function filterElydoraEntries(arr: Array>, agentId?: string): Array> { - return arr.filter( - (h) => typeof h.command === 'string' && !isElydoraCommand(h.command, agentId), - ); -} - export const cursorPlugin: AgentPlugin = { async install(config: InstallConfig): Promise { - const configDir = resolveConfigDir(); - await fsp.mkdir(configDir, { recursive: true }); - - const configPath = resolveConfigPath(); - let settings: Record = {}; - - try { - const raw = await fsp.readFile(configPath, 'utf-8'); - settings = JSON.parse(raw); - } catch { - // Start fresh + 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 (!settings.hooks || typeof settings.hooks !== 'object') { - settings.hooks = {}; + if (!samePath(config.hookScriptPath, expectedAudit)) { + throw new Error(`Elydora audit runtime must use the managed agent directory: ${expectedAudit}`); } - const hooks = settings.hooks as Record; + await requireRuntime(expectedGuard, 'Elydora guard runtime'); + await requireRuntime(expectedAudit, 'Elydora audit runtime'); - // --- preToolUse (guard — freeze enforcement) --- - if (!Array.isArray(hooks.preToolUse)) { - hooks.preToolUse = []; - } - const preFiltered = filterElydoraEntries(hooks.preToolUse as Array>); - preFiltered.push({ command: buildHookCommand(config.guardScriptPath) }); - hooks.preToolUse = preFiltered; - - // --- postToolUse (audit logging) --- - if (!Array.isArray(hooks.postToolUse)) { - hooks.postToolUse = []; - } - const postFiltered = filterElydoraEntries(hooks.postToolUse as Array>); - postFiltered.push({ command: buildHookCommand(config.hookScriptPath) }); - hooks.postToolUse = postFiltered; - - settings.hooks = hooks; - settings.version = 1; - await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); + const hooks = removeManagedHooks(document.hooks); + hooks.preToolUse = [...(hooks.preToolUse ?? []), buildHandler(expectedGuard)]; + hooks.postToolUse = [...(hooks.postToolUse ?? []), buildHandler(expectedAudit)]; + await writeDocument(renderDocument(document, hooks)); + console.log(` Cursor hooks: ${document.filePath}`); }, async uninstall(agentId?: string): Promise { - const configPath = resolveConfigPath(); - - let settings: Record; - try { - const raw = await fsp.readFile(configPath, 'utf-8'); - settings = JSON.parse(raw); - } catch { - return; - } - - const hooks = settings.hooks as Record | undefined; - if (!hooks) return; - - if (Array.isArray(hooks.preToolUse)) { - hooks.preToolUse = filterElydoraEntries(hooks.preToolUse as Array>, agentId); - } - if (Array.isArray(hooks.postToolUse)) { - hooks.postToolUse = filterElydoraEntries(hooks.postToolUse as Array>, agentId); - } - - await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); + const document = await readDocument(); + const hooks = removeManagedHooks(document.hooks, agentId); + await writeDocument(renderDocument(document, hooks)); }, async status(): Promise { - const configPath = resolveConfigPath(); - - let hookConfigured = false; - let hookScriptPath = ''; - try { - const raw = await fsp.readFile(configPath, 'utf-8'); - const settings = JSON.parse(raw); - const hooks = settings.hooks as Record | undefined; - if (hooks) { - const check = (arr: unknown) => - Array.isArray(arr) && (arr as Array>).some( - (h) => typeof h.command === 'string' && h.command.includes('elydora'), - ); - hookConfigured = check(hooks.preToolUse) && check(hooks.postToolUse); - - // Extract hook script path from postToolUse command - if (hookConfigured && Array.isArray(hooks.postToolUse)) { - for (const h of hooks.postToolUse as Array>) { - const cmd = h.command as string; - if (cmd && cmd.includes('elydora')) { - hookScriptPath = cmd.replace(/^node\s+"?/, '').replace(/"?\s*$/, ''); - } - } - } - } - } catch { - // Config not readable - } - - let hookScriptExists = false; - if (hookScriptPath) { - try { - await fsp.access(hookScriptPath); - hookScriptExists = true; - } catch { - // File doesn't exist - } - } - + const document = await readDocument(); + const contracts = runtimeContracts(document.hooks); + const hookConfigured = contracts.length > 0; + const hookScriptExists = hookConfigured && await runtimeFilesExist(contracts); return { installed: hookConfigured && hookScriptExists, agentName: AGENT_KEY, displayName: entry.name, hookConfigured, hookScriptExists, - configPath, + configPath: document.filePath, }; }, }; diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 9228d3a..a9f2f61 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -7,7 +7,7 @@ export interface AgentRegistryEntry { export const SUPPORTED_AGENTS: ReadonlyMap = new Map([ ['augment', { name: 'Augment Code CLI', configDir: '~/.augment', configFile: 'settings.json' }], ['claudecode', { name: 'Claude Code', configDir: '~/.claude', configFile: 'settings.json' }], - ['cursor', { name: 'Cursor', configDir: '.cursor', configFile: 'hooks.json' }], + ['cursor', { name: 'Cursor', configDir: '~/.cursor', configFile: 'hooks.json' }], ['gemini', { name: 'Gemini CLI', configDir: '~/.gemini', configFile: 'settings.json' }], ['kirocli', { name: 'Kiro CLI', configDir: '~/.kiro/hooks', configFile: 'elydora-audit.json' }], ['kiroide', { name: 'Kiro IDE', configDir: '.kiro/hooks', configFile: 'elydora-audit.kiro.hook' }], diff --git a/test/cursor-plugin.test.mjs b/test/cursor-plugin.test.mjs new file mode 100644 index 0000000..e25fd03 --- /dev/null +++ b/test/cursor-plugin.test.mjs @@ -0,0 +1,490 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { + lstat, + mkdir, + mkdtemp, + readFile, + readdir, + rm, + stat, + 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}"` }; +} + +test('Cursor is registered with the native user hook file', async () => { + const { SUPPORTED_AGENTS } = await import(registryModuleUrl); + assert.deepEqual(SUPPORTED_AGENTS.get('cursor'), { + name: 'Cursor', + configDir: '~/.cursor', + configFile: 'hooks.json', + }); +}); + +test('Cursor install preserves user 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' }], + preToolUse: [ + { command: 'user-pre' }, + legacyHandler(fixture.guardScriptPath), + ], + postToolUse: [legacyHandler(fixture.hookScriptPath)], + }, + }); + assert.equal((await fixture.install()).code, 0); + const second = await fixture.install(); + assert.equal(second.code, 0, second.stderr); + + const config = JSON.parse(await readFile(fixture.configPath, 'utf-8')); + assert.equal(config.version, 1); + assert.equal(config.description, 'user-owned'); + assert.deepEqual(config.hooks.sessionStart, [{ command: 'user-session' }]); + assert.deepEqual(config.hooks.preToolUse[0], { command: 'user-pre' }); + assert.equal(config.hooks.preToolUse.length, 2); + assert.equal(config.hooks.postToolUse.length, 1); + assertNativeHandler(managedHandler(config, 'preToolUse', 'guard.js')); + assertNativeHandler(managedHandler(config, 'postToolUse', '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 () => { + const fixture = await createFixture(); + try { + assert.equal((await fixture.install()).code, 0); + let status = await runPlugin(fixture, 'status', null); + 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); + 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' }); + status = await runPlugin(fixture, 'status', null); + assert.equal(JSON.parse(status.stdout).installed, false); + + await writeFile(runtimeConfigPath, '{ malformed'); + status = await runPlugin(fixture, 'status', null); + assert.equal(status.code, 1); + assert.match(status.stderr, /parse Elydora runtime config/i); + } finally { + await fixture.close(); + } +}); + +test('Cursor uninstall removes exact ownership and preserves user entries', async () => { + const fixture = await createFixture({ + existingConfig: { version: 1, hooks: { sessionStart: [{ command: 'keep' }] } }, + }); + 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); + await writeJson(fixture.configPath, config); + + const result = await runPlugin(fixture, 'uninstall', 'agent-1'); + assert.equal(result.code, 0, result.stderr); + const remaining = JSON.parse(await readFile(fixture.configPath, 'utf-8')); + assert.deepEqual(remaining.hooks.sessionStart, [{ command: 'keep' }]); + assert.equal(remaining.hooks.preToolUse.length, 2); + assert.equal(remaining.hooks.postToolUse.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 () => { + const invalidConfigs = [ + '{ malformed', + '[]\n', + '{"hooks":{}}\n', + '{"version":2,"hooks":{}}\n', + '{"version":1,"hooks":null}\n', + '{"version":1,"hooks":{"preToolUse":null}}\n', + '{"version":1,"hooks":{"preToolUse":[null]}}\n', + '{"version":1,"version":1,"hooks":{}}\n', + '{"version":1,"hooks":{},}\n', + ]; + for (const existingConfig of invalidConfigs) { + const fixture = await createFixture({ existingConfig }); + try { + const before = await readFile(fixture.configPath, 'utf-8'); + const result = await fixture.install(); + assert.equal(result.code, 1, `accepted ${existingConfig}`); + assert.equal(await readFile(fixture.configPath, 'utf-8'), before); + } 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 () => { + 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, + [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' }); + } finally { + await fixture.close(); + } + } +}); + +test('Cursor rejects 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 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}`); + throw error; + } + const result = await fixture.install(); + assert.equal(result.code, 1); + assert.match(result.stderr, /physical file/i); + assert.equal(await readFile(target, 'utf-8'), original); + assert.equal((await lstat(fixture.configPath)).isSymbolicLink(), true); + } finally { + await fixture.close(); + } +}); + +test('Cursor status rejects symbolic-link runtime files', async (t) => { + for (const name of ['config', 'guard', 'audit']) { + const fixture = await createFixture(); + try { + assert.equal((await fixture.install()).code, 0); + const filePath = { + config: path.join(fixture.agentDir, 'config.json'), + guard: fixture.guardScriptPath, + audit: fixture.hookScriptPath, + }[name]; + const contents = await readFile(filePath); + const target = path.join(fixture.homeDir, `${name}-runtime-target`); + await writeFile(target, contents); + await rm(filePath); + try { + await symlink(target, filePath); + } catch (error) { + if (error?.code === 'EPERM') t.skip(`symbolic links unavailable: ${error.message}`); + throw error; + } + const status = await runPlugin(fixture, 'status', null); + assert.equal(status.code, 1); + assert.match(status.stderr, /physical file/i); + } finally { + await fixture.close(); + } + } +}); + +test('Cursor removes an entirely managed config and leaves absent config absent', async () => { + const fixture = await createFixture(); + try { + assert.equal((await fixture.install()).code, 0); + assert.equal((await runPlugin(fixture, 'uninstall', 'agent-1')).code, 0); + await assert.rejects(readFile(fixture.configPath), { code: 'ENOENT' }); + + assert.equal((await runPlugin(fixture, 'uninstall', 'agent-1')).code, 0); + await assert.rejects(readFile(fixture.configPath), { code: 'ENOENT' }); + } finally { + await fixture.close(); + } +}); + +test('Cursor atomic writes are private and leave no staging 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); + if (process.platform !== 'win32') { + assert.equal((await stat(fixture.configPath)).mode & 0o777, 0o600); + } + } 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(); + } +});