fix: harden Cursor hooks

This commit is contained in:
ZacharyZhang-NY committed 2026-07-19 23:28:01 -04:00
1 parent d5a2dcbe53
commit 6d36f557b1
7 files changed
+1109 -126

No files matched your search

+490
View File
@@ -0,0 +1,490 @@
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import {
lstat,
mkdir,
mkdtemp,
readFile,
readdir,
rm,
stat,
symlink,
writeFile,
} from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { pathToFileURL } from 'node:url';
const pluginModuleUrl = pathToFileURL(path.resolve('dist/plugins/cursor.js')).href;
const registryModuleUrl = pathToFileURL(path.resolve('dist/plugins/registry.js')).href;
const ioModuleUrl = pathToFileURL(path.resolve('dist/plugins/cursor-io.js')).href;
function runNode(args, env, cwd, input = '') {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, args, {
cwd,
env: { ...process.env, ...env },
stdio: ['pipe', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (chunk) => { stdout += chunk; });
child.stderr.on('data', (chunk) => { stderr += chunk; });
child.once('error', reject);
child.once('close', (code) => resolve({ code, stdout, stderr }));
child.stdin.end(input);
});
}
function runHook(handler, input, environment = {}) {
const executable = process.platform === 'win32' ? 'powershell.exe' : '/bin/sh';
const args = process.platform === 'win32'
? ['-NoProfile', '-NonInteractive', '-Command', handler.command]
: ['-c', handler.command];
return new Promise((resolve, reject) => {
const child = spawn(executable, args, {
env: { ...process.env, ...environment },
stdio: ['pipe', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (chunk) => { stdout += chunk; });
child.stderr.on('data', (chunk) => { stderr += chunk; });
child.once('error', reject);
child.once('close', (code) => resolve({ code, stdout, stderr }));
child.stdin.end(input);
});
}
async function writeJson(filePath, value) {
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, typeof value === 'string' ? value : `${JSON.stringify(value, null, 2)}\n`);
}
async function runPlugin(fixture, method, argument) {
const source = `
import { cursorPlugin } from ${JSON.stringify(pluginModuleUrl)};
const argument = JSON.parse(process.env.ELYDORA_TEST_ARGUMENT);
const result = await cursorPlugin[process.env.ELYDORA_TEST_METHOD](argument);
if (result !== undefined) console.log(JSON.stringify(result));
`;
return runNode(
['--input-type=module', '--eval', source],
{
HOME: fixture.homeDir,
USERPROFILE: fixture.homeDir,
ELYDORA_TEST_ARGUMENT: JSON.stringify(argument),
ELYDORA_TEST_METHOD: method,
},
fixture.projectDir,
);
}
async function createFixture({ existingConfig, createRuntimes = true, hookSource } = {}) {
const rootDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-cursor-'));
const homeDir = path.join(rootDir, "home with spaces and 'quote");
const projectDir = path.join(rootDir, 'project with spaces');
const configPath = path.join(homeDir, '.cursor', 'hooks.json');
const agentDir = path.join(homeDir, '.elydora', 'agent-1');
const guardScriptPath = path.join(agentDir, 'guard.js');
const hookScriptPath = path.join(agentDir, 'hook.js');
await mkdir(projectDir, { recursive: true });
if (createRuntimes) {
await mkdir(agentDir, { recursive: true });
await writeFile(
guardScriptPath,
"process.stdin.resume(); process.stdin.once('end', () => { process.stderr.write('Agent is frozen by Elydora.'); process.exit(2); });\n",
);
await writeFile(hookScriptPath, hookSource ?? 'process.stdin.resume();\n');
await writeFile(path.join(agentDir, 'config.json'), JSON.stringify({
agent_id: 'agent-1',
agent_name: 'cursor',
}));
}
if (existingConfig !== undefined) await writeJson(configPath, existingConfig);
return {
agentDir,
configPath,
guardScriptPath,
homeDir,
hookScriptPath,
projectDir,
rootDir,
async install() {
return runPlugin(this, 'install', {
agentName: 'cursor',
agentId: 'agent-1',
guardScriptPath,
hookScriptPath,
});
},
async close() {
await rm(rootDir, { recursive: true, force: true });
},
};
}
function managedHandler(config, event, scriptName) {
return config.hooks?.[event]?.find((handler) => handler.command?.includes(scriptName));
}
function assertNativeHandler(handler) {
assert.deepEqual(Object.keys(handler).sort(), ['command', 'failClosed', 'timeout']);
assert.equal(handler.failClosed, true);
assert.equal(handler.timeout, 10);
assert.match(handler.command, /node(?:\.exe)?/i);
if (process.platform === 'win32') {
assert.match(handler.command, /^& /);
assert.match(handler.command, /; exit \$LASTEXITCODE$/);
} else {
assert.match(handler.command, /^'/);
}
}
function legacyHandler(scriptPath) {
return { command: `node "${scriptPath}"` };
}
test('Cursor is registered with the native user hook file', async () => {
const { SUPPORTED_AGENTS } = await import(registryModuleUrl);
assert.deepEqual(SUPPORTED_AGENTS.get('cursor'), {
name: 'Cursor',
configDir: '~/.cursor',
configFile: 'hooks.json',
});
});
test('Cursor install preserves user hooks, migrates legacy entries, and is idempotent', async () => {
const fixture = await createFixture();
try {
await writeJson(fixture.configPath, {
version: 1,
description: 'user-owned',
hooks: {
sessionStart: [{ command: 'user-session' }],
preToolUse: [
{ command: 'user-pre' },
legacyHandler(fixture.guardScriptPath),
],
postToolUse: [legacyHandler(fixture.hookScriptPath)],
},
});
assert.equal((await fixture.install()).code, 0);
const second = await fixture.install();
assert.equal(second.code, 0, second.stderr);
const config = JSON.parse(await readFile(fixture.configPath, 'utf-8'));
assert.equal(config.version, 1);
assert.equal(config.description, 'user-owned');
assert.deepEqual(config.hooks.sessionStart, [{ command: 'user-session' }]);
assert.deepEqual(config.hooks.preToolUse[0], { command: 'user-pre' });
assert.equal(config.hooks.preToolUse.length, 2);
assert.equal(config.hooks.postToolUse.length, 1);
assertNativeHandler(managedHandler(config, 'preToolUse', 'guard.js'));
assertNativeHandler(managedHandler(config, 'postToolUse', 'hook.js'));
} finally {
await fixture.close();
}
});
test('Cursor hooks block freezes and forward official input byte-for-byte', async () => {
const capturePath = path.join(os.tmpdir(), `elydora-cursor-event-${process.pid}-${Date.now()}.json`);
const fixture = await createFixture({
hookSource: `
const fs = require('node:fs');
const chunks = [];
process.stdin.on('data', (chunk) => chunks.push(chunk));
process.stdin.on('end', () => fs.writeFileSync(process.env.ELYDORA_CAPTURE, Buffer.concat(chunks)));
`,
});
try {
const install = await fixture.install();
assert.equal(install.code, 0, install.stderr);
const config = JSON.parse(await readFile(fixture.configPath, 'utf-8'));
const guard = managedHandler(config, 'preToolUse', 'guard.js');
const audit = managedHandler(config, 'postToolUse', 'hook.js');
const prePayload = `${JSON.stringify({
conversation_id: 'conversation-1',
generation_id: 'generation-1',
hook_event_name: 'preToolUse',
tool_name: 'Shell',
tool_input: { command: 'Get-ChildItem', working_directory: fixture.projectDir },
tool_use_id: 'call-1',
cwd: fixture.projectDir,
})}\n`;
const guardResult = await runHook(guard, prePayload);
assert.equal(guardResult.code, 2, guardResult.stderr);
assert.match(guardResult.stderr, /Agent is frozen by Elydora/);
const postPayload = `${JSON.stringify({
conversation_id: 'conversation-1',
generation_id: 'generation-1',
hook_event_name: 'postToolUse',
tool_name: 'Shell',
tool_input: { command: 'Get-ChildItem' },
tool_output: '{"exitCode":0,"stdout":"ok"}',
tool_use_id: 'call-1',
cwd: fixture.projectDir,
duration: 42,
})}\n`;
const auditResult = await runHook(audit, postPayload, { ELYDORA_CAPTURE: capturePath });
assert.equal(auditResult.code, 0, auditResult.stderr);
assert.equal(await readFile(capturePath, 'utf-8'), postPayload);
} finally {
await fixture.close();
await rm(capturePath, { force: true });
}
});
test('Cursor status requires an exact pair, matching identity, and physical runtimes', async () => {
const fixture = await createFixture();
try {
assert.equal((await fixture.install()).code, 0);
let status = await runPlugin(fixture, 'status', null);
assert.equal(status.code, 0, status.stderr);
assert.equal(JSON.parse(status.stdout).installed, true);
const config = JSON.parse(await readFile(fixture.configPath, 'utf-8'));
config.hooks.preToolUse[0].failClosed = false;
await writeJson(fixture.configPath, config);
status = await runPlugin(fixture, 'status', null);
assert.equal(JSON.parse(status.stdout).hookConfigured, false);
assert.equal((await fixture.install()).code, 0);
const auditSource = await readFile(fixture.hookScriptPath);
await rm(fixture.hookScriptPath);
status = await runPlugin(fixture, 'status', null);
assert.equal(JSON.parse(status.stdout).installed, false);
await writeFile(fixture.hookScriptPath, auditSource);
const runtimeConfigPath = path.join(fixture.agentDir, 'config.json');
await writeJson(runtimeConfigPath, { agent_id: 'another-agent', agent_name: 'cursor' });
status = await runPlugin(fixture, 'status', null);
assert.equal(JSON.parse(status.stdout).installed, false);
await writeFile(runtimeConfigPath, '{ malformed');
status = await runPlugin(fixture, 'status', null);
assert.equal(status.code, 1);
assert.match(status.stderr, /parse Elydora runtime config/i);
} finally {
await fixture.close();
}
});
test('Cursor uninstall removes exact ownership and preserves user entries', async () => {
const fixture = await createFixture({
existingConfig: { version: 1, hooks: { sessionStart: [{ command: 'keep' }] } },
});
try {
assert.equal((await fixture.install()).code, 0);
const config = JSON.parse(await readFile(fixture.configPath, 'utf-8'));
const otherGuard = structuredClone(managedHandler(config, 'preToolUse', 'guard.js'));
const otherAudit = structuredClone(managedHandler(config, 'postToolUse', 'hook.js'));
otherGuard.command = otherGuard.command.replaceAll('agent-1', 'agent-10');
otherAudit.command = otherAudit.command.replaceAll('agent-1', 'agent-10');
config.hooks.preToolUse.push(otherGuard, {
command: 'echo elydora agent-1 guard.js', timeout: 10, failClosed: true,
});
config.hooks.postToolUse.push(otherAudit);
await writeJson(fixture.configPath, config);
const result = await runPlugin(fixture, 'uninstall', 'agent-1');
assert.equal(result.code, 0, result.stderr);
const remaining = JSON.parse(await readFile(fixture.configPath, 'utf-8'));
assert.deepEqual(remaining.hooks.sessionStart, [{ command: 'keep' }]);
assert.equal(remaining.hooks.preToolUse.length, 2);
assert.equal(remaining.hooks.postToolUse.length, 1);
assert.match(remaining.hooks.preToolUse[0].command, /agent-10/);
} finally {
await fixture.close();
}
});
test('Cursor rejects malformed, duplicate, and invalid configs before writes', async () => {
const invalidConfigs = [
'{ malformed',
'[]\n',
'{"hooks":{}}\n',
'{"version":2,"hooks":{}}\n',
'{"version":1,"hooks":null}\n',
'{"version":1,"hooks":{"preToolUse":null}}\n',
'{"version":1,"hooks":{"preToolUse":[null]}}\n',
'{"version":1,"version":1,"hooks":{}}\n',
'{"version":1,"hooks":{},}\n',
];
for (const existingConfig of invalidConfigs) {
const fixture = await createFixture({ existingConfig });
try {
const before = await readFile(fixture.configPath, 'utf-8');
const result = await fixture.install();
assert.equal(result.code, 1, `accepted ${existingConfig}`);
assert.equal(await readFile(fixture.configPath, 'utf-8'), before);
} finally {
await fixture.close();
}
}
});
test('Cursor rejects missing and symbolic-link files before config writes', async (t) => {
const missing = await createFixture({ createRuntimes: false });
try {
const result = await missing.install();
assert.equal(result.code, 1);
assert.match(result.stderr, /runtime is missing/i);
await assert.rejects(readFile(missing.configPath), { code: 'ENOENT' });
} finally {
await missing.close();
}
const linkedRuntime = await createFixture();
try {
const target = path.join(linkedRuntime.homeDir, 'guard-target.js');
await writeFile(target, 'process.exit(2);\n');
await rm(linkedRuntime.guardScriptPath);
try {
await symlink(target, linkedRuntime.guardScriptPath);
} catch (error) {
if (error?.code === 'EPERM') t.skip(`symbolic links unavailable: ${error.message}`);
throw error;
}
const result = await linkedRuntime.install();
assert.equal(result.code, 1);
assert.match(result.stderr, /physical file/i);
await assert.rejects(readFile(linkedRuntime.configPath), { code: 'ENOENT' });
} finally {
await linkedRuntime.close();
}
});
test('Cursor confines both runtimes to the managed agent directory', async () => {
for (const field of ['guardScriptPath', 'hookScriptPath']) {
const fixture = await createFixture();
try {
const result = await runPlugin(fixture, 'install', {
agentName: 'cursor',
agentId: 'agent-1',
guardScriptPath: fixture.guardScriptPath,
hookScriptPath: fixture.hookScriptPath,
[field]: path.join(fixture.homeDir, `unmanaged-${field}.js`),
});
assert.equal(result.code, 1);
assert.match(result.stderr, /managed agent directory/i);
await assert.rejects(readFile(fixture.configPath), { code: 'ENOENT' });
} finally {
await fixture.close();
}
}
});
test('Cursor rejects symbolic-link config and preserves its target', async (t) => {
const fixture = await createFixture();
try {
const target = path.join(fixture.homeDir, 'cursor-hooks-target.json');
const original = '{"version":1,"hooks":{}}\n';
await writeFile(target, original);
await mkdir(path.dirname(fixture.configPath), { recursive: true });
try {
await symlink(target, fixture.configPath);
} catch (error) {
if (error?.code === 'EPERM') t.skip(`symbolic links unavailable: ${error.message}`);
throw error;
}
const result = await fixture.install();
assert.equal(result.code, 1);
assert.match(result.stderr, /physical file/i);
assert.equal(await readFile(target, 'utf-8'), original);
assert.equal((await lstat(fixture.configPath)).isSymbolicLink(), true);
} finally {
await fixture.close();
}
});
test('Cursor status rejects symbolic-link runtime files', async (t) => {
for (const name of ['config', 'guard', 'audit']) {
const fixture = await createFixture();
try {
assert.equal((await fixture.install()).code, 0);
const filePath = {
config: path.join(fixture.agentDir, 'config.json'),
guard: fixture.guardScriptPath,
audit: fixture.hookScriptPath,
}[name];
const contents = await readFile(filePath);
const target = path.join(fixture.homeDir, `${name}-runtime-target`);
await writeFile(target, contents);
await rm(filePath);
try {
await symlink(target, filePath);
} catch (error) {
if (error?.code === 'EPERM') t.skip(`symbolic links unavailable: ${error.message}`);
throw error;
}
const status = await runPlugin(fixture, 'status', null);
assert.equal(status.code, 1);
assert.match(status.stderr, /physical file/i);
} finally {
await fixture.close();
}
}
});
test('Cursor removes an entirely managed config and leaves absent config absent', async () => {
const fixture = await createFixture();
try {
assert.equal((await fixture.install()).code, 0);
assert.equal((await runPlugin(fixture, 'uninstall', 'agent-1')).code, 0);
await assert.rejects(readFile(fixture.configPath), { code: 'ENOENT' });
assert.equal((await runPlugin(fixture, 'uninstall', 'agent-1')).code, 0);
await assert.rejects(readFile(fixture.configPath), { code: 'ENOENT' });
} finally {
await fixture.close();
}
});
test('Cursor atomic writes are private and leave no staging files', async () => {
const fixture = await createFixture();
try {
assert.equal((await fixture.install()).code, 0);
const files = await readdir(path.dirname(fixture.configPath));
assert.equal(files.some((name) => name.endsWith('.tmp') || name.endsWith('.rollback')), false);
if (process.platform !== 'win32') {
assert.equal((await stat(fixture.configPath)).mode & 0o777, 0o600);
}
} finally {
await fixture.close();
}
});
test('Cursor atomic write detects a concurrent source change', async () => {
const fixture = await createFixture({
existingConfig: { version: 1, hooks: { sessionStart: [{ command: 'original' }] } },
});
const concurrent = '{"version":1,"hooks":{"sessionStart":[{"command":"concurrent"}]}}\n';
try {
const source = `
import { writeFile } from 'node:fs/promises';
import { readDocument, writeDocument } from ${JSON.stringify(ioModuleUrl)};
const document = await readDocument();
await writeFile(document.filePath, process.env.ELYDORA_CONCURRENT, 'utf-8');
await writeDocument({
document,
changed: true,
next: '{"version":1,"hooks":{}}\\n',
});
`;
const result = await runNode(
['--input-type=module', '--eval', source],
{ HOME: fixture.homeDir, USERPROFILE: fixture.homeDir, ELYDORA_CONCURRENT: concurrent },
fixture.projectDir,
);
assert.equal(result.code, 1);
assert.match(result.stderr, /changed during update/i);
assert.equal(await readFile(fixture.configPath, 'utf-8'), concurrent);
const files = await readdir(path.dirname(fixture.configPath));
assert.equal(files.some((name) => name.endsWith('.tmp')), false);
} finally {
await fixture.close();
}
});