fix(node): sync transactional Cursor hooks

This commit is contained in:
ZacharyZhang-NY committed 2026-07-20 00:58:40 -04:00
1 parent 68918e22d7
commit 2e699c11e3
16 files changed
+2466 -462

No files matched your search

+1
View File
@@ -31,6 +31,7 @@ This repository publishes the open-source server, console, integration catalog,
- Write Cline file hooks to `$CLINE_DIR/hooks` with `~/.cline/hooks` as the default; keep Documents and workspace hook roots read-only. Use supported event filenames, preserve official hook input byte-for-byte, use `process.execPath` in Node and Go wrappers, and resolve Python runtimes from absolute shebangs. Translate guard exit code `2` into JSON stdout with `cancel: true`. Treat hook errors, timeouts, and invalid control JSON as fail-open. - Write Cline file hooks to `$CLINE_DIR/hooks` with `~/.cline/hooks` as the default; keep Documents and workspace hook roots read-only. Use supported event filenames, preserve official hook input byte-for-byte, use `process.execPath` in Node and Go wrappers, and resolve Python runtimes from absolute shebangs. Translate guard exit code `2` into JSON stdout with `cancel: true`. Treat hook errors, timeouts, and invalid control JSON as fail-open.
- Write GitHub Copilot CLI hooks only to `$COPILOT_HOME/hooks/elydora-audit.json` with `~/.copilot/hooks/elydora-audit.json` as the default. Migrate exact Elydora-owned entries from project `.github/hooks/hooks.json`, preserve native camelCase input, require `disableAllHooks` to be false, and commit runtime plus hook documents in one rollback-capable transaction. - Write GitHub Copilot CLI hooks only to `$COPILOT_HOME/hooks/elydora-audit.json` with `~/.copilot/hooks/elydora-audit.json` as the default. Migrate exact Elydora-owned entries from project `.github/hooks/hooks.json`, preserve native camelCase input, require `disableAllHooks` to be false, and commit runtime plus hook documents in one rollback-capable transaction.
- Model Cursor as one CLI-and-IDE hook contract. Preserve user, project, and enterprise sources in documented priority order; forward `tool_name`, `tool_input`, and `conversation_id`; use exit code `2` for denial; record `failClosed` as the per-script failure-mode control. - Model Cursor as one CLI-and-IDE hook contract. Preserve user, project, and enterprise sources in documented priority order; forward `tool_name`, `tool_input`, and `conversation_id`; use exit code `2` for denial; record `failClosed` as the per-script failure-mode control.
- Write Node Cursor hooks only to `~/.cursor/hooks.json`; keep project and enterprise sources read-only. Register `preToolUse`, `postToolUse`, and `postToolUseFailure`, preserve the complete native payload, emit valid native JSON responses, retain PowerShell exit codes, and commit guard, runtime metadata, private key, audit runtime, and user hooks in one rollback-capable transaction.
- Write Go Cursor hooks only to `~/.cursor/hooks.json`; keep project and enterprise sources read-only. Preserve user hooks, migrate the prior versionless Elydora contract, audit successful and failed tool calls, emit valid native JSON responses, retain PowerShell exit codes, and commit guard, runtime metadata, private key, audit runtime, and user hooks in one rollback-capable transaction. - Write Go Cursor hooks only to `~/.cursor/hooks.json`; keep project and enterprise sources read-only. Preserve user hooks, migrate the prior versionless Elydora contract, audit successful and failed tool calls, emit valid native JSON responses, retain PowerShell exit codes, and commit guard, runtime metadata, private key, audit runtime, and user hooks in one rollback-capable transaction.
- 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, use SDK-native absolute runtime commands, commit generated runtime artifacts and hook sources as one recoverable transaction, and require `/hooks` review after external changes. - 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, use SDK-native absolute runtime commands, commit generated runtime artifacts and hook sources as one recoverable transaction, and require `/hooks` review after external changes.
- Qwen Code `0.20.0` resolves 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. - Qwen Code `0.20.0` resolves 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.
+2
View File
@@ -55,6 +55,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. 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`, `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. 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. 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.
+24 -31
View File
@@ -188,41 +188,10 @@ async function cmdInstall(args: string[]): Promise<void> {
console.log(`Verifying private key... Public key: ${publicKey.slice(0, 12)}...`); 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 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'); 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 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 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 plugin = PLUGINS.get(agentName)!;
const installConfig: InstallConfig = { const installConfig: InstallConfig = {
agentName, agentName,
@@ -235,8 +204,32 @@ async function cmdInstall(args: string[]): Promise<void> {
hookScriptPath, hookScriptPath,
guardScriptPath, 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); 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)!; const entry = SUPPORTED_AGENTS.get(agentName)!;
console.log(`\nElydora audit hook installed for ${entry.name}.`); console.log(`\nElydora audit hook installed for ${entry.name}.`);
+2
View File
@@ -20,6 +20,8 @@ export interface PluginStatus {
} }
export interface AgentPlugin { export interface AgentPlugin {
readonly managesRuntime?: boolean;
preflightInstall?(config: InstallConfig): Promise<void>;
install(config: InstallConfig): Promise<void>; install(config: InstallConfig): Promise<void>;
uninstall(agentId?: string): Promise<void>; uninstall(agentId?: string): Promise<void>;
status(): Promise<PluginStatus>; status(): Promise<PluginStatus>;
+343
View File
@@ -0,0 +1,343 @@
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<string, unknown>;
export type CursorHooks = Record<string, JsonObject[]>;
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<string>();
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);
}
}
export function parseStrictJsonObject(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 = 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,
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],
['postToolUseFailure', 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' || event === 'postToolUseFailure'
? 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<string, string> {
const result = new Map<string, string>();
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;
}
function containsManagedHook(hooks: CursorHooks): boolean {
return [
...(hooks.preToolUse ?? []).map((handler) => 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) && failures.has(key))
.map(([, agentId]) => ({
agentId,
guardPath: path.join(root, agentId, GUARD_SCRIPT),
auditPath: path.join(root, agentId, AUDIT_SCRIPT),
}));
}
@@ -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<void>;
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<FileSnapshot | undefined> {
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<boolean> {
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<void> {
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<CursorRuntimePaths> {
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<FileChange> {
return {
filePath,
label,
next,
mode,
original: await readPhysicalFile(filePath, label, maximumBytes),
};
}
export async function prepareCursorInstallation(
config: InstallConfig,
rendered: RenderedDocument,
): Promise<PreparedCursorInstallation> {
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<void> {
try {
await fsp.unlink(filePath);
} catch (error) {
if (!hasCode(error, 'ENOENT')) throw error;
}
}
async function assertUnchanged(change: FileChange): Promise<void> {
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<void> {
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<StagedChange> {
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<void> {
await assertUnchanged(staged.change);
await renameFile(staged.temporaryPath, staged.change.filePath);
staged.committed = true;
}
async function rollback(staged: StagedChange, renameFile: RenameFile): Promise<void> {
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<void> {
await Promise.all(
[staged.temporaryPath, staged.rollbackPath].filter(Boolean).map((item) => unlinkOptional(item!)),
);
}
async function ensureCursorDirectory(directory: string): Promise<void> {
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<void> {
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');
}
}
+237
View File
@@ -0,0 +1,237 @@
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,
parseStrictJsonObject,
} 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<string | undefined> {
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<CursorDocument> {
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<void> {
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<void> {
try {
await fsp.unlink(filePath);
} catch (error) {
if (!hasErrorCode(error, 'ENOENT')) throw error;
}
}
async function writeExclusive(filePath: string, contents: string): Promise<void> {
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<void> {
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<void> {
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<boolean> {
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<void> {
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<JsonObject | undefined> {
const raw = await readOptionalPhysical(filePath, 'Elydora runtime config');
if (raw === undefined) return undefined;
return parseStrictJsonObject(raw, `Elydora runtime config at ${filePath}`);
}
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<boolean> {
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'),
physicalFileExists(
path.join(path.dirname(contract.guardPath), 'private.key'),
'Elydora private key',
),
]);
if (files.every(Boolean)) return true;
}
return false;
}
+47 -128
View File
@@ -1,151 +1,70 @@
import fsp from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js'; import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
import {
AGENT_KEY,
buildHandler,
removeManagedHooks,
renderDocument,
runtimeContracts,
} from './cursor-contract.js';
import {
commitCursorInstallation,
preflightCursorInstallation,
prepareCursorInstallation,
} from './cursor-installation.js';
import {
readDocument,
runtimeFilesExist,
writeDocument,
} from './cursor-io.js';
import { SUPPORTED_AGENTS } from './registry.js'; import { SUPPORTED_AGENTS } from './registry.js';
const AGENT_KEY = 'cursor';
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!; 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<Record<string, unknown>>, agentId?: string): Array<Record<string, unknown>> {
return arr.filter(
(h) => typeof h.command === 'string' && !isElydoraCommand(h.command, agentId),
);
}
export const cursorPlugin: AgentPlugin = { export const cursorPlugin: AgentPlugin = {
managesRuntime: true,
async preflightInstall(config: InstallConfig): Promise<void> {
await readDocument();
await preflightCursorInstallation(config);
},
async install(config: InstallConfig): Promise<void> { async install(config: InstallConfig): Promise<void> {
const configDir = resolveConfigDir(); const document = await readDocument();
await fsp.mkdir(configDir, { recursive: true }); const paths = await preflightCursorInstallation(config);
const configPath = resolveConfigPath(); const hooks = removeManagedHooks(document.hooks);
let settings: Record<string, unknown> = {}; hooks.preToolUse = [...(hooks.preToolUse ?? []), buildHandler(paths.guardPath)];
hooks.postToolUse = [...(hooks.postToolUse ?? []), buildHandler(paths.auditPath)];
try { hooks.postToolUseFailure = [
const raw = await fsp.readFile(configPath, 'utf-8'); ...(hooks.postToolUseFailure ?? []),
settings = JSON.parse(raw); buildHandler(paths.auditPath),
} catch { ];
// Start fresh const prepared = await prepareCursorInstallation(
} config,
renderDocument(document, hooks),
if (!settings.hooks || typeof settings.hooks !== 'object') { );
settings.hooks = {}; await commitCursorInstallation(prepared);
} console.log(` Cursor hooks: ${document.filePath}`);
const hooks = settings.hooks as Record<string, unknown>;
// --- preToolUse (guard — freeze enforcement) ---
if (!Array.isArray(hooks.preToolUse)) {
hooks.preToolUse = [];
}
const preFiltered = filterElydoraEntries(hooks.preToolUse as Array<Record<string, unknown>>);
preFiltered.push({ command: buildHookCommand(config.guardScriptPath) });
hooks.preToolUse = preFiltered;
// --- postToolUse (audit logging) ---
if (!Array.isArray(hooks.postToolUse)) {
hooks.postToolUse = [];
}
const postFiltered = filterElydoraEntries(hooks.postToolUse as Array<Record<string, unknown>>);
postFiltered.push({ 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');
}, },
async uninstall(agentId?: string): Promise<void> { async uninstall(agentId?: string): Promise<void> {
const configPath = resolveConfigPath(); const document = await readDocument();
const hooks = removeManagedHooks(document.hooks, agentId);
let settings: Record<string, unknown>; await writeDocument(renderDocument(document, hooks));
try {
const raw = await fsp.readFile(configPath, 'utf-8');
settings = JSON.parse(raw);
} catch {
return;
}
const hooks = settings.hooks as Record<string, unknown> | undefined;
if (!hooks) return;
if (Array.isArray(hooks.preToolUse)) {
hooks.preToolUse = filterElydoraEntries(hooks.preToolUse as Array<Record<string, unknown>>, agentId);
}
if (Array.isArray(hooks.postToolUse)) {
hooks.postToolUse = filterElydoraEntries(hooks.postToolUse as Array<Record<string, unknown>>, agentId);
}
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
}, },
async status(): Promise<PluginStatus> { async status(): Promise<PluginStatus> {
const configPath = resolveConfigPath(); const document = await readDocument();
const contracts = runtimeContracts(document.hooks);
let hookConfigured = false; const hookConfigured = contracts.length > 0;
let hookScriptPath = ''; const hookScriptExists = hookConfigured && await runtimeFilesExist(contracts);
try {
const raw = await fsp.readFile(configPath, 'utf-8');
const settings = JSON.parse(raw);
const hooks = settings.hooks as Record<string, unknown> | undefined;
if (hooks) {
const check = (arr: unknown) =>
Array.isArray(arr) && (arr as Array<Record<string, unknown>>).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<Record<string, unknown>>) {
const cmd = h.command as string;
if (cmd && cmd.includes('elydora')) {
hookScriptPath = cmd.replace(/^node\s+"?/, '').replace(/"?\s*$/, '');
}
}
}
}
} catch {
// Config not readable
}
let hookScriptExists = false;
if (hookScriptPath) {
try {
await fsp.access(hookScriptPath);
hookScriptExists = true;
} catch {
// File doesn't exist
}
}
return { return {
installed: hookConfigured && hookScriptExists, installed: hookConfigured && hookScriptExists,
agentName: AGENT_KEY, agentName: AGENT_KEY,
displayName: entry.name, displayName: entry.name,
hookConfigured, hookConfigured,
hookScriptExists, hookScriptExists,
configPath, configPath: document.filePath,
}; };
}, },
}; };
+190
View File
@@ -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;
});
`;
}
+230 -302
View File
@@ -1,49 +1,59 @@
/** import { PROTECTED_RUNTIME_READER } from './runtime-reader-template.js';
* Generates a self-contained Node.js hook script that embeds all crypto logic
* inline so it has zero runtime dependency on the SDK.
*/
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 return `#!/usr/bin/env node
'use strict'; 'use strict';
// --------------------------------------------------------------------------- // Elydora audit hook for ${agentName}. Generated by @elydora/sdk.
// Self-contained Elydora audit hook for: ${agentName}
// Generated by @elydora/sdk — DO NOT EDIT
// ---------------------------------------------------------------------------
const crypto = require('node:crypto'); const crypto = require('node:crypto');
const fs = require('node:fs'); const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const os = require('node:os'); const os = require('node:os');
const AGENT_NAME = ${JSON.stringify(agentName)}; const AGENT_NAME = ${JSON.stringify(agentName)};
const AGENT_ID = ${JSON.stringify(agentId)}; 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 ELYDORA_DIR = path.join(os.homedir(), '.elydora');
const CONFIG_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'config.json'); const CONFIG_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'config.json');
const KEY_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'private.key'); 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 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 ERROR_LOG_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'error.log');
const ZERO_CHAIN_HASH = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
// --------------------------------------------------------------------------- ${PROTECTED_RUNTIME_READER}
// Embedded crypto (from @elydora/sdk src/crypto.ts + src/utils.ts)
// ---------------------------------------------------------------------------
function base64urlEncode(data) { function base64urlEncode(data) {
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data); const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data);
return buf.toString('base64url'); return buffer.toString('base64url');
} }
function jcsCanonicalise(value) { function jcsCanonicalise(value) {
if (value === null || value === undefined) return 'null'; if (value === null || value === undefined) return 'null';
if (typeof value === 'boolean' || typeof value === 'number') return JSON.stringify(value); if (typeof value === 'boolean' || typeof value === 'number') return JSON.stringify(value);
if (typeof value === 'string') return JSON.stringify(value); if (typeof value === 'string') return JSON.stringify(value);
if (Array.isArray(value)) { if (Array.isArray(value)) return '[' + value.map(jcsCanonicalise).join(',') + ']';
return '[' + value.map(v => jcsCanonicalise(v)).join(',') + ']';
}
if (typeof value === 'object') { if (typeof value === 'object') {
const keys = Object.keys(value).sort();
const pairs = []; const pairs = [];
for (const key of keys) { for (const key of Object.keys(value).sort()) {
if (value[key] !== undefined) { if (value[key] !== undefined) {
pairs.push(JSON.stringify(key) + ':' + jcsCanonicalise(value[key])); pairs.push(JSON.stringify(key) + ':' + jcsCanonicalise(value[key]));
} }
@@ -62,9 +72,8 @@ function computePayloadHash(payload) {
return sha256Base64url(jcsCanonicalise(payload)); return sha256Base64url(jcsCanonicalise(payload));
} }
function computeChainHash(prevChainHash, payloadHash, operationId, issuedAt) { function computeChainHash(previous, payload, operationId, issuedAt) {
const input = prevChainHash + '|' + payloadHash + '|' + operationId + '|' + issuedAt; return sha256Base64url(previous + '|' + payload + '|' + operationId + '|' + issuedAt);
return sha256Base64url(input);
} }
const PKCS8_ED25519_PREFIX = Buffer.from([ 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, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
]); ]);
function signEd25519(privateKeyBase64url, data) { function parsePrivateKey(raw) {
const seed = Buffer.from(privateKeyBase64url, 'base64url'); let value = raw.toString('utf-8');
const pkcs8 = Buffer.concat([PKCS8_ED25519_PREFIX, seed]); if (value.endsWith('\\r\\n')) value = value.slice(0, -2);
const keyObject = crypto.createPrivateKey({ key: pkcs8, format: 'der', type: 'pkcs8' }); else if (value.endsWith('\\n')) value = value.slice(0, -1);
const signature = crypto.sign(null, data, keyObject); if (!value || value.includes('\\r') || value.includes('\\n') || value.includes('\\0')) {
return base64urlEncode(signature); 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() { function uuidv7() {
const now = Date.now(); const now = Date.now();
@@ -103,308 +122,217 @@ function uuidv7() {
bytes[14] = random[8]; bytes[14] = random[8];
bytes[15] = random[9]; bytes[15] = random[9];
const hex = bytes.toString('hex'); const hex = bytes.toString('hex');
return ( return hex.slice(0, 8) + '-' + hex.slice(8, 12) + '-' + hex.slice(12, 16) + '-' +
hex.slice(0, 8) + '-' + hex.slice(16, 20) + '-' + hex.slice(20);
hex.slice(8, 12) + '-' +
hex.slice(12, 16) + '-' +
hex.slice(16, 20) + '-' +
hex.slice(20, 32)
);
} }
function generateNonce() { function logError(error) {
return base64urlEncode(crypto.randomBytes(16)); 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() { function readChainState() {
let raw;
try { try {
const raw = fs.readFileSync(CHAIN_STATE_PATH, 'utf-8'); raw = fs.readFileSync(CHAIN_STATE_PATH, 'utf-8');
const state = JSON.parse(raw); } catch (error) {
return state.prev_chain_hash || ZERO_CHAIN_HASH; if (error.code === 'ENOENT') return ZERO_CHAIN_HASH;
} catch { throw new Error('Failed to read chain state: ' + error.message);
return ZERO_CHAIN_HASH;
} }
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) { function writeChainState(chainHash) {
const tmpPath = CHAIN_STATE_PATH + '.tmp'; const temporaryPath = CHAIN_STATE_PATH + '.' + process.pid + '.tmp';
fs.writeFileSync(tmpPath, JSON.stringify({ prev_chain_hash: chainHash }), 'utf-8'); let descriptor;
try { try {
fs.renameSync(tmpPath, CHAIN_STATE_PATH); descriptor = fs.openSync(temporaryPath, 'wx', 0o600);
} catch (err) { fs.writeFileSync(descriptor, JSON.stringify({ prev_chain_hash: chainHash }), 'utf-8');
if (err.code === 'EPERM' || err.code === 'EEXIST') { fs.fsyncSync(descriptor);
try { fs.unlinkSync(CHAIN_STATE_PATH); } catch { /* ignore */ } fs.closeSync(descriptor);
fs.renameSync(tmpPath, CHAIN_STATE_PATH); descriptor = undefined;
} 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;
try { try {
hookData = JSON.parse(rawInput); fs.renameSync(temporaryPath, CHAIN_STATE_PATH);
} catch { } catch (error) {
return; 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;
// 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);
const res = await fetch(url, { }
method: 'POST', } finally {
headers: headers, if (descriptor !== undefined) fs.closeSync(descriptor);
body: JSON.stringify(eor), try { fs.unlinkSync(temporaryPath); } catch (error) {
signal: controller.signal, if (error.code !== 'ENOENT') throw error;
});
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);
} }
} catch (err) {
logError(err);
} }
} }
main(); function readConfigAndKey() {
`; const raw = readProtectedFile(
} CONFIG_PATH,
'Agent config',
/** MAX_PROTECTED_CONFIG_BYTES,
* Generates a lightweight PreToolUse guard script that checks if the agent ).toString('utf-8');
* 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
let config; let config;
try { try {
config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); config = JSON.parse(raw);
} catch { } catch (error) {
return; // Can't read config — fail-open (allow) throw new Error('Agent config is invalid JSON: ' + error.message);
} }
if (!config || typeof config !== 'object') throw new Error('Agent config must be an object');
// Check cached status if (config.agent_name !== AGENT_NAME || config.agent_id !== AGENT_ID) {
try { throw new Error('Agent config identity does not match the generated audit hook');
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
} }
for (const field of ['org_id', 'agent_id', 'kid']) {
// Fetch agent status from API if (typeof config[field] !== 'string' || !config[field]) {
const baseUrl = (config.base_url || 'https://api.elydora.com').replace(/\\/+$/, ''); throw new Error('Agent config is missing ' + field);
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;
} }
}
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), { async function readHookInput() {
headers: headers, 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, signal: controller.signal,
}); });
clearTimeout(timeout); if (response.ok) {
writeChainState(chainHash);
if (res.ok) { return;
const data = await res.json(); }
const agentStatus = (data.agent && data.agent.status) || 'active'; const responseBody = await response.text();
if (response.status === 400 && responseBody) {
// Update cache let failure;
try { try { failure = JSON.parse(responseBody); } catch (error) {
fs.writeFileSync(STATUS_CACHE_PATH, JSON.stringify({ throw new Error('Audit API returned invalid error JSON: ' + error.message);
status: agentStatus,
cached_at: Date.now(),
}), 'utf-8');
} catch {
// Ignore cache write failures
} }
if (failure.error && failure.error.code === 'PREV_HASH_MISMATCH') {
if (agentStatus === 'frozen') { const match = String(failure.error.message || '').match(
process.stderr.write('Agent "' + AGENT_NAME + '" is frozen by Elydora. Tool execution blocked.\\n'); /Expected prev_chain_hash "([A-Za-z0-9_-]{43})"/,
process.exitCode = 2; );
if (match) {
writeChainState(match[1]);
logError(new Error('Chain hash resynced to server: ' + match[1]));
}
} }
} }
} catch { throw new Error('Audit API returned HTTP ' + response.status);
// API unreachable — fail-open (allow) } 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;
});
`; `;
} }
+1 -1
View File
@@ -7,7 +7,7 @@ export interface AgentRegistryEntry {
export const SUPPORTED_AGENTS: ReadonlyMap<string, AgentRegistryEntry> = new Map([ export const SUPPORTED_AGENTS: ReadonlyMap<string, AgentRegistryEntry> = new Map([
['augment', { name: 'Augment Code CLI', configDir: '~/.augment', configFile: 'settings.json' }], ['augment', { name: 'Augment Code CLI', configDir: '~/.augment', configFile: 'settings.json' }],
['claudecode', { name: 'Claude Code', configDir: '~/.claude', 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' }], ['gemini', { name: 'Gemini CLI', configDir: '~/.gemini', configFile: 'settings.json' }],
['kirocli', { name: 'Kiro CLI', configDir: '~/.kiro/hooks', configFile: 'elydora-audit.json' }], ['kirocli', { name: 'Kiro CLI', configDir: '~/.kiro/hooks', configFile: 'elydora-audit.json' }],
['kiroide', { name: 'Kiro IDE', configDir: '.kiro/hooks', configFile: 'elydora-audit.kiro.hook' }], ['kiroide', { name: 'Kiro IDE', configDir: '.kiro/hooks', configFile: 'elydora-audit.kiro.hook' }],
@@ -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);
}
}
`;
@@ -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());
}),
};
}
+281
View File
@@ -0,0 +1,281 @@
import assert from 'node:assert/strict';
import {
lstat,
mkdir,
readFile,
readdir,
rm,
stat,
symlink,
writeFile,
} from 'node:fs/promises';
import path from 'node:path';
import test from 'node:test';
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);
assert.deepEqual(SUPPORTED_AGENTS.get('cursor'), {
name: 'Cursor',
configDir: '~/.cursor',
configFile: 'hooks.json',
});
});
test('Cursor install preserves hooks, migrates legacy entries, and is idempotent', async () => {
const fixture = await createFixture();
try {
await writeJson(fixture.configPath, {
description: 'user-owned',
hooks: {
sessionStart: [{ command: 'user-session' }],
preToolUse: [
{ command: 'user-pre' },
legacyHandler(fixture.guardScriptPath),
],
postToolUse: [legacyHandler(fixture.hookScriptPath)],
postToolUseFailure: [legacyHandler(fixture.hookScriptPath)],
},
});
const first = await fixture.install();
assert.equal(first.code, 0, first.stderr);
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);
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 status requires three handlers, matching identity, and every runtime secret', 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 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 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(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);
} 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'));
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');
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.equal(remaining.hooks.postToolUseFailure.length, 1);
assert.match(remaining.hooks.preToolUse[0].command, /agent-10/);
} finally {
await fixture.close();
}
});
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',
'{"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);
await assert.rejects(lstat(path.join(fixture.homeDir, '.elydora')), { code: 'ENOENT' });
} finally {
await fixture.close();
}
}
});
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', 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' });
} finally {
await fixture.close();
}
}
});
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}`);
return;
}
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', '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];
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}`);
return;
}
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 an 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 installation writes private files and leaves no transaction files', async () => {
const fixture = await createFixture();
try {
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') {
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();
}
});
+176
View File
@@ -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();
}
});
+262
View File
@@ -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();
}
});