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 (age < CACHE_TTL_MS) {
|
||||||
if (cache.status === 'frozen') {
|
if (cache.status === 'frozen') {
|
||||||
process.stderr.write('Agent "' + AGENT_NAME + '" is frozen by Elydora. Tool execution blocked.\n');
|
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
|
return; // Cache is fresh — use cached result
|
||||||
}
|
}
|
||||||
@@ -161,7 +161,7 @@ async function main() {
|
|||||||
|
|
||||||
if (agentStatus === 'frozen') {
|
if (agentStatus === 'frozen') {
|
||||||
process.stderr.write('Agent "' + AGENT_NAME + '" is frozen by Elydora. Tool execution blocked.\n');
|
process.stderr.write('Agent "' + AGENT_NAME + '" is frozen by Elydora. Tool execution blocked.\n');
|
||||||
process.exitCode = 1;
|
process.exitCode = 2;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} 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": {
|
"scripts": {
|
||||||
"build": "tsc && node scripts/postbuild.mjs",
|
"build": "tsc && node scripts/postbuild.mjs",
|
||||||
|
"test": "npm run build && node --test",
|
||||||
"prepublishOnly": "npm run build"
|
"prepublishOnly": "npm run build"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -301,7 +301,7 @@ main();
|
|||||||
*
|
*
|
||||||
* - Caches the agent status locally (60s TTL) to avoid hitting the API on every tool call
|
* - 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
|
* - 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 {
|
export function generateGuardScript(agentName: string, agentId: string): string {
|
||||||
return `#!/usr/bin/env node
|
return `#!/usr/bin/env node
|
||||||
|
|||||||
@@ -25,21 +25,20 @@ export const ElydoraAuditPlugin = async (ctx) => {
|
|||||||
return {
|
return {
|
||||||
"tool.execute.before": async (input, output) => {
|
"tool.execute.before": async (input, output) => {
|
||||||
// Guard — blocks tool if agent is frozen
|
// Guard — blocks tool if agent is frozen
|
||||||
|
let result;
|
||||||
try {
|
try {
|
||||||
const result = spawnSync('node', ['__ELYDORA_GUARD_SCRIPT_PATH__'], {
|
result = spawnSync('node', ['__ELYDORA_GUARD_SCRIPT_PATH__'], {
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
stdio: ['pipe', 'ignore', 'pipe'],
|
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 {
|
} catch {
|
||||||
// Fail-open — allow if guard can't run
|
// 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
|
1. Reads agent config from ~/.elydora/{agent_id}/config.json
|
||||||
2. Checks cached status from ~/.elydora/{agent_id}/status-cache.json (60s TTL)
|
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}
|
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)
|
5. Fail-open: if API unreachable or config missing, exits 0 (allow)
|
||||||
6. Uses only stdlib — no external deps
|
6. Uses only stdlib — no external deps
|
||||||
"""
|
"""
|
||||||
@@ -343,7 +343,7 @@ def main():
|
|||||||
sys.stderr.write(
|
sys.stderr.write(
|
||||||
'Agent "' + AGENT_NAME + '" is frozen by Elydora. Tool execution blocked.\\n'
|
'Agent "' + AGENT_NAME + '" is frozen by Elydora. Tool execution blocked.\\n'
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
sys.exit(2)
|
||||||
return # Cache is fresh — use cached result
|
return # Cache is fresh — use cached result
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # No cache or invalid — need to check API
|
pass # No cache or invalid — need to check API
|
||||||
@@ -381,7 +381,7 @@ def main():
|
|||||||
sys.stderr.write(
|
sys.stderr.write(
|
||||||
'Agent "' + AGENT_NAME + '" is frozen by Elydora. Tool execution blocked.\\n'
|
'Agent "' + AGENT_NAME + '" is frozen by Elydora. Tool execution blocked.\\n'
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
sys.exit(2)
|
||||||
except SystemExit:
|
except SystemExit:
|
||||||
raise # Re-raise sys.exit
|
raise # Re-raise sys.exit
|
||||||
except Exception:
|
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