diff --git a/package.json b/package.json index c54301e..b2f42be 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ ], "scripts": { "build": "tsc && node scripts/postbuild.mjs", + "test": "npm run build && node --test", "prepublishOnly": "npm run build" }, "engines": { diff --git a/src/plugins/hook-template.ts b/src/plugins/hook-template.ts index 4f42892..880d0ce 100644 --- a/src/plugins/hook-template.ts +++ b/src/plugins/hook-template.ts @@ -301,7 +301,7 @@ main(); * * - 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 1 to block the tool + * - 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 @@ -344,7 +344,7 @@ async function main() { 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 = 1; + process.exitCode = 2; } return; // Cache is fresh — use cached result } @@ -388,7 +388,7 @@ async function main() { if (agentStatus === 'frozen') { process.stderr.write('Agent "' + AGENT_NAME + '" is frozen by Elydora. Tool execution blocked.\\n'); - process.exitCode = 1; + process.exitCode = 2; } } } catch { diff --git a/src/plugins/opencode.ts b/src/plugins/opencode.ts index 59ba42a..906db75 100644 --- a/src/plugins/opencode.ts +++ b/src/plugins/opencode.ts @@ -25,21 +25,20 @@ export const ElydoraAuditPlugin = async (ctx) => { return { "tool.execute.before": async (input, output) => { // Guard — blocks tool if agent is frozen + let result; try { - const result = spawnSync('node', ['__ELYDORA_GUARD_SCRIPT_PATH__'], { + result = spawnSync('node', ['__ELYDORA_GUARD_SCRIPT_PATH__'], { timeout: 5000, stdio: ['pipe', 'ignore', 'pipe'], }); - if (result.status === 2) { - const msg = result.stderr ? result.stderr.toString().trim() : 'Agent is frozen by Elydora.'; - // Signal denial by setting a blocked flag - if (output && typeof output === 'object') { - output.blocked = true; - output.reason = msg; - } - } } catch { // Fail-open — allow if guard can't run + return; + } + + if (result.status === 2) { + const msg = result.stderr ? result.stderr.toString().trim() : 'Agent is frozen by Elydora.'; + throw new Error(msg); } }, diff --git a/test/guard-template.test.mjs b/test/guard-template.test.mjs new file mode 100644 index 0000000..0d11953 --- /dev/null +++ b/test/guard-template.test.mjs @@ -0,0 +1,103 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import http from 'node:http'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { generateGuardScript } from '../dist/plugins/hook-template.js'; + +const AGENT_ID = 'agent-1'; + +async function createFixture(status) { + const homeDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-guard-')); + const agentDir = path.join(homeDir, '.elydora', AGENT_ID); + await mkdir(agentDir, { recursive: true }); + + const server = http.createServer((_request, response) => { + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify({ agent: { status } })); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + assert(address && typeof address === 'object'); + + await writeFile( + path.join(agentDir, 'config.json'), + JSON.stringify({ agent_id: AGENT_ID, base_url: `http://127.0.0.1:${address.port}` }), + ); + + const scriptPath = path.join(homeDir, 'guard.cjs'); + await writeFile(scriptPath, generateGuardScript('claudecode', AGENT_ID)); + + return { + homeDir, + scriptPath, + async close() { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + await rm(homeDir, { recursive: true, force: true }); + }, + }; +} + +function runGuard(scriptPath, homeDir) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [scriptPath], { + env: { ...process.env, HOME: homeDir, USERPROFILE: homeDir }, + stdio: ['ignore', '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 })); + }); +} + +test('frozen agents use the blocking exit code required by hook CLIs', async () => { + const fixture = await createFixture('frozen'); + try { + const result = await runGuard(fixture.scriptPath, fixture.homeDir); + + assert.equal(result.code, 2); + assert.match(result.stderr, /Tool execution blocked/); + assert.equal(result.stdout, ''); + } finally { + await fixture.close(); + } +}); + +test('active agents allow tool execution', async () => { + const fixture = await createFixture('active'); + try { + const result = await runGuard(fixture.scriptPath, fixture.homeDir); + + assert.equal(result.code, 0); + assert.equal(result.stderr, ''); + assert.equal(result.stdout, ''); + } finally { + await fixture.close(); + } +}); + +test('cached frozen status keeps the blocking exit code', async () => { + const fixture = await createFixture('active'); + try { + await writeFile( + path.join(fixture.homeDir, '.elydora', AGENT_ID, 'status-cache.json'), + JSON.stringify({ status: 'frozen', cached_at: Date.now() }), + ); + + const result = await runGuard(fixture.scriptPath, fixture.homeDir); + + assert.equal(result.code, 2); + assert.match(result.stderr, /Tool execution blocked/); + assert.equal(result.stdout, ''); + } finally { + await fixture.close(); + } +}); diff --git a/test/opencode-plugin.test.mjs b/test/opencode-plugin.test.mjs new file mode 100644 index 0000000..46d2465 --- /dev/null +++ b/test/opencode-plugin.test.mjs @@ -0,0 +1,69 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import test from 'node:test'; + +function runNode(args, env) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, args, { + env: { ...process.env, ...env }, + stdio: ['ignore', '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 })); + }); +} + +test('OpenCode rejects tool execution when the Elydora guard blocks', async () => { + const homeDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-opencode-')); + 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( + hooks['tool.execute.before']({ tool: 'bash' }, { args: { command: 'echo test' } }), + /Agent is frozen by Elydora/, + ); + } finally { + await rm(homeDir, { recursive: true, force: true }); + } +});