fix: sync frozen agent hook enforcement
This commit is contained in:
1 parent
75f82971c3
commit
c2f49e1524
9 files changed
+400
-15
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc && node scripts/postbuild.mjs",
|
||||
"test": "npm run build && node --test",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user