fix: make Cursor hooks transactional
This commit is contained in:
1 parent
6d36f557b1
commit
6a7fdb7595
15 files changed
+1686
-665
No files matched your search
@@ -21,12 +21,12 @@ This repository owns the published `@elydora/sdk` package, its `elydora` CLI, lo
|
|||||||
- Quote the current Node executable and generated script paths for the host shell.
|
- Quote the current Node executable and generated script paths for the host shell.
|
||||||
- Forward official hook JSON from STDIN without reshaping provider fields.
|
- Forward official hook JSON from STDIN without reshaping provider fields.
|
||||||
- Use the provider's documented blocking mechanism. Command-hook providers that define exit code `2` must receive exit code `2` from the freeze guard.
|
- Use the provider's documented blocking mechanism. Command-hook providers that define exit code `2` must receive exit code `2` from the freeze guard.
|
||||||
- Report installation as healthy only when a complete hook contract references both generated runtime scripts and both scripts exist.
|
- Report installation as healthy only when a complete hook contract references every required event and both generated runtime scripts, runtime config, and private key exist as physical files.
|
||||||
- Model stable, legacy, and early-access hook generations as explicit contracts. Keep their activation requirements visible in CLI output and README guidance.
|
- Model stable, legacy, and early-access hook generations as explicit contracts. Keep their activation requirements visible in CLI output and README guidance.
|
||||||
- Keep Grok Build writes inside its native global `$GROK_HOME/hooks/*.json` contract. Treat Claude Code and Cursor compatibility files plus project `.grok/hooks` as read-only integration sources.
|
- Keep Grok Build writes inside its native global `$GROK_HOME/hooks/*.json` contract. Treat Claude Code and Cursor compatibility files plus project `.grok/hooks` as read-only integration sources.
|
||||||
- Write Auggie hooks only to `~/.augment/settings.json`; keep system and workspace settings read-only. Generate `.cmd` wrappers on Windows and `.sh` wrappers on Unix because Auggie dispatches supported script paths, and express hook timeouts in milliseconds.
|
- Write Auggie hooks only to `~/.augment/settings.json`; keep system and workspace settings read-only. Generate `.cmd` wrappers on Windows and `.sh` wrappers on Unix because Auggie dispatches supported script paths, and express hook timeouts in milliseconds.
|
||||||
- Write Cline hooks only to `$CLINE_DIR/hooks` with `~/.cline/hooks` as the default; keep Documents and workspace hook roots read-only. Translate guard exit code `2` into Cline's JSON stdout cancellation control and preserve official hook input byte-for-byte.
|
- Write Cline hooks only to `$CLINE_DIR/hooks` with `~/.cline/hooks` as the default; keep Documents and workspace hook roots read-only. Translate guard exit code `2` into Cline's JSON stdout cancellation control and preserve official hook input byte-for-byte.
|
||||||
- Write Cursor hooks only to `~/.cursor/hooks.json`; keep project and enterprise sources read-only. Preserve native `tool_name`, `tool_input`, and `conversation_id` input, set a ten-second timeout plus `failClosed` on both managed handlers, and propagate guard exit code `2` through the host shell.
|
- Write Cursor hooks only to `~/.cursor/hooks.json`; keep project and enterprise sources read-only. Register `preToolUse`, `postToolUse`, and `postToolUseFailure`, preserve the complete native payload plus `conversation_id`, and emit valid JSON on successful hook execution. Commit Cursor's config, guard, audit runtime, runtime config, and private key through one rollback-capable transaction. Set a ten-second timeout plus `failClosed` on every managed handler and propagate guard exit code `2` through the host shell.
|
||||||
- Preserve Factory Droid's scope-root `hooks.json` precedence, legacy nested hook source, and per-event `settings.json` fallback. Scope-root and legacy files store event keys at the document root; settings stores the same map under `hooks`. Edit only effective user sources; keep project and organization sources read-only. Preserve JSONC comments through syntax-tree edits and require `/hooks` review after external changes.
|
- Preserve Factory Droid's scope-root `hooks.json` precedence, legacy nested hook source, and per-event `settings.json` fallback. Scope-root and legacy files store event keys at the document root; settings stores the same map under `hooks`. Edit only effective user sources; keep project and organization sources read-only. Preserve JSONC comments through syntax-tree edits and require `/hooks` review after external changes.
|
||||||
- Resolve Qwen Code user settings through explicit `QWEN_HOME`, then user-level `.qwen/.env`, then `~/.env`, with `~/.qwen/settings.json` as the default. Keep workspace settings read-only. Preserve JSON comments, reject trailing commas and duplicate keys before writes, express timeouts in milliseconds, propagate native exit code `2` through PowerShell, and require `/hooks` review.
|
- Resolve Qwen Code user settings through explicit `QWEN_HOME`, then user-level `.qwen/.env`, then `~/.env`, with `~/.qwen/settings.json` as the default. Keep workspace settings read-only. Preserve JSON comments, reject trailing commas and duplicate keys before writes, express timeouts in milliseconds, propagate native exit code `2` through PowerShell, and require `/hooks` review.
|
||||||
|
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ The CLI reads the private key and optional API token through hidden terminal pro
|
|||||||
|
|
||||||
Codex performs a one-time trust review for user hooks. Run `/hooks` in Codex after installation and trust the Elydora `PreToolUse` and `PostToolUse` definitions.
|
Codex performs a one-time trust review for user hooks. Run `/hooks` in Codex after installation and trust the Elydora `PreToolUse` and `PostToolUse` definitions.
|
||||||
|
|
||||||
Cursor installation writes native `preToolUse` and `postToolUse` handlers to `~/.cursor/hooks.json` for both the CLI and IDE. Project and enterprise hook sources remain unchanged. Managed commands use the current Node.js executable, preserve Cursor's JSON input, propagate freeze exit code `2`, and enable `failClosed` with a ten-second timeout.
|
Cursor installation writes native `preToolUse`, `postToolUse`, and `postToolUseFailure` handlers to `~/.cursor/hooks.json` for both the CLI and IDE. Project and enterprise hook sources remain unchanged. The installation commits Cursor's hook file, generated runtimes, runtime config, and private key as one rollback-capable update. Managed commands use the current Node.js executable, preserve the complete Cursor JSON payload, return valid JSON, propagate freeze and revocation exit code `2`, and enable `failClosed` with a ten-second timeout.
|
||||||
|
|
||||||
Cline installation writes `PreToolUse.mjs` and `PostToolUse.mjs` to `$CLINE_DIR/hooks` (default `~/.cline/hooks`). Elydora leaves the Documents and workspace hook roots unchanged. The guard translates a frozen agent into Cline's JSON stdout cancellation control.
|
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.
|
||||||
|
|
||||||
|
|||||||
+24
-31
@@ -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}.`);
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ function rejectDuplicateKeys(node: Node | undefined, label: string, location: st
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseJsonObject(raw: string, label: string): JsonObject {
|
export function parseStrictJsonObject(raw: string, label: string): JsonObject {
|
||||||
const errors: ParseError[] = [];
|
const errors: ParseError[] = [];
|
||||||
const value: unknown = parse(raw, errors, {
|
const value: unknown = parse(raw, errors, {
|
||||||
allowTrailingComma: false,
|
allowTrailingComma: false,
|
||||||
@@ -233,13 +233,17 @@ function readHooks(value: unknown, label: string): CursorHooks {
|
|||||||
|
|
||||||
export function parseDocument(filePath: string, raw: string): CursorDocument {
|
export function parseDocument(filePath: string, raw: string): CursorDocument {
|
||||||
const label = `Cursor user hooks at ${filePath}`;
|
const label = `Cursor user hooks at ${filePath}`;
|
||||||
const root = parseJsonObject(raw, label);
|
const root = parseStrictJsonObject(raw, label);
|
||||||
if (root.version !== 1) throw new Error(`${label} must declare version 1`);
|
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 {
|
return {
|
||||||
exists: true,
|
exists: true,
|
||||||
filePath,
|
filePath,
|
||||||
root,
|
root,
|
||||||
hooks: readHooks(root.hooks, label),
|
hooks,
|
||||||
raw,
|
raw,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -255,6 +259,7 @@ export function removeManagedHooks(hooks: CursorHooks, agentId?: string): Cursor
|
|||||||
for (const [event, scriptName] of [
|
for (const [event, scriptName] of [
|
||||||
['preToolUse', GUARD_SCRIPT],
|
['preToolUse', GUARD_SCRIPT],
|
||||||
['postToolUse', AUDIT_SCRIPT],
|
['postToolUse', AUDIT_SCRIPT],
|
||||||
|
['postToolUseFailure', AUDIT_SCRIPT],
|
||||||
] as const) {
|
] as const) {
|
||||||
const handlers = (next[event] ?? []).filter((handler) => {
|
const handlers = (next[event] ?? []).filter((handler) => {
|
||||||
const managedId = managedAgentId(handler, scriptName);
|
const managedId = managedAgentId(handler, scriptName);
|
||||||
@@ -277,7 +282,7 @@ function entirelyManaged(document: CursorDocument): boolean {
|
|||||||
for (const [event, handlers] of events) {
|
for (const [event, handlers] of events) {
|
||||||
const scriptName = event === 'preToolUse'
|
const scriptName = event === 'preToolUse'
|
||||||
? GUARD_SCRIPT
|
? GUARD_SCRIPT
|
||||||
: event === 'postToolUse'
|
: event === 'postToolUse' || event === 'postToolUseFailure'
|
||||||
? AUDIT_SCRIPT
|
? AUDIT_SCRIPT
|
||||||
: undefined;
|
: undefined;
|
||||||
if (!scriptName || handlers.length === 0) return false;
|
if (!scriptName || handlers.length === 0) return false;
|
||||||
@@ -315,12 +320,21 @@ function managedIds(handlers: JsonObject[], scriptName: string): Map<string, str
|
|||||||
return result;
|
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[] {
|
export function runtimeContracts(hooks: CursorHooks): RuntimeContract[] {
|
||||||
const guards = managedIds(hooks.preToolUse ?? [], GUARD_SCRIPT);
|
const guards = managedIds(hooks.preToolUse ?? [], GUARD_SCRIPT);
|
||||||
const audits = managedIds(hooks.postToolUse ?? [], AUDIT_SCRIPT);
|
const audits = managedIds(hooks.postToolUse ?? [], AUDIT_SCRIPT);
|
||||||
|
const failures = managedIds(hooks.postToolUseFailure ?? [], AUDIT_SCRIPT);
|
||||||
const root = path.join(os.homedir(), '.elydora');
|
const root = path.join(os.homedir(), '.elydora');
|
||||||
return [...guards]
|
return [...guards]
|
||||||
.filter(([key]) => audits.has(key))
|
.filter(([key]) => audits.has(key) && failures.has(key))
|
||||||
.map(([, agentId]) => ({
|
.map(([, agentId]) => ({
|
||||||
agentId,
|
agentId,
|
||||||
guardPath: path.join(root, agentId, GUARD_SCRIPT),
|
guardPath: path.join(root, agentId, GUARD_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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
createDocument,
|
createDocument,
|
||||||
isObject,
|
isObject,
|
||||||
parseDocument,
|
parseDocument,
|
||||||
|
parseStrictJsonObject,
|
||||||
} from './cursor-contract.js';
|
} from './cursor-contract.js';
|
||||||
|
|
||||||
function errorMessage(error: unknown): string {
|
function errorMessage(error: unknown): string {
|
||||||
@@ -204,18 +205,7 @@ export async function requireRuntime(filePath: string, label: string): Promise<v
|
|||||||
async function readRuntimeConfig(filePath: string): Promise<JsonObject | undefined> {
|
async function readRuntimeConfig(filePath: string): Promise<JsonObject | undefined> {
|
||||||
const raw = await readOptionalPhysical(filePath, 'Elydora runtime config');
|
const raw = await readOptionalPhysical(filePath, 'Elydora runtime config');
|
||||||
if (raw === undefined) return undefined;
|
if (raw === undefined) return undefined;
|
||||||
let value: unknown;
|
return parseStrictJsonObject(raw, `Elydora runtime config at ${filePath}`);
|
||||||
try {
|
|
||||||
value = JSON.parse(raw);
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(`Failed to parse Elydora runtime config at ${filePath}: ${errorMessage(error)}`, {
|
|
||||||
cause: asError(error),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (!isObject(value)) {
|
|
||||||
throw new Error(`Elydora runtime config at ${filePath} must contain a JSON object`);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function sameAgentId(left: unknown, right: string): boolean {
|
function sameAgentId(left: unknown, right: string): boolean {
|
||||||
@@ -236,6 +226,10 @@ export async function runtimeFilesExist(contracts: RuntimeContract[]): Promise<b
|
|||||||
const files = await Promise.all([
|
const files = await Promise.all([
|
||||||
physicalFileExists(contract.guardPath, 'Elydora guard runtime'),
|
physicalFileExists(contract.guardPath, 'Elydora guard runtime'),
|
||||||
physicalFileExists(contract.auditPath, 'Elydora audit 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;
|
if (files.every(Boolean)) return true;
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-23
@@ -1,20 +1,18 @@
|
|||||||
import os from 'node:os';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { resolvePrivateChildDirectory } from '../runtime-paths.js';
|
|
||||||
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
|
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
|
||||||
import {
|
import {
|
||||||
AGENT_KEY,
|
AGENT_KEY,
|
||||||
AUDIT_SCRIPT,
|
|
||||||
GUARD_SCRIPT,
|
|
||||||
buildHandler,
|
buildHandler,
|
||||||
removeManagedHooks,
|
removeManagedHooks,
|
||||||
renderDocument,
|
renderDocument,
|
||||||
runtimeContracts,
|
runtimeContracts,
|
||||||
samePath,
|
|
||||||
} from './cursor-contract.js';
|
} from './cursor-contract.js';
|
||||||
|
import {
|
||||||
|
commitCursorInstallation,
|
||||||
|
preflightCursorInstallation,
|
||||||
|
prepareCursorInstallation,
|
||||||
|
} from './cursor-installation.js';
|
||||||
import {
|
import {
|
||||||
readDocument,
|
readDocument,
|
||||||
requireRuntime,
|
|
||||||
runtimeFilesExist,
|
runtimeFilesExist,
|
||||||
writeDocument,
|
writeDocument,
|
||||||
} from './cursor-io.js';
|
} from './cursor-io.js';
|
||||||
@@ -23,26 +21,29 @@ import { SUPPORTED_AGENTS } from './registry.js';
|
|||||||
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
|
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
|
||||||
|
|
||||||
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> {
|
||||||
if (!config.agentId) throw new Error('agentId is required');
|
|
||||||
const document = await readDocument();
|
const document = await readDocument();
|
||||||
const runtimeRoot = path.join(os.homedir(), '.elydora');
|
const paths = await preflightCursorInstallation(config);
|
||||||
const agentDirectory = resolvePrivateChildDirectory(runtimeRoot, config.agentId);
|
|
||||||
const expectedGuard = path.join(agentDirectory, GUARD_SCRIPT);
|
|
||||||
const expectedAudit = path.join(agentDirectory, AUDIT_SCRIPT);
|
|
||||||
if (!samePath(config.guardScriptPath, expectedGuard)) {
|
|
||||||
throw new Error(`Elydora guard runtime must use the managed agent directory: ${expectedGuard}`);
|
|
||||||
}
|
|
||||||
if (!samePath(config.hookScriptPath, expectedAudit)) {
|
|
||||||
throw new Error(`Elydora audit runtime must use the managed agent directory: ${expectedAudit}`);
|
|
||||||
}
|
|
||||||
await requireRuntime(expectedGuard, 'Elydora guard runtime');
|
|
||||||
await requireRuntime(expectedAudit, 'Elydora audit runtime');
|
|
||||||
|
|
||||||
const hooks = removeManagedHooks(document.hooks);
|
const hooks = removeManagedHooks(document.hooks);
|
||||||
hooks.preToolUse = [...(hooks.preToolUse ?? []), buildHandler(expectedGuard)];
|
hooks.preToolUse = [...(hooks.preToolUse ?? []), buildHandler(paths.guardPath)];
|
||||||
hooks.postToolUse = [...(hooks.postToolUse ?? []), buildHandler(expectedAudit)];
|
hooks.postToolUse = [...(hooks.postToolUse ?? []), buildHandler(paths.auditPath)];
|
||||||
await writeDocument(renderDocument(document, hooks));
|
hooks.postToolUseFailure = [
|
||||||
|
...(hooks.postToolUseFailure ?? []),
|
||||||
|
buildHandler(paths.auditPath),
|
||||||
|
];
|
||||||
|
const prepared = await prepareCursorInstallation(
|
||||||
|
config,
|
||||||
|
renderDocument(document, hooks),
|
||||||
|
);
|
||||||
|
await commitCursorInstallation(prepared);
|
||||||
console.log(` Cursor hooks: ${document.filePath}`);
|
console.log(` Cursor hooks: ${document.filePath}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
});
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -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());
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
+79
-288
@@ -1,9 +1,7 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { spawn } from 'node:child_process';
|
|
||||||
import {
|
import {
|
||||||
lstat,
|
lstat,
|
||||||
mkdir,
|
mkdir,
|
||||||
mkdtemp,
|
|
||||||
readFile,
|
readFile,
|
||||||
readdir,
|
readdir,
|
||||||
rm,
|
rm,
|
||||||
@@ -11,140 +9,18 @@ import {
|
|||||||
symlink,
|
symlink,
|
||||||
writeFile,
|
writeFile,
|
||||||
} from 'node:fs/promises';
|
} from 'node:fs/promises';
|
||||||
import os from 'node:os';
|
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { pathToFileURL } from 'node:url';
|
import {
|
||||||
|
assertNativeHandler,
|
||||||
const pluginModuleUrl = pathToFileURL(path.resolve('dist/plugins/cursor.js')).href;
|
createFixture,
|
||||||
const registryModuleUrl = pathToFileURL(path.resolve('dist/plugins/registry.js')).href;
|
installConfig,
|
||||||
const ioModuleUrl = pathToFileURL(path.resolve('dist/plugins/cursor-io.js')).href;
|
legacyHandler,
|
||||||
|
managedHandler,
|
||||||
function runNode(args, env, cwd, input = '') {
|
registryModuleUrl,
|
||||||
return new Promise((resolve, reject) => {
|
runPlugin,
|
||||||
const child = spawn(process.execPath, args, {
|
writeJson,
|
||||||
cwd,
|
} from '../test-support/cursor-test-helpers.mjs';
|
||||||
env: { ...process.env, ...env },
|
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
|
||||||
});
|
|
||||||
let stdout = '';
|
|
||||||
let stderr = '';
|
|
||||||
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
|
||||||
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
||||||
child.once('error', reject);
|
|
||||||
child.once('close', (code) => resolve({ code, stdout, stderr }));
|
|
||||||
child.stdin.end(input);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function runHook(handler, input, environment = {}) {
|
|
||||||
const executable = process.platform === 'win32' ? 'powershell.exe' : '/bin/sh';
|
|
||||||
const args = process.platform === 'win32'
|
|
||||||
? ['-NoProfile', '-NonInteractive', '-Command', handler.command]
|
|
||||||
: ['-c', handler.command];
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const child = spawn(executable, args, {
|
|
||||||
env: { ...process.env, ...environment },
|
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
|
||||||
});
|
|
||||||
let stdout = '';
|
|
||||||
let stderr = '';
|
|
||||||
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
|
||||||
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
||||||
child.once('error', reject);
|
|
||||||
child.once('close', (code) => resolve({ code, stdout, stderr }));
|
|
||||||
child.stdin.end(input);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function writeJson(filePath, value) {
|
|
||||||
await mkdir(path.dirname(filePath), { recursive: true });
|
|
||||||
await writeFile(filePath, typeof value === 'string' ? value : `${JSON.stringify(value, null, 2)}\n`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runPlugin(fixture, method, argument) {
|
|
||||||
const source = `
|
|
||||||
import { cursorPlugin } from ${JSON.stringify(pluginModuleUrl)};
|
|
||||||
const argument = JSON.parse(process.env.ELYDORA_TEST_ARGUMENT);
|
|
||||||
const result = await cursorPlugin[process.env.ELYDORA_TEST_METHOD](argument);
|
|
||||||
if (result !== undefined) console.log(JSON.stringify(result));
|
|
||||||
`;
|
|
||||||
return runNode(
|
|
||||||
['--input-type=module', '--eval', source],
|
|
||||||
{
|
|
||||||
HOME: fixture.homeDir,
|
|
||||||
USERPROFILE: fixture.homeDir,
|
|
||||||
ELYDORA_TEST_ARGUMENT: JSON.stringify(argument),
|
|
||||||
ELYDORA_TEST_METHOD: method,
|
|
||||||
},
|
|
||||||
fixture.projectDir,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createFixture({ existingConfig, createRuntimes = true, hookSource } = {}) {
|
|
||||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-cursor-'));
|
|
||||||
const homeDir = path.join(rootDir, "home with spaces and 'quote");
|
|
||||||
const projectDir = path.join(rootDir, 'project with spaces');
|
|
||||||
const configPath = path.join(homeDir, '.cursor', 'hooks.json');
|
|
||||||
const agentDir = path.join(homeDir, '.elydora', 'agent-1');
|
|
||||||
const guardScriptPath = path.join(agentDir, 'guard.js');
|
|
||||||
const hookScriptPath = path.join(agentDir, 'hook.js');
|
|
||||||
await mkdir(projectDir, { recursive: true });
|
|
||||||
if (createRuntimes) {
|
|
||||||
await mkdir(agentDir, { recursive: true });
|
|
||||||
await writeFile(
|
|
||||||
guardScriptPath,
|
|
||||||
"process.stdin.resume(); process.stdin.once('end', () => { process.stderr.write('Agent is frozen by Elydora.'); process.exit(2); });\n",
|
|
||||||
);
|
|
||||||
await writeFile(hookScriptPath, hookSource ?? 'process.stdin.resume();\n');
|
|
||||||
await writeFile(path.join(agentDir, 'config.json'), JSON.stringify({
|
|
||||||
agent_id: 'agent-1',
|
|
||||||
agent_name: 'cursor',
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
if (existingConfig !== undefined) await writeJson(configPath, existingConfig);
|
|
||||||
return {
|
|
||||||
agentDir,
|
|
||||||
configPath,
|
|
||||||
guardScriptPath,
|
|
||||||
homeDir,
|
|
||||||
hookScriptPath,
|
|
||||||
projectDir,
|
|
||||||
rootDir,
|
|
||||||
async install() {
|
|
||||||
return runPlugin(this, 'install', {
|
|
||||||
agentName: 'cursor',
|
|
||||||
agentId: 'agent-1',
|
|
||||||
guardScriptPath,
|
|
||||||
hookScriptPath,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
async close() {
|
|
||||||
await rm(rootDir, { recursive: true, force: true });
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function managedHandler(config, event, scriptName) {
|
|
||||||
return config.hooks?.[event]?.find((handler) => handler.command?.includes(scriptName));
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertNativeHandler(handler) {
|
|
||||||
assert.deepEqual(Object.keys(handler).sort(), ['command', 'failClosed', 'timeout']);
|
|
||||||
assert.equal(handler.failClosed, true);
|
|
||||||
assert.equal(handler.timeout, 10);
|
|
||||||
assert.match(handler.command, /node(?:\.exe)?/i);
|
|
||||||
if (process.platform === 'win32') {
|
|
||||||
assert.match(handler.command, /^& /);
|
|
||||||
assert.match(handler.command, /; exit \$LASTEXITCODE$/);
|
|
||||||
} else {
|
|
||||||
assert.match(handler.command, /^'/);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function legacyHandler(scriptPath) {
|
|
||||||
return { command: `node "${scriptPath}"` };
|
|
||||||
}
|
|
||||||
|
|
||||||
test('Cursor is registered with the native user hook file', async () => {
|
test('Cursor is registered with the native user hook file', async () => {
|
||||||
const { SUPPORTED_AGENTS } = await import(registryModuleUrl);
|
const { SUPPORTED_AGENTS } = await import(registryModuleUrl);
|
||||||
@@ -155,11 +31,10 @@ test('Cursor is registered with the native user hook file', async () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Cursor install preserves user hooks, migrates legacy entries, and is idempotent', async () => {
|
test('Cursor install preserves hooks, migrates legacy entries, and is idempotent', async () => {
|
||||||
const fixture = await createFixture();
|
const fixture = await createFixture();
|
||||||
try {
|
try {
|
||||||
await writeJson(fixture.configPath, {
|
await writeJson(fixture.configPath, {
|
||||||
version: 1,
|
|
||||||
description: 'user-owned',
|
description: 'user-owned',
|
||||||
hooks: {
|
hooks: {
|
||||||
sessionStart: [{ command: 'user-session' }],
|
sessionStart: [{ command: 'user-session' }],
|
||||||
@@ -168,9 +43,11 @@ test('Cursor install preserves user hooks, migrates legacy entries, and is idemp
|
|||||||
legacyHandler(fixture.guardScriptPath),
|
legacyHandler(fixture.guardScriptPath),
|
||||||
],
|
],
|
||||||
postToolUse: [legacyHandler(fixture.hookScriptPath)],
|
postToolUse: [legacyHandler(fixture.hookScriptPath)],
|
||||||
|
postToolUseFailure: [legacyHandler(fixture.hookScriptPath)],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
assert.equal((await fixture.install()).code, 0);
|
const first = await fixture.install();
|
||||||
|
assert.equal(first.code, 0, first.stderr);
|
||||||
const second = await fixture.install();
|
const second = await fixture.install();
|
||||||
assert.equal(second.code, 0, second.stderr);
|
assert.equal(second.code, 0, second.stderr);
|
||||||
|
|
||||||
@@ -181,63 +58,16 @@ test('Cursor install preserves user hooks, migrates legacy entries, and is idemp
|
|||||||
assert.deepEqual(config.hooks.preToolUse[0], { command: 'user-pre' });
|
assert.deepEqual(config.hooks.preToolUse[0], { command: 'user-pre' });
|
||||||
assert.equal(config.hooks.preToolUse.length, 2);
|
assert.equal(config.hooks.preToolUse.length, 2);
|
||||||
assert.equal(config.hooks.postToolUse.length, 1);
|
assert.equal(config.hooks.postToolUse.length, 1);
|
||||||
|
assert.equal(config.hooks.postToolUseFailure.length, 1);
|
||||||
assertNativeHandler(managedHandler(config, 'preToolUse', 'guard.js'));
|
assertNativeHandler(managedHandler(config, 'preToolUse', 'guard.js'));
|
||||||
assertNativeHandler(managedHandler(config, 'postToolUse', 'hook.js'));
|
assertNativeHandler(managedHandler(config, 'postToolUse', 'hook.js'));
|
||||||
|
assertNativeHandler(managedHandler(config, 'postToolUseFailure', 'hook.js'));
|
||||||
} finally {
|
} finally {
|
||||||
await fixture.close();
|
await fixture.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Cursor hooks block freezes and forward official input byte-for-byte', async () => {
|
test('Cursor status requires three handlers, matching identity, and every runtime secret', async () => {
|
||||||
const capturePath = path.join(os.tmpdir(), `elydora-cursor-event-${process.pid}-${Date.now()}.json`);
|
|
||||||
const fixture = await createFixture({
|
|
||||||
hookSource: `
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const chunks = [];
|
|
||||||
process.stdin.on('data', (chunk) => chunks.push(chunk));
|
|
||||||
process.stdin.on('end', () => fs.writeFileSync(process.env.ELYDORA_CAPTURE, Buffer.concat(chunks)));
|
|
||||||
`,
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
const install = await fixture.install();
|
|
||||||
assert.equal(install.code, 0, install.stderr);
|
|
||||||
const config = JSON.parse(await readFile(fixture.configPath, 'utf-8'));
|
|
||||||
const guard = managedHandler(config, 'preToolUse', 'guard.js');
|
|
||||||
const audit = managedHandler(config, 'postToolUse', 'hook.js');
|
|
||||||
const prePayload = `${JSON.stringify({
|
|
||||||
conversation_id: 'conversation-1',
|
|
||||||
generation_id: 'generation-1',
|
|
||||||
hook_event_name: 'preToolUse',
|
|
||||||
tool_name: 'Shell',
|
|
||||||
tool_input: { command: 'Get-ChildItem', working_directory: fixture.projectDir },
|
|
||||||
tool_use_id: 'call-1',
|
|
||||||
cwd: fixture.projectDir,
|
|
||||||
})}\n`;
|
|
||||||
const guardResult = await runHook(guard, prePayload);
|
|
||||||
assert.equal(guardResult.code, 2, guardResult.stderr);
|
|
||||||
assert.match(guardResult.stderr, /Agent is frozen by Elydora/);
|
|
||||||
|
|
||||||
const postPayload = `${JSON.stringify({
|
|
||||||
conversation_id: 'conversation-1',
|
|
||||||
generation_id: 'generation-1',
|
|
||||||
hook_event_name: 'postToolUse',
|
|
||||||
tool_name: 'Shell',
|
|
||||||
tool_input: { command: 'Get-ChildItem' },
|
|
||||||
tool_output: '{"exitCode":0,"stdout":"ok"}',
|
|
||||||
tool_use_id: 'call-1',
|
|
||||||
cwd: fixture.projectDir,
|
|
||||||
duration: 42,
|
|
||||||
})}\n`;
|
|
||||||
const auditResult = await runHook(audit, postPayload, { ELYDORA_CAPTURE: capturePath });
|
|
||||||
assert.equal(auditResult.code, 0, auditResult.stderr);
|
|
||||||
assert.equal(await readFile(capturePath, 'utf-8'), postPayload);
|
|
||||||
} finally {
|
|
||||||
await fixture.close();
|
|
||||||
await rm(capturePath, { force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Cursor status requires an exact pair, matching identity, and physical runtimes', async () => {
|
|
||||||
const fixture = await createFixture();
|
const fixture = await createFixture();
|
||||||
try {
|
try {
|
||||||
assert.equal((await fixture.install()).code, 0);
|
assert.equal((await fixture.install()).code, 0);
|
||||||
@@ -245,25 +75,27 @@ test('Cursor status requires an exact pair, matching identity, and physical runt
|
|||||||
assert.equal(status.code, 0, status.stderr);
|
assert.equal(status.code, 0, status.stderr);
|
||||||
assert.equal(JSON.parse(status.stdout).installed, true);
|
assert.equal(JSON.parse(status.stdout).installed, true);
|
||||||
|
|
||||||
const config = JSON.parse(await readFile(fixture.configPath, 'utf-8'));
|
const hooks = JSON.parse(await readFile(fixture.configPath, 'utf-8'));
|
||||||
config.hooks.preToolUse[0].failClosed = false;
|
delete hooks.hooks.postToolUseFailure;
|
||||||
await writeJson(fixture.configPath, config);
|
await writeJson(fixture.configPath, hooks);
|
||||||
status = await runPlugin(fixture, 'status', null);
|
status = await runPlugin(fixture, 'status', null);
|
||||||
assert.equal(JSON.parse(status.stdout).hookConfigured, false);
|
assert.equal(JSON.parse(status.stdout).hookConfigured, false);
|
||||||
|
|
||||||
assert.equal((await fixture.install()).code, 0);
|
assert.equal((await fixture.install()).code, 0);
|
||||||
const auditSource = await readFile(fixture.hookScriptPath);
|
const keyPath = path.join(fixture.agentDir, 'private.key');
|
||||||
await rm(fixture.hookScriptPath);
|
await rm(keyPath);
|
||||||
status = await runPlugin(fixture, 'status', null);
|
|
||||||
assert.equal(JSON.parse(status.stdout).installed, false);
|
|
||||||
await writeFile(fixture.hookScriptPath, auditSource);
|
|
||||||
|
|
||||||
const runtimeConfigPath = path.join(fixture.agentDir, 'config.json');
|
|
||||||
await writeJson(runtimeConfigPath, { agent_id: 'another-agent', agent_name: 'cursor' });
|
|
||||||
status = await runPlugin(fixture, 'status', null);
|
status = await runPlugin(fixture, 'status', null);
|
||||||
assert.equal(JSON.parse(status.stdout).installed, false);
|
assert.equal(JSON.parse(status.stdout).installed, false);
|
||||||
|
|
||||||
await writeFile(runtimeConfigPath, '{ malformed');
|
await writeFile(keyPath, 'restored', { mode: 0o600 });
|
||||||
|
await writeJson(path.join(fixture.agentDir, 'config.json'), {
|
||||||
|
agent_id: 'another-agent',
|
||||||
|
agent_name: 'cursor',
|
||||||
|
});
|
||||||
|
status = await runPlugin(fixture, 'status', null);
|
||||||
|
assert.equal(JSON.parse(status.stdout).installed, false);
|
||||||
|
|
||||||
|
await writeFile(path.join(fixture.agentDir, 'config.json'), '{ malformed');
|
||||||
status = await runPlugin(fixture, 'status', null);
|
status = await runPlugin(fixture, 'status', null);
|
||||||
assert.equal(status.code, 1);
|
assert.equal(status.code, 1);
|
||||||
assert.match(status.stderr, /parse Elydora runtime config/i);
|
assert.match(status.stderr, /parse Elydora runtime config/i);
|
||||||
@@ -279,14 +111,16 @@ test('Cursor uninstall removes exact ownership and preserves user entries', asyn
|
|||||||
try {
|
try {
|
||||||
assert.equal((await fixture.install()).code, 0);
|
assert.equal((await fixture.install()).code, 0);
|
||||||
const config = JSON.parse(await readFile(fixture.configPath, 'utf-8'));
|
const config = JSON.parse(await readFile(fixture.configPath, 'utf-8'));
|
||||||
const otherGuard = structuredClone(managedHandler(config, 'preToolUse', 'guard.js'));
|
for (const [event, script] of [
|
||||||
const otherAudit = structuredClone(managedHandler(config, 'postToolUse', 'hook.js'));
|
['preToolUse', 'guard.js'],
|
||||||
otherGuard.command = otherGuard.command.replaceAll('agent-1', 'agent-10');
|
['postToolUse', 'hook.js'],
|
||||||
otherAudit.command = otherAudit.command.replaceAll('agent-1', 'agent-10');
|
['postToolUseFailure', 'hook.js'],
|
||||||
config.hooks.preToolUse.push(otherGuard, {
|
]) {
|
||||||
command: 'echo elydora agent-1 guard.js', timeout: 10, failClosed: true,
|
const other = structuredClone(managedHandler(config, event, script));
|
||||||
});
|
other.command = other.command.replaceAll('agent-1', 'agent-10');
|
||||||
config.hooks.postToolUse.push(otherAudit);
|
config.hooks[event].push(other);
|
||||||
|
}
|
||||||
|
config.hooks.preToolUse.push({ command: 'user-pre' });
|
||||||
await writeJson(fixture.configPath, config);
|
await writeJson(fixture.configPath, config);
|
||||||
|
|
||||||
const result = await runPlugin(fixture, 'uninstall', 'agent-1');
|
const result = await runPlugin(fixture, 'uninstall', 'agent-1');
|
||||||
@@ -295,17 +129,19 @@ test('Cursor uninstall removes exact ownership and preserves user entries', asyn
|
|||||||
assert.deepEqual(remaining.hooks.sessionStart, [{ command: 'keep' }]);
|
assert.deepEqual(remaining.hooks.sessionStart, [{ command: 'keep' }]);
|
||||||
assert.equal(remaining.hooks.preToolUse.length, 2);
|
assert.equal(remaining.hooks.preToolUse.length, 2);
|
||||||
assert.equal(remaining.hooks.postToolUse.length, 1);
|
assert.equal(remaining.hooks.postToolUse.length, 1);
|
||||||
|
assert.equal(remaining.hooks.postToolUseFailure.length, 1);
|
||||||
assert.match(remaining.hooks.preToolUse[0].command, /agent-10/);
|
assert.match(remaining.hooks.preToolUse[0].command, /agent-10/);
|
||||||
} finally {
|
} finally {
|
||||||
await fixture.close();
|
await fixture.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Cursor rejects malformed, duplicate, and invalid configs before writes', async () => {
|
test('Cursor rejects malformed, duplicate, versionless user, and invalid configs before writes', async () => {
|
||||||
const invalidConfigs = [
|
const invalidConfigs = [
|
||||||
'{ malformed',
|
'{ malformed',
|
||||||
'[]\n',
|
'[]\n',
|
||||||
'{"hooks":{}}\n',
|
'{"hooks":{}}\n',
|
||||||
|
'{"hooks":{"preToolUse":[{"command":"user"}]}}\n',
|
||||||
'{"version":2,"hooks":{}}\n',
|
'{"version":2,"hooks":{}}\n',
|
||||||
'{"version":1,"hooks":null}\n',
|
'{"version":1,"hooks":null}\n',
|
||||||
'{"version":1,"hooks":{"preToolUse":null}}\n',
|
'{"version":1,"hooks":{"preToolUse":null}}\n',
|
||||||
@@ -320,54 +156,20 @@ test('Cursor rejects malformed, duplicate, and invalid configs before writes', a
|
|||||||
const result = await fixture.install();
|
const result = await fixture.install();
|
||||||
assert.equal(result.code, 1, `accepted ${existingConfig}`);
|
assert.equal(result.code, 1, `accepted ${existingConfig}`);
|
||||||
assert.equal(await readFile(fixture.configPath, 'utf-8'), before);
|
assert.equal(await readFile(fixture.configPath, 'utf-8'), before);
|
||||||
|
await assert.rejects(lstat(path.join(fixture.homeDir, '.elydora')), { code: 'ENOENT' });
|
||||||
} finally {
|
} finally {
|
||||||
await fixture.close();
|
await fixture.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Cursor rejects missing and symbolic-link files before config writes', async (t) => {
|
test('Cursor confines runtime scripts to the managed agent directory', async () => {
|
||||||
const missing = await createFixture({ createRuntimes: false });
|
|
||||||
try {
|
|
||||||
const result = await missing.install();
|
|
||||||
assert.equal(result.code, 1);
|
|
||||||
assert.match(result.stderr, /runtime is missing/i);
|
|
||||||
await assert.rejects(readFile(missing.configPath), { code: 'ENOENT' });
|
|
||||||
} finally {
|
|
||||||
await missing.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
const linkedRuntime = await createFixture();
|
|
||||||
try {
|
|
||||||
const target = path.join(linkedRuntime.homeDir, 'guard-target.js');
|
|
||||||
await writeFile(target, 'process.exit(2);\n');
|
|
||||||
await rm(linkedRuntime.guardScriptPath);
|
|
||||||
try {
|
|
||||||
await symlink(target, linkedRuntime.guardScriptPath);
|
|
||||||
} catch (error) {
|
|
||||||
if (error?.code === 'EPERM') t.skip(`symbolic links unavailable: ${error.message}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
const result = await linkedRuntime.install();
|
|
||||||
assert.equal(result.code, 1);
|
|
||||||
assert.match(result.stderr, /physical file/i);
|
|
||||||
await assert.rejects(readFile(linkedRuntime.configPath), { code: 'ENOENT' });
|
|
||||||
} finally {
|
|
||||||
await linkedRuntime.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Cursor confines both runtimes to the managed agent directory', async () => {
|
|
||||||
for (const field of ['guardScriptPath', 'hookScriptPath']) {
|
for (const field of ['guardScriptPath', 'hookScriptPath']) {
|
||||||
const fixture = await createFixture();
|
const fixture = await createFixture();
|
||||||
try {
|
try {
|
||||||
const result = await runPlugin(fixture, 'install', {
|
const result = await runPlugin(fixture, 'install', installConfig(fixture, {
|
||||||
agentName: 'cursor',
|
|
||||||
agentId: 'agent-1',
|
|
||||||
guardScriptPath: fixture.guardScriptPath,
|
|
||||||
hookScriptPath: fixture.hookScriptPath,
|
|
||||||
[field]: path.join(fixture.homeDir, `unmanaged-${field}.js`),
|
[field]: path.join(fixture.homeDir, `unmanaged-${field}.js`),
|
||||||
});
|
}));
|
||||||
assert.equal(result.code, 1);
|
assert.equal(result.code, 1);
|
||||||
assert.match(result.stderr, /managed agent directory/i);
|
assert.match(result.stderr, /managed agent directory/i);
|
||||||
await assert.rejects(readFile(fixture.configPath), { code: 'ENOENT' });
|
await assert.rejects(readFile(fixture.configPath), { code: 'ENOENT' });
|
||||||
@@ -377,17 +179,21 @@ test('Cursor confines both runtimes to the managed agent directory', async () =>
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Cursor rejects symbolic-link config and preserves its target', async (t) => {
|
test('Cursor rejects a symbolic-link config and preserves its target', async (t) => {
|
||||||
const fixture = await createFixture();
|
const fixture = await createFixture();
|
||||||
try {
|
try {
|
||||||
const target = path.join(fixture.homeDir, 'cursor-hooks-target.json');
|
const target = path.join(fixture.homeDir, 'cursor-hooks-target.json');
|
||||||
const original = '{"version":1,"hooks":{}}\n';
|
const original = '{"version":1,"hooks":{}}\n';
|
||||||
|
await mkdir(fixture.homeDir, { recursive: true });
|
||||||
await writeFile(target, original);
|
await writeFile(target, original);
|
||||||
await mkdir(path.dirname(fixture.configPath), { recursive: true });
|
await mkdir(path.dirname(fixture.configPath), { recursive: true });
|
||||||
try {
|
try {
|
||||||
await symlink(target, fixture.configPath);
|
await symlink(target, fixture.configPath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error?.code === 'EPERM') t.skip(`symbolic links unavailable: ${error.message}`);
|
if (error?.code === 'EPERM') {
|
||||||
|
t.skip(`symbolic links unavailable: ${error.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
const result = await fixture.install();
|
const result = await fixture.install();
|
||||||
@@ -401,12 +207,13 @@ test('Cursor rejects symbolic-link config and preserves its target', async (t) =
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('Cursor status rejects symbolic-link runtime files', async (t) => {
|
test('Cursor status rejects symbolic-link runtime files', async (t) => {
|
||||||
for (const name of ['config', 'guard', 'audit']) {
|
for (const name of ['config', 'key', 'guard', 'audit']) {
|
||||||
const fixture = await createFixture();
|
const fixture = await createFixture();
|
||||||
try {
|
try {
|
||||||
assert.equal((await fixture.install()).code, 0);
|
assert.equal((await fixture.install()).code, 0);
|
||||||
const filePath = {
|
const filePath = {
|
||||||
config: path.join(fixture.agentDir, 'config.json'),
|
config: path.join(fixture.agentDir, 'config.json'),
|
||||||
|
key: path.join(fixture.agentDir, 'private.key'),
|
||||||
guard: fixture.guardScriptPath,
|
guard: fixture.guardScriptPath,
|
||||||
audit: fixture.hookScriptPath,
|
audit: fixture.hookScriptPath,
|
||||||
}[name];
|
}[name];
|
||||||
@@ -417,7 +224,10 @@ test('Cursor status rejects symbolic-link runtime files', async (t) => {
|
|||||||
try {
|
try {
|
||||||
await symlink(target, filePath);
|
await symlink(target, filePath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error?.code === 'EPERM') t.skip(`symbolic links unavailable: ${error.message}`);
|
if (error?.code === 'EPERM') {
|
||||||
|
t.skip(`symbolic links unavailable: ${error.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
const status = await runPlugin(fixture, 'status', null);
|
const status = await runPlugin(fixture, 'status', null);
|
||||||
@@ -429,7 +239,7 @@ test('Cursor status rejects symbolic-link runtime files', async (t) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Cursor removes an entirely managed config and leaves absent config absent', async () => {
|
test('Cursor removes an entirely managed config and leaves an absent config absent', async () => {
|
||||||
const fixture = await createFixture();
|
const fixture = await createFixture();
|
||||||
try {
|
try {
|
||||||
assert.equal((await fixture.install()).code, 0);
|
assert.equal((await fixture.install()).code, 0);
|
||||||
@@ -443,48 +253,29 @@ test('Cursor removes an entirely managed config and leaves absent config absent'
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Cursor atomic writes are private and leave no staging files', async () => {
|
test('Cursor installation writes private files and leaves no transaction files', async () => {
|
||||||
const fixture = await createFixture();
|
const fixture = await createFixture();
|
||||||
try {
|
try {
|
||||||
assert.equal((await fixture.install()).code, 0);
|
const result = await fixture.install();
|
||||||
const files = await readdir(path.dirname(fixture.configPath));
|
assert.equal(result.code, 0, result.stderr);
|
||||||
assert.equal(files.some((name) => name.endsWith('.tmp') || name.endsWith('.rollback')), false);
|
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') {
|
if (process.platform !== 'win32') {
|
||||||
assert.equal((await stat(fixture.configPath)).mode & 0o777, 0o600);
|
for (const filePath of [
|
||||||
|
fixture.configPath,
|
||||||
|
path.join(fixture.agentDir, 'config.json'),
|
||||||
|
path.join(fixture.agentDir, 'private.key'),
|
||||||
|
]) {
|
||||||
|
assert.equal((await stat(filePath)).mode & 0o777, 0o600);
|
||||||
|
}
|
||||||
|
for (const filePath of [fixture.guardScriptPath, fixture.hookScriptPath]) {
|
||||||
|
assert.equal((await stat(filePath)).mode & 0o777, 0o700);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
await fixture.close();
|
await fixture.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Cursor atomic write detects a concurrent source change', async () => {
|
|
||||||
const fixture = await createFixture({
|
|
||||||
existingConfig: { version: 1, hooks: { sessionStart: [{ command: 'original' }] } },
|
|
||||||
});
|
|
||||||
const concurrent = '{"version":1,"hooks":{"sessionStart":[{"command":"concurrent"}]}}\n';
|
|
||||||
try {
|
|
||||||
const source = `
|
|
||||||
import { writeFile } from 'node:fs/promises';
|
|
||||||
import { readDocument, writeDocument } from ${JSON.stringify(ioModuleUrl)};
|
|
||||||
const document = await readDocument();
|
|
||||||
await writeFile(document.filePath, process.env.ELYDORA_CONCURRENT, 'utf-8');
|
|
||||||
await writeDocument({
|
|
||||||
document,
|
|
||||||
changed: true,
|
|
||||||
next: '{"version":1,"hooks":{}}\\n',
|
|
||||||
});
|
|
||||||
`;
|
|
||||||
const result = await runNode(
|
|
||||||
['--input-type=module', '--eval', source],
|
|
||||||
{ HOME: fixture.homeDir, USERPROFILE: fixture.homeDir, ELYDORA_CONCURRENT: concurrent },
|
|
||||||
fixture.projectDir,
|
|
||||||
);
|
|
||||||
assert.equal(result.code, 1);
|
|
||||||
assert.match(result.stderr, /changed during update/i);
|
|
||||||
assert.equal(await readFile(fixture.configPath, 'utf-8'), concurrent);
|
|
||||||
const files = await readdir(path.dirname(fixture.configPath));
|
|
||||||
assert.equal(files.some((name) => name.endsWith('.tmp')), false);
|
|
||||||
} finally {
|
|
||||||
await fixture.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in new issue
Block a user