diff --git a/sdks/go/cmd/elydora/plugins/hooktemplate.go b/sdks/go/cmd/elydora/plugins/hooktemplate.go index d7bf2f3..2118efc 100644 --- a/sdks/go/cmd/elydora/plugins/hooktemplate.go +++ b/sdks/go/cmd/elydora/plugins/hooktemplate.go @@ -117,7 +117,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 } @@ -161,7 +161,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/sdks/go/cmd/elydora/plugins/hooktemplate_test.go b/sdks/go/cmd/elydora/plugins/hooktemplate_test.go new file mode 100644 index 0000000..cb6bf94 --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/hooktemplate_test.go @@ -0,0 +1,118 @@ +package plugins + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +const guardTestAgentID = "agent 1" + +func writeGuardFixture(t *testing.T, status string) (string, string, *httptest.Server) { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.EscapedPath() != "/v1/agents/agent%201" { + t.Errorf("request path = %q, want encoded agent path", r.URL.EscapedPath()) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "agent": map[string]string{"status": status}, + }) + })) + homeDir := t.TempDir() + agentDir := filepath.Join(homeDir, ".elydora", guardTestAgentID) + if err := os.MkdirAll(agentDir, 0755); err != nil { + t.Fatalf("create agent directory: %v", err) + } + config, err := json.Marshal(map[string]string{ + "agent_id": guardTestAgentID, + "base_url": server.URL, + }) + if err != nil { + t.Fatalf("marshal config: %v", err) + } + if err := os.WriteFile(filepath.Join(agentDir, "config.json"), config, 0600); err != nil { + t.Fatalf("write config: %v", err) + } + scriptPath := filepath.Join(homeDir, "guard.js") + if err := os.WriteFile(scriptPath, []byte(GenerateGuardScript("claudecode", guardTestAgentID)), 0755); err != nil { + t.Fatalf("write guard script: %v", err) + } + return scriptPath, homeDir, server +} + +func runGuard(t *testing.T, scriptPath string, homeDir string) (int, string) { + t.Helper() + nodeBinary, err := exec.LookPath("node") + if err != nil { + t.Skip("Node.js is required to execute generated hook scripts") + } + cmd := exec.Command(nodeBinary, scriptPath) + cmd.Env = append(os.Environ(), "HOME="+homeDir, "USERPROFILE="+homeDir) + var stderr bytes.Buffer + cmd.Stderr = &stderr + err = cmd.Run() + if err == nil { + return 0, stderr.String() + } + exitError, ok := err.(*exec.ExitError) + if !ok { + t.Fatalf("run guard: %v", err) + } + return exitError.ExitCode(), stderr.String() +} + +func TestGuardBlocksRemoteFrozenAgent(t *testing.T) { + scriptPath, homeDir, server := writeGuardFixture(t, "frozen") + defer server.Close() + exitCode, stderr := runGuard(t, scriptPath, homeDir) + if exitCode != 2 { + t.Fatalf("exit code = %d, want 2; stderr = %q", exitCode, stderr) + } + if !strings.Contains(stderr, "Tool execution blocked") { + t.Fatalf("stderr = %q, want blocking message", stderr) + } +} + +func TestGuardAllowsActiveAgent(t *testing.T) { + scriptPath, homeDir, server := writeGuardFixture(t, "active") + defer server.Close() + exitCode, stderr := runGuard(t, scriptPath, homeDir) + if exitCode != 0 { + t.Fatalf("exit code = %d, want 0; stderr = %q", exitCode, stderr) + } + if stderr != "" { + t.Fatalf("stderr = %q, want empty output", stderr) + } +} + +func TestGuardBlocksCachedFrozenAgent(t *testing.T) { + scriptPath, homeDir, server := writeGuardFixture(t, "active") + defer server.Close() + cache := map[string]any{ + "status": "frozen", + "cached_at": time.Now().Add(time.Minute).UnixMilli(), + } + cacheJSON, err := json.Marshal(cache) + if err != nil { + t.Fatalf("marshal cache: %v", err) + } + cachePath := filepath.Join(homeDir, ".elydora", guardTestAgentID, "status-cache.json") + if err := os.WriteFile(cachePath, cacheJSON, 0600); err != nil { + t.Fatalf("write cache: %v", err) + } + exitCode, stderr := runGuard(t, scriptPath, homeDir) + if exitCode != 2 { + t.Fatalf("exit code = %d, want 2; stderr = %q", exitCode, stderr) + } + if !strings.Contains(stderr, "Tool execution blocked") { + t.Fatalf("stderr = %q, want blocking message", stderr) + } +} diff --git a/sdks/node/package.json b/sdks/node/package.json index 789a6e2..deb4070 100644 --- a/sdks/node/package.json +++ b/sdks/node/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/sdks/node/src/plugins/hook-template.ts b/sdks/node/src/plugins/hook-template.ts index 3d1de7d..880d0ce 100644 --- a/sdks/node/src/plugins/hook-template.ts +++ b/sdks/node/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 diff --git a/sdks/node/src/plugins/opencode.ts b/sdks/node/src/plugins/opencode.ts index 59ba42a..906db75 100644 --- a/sdks/node/src/plugins/opencode.ts +++ b/sdks/node/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/sdks/node/test/guard-template.test.mjs b/sdks/node/test/guard-template.test.mjs new file mode 100644 index 0000000..b112b69 --- /dev/null +++ b/sdks/node/test/guard-template.test.mjs @@ -0,0 +1,95 @@ +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/sdks/node/test/opencode-plugin.test.mjs b/sdks/node/test/opencode-plugin.test.mjs new file mode 100644 index 0000000..7dde4cb --- /dev/null +++ b/sdks/node/test/opencode-plugin.test.mjs @@ -0,0 +1,66 @@ +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 }); + } +}); diff --git a/sdks/python/elydora/plugins/hook_template.py b/sdks/python/elydora/plugins/hook_template.py index 09cdd15..9e59879 100644 --- a/sdks/python/elydora/plugins/hook_template.py +++ b/sdks/python/elydora/plugins/hook_template.py @@ -297,7 +297,7 @@ def generate_guard_script(agent_name: str, agent_id: str) -> str: 1. Reads agent config from ~/.elydora/{agent_id}/config.json 2. Checks cached status from ~/.elydora/{agent_id}/status-cache.json (60s TTL) 3. If cache is stale, fetches agent status from GET {base_url}/v1/agents/{agent_id} - 4. If status is "frozen", writes error to stderr and exits with code 1 (blocks tool) + 4. If status is "frozen", writes error to stderr and exits with blocking code 2 5. Fail-open: if API unreachable or config missing, exits 0 (allow) 6. Uses only stdlib — no external deps """ @@ -343,7 +343,7 @@ def main(): sys.stderr.write( 'Agent "' + AGENT_NAME + '" is frozen by Elydora. Tool execution blocked.\\n' ) - sys.exit(1) + sys.exit(2) return # Cache is fresh — use cached result except Exception: pass # No cache or invalid — need to check API @@ -381,7 +381,7 @@ def main(): sys.stderr.write( 'Agent "' + AGENT_NAME + '" is frozen by Elydora. Tool execution blocked.\\n' ) - sys.exit(1) + sys.exit(2) except SystemExit: raise # Re-raise sys.exit except Exception: diff --git a/sdks/python/tests/test_guard_template.py b/sdks/python/tests/test_guard_template.py new file mode 100644 index 0000000..a8383e4 --- /dev/null +++ b/sdks/python/tests/test_guard_template.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import json +import os +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +import subprocess +import sys +from threading import Thread + +from elydora.plugins.hook_template import generate_guard_script + + +AGENT_ID = "agent 1" + + +class StatusHandler(BaseHTTPRequestHandler): + status = "active" + request_path = "" + + def do_GET(self) -> None: + type(self).request_path = self.path + body = json.dumps({"agent": {"status": type(self).status}}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, _format: str, *_args: object) -> None: + return + + +def create_guard(tmp_path: Path, status: str) -> tuple[Path, ThreadingHTTPServer]: + StatusHandler.status = status + StatusHandler.request_path = "" + server = ThreadingHTTPServer(("127.0.0.1", 0), StatusHandler) + Thread(target=server.serve_forever, daemon=True).start() + agent_dir = tmp_path / ".elydora" / AGENT_ID + agent_dir.mkdir(parents=True) + config = { + "agent_id": AGENT_ID, + "base_url": f"http://127.0.0.1:{server.server_port}", + } + (agent_dir / "config.json").write_text(json.dumps(config), encoding="utf-8") + script_path = tmp_path / "guard.py" + script_path.write_text( + generate_guard_script("claudecode", AGENT_ID), + encoding="utf-8", + ) + return script_path, server + + +def run_guard(script_path: Path, home_dir: Path) -> subprocess.CompletedProcess[str]: + env = { + **os.environ, + "HOME": str(home_dir), + "USERPROFILE": str(home_dir), + } + return subprocess.run( + [sys.executable, str(script_path)], + capture_output=True, + check=False, + env=env, + input="", + text=True, + ) + + +def test_remote_frozen_status_blocks_tool_execution(tmp_path: Path) -> None: + script_path, server = create_guard(tmp_path, "frozen") + try: + result = run_guard(script_path, tmp_path) + finally: + server.shutdown() + server.server_close() + assert result.returncode == 2 + assert "Tool execution blocked" in result.stderr + assert StatusHandler.request_path == "/v1/agents/agent%201" + + +def test_active_status_allows_tool_execution(tmp_path: Path) -> None: + script_path, server = create_guard(tmp_path, "active") + try: + result = run_guard(script_path, tmp_path) + finally: + server.shutdown() + server.server_close() + assert result.returncode == 0 + assert result.stderr == "" + + +def test_cached_frozen_status_uses_blocking_exit_code(tmp_path: Path) -> None: + script_path, server = create_guard(tmp_path, "active") + cache_path = tmp_path / ".elydora" / AGENT_ID / "status-cache.json" + cache_path.write_text( + json.dumps({"status": "frozen", "cached_at": 4_102_444_800}), + encoding="utf-8", + ) + try: + result = run_guard(script_path, tmp_path) + finally: + server.shutdown() + server.server_close() + assert result.returncode == 2 + assert "Tool execution blocked" in result.stderr