fix: sync frozen agent hook enforcement

This commit is contained in:
ZacharyZhang-NY committed 2026-07-19 01:51:20 -04:00
1 parent 75f82971c3
commit c2f49e1524
9 files changed
+400 -15

No files matched your search

+2 -2
View File
@@ -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 {
@@ -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)
}
}
+1
View File
@@ -13,6 +13,7 @@
],
"scripts": {
"build": "tsc && node scripts/postbuild.mjs",
"test": "npm run build && node --test",
"prepublishOnly": "npm run build"
},
"engines": {
+1 -1
View File
@@ -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
+8 -9
View File
@@ -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);
}
},
+95
View File
@@ -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();
}
});
+66
View File
@@ -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 });
}
});
+3 -3
View File
@@ -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:
+106
View File
@@ -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