use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::time::Duration;
use serde_json::{Value, json};
use crate::sandbox::decide::{GuardMarker, decide};
use crate::sandbox::install::{
GUARD_MANIFEST, GUARD_MARKER, write_json, write_manifest, write_marker,
};
use crate::sandbox::{now_ms, parse_tool_call};
use super::descriptor::{GuardEngine, GuardSection, subst};
pub(crate) fn install_guard(
guard: &GuardSection,
skills_dir: &Path,
stage_root: &Path,
guard_exe: &Path,
ttl: Option<Duration>,
) -> io::Result<PathBuf> {
fs::create_dir_all(skills_dir)?;
let marker_path = skills_dir.join(GUARD_MARKER);
write_marker(&marker_path, stage_root, ttl)?;
match guard.engine {
GuardEngine::JsonHooks => {
install_json_hooks(guard, skills_dir, stage_root, guard_exe, &marker_path)
}
GuardEngine::OpencodePlugin => {
install_opencode_plugin(guard, skills_dir, stage_root, guard_exe, &marker_path)
}
}
}
const OPENCODE_GUARD_PLUGIN_TEMPLATE: &str =
include_str!("../../harnesses/opencode-guard-plugin.js");
fn install_opencode_plugin(
guard: &GuardSection,
skills_dir: &Path,
stage_root: &Path,
guard_exe: &Path,
marker_path: &Path,
) -> io::Result<PathBuf> {
let plugin_path = resolve_rel(
stage_root,
guard
.plugin_file
.as_deref()
.expect("guard.plugin_file is declared (proven at descriptor load)"),
);
if let Some(parent) = plugin_path.parent() {
fs::create_dir_all(parent)?;
}
let plugin_existed = plugin_path.exists();
let backup = if plugin_existed {
Some(fs::read_to_string(&plugin_path)?)
} else {
None
};
let exe = serde_json::to_string(&guard_exe.display().to_string())
.expect("a path string serializes as JSON");
let marker = serde_json::to_string(&marker_path.display().to_string())
.expect("a path string serializes as JSON");
let plugin = subst(
OPENCODE_GUARD_PLUGIN_TEMPLATE,
&[("exe", &exe), ("marker", &marker)],
);
fs::write(&plugin_path, plugin)?;
write_manifest(
&skills_dir.join(GUARD_MANIFEST),
&plugin_path,
plugin_existed,
backup,
marker_path,
)?;
Ok(marker_path.to_path_buf())
}
fn install_json_hooks(
guard: &GuardSection,
skills_dir: &Path,
stage_root: &Path,
guard_exe: &Path,
marker_path: &Path,
) -> io::Result<PathBuf> {
let hooks_path = resolve_rel(
stage_root,
guard
.hooks_file
.as_deref()
.expect("guard.hooks_file is declared (proven at descriptor load)"),
);
if let Some(parent) = hooks_path.parent() {
fs::create_dir_all(parent)?;
}
let hooks_existed = hooks_path.exists();
let backup = if hooks_existed {
Some(fs::read_to_string(&hooks_path)?)
} else {
None
};
let mut config: Value = backup
.as_deref()
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or_else(|| json!({}));
let exe = guard_exe.display().to_string();
let marker = marker_path.display().to_string();
let command = subst(
guard
.command_template
.as_deref()
.expect("guard.command_template is declared (proven at descriptor load)"),
&[("exe", &exe), ("marker", &marker)],
);
let mut entry: Value = serde_json::from_str(
guard
.hook_entry
.as_deref()
.expect("guard.hook_entry is declared (proven at descriptor load)"),
)
.expect("guard.hook_entry parses as JSON (proven at descriptor load)");
substitute_strings(
&mut entry,
&[
(
"matcher",
guard
.matcher
.as_deref()
.expect("guard.matcher is declared (proven at descriptor load)"),
),
("command", &command),
],
);
let hooks = config
.as_object_mut()
.expect("hook config root is a JSON object")
.entry("hooks")
.or_insert_with(|| json!({}));
let pre = hooks
.as_object_mut()
.expect("hooks is a JSON object")
.entry("PreToolUse")
.or_insert_with(|| json!([]));
pre.as_array_mut()
.expect("PreToolUse is an array")
.push(entry);
write_json(&hooks_path, &config)?;
write_manifest(
&skills_dir.join(GUARD_MANIFEST),
&hooks_path,
hooks_existed,
backup,
marker_path,
)?;
Ok(marker_path.to_path_buf())
}
pub(crate) fn guard_verdict(
guard: &GuardSection,
payload: &str,
marker: Option<GuardMarker>,
) -> Option<String> {
let (tool_name, tool_input) = parse_tool_call(payload)?;
let decision = decide(&tool_name, &tool_input, marker.as_ref(), now_ms());
if decision.allow {
return None;
}
let mut verdict: Value = serde_json::from_str(&guard.verdict_template).ok()?;
substitute_strings(
&mut verdict,
&[("reason", decision.reason.as_deref().unwrap_or(""))],
);
serde_json::to_string(&verdict).ok()
}
pub(crate) fn hook_cleanup_dir(
guard: &GuardSection,
skills_dir_rel: Option<&str>,
stage_root: &Path,
) -> Option<PathBuf> {
let hook_file = match guard.engine {
GuardEngine::JsonHooks => guard.hooks_file.as_deref(),
GuardEngine::OpencodePlugin => guard.plugin_file.as_deref(),
}?;
let (parent, _) = hook_file.rsplit_once('/')?;
if let Some(skills) = skills_dir_rel {
let is_ancestor = skills == parent
|| skills
.strip_prefix(parent)
.is_some_and(|rest| rest.starts_with('/'));
if is_ancestor {
return None;
}
}
Some(resolve_rel(stage_root, parent))
}
pub(crate) fn any_string_value_contains(value: &Value, token: &str) -> bool {
match value {
Value::String(s) => s.contains(token),
Value::Array(items) => items.iter().any(|v| any_string_value_contains(v, token)),
Value::Object(map) => map.values().any(|v| any_string_value_contains(v, token)),
_ => false,
}
}
fn substitute_strings(value: &mut Value, vars: &[(&str, &str)]) {
match value {
Value::String(s) if s.contains('{') => {
*s = subst(s, vars);
}
Value::Array(items) => {
for item in items {
substitute_strings(item, vars);
}
}
Value::Object(map) => {
for item in map.values_mut() {
substitute_strings(item, vars);
}
}
_ => {}
}
}
fn resolve_rel(root: &Path, rel: &str) -> PathBuf {
rel.split('/')
.fold(root.to_path_buf(), |path, segment| path.join(segment))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::adapters::descriptor::{EMBEDDED_DESCRIPTORS, HarnessDescriptor, load_descriptor};
use crate::sandbox::guard_is_armed;
use crate::sandbox::install::{iso_millis, teardown_guard};
use chrono::DateTime;
use tempfile::TempDir;
struct Case {
_tmp: TempDir,
stage_root: PathBuf,
}
fn setup() -> Case {
let tmp = TempDir::new().unwrap();
let stage_root = tmp.path().join("stage");
fs::create_dir_all(&stage_root).unwrap();
Case {
_tmp: tmp,
stage_root,
}
}
fn descriptor(label: &str) -> HarnessDescriptor {
let (source, toml_src) = EMBEDDED_DESCRIPTORS
.iter()
.find(|(path, _)| path.ends_with(&format!("{label}.toml")))
.unwrap_or_else(|| panic!("no embedded descriptor for {label}"));
load_descriptor(toml_src, source).unwrap()
}
fn install(label: &str, stage_root: &Path) -> PathBuf {
let d = descriptor(label);
let skills = resolve_rel(stage_root, d.skills_dir.as_deref().unwrap());
install_guard(
d.guard.as_ref().unwrap(),
&skills,
stage_root,
Path::new("/g/eval-magic"),
None,
)
.unwrap()
}
fn verdict(label: &str, payload: &str, marker: Option<GuardMarker>) -> Option<String> {
let d = descriptor(label);
guard_verdict(d.guard.as_ref().unwrap(), payload, marker)
}
fn claude_skills_dir(stage_root: &Path) -> PathBuf {
stage_root.join(".claude").join("skills")
}
fn settings_path(stage_root: &Path) -> PathBuf {
stage_root.join(".claude").join("settings.local.json")
}
fn codex_hooks_path(stage_root: &Path) -> PathBuf {
stage_root.join(".codex").join("hooks.json")
}
fn read_json(path: &Path) -> Value {
serde_json::from_str(&fs::read_to_string(path).unwrap()).unwrap()
}
fn absolutize(p: &Path) -> PathBuf {
std::path::absolute(p).unwrap_or_else(|_| p.to_path_buf())
}
fn marker() -> GuardMarker {
GuardMarker {
active: Some(true),
allowed_roots: Some(vec!["/work/.eval-magic".to_string()]),
expires_at: None,
}
}
#[test]
fn install_writes_an_active_marker_hook_and_manifest() {
let c = setup();
install("claude-code", &c.stage_root);
let marker = read_json(&claude_skills_dir(&c.stage_root).join(GUARD_MARKER));
assert_eq!(marker["active"], json!(true));
let expires = marker["expiresAt"].as_str().unwrap();
let exp_ms = DateTime::parse_from_rfc3339(expires)
.unwrap()
.timestamp_millis();
assert!(exp_ms > now_ms());
let env = absolutize(&c.stage_root).display().to_string();
assert!(
marker["allowedRoots"]
.as_array()
.unwrap()
.iter()
.any(|r| r.as_str().unwrap() == env)
);
let settings = read_json(&settings_path(&c.stage_root));
let hook = &settings["hooks"]["PreToolUse"][0];
assert!(hook["matcher"].as_str().unwrap().contains("Write"));
assert!(
hook["hooks"][0]["command"]
.as_str()
.unwrap()
.contains("guard")
);
assert!(
claude_skills_dir(&c.stage_root)
.join(GUARD_MANIFEST)
.exists()
);
}
#[test]
fn marker_scopes_allowed_roots_to_the_env_and_temp_only() {
let c = setup();
install("claude-code", &c.stage_root);
let marker = read_json(&claude_skills_dir(&c.stage_root).join(GUARD_MARKER));
let roots: Vec<String> = marker["allowedRoots"]
.as_array()
.unwrap()
.iter()
.map(|r| r.as_str().unwrap().to_string())
.collect();
let env = absolutize(&c.stage_root).display().to_string();
let temp = absolutize(&std::env::temp_dir()).display().to_string();
assert_eq!(roots, vec![env, temp]);
assert!(
!roots.iter().any(|r| r.ends_with(".eval-magic")),
"workspace_root must not be an allowed root: {roots:?}"
);
}
#[test]
fn hook_command_invokes_the_binary_guard_subcommand() {
let c = setup();
let marker = install("claude-code", &c.stage_root);
let settings = read_json(&settings_path(&c.stage_root));
let command = settings["hooks"]["PreToolUse"][0]["hooks"][0]["command"]
.as_str()
.unwrap()
.to_string();
assert_eq!(
command,
format!("\"/g/eval-magic\" guard \"{}\"", marker.display())
);
}
#[test]
fn teardown_deletes_settings_it_created() {
let c = setup();
install("claude-code", &c.stage_root);
assert!(settings_path(&c.stage_root).exists());
assert!(teardown_guard(&c.stage_root));
assert!(!settings_path(&c.stage_root).exists());
assert!(!claude_skills_dir(&c.stage_root).join(GUARD_MARKER).exists());
assert!(
!claude_skills_dir(&c.stage_root)
.join(GUARD_MANIFEST)
.exists()
);
}
#[test]
fn teardown_restores_a_pre_existing_settings_verbatim() {
let c = setup();
fs::create_dir_all(c.stage_root.join(".claude")).unwrap();
let original = format!(
"{}\n",
serde_json::to_string_pretty(&json!({
"permissions": { "allow": ["Bash(ls)"] }
}))
.unwrap()
);
fs::write(settings_path(&c.stage_root), &original).unwrap();
install("claude-code", &c.stage_root);
assert!(
fs::read_to_string(settings_path(&c.stage_root))
.unwrap()
.contains("PreToolUse")
);
teardown_guard(&c.stage_root);
assert_eq!(
fs::read_to_string(settings_path(&c.stage_root)).unwrap(),
original
);
}
#[test]
fn guard_is_armed_ignores_missing_inactive_expired_and_malformed_markers() {
let c = setup();
let marker_path = claude_skills_dir(&c.stage_root).join(GUARD_MARKER);
fs::create_dir_all(claude_skills_dir(&c.stage_root)).unwrap();
assert!(!guard_is_armed(&c.stage_root));
fs::write(
&marker_path,
serde_json::to_string(&json!({ "active": false })).unwrap(),
)
.unwrap();
assert!(!guard_is_armed(&c.stage_root));
fs::write(
&marker_path,
serde_json::to_string(&json!({
"active": true,
"expiresAt": iso_millis(now_ms() - 60_000),
}))
.unwrap(),
)
.unwrap();
assert!(!guard_is_armed(&c.stage_root));
fs::write(&marker_path, "not json").unwrap();
assert!(!guard_is_armed(&c.stage_root));
}
#[test]
fn teardown_sweeps_a_stray_marker_even_without_a_manifest() {
let c = setup();
fs::create_dir_all(claude_skills_dir(&c.stage_root)).unwrap();
fs::write(claude_skills_dir(&c.stage_root).join(GUARD_MARKER), "{}").unwrap();
assert!(teardown_guard(&c.stage_root));
assert!(!claude_skills_dir(&c.stage_root).join(GUARD_MARKER).exists());
}
#[test]
fn allows_returns_none() {
let payload = r#"{ "tool_name": "Read", "tool_input": { "file_path": "/etc/passwd" } }"#;
assert_eq!(verdict("claude-code", payload, Some(marker())), None);
}
#[test]
fn deny_returns_pretooluse_deny_json() {
let payload = r#"{ "tool_name": "Write", "tool_input": { "file_path": "/etc/passwd" } }"#;
let out = verdict("claude-code", payload, Some(marker())).expect("should deny");
let v: Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["hookSpecificOutput"]["hookEventName"], "PreToolUse");
assert_eq!(v["hookSpecificOutput"]["permissionDecision"], "deny");
assert!(
v["hookSpecificOutput"]["permissionDecisionReason"]
.as_str()
.unwrap()
.contains("outside")
);
}
#[test]
fn deny_verdict_bytes_match_the_on_disk_contract() {
let payload = r#"{ "tool_name": "Write", "tool_input": { "file_path": "/etc/passwd" } }"#;
assert_eq!(
verdict("claude-code", payload, Some(marker())).expect("should deny"),
"{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\
\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\
\"eval guard: Write to /etc/passwd is outside the eval sandbox \
(allowed: /work/.eval-magic)\"}}"
);
let payload =
r#"{ "tool_name": "Bash", "tool_input": { "command": "npm install left-pad" } }"#;
assert_eq!(
verdict("codex", payload, Some(marker())).expect("should block"),
"{\"decision\":\"block\",\"reason\":\"eval guard: blocked Bash \
(package install/add) — runs outside the eval sandbox\"}"
);
}
#[test]
fn no_marker_allows_everything() {
let payload = r#"{ "tool_name": "Write", "tool_input": { "file_path": "/etc/passwd" } }"#;
assert_eq!(verdict("claude-code", payload, None), None);
}
#[test]
fn empty_or_malformed_payload_fails_open() {
assert_eq!(verdict("claude-code", "", Some(marker())), None);
assert_eq!(verdict("claude-code", "not json", Some(marker())), None);
}
#[test]
fn codex_install_writes_project_hook_marker_and_manifest() {
let c = setup();
install("codex", &c.stage_root);
let marker = read_json(
&c.stage_root
.join(".agents")
.join("skills")
.join(GUARD_MARKER),
);
assert_eq!(marker["active"], json!(true));
let env = absolutize(&c.stage_root).display().to_string();
assert!(
marker["allowedRoots"]
.as_array()
.unwrap()
.iter()
.any(|r| r.as_str().unwrap() == env)
);
let hooks = read_json(&codex_hooks_path(&c.stage_root));
let hook = &hooks["hooks"]["PreToolUse"][0];
assert!(hook["matcher"].as_str().unwrap().contains("apply_patch"));
assert!(
hook["hooks"][0]["command"]
.as_str()
.unwrap()
.contains("guard-codex")
);
assert!(
c.stage_root
.join(".agents")
.join("skills")
.join(GUARD_MANIFEST)
.exists()
);
}
#[test]
fn codex_teardown_restores_pre_existing_hooks_json_verbatim() {
let c = setup();
fs::create_dir_all(c.stage_root.join(".codex")).unwrap();
let original = format!(
"{}\n",
serde_json::to_string_pretty(&json!({
"hooks": {
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [{ "type": "command", "command": "echo ok" }]
}
]
}
}))
.unwrap()
);
fs::write(codex_hooks_path(&c.stage_root), &original).unwrap();
install("codex", &c.stage_root);
assert!(
fs::read_to_string(codex_hooks_path(&c.stage_root))
.unwrap()
.contains("guard-codex")
);
teardown_guard(&c.stage_root);
assert_eq!(
fs::read_to_string(codex_hooks_path(&c.stage_root)).unwrap(),
original
);
}
#[test]
fn codex_deny_returns_decision_block_json() {
let payload = r#"{ "hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": { "command": "npm install left-pad" } }"#;
let out = verdict("codex", payload, Some(marker())).expect("should block");
let v: Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["decision"], "block");
assert!(v["reason"].as_str().unwrap().contains("blocked Bash"));
}
#[test]
fn codex_apply_patch_outside_allowed_roots_blocks() {
let payload = r#"{ "hook_event_name": "PreToolUse", "tool_name": "apply_patch", "tool_input": { "files": ["/etc/passwd"] } }"#;
let out = verdict("codex", payload, Some(marker())).expect("should block");
let v: Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["decision"], "block");
assert!(v["reason"].as_str().unwrap().contains("apply_patch"));
}
#[test]
fn codex_apply_patch_inside_allowed_roots_allows() {
let payload = r#"{ "hook_event_name": "PreToolUse", "tool_name": "apply_patch", "tool_input": { "files": ["/work/.eval-magic/out.md"] } }"#;
assert_eq!(verdict("codex", payload, Some(marker())), None);
}
#[test]
fn hook_cleanup_dir_derivation_table() {
let root = Path::new("/env");
let with = |hooks_file: &str| {
let mut guard = descriptor("codex").guard.unwrap();
guard.hooks_file = Some(hooks_file.to_string());
guard
};
let with_plugin = |plugin_file: &str| {
let mut guard = descriptor("opencode").guard.unwrap();
guard.plugin_file = Some(plugin_file.to_string());
guard
};
assert_eq!(
hook_cleanup_dir(
&with(".claude/settings.local.json"),
Some(".claude/skills"),
root
),
None
);
assert_eq!(
hook_cleanup_dir(&with(".codex/hooks.json"), Some(".agents/skills"), root),
Some(PathBuf::from("/env/.codex"))
);
assert_eq!(
hook_cleanup_dir(&with("hooks.json"), Some(".agents/skills"), root),
None
);
assert_eq!(
hook_cleanup_dir(&with(".a/hooks.json"), Some(".a/b/skills"), root),
None
);
assert_eq!(
hook_cleanup_dir(&with(".a/hooks.json"), Some(".ab/skills"), root),
Some(PathBuf::from("/env/.a"))
);
assert_eq!(
hook_cleanup_dir(
&with_plugin(".opencode/plugins/slow-powers-eval-guard.js"),
Some(".opencode/skills"),
root
),
Some(PathBuf::from("/env/.opencode/plugins"))
);
assert_eq!(
hook_cleanup_dir(
&with_plugin(".opencode/guard.js"),
Some(".opencode/skills"),
root
),
None
);
assert_eq!(
hook_cleanup_dir(&with_plugin("guard.js"), Some(".opencode/skills"), root),
None
);
}
fn opencode_skills_dir(stage_root: &Path) -> PathBuf {
stage_root.join(".opencode").join("skills")
}
fn opencode_plugin_path(stage_root: &Path) -> PathBuf {
stage_root
.join(".opencode")
.join("plugins")
.join("slow-powers-eval-guard.js")
}
const EXPECTED_PLUGIN_TEMPLATE: &str = r#"// slow-powers eval write guard — staged by `eval-magic` into this env's
// project plugins; removed by `eval-magic teardown-guard` (or the next run).
// Do not edit: re-staging overwrites, and teardown restores the original.
//
// Dumb forwarder by design: every tool call goes to
// `eval-magic guard-hook --harness opencode <marker>` on stdin and the shared
// arbiter inside the binary classifies it. Empty stdout allows; non-empty
// stdout is the deny verdict JSON whose reason blocks the call.
import { spawnSync } from "node:child_process";
const EXE = {exe};
const MARKER = {marker};
export const SlowPowersEvalGuard = async () => {
return {
"tool.execute.before": async (input, output) => {
const payload = JSON.stringify({
tool_name: input.tool,
tool_input: output?.args ?? {},
});
const result = spawnSync(EXE, ["guard-hook", "--harness", "opencode", MARKER], {
input: payload,
encoding: "utf8",
timeout: 10000,
stdio: ["pipe", "pipe", "ignore"],
});
const stdout = (result.stdout ?? "").trim();
if (!stdout) {
return; // allow — also the fail-open path on spawn error or timeout
}
let reason = stdout;
try {
const verdict = JSON.parse(stdout);
if (typeof verdict?.reason === "string") {
reason = verdict.reason;
}
} catch {
// Not the verdict shape — surface the raw stdout as the reason.
}
throw new Error(reason);
},
};
};
"#;
fn expected_plugin(marker_path: &Path) -> String {
let exe = serde_json::to_string("/g/eval-magic").unwrap();
let marker = serde_json::to_string(&marker_path.display().to_string()).unwrap();
subst(
EXPECTED_PLUGIN_TEMPLATE,
&[("exe", &exe), ("marker", &marker)],
)
}
#[test]
fn opencode_install_stages_the_byte_exact_plugin_marker_and_manifest() {
let c = setup();
let marker_path = install("opencode", &c.stage_root);
let marker = read_json(&opencode_skills_dir(&c.stage_root).join(GUARD_MARKER));
assert_eq!(marker["active"], json!(true));
let env = absolutize(&c.stage_root).display().to_string();
assert!(
marker["allowedRoots"]
.as_array()
.unwrap()
.iter()
.any(|r| r.as_str().unwrap() == env)
);
let plugin = fs::read_to_string(opencode_plugin_path(&c.stage_root)).unwrap();
assert_eq!(plugin, expected_plugin(&marker_path));
assert!(plugin.contains("\"guard-hook\""), "{plugin}");
assert!(plugin.contains("\"opencode\""), "{plugin}");
assert!(
opencode_skills_dir(&c.stage_root)
.join(GUARD_MANIFEST)
.exists()
);
}
#[test]
fn opencode_teardown_removes_the_plugin_and_prunes_the_plugins_dir() {
let c = setup();
install("opencode", &c.stage_root);
assert!(opencode_plugin_path(&c.stage_root).exists());
assert!(teardown_guard(&c.stage_root));
assert!(!opencode_plugin_path(&c.stage_root).exists());
assert!(
!c.stage_root.join(".opencode").join("plugins").exists(),
"the dir created for the plugin alone is pruned"
);
assert!(
!opencode_skills_dir(&c.stage_root)
.join(GUARD_MARKER)
.exists()
);
assert!(
!opencode_skills_dir(&c.stage_root)
.join(GUARD_MANIFEST)
.exists()
);
}
#[test]
fn opencode_teardown_restores_a_pre_existing_plugin_verbatim() {
let c = setup();
fs::create_dir_all(c.stage_root.join(".opencode").join("plugins")).unwrap();
let original = "// the user's own plugin\nexport const Theirs = async () => ({});\n";
fs::write(opencode_plugin_path(&c.stage_root), original).unwrap();
install("opencode", &c.stage_root);
assert!(
fs::read_to_string(opencode_plugin_path(&c.stage_root))
.unwrap()
.contains("SlowPowersEvalGuard")
);
teardown_guard(&c.stage_root);
assert_eq!(
fs::read_to_string(opencode_plugin_path(&c.stage_root)).unwrap(),
original
);
}
#[test]
fn opencode_deny_verdict_bytes_match_the_on_disk_contract() {
let payload = r#"{ "tool_name": "write", "tool_input": { "filePath": "/etc/passwd" } }"#;
assert_eq!(
verdict("opencode", payload, Some(marker())).expect("should block"),
"{\"decision\":\"block\",\"reason\":\"eval guard: write to /etc/passwd is \
outside the eval sandbox (allowed: /work/.eval-magic)\"}"
);
}
#[test]
fn opencode_allows_an_in_bounds_write() {
let payload =
r#"{ "tool_name": "write", "tool_input": { "filePath": "/work/.eval-magic/out.md" } }"#;
assert_eq!(verdict("opencode", payload, Some(marker())), None);
}
}