fix: align OpenCode hook event contract

This commit is contained in:
ZacharyZhang-NY committed 2026-07-19 02:01:56 -04:00
1 parent f0e82b46fb
commit 212fb61e83
2 files changed
+181 -79

No files matched your search

+41 -40
View File
@@ -21,49 +21,51 @@ function generatePluginScript(hookScriptPath: string, guardScriptPath: string):
import { spawn, spawnSync } from 'node:child_process'; import { spawn, spawnSync } from 'node:child_process';
const HOOK_SCRIPT_PATH = ${JSON.stringify(hookScriptPath)};
const GUARD_SCRIPT_PATH = ${JSON.stringify(guardScriptPath)};
export const ElydoraAuditPlugin = async (ctx) => { export const ElydoraAuditPlugin = async (ctx) => {
return { return {
"tool.execute.before": async (input, output) => { "tool.execute.before": async () => {
// Guard — blocks tool if agent is frozen // Guard — blocks tool if agent is frozen
let result; let result;
try { try {
result = spawnSync('node', ['__ELYDORA_GUARD_SCRIPT_PATH__'], { result = spawnSync('node', [GUARD_SCRIPT_PATH], {
timeout: 5000, timeout: 5000,
stdio: ['pipe', 'ignore', 'pipe'], stdio: ['pipe', 'ignore', 'pipe'],
}); });
} catch { } catch (error) {
// Fail-open — allow if guard can't run throw new Error('Elydora guard failed: ' + error.message);
return;
} }
if (result.status === 2) { if (result.error) {
const msg = result.stderr ? result.stderr.toString().trim() : 'Agent is frozen by Elydora.'; throw new Error('Elydora guard failed: ' + result.error.message);
throw new Error(msg); }
if (result.status !== 0) {
const message = result.stderr?.toString().trim() || 'Guard exited with status ' + result.status;
const prefix = result.status === 2 ? '' : 'Elydora guard failed: ';
throw new Error(prefix + message);
} }
}, },
"tool.execute.after": async (input, output) => { "tool.execute.after": async (input, output) => {
try { const data = JSON.stringify({
const toolName = input?.tool || 'unknown'; tool_name: input?.tool || 'unknown',
const toolInput = output?.args || input?.args || {}; tool_input: input?.args || output?.args || {},
const sessionId = ctx?.project?.name || 'unknown'; session_id: input?.sessionID || ctx?.project?.name || 'unknown',
});
const data = JSON.stringify({ const child = spawn('node', [HOOK_SCRIPT_PATH], {
tool_name: toolName, detached: true,
tool_input: toolInput, stdio: ['pipe', 'ignore', 'ignore'],
session_id: sessionId, });
}); child.on('error', (error) => {
console.error('[elydora] audit hook failed:', error.message);
const child = spawn('node', ['__ELYDORA_HOOK_SCRIPT_PATH__'], { });
detached: true, child.stdin.on('error', (error) => {
stdio: ['pipe', 'ignore', 'ignore'], console.error('[elydora] audit input failed:', error.message);
}); });
child.stdin.write(data); child.stdin.end(data);
child.stdin.end(); child.unref();
child.unref();
} catch {
// Fire-and-forget — never block the developer
}
}, },
}; };
}; };
@@ -76,9 +78,7 @@ export const opencodePlugin: AgentPlugin = {
await fsp.mkdir(configDir, { recursive: true }); await fsp.mkdir(configDir, { recursive: true });
const configPath = resolveConfigPath(); const configPath = resolveConfigPath();
const script = generatePluginScript(config.hookScriptPath, config.guardScriptPath) const script = generatePluginScript(config.hookScriptPath, config.guardScriptPath);
.replace('__ELYDORA_HOOK_SCRIPT_PATH__', config.hookScriptPath.replace(/\\/g, '\\\\'))
.replace('__ELYDORA_GUARD_SCRIPT_PATH__', config.guardScriptPath.replace(/\\/g, '\\\\'));
await fsp.writeFile(configPath, script, 'utf-8'); await fsp.writeFile(configPath, script, 'utf-8');
}, },
@@ -97,24 +97,25 @@ export const opencodePlugin: AgentPlugin = {
let hookConfigured = false; let hookConfigured = false;
let hookScriptPath = ''; let hookScriptPath = '';
let guardScriptPath = '';
try { try {
const raw = await fsp.readFile(configPath, 'utf-8'); const raw = await fsp.readFile(configPath, 'utf-8');
hookConfigured = true; const hookMatch = raw.match(/const HOOK_SCRIPT_PATH = ("(?:\\.|[^"\\])*");/);
const guardMatch = raw.match(/const GUARD_SCRIPT_PATH = ("(?:\\.|[^"\\])*");/);
// Extract hook script path from plugin script content if (hookMatch && guardMatch) {
// The plugin script contains a spawn call like: spawn('node', ['<path>']) hookScriptPath = JSON.parse(hookMatch[1]);
const match = raw.match(/spawn\('node',\s*\['([^']*elydora[^']*)'\]/); guardScriptPath = JSON.parse(guardMatch[1]);
if (match) { hookConfigured = true;
hookScriptPath = match[1];
} }
} catch { } catch {
// File doesn't exist // File doesn't exist
} }
let hookScriptExists = false; let hookScriptExists = false;
if (hookScriptPath) { if (hookScriptPath && guardScriptPath) {
try { try {
await fsp.access(hookScriptPath); await fsp.access(hookScriptPath);
await fsp.access(guardScriptPath);
hookScriptExists = true; hookScriptExists = true;
} catch { } catch {
// File doesn't exist // File doesn't exist
+140 -39
View File
@@ -1,8 +1,9 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { setTimeout as delay } from 'node:timers/promises';
import { pathToFileURL } from 'node:url'; import { pathToFileURL } from 'node:url';
import test from 'node:test'; import test from 'node:test';
@@ -21,49 +22,149 @@ function runNode(args, env) {
}); });
} }
test('OpenCode rejects tool execution when the Elydora guard blocks', async () => { async function createFixture({ guardSource, hookSource }) {
const homeDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-opencode-')); const homeDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-opencode-'));
const agentDir = path.join(homeDir, '.elydora', 'agent-1');
await mkdir(agentDir, { recursive: true });
const guardScriptPath = path.join(agentDir, 'guard.cjs');
const hookScriptPath = path.join(agentDir, 'hook.cjs');
if (guardSource !== undefined) await writeFile(guardScriptPath, guardSource);
await writeFile(hookScriptPath, hookSource ?? 'process.exit(0);\n');
const pluginModuleUrl = pathToFileURL(path.resolve('dist/plugins/opencode.js')).href;
const installScript = `
import { opencodePlugin } from ${JSON.stringify(pluginModuleUrl)};
await opencodePlugin.install(JSON.parse(process.env.ELYDORA_TEST_CONFIG));
`;
const installResult = await runNode(
['--input-type=module', '--eval', installScript],
{
HOME: homeDir,
USERPROFILE: homeDir,
ELYDORA_TEST_CONFIG: JSON.stringify({ guardScriptPath, hookScriptPath }),
},
);
assert.equal(installResult.code, 0, installResult.stderr);
const generatedPath = path.join(
homeDir,
'.config',
'opencode',
'plugins',
'elydora-audit.mjs',
);
const generatedPlugin = await import(`${pathToFileURL(generatedPath).href}?test=${Date.now()}`);
const hooks = await generatedPlugin.ElydoraAuditPlugin({ project: { name: 'project-fallback' } });
return {
guardScriptPath,
homeDir,
hooks,
async close() {
await rm(homeDir, { recursive: true, force: true });
},
};
}
async function readPluginStatus(homeDir) {
const pluginModuleUrl = pathToFileURL(path.resolve('dist/plugins/opencode.js')).href;
const statusScript = `
import { opencodePlugin } from ${JSON.stringify(pluginModuleUrl)};
console.log(JSON.stringify(await opencodePlugin.status()));
`;
const result = await runNode(
['--input-type=module', '--eval', statusScript],
{ HOME: homeDir, USERPROFILE: homeDir },
);
assert.equal(result.code, 0, result.stderr);
return JSON.parse(result.stdout);
}
test('OpenCode rejects tool execution when the Elydora guard blocks', async () => {
const fixture = await createFixture({
guardSource: "process.stderr.write('Agent is frozen by Elydora.'); process.exit(2);\n",
});
try { try {
const agentDir = path.join(homeDir, '.elydora', 'agent-1');
await mkdir(agentDir, { recursive: true });
const guardScriptPath = path.join(agentDir, 'guard.cjs');
const hookScriptPath = path.join(agentDir, 'hook.cjs');
await writeFile(
guardScriptPath,
"process.stderr.write('Agent is frozen by Elydora.'); process.exit(2);\n",
);
await writeFile(hookScriptPath, 'process.exit(0);\n');
const pluginModuleUrl = pathToFileURL(path.resolve('dist/plugins/opencode.js')).href;
const installScript = `
import { opencodePlugin } from ${JSON.stringify(pluginModuleUrl)};
await opencodePlugin.install(JSON.parse(process.env.ELYDORA_TEST_CONFIG));
`;
const installResult = await runNode(
['--input-type=module', '--eval', installScript],
{
HOME: homeDir,
USERPROFILE: homeDir,
ELYDORA_TEST_CONFIG: JSON.stringify({ guardScriptPath, hookScriptPath }),
},
);
assert.equal(installResult.code, 0, installResult.stderr);
const generatedPath = path.join(
homeDir,
'.config',
'opencode',
'plugins',
'elydora-audit.mjs',
);
const generatedPlugin = await import(`${pathToFileURL(generatedPath).href}?test=${Date.now()}`);
const hooks = await generatedPlugin.ElydoraAuditPlugin({ project: { name: 'test' } });
await assert.rejects( await assert.rejects(
hooks['tool.execute.before']({ tool: 'bash' }, { args: { command: 'echo test' } }), fixture.hooks['tool.execute.before'](
{ tool: 'bash', sessionID: 'session-1', callID: 'call-1' },
{ args: { command: 'echo test' } },
),
/Agent is frozen by Elydora/, /Agent is frozen by Elydora/,
); );
} finally { } finally {
await rm(homeDir, { recursive: true, force: true }); await fixture.close();
}
});
test('OpenCode blocks when the guard process cannot start', async () => {
const fixture = await createFixture({});
try {
await assert.rejects(
fixture.hooks['tool.execute.before'](
{ tool: 'bash', sessionID: 'session-1', callID: 'call-1' },
{ args: { command: 'echo test' } },
),
/Elydora guard failed/,
);
} finally {
await fixture.close();
}
});
test('OpenCode forwards the current tool event contract to the audit hook', async () => {
const capturePath = path.join(os.tmpdir(), `elydora-event-${process.pid}-${Date.now()}.json`);
const hookSource = `
const fs = require('node:fs');
const chunks = [];
process.stdin.on('data', (chunk) => chunks.push(chunk));
process.stdin.on('end', () => fs.writeFileSync(${JSON.stringify(capturePath)}, Buffer.concat(chunks)));
`;
const fixture = await createFixture({
guardSource: 'process.exit(0);\n',
hookSource,
});
try {
await fixture.hooks['tool.execute.after'](
{
tool: 'bash',
sessionID: 'session-1',
callID: 'call-1',
args: { command: 'echo test' },
},
{ title: 'Shell', output: 'test' },
);
let payload;
const deadline = Date.now() + 3000;
while (Date.now() < deadline) {
try {
payload = await readFile(capturePath, 'utf-8');
break;
} catch (error) {
if (error.code !== 'ENOENT') throw error;
await delay(20);
}
}
assert(payload, 'audit hook did not receive the tool event');
assert.deepEqual(JSON.parse(payload), {
tool_name: 'bash',
tool_input: { command: 'echo test' },
session_id: 'session-1',
});
} finally {
await fixture.close();
await rm(capturePath, { force: true });
}
});
test('OpenCode status requires both generated runtime scripts', async () => {
const fixture = await createFixture({ guardSource: 'process.exit(0);\n' });
try {
assert.equal((await readPluginStatus(fixture.homeDir)).installed, true);
await rm(fixture.guardScriptPath);
assert.equal((await readPluginStatus(fixture.homeDir)).installed, false);
} finally {
await fixture.close();
} }
}); });