Skip to main content

eval_magic/sandbox/
install.rs

1//! Arm / disarm the write guard.
2//!
3//! [`install_guard`] writes a marker listing the allowed roots and merges a
4//! `PreToolUse` hook into the target harness's project config. The original hook
5//! file is backed up verbatim in a manifest so [`teardown_guard`] restores it
6//! exactly.
7//!
8//! The hook command points at the running binary (`std::env::current_exe`), so
9//! there is no separate hook script to ship and no interpreter to select.
10
11use std::fs;
12use std::io;
13use std::path::{Path, PathBuf};
14use std::time::Duration;
15
16use chrono::{DateTime, SecondsFormat};
17use serde::{Deserialize, Serialize};
18use serde_json::{Value, json};
19
20use super::now_ms;
21use super::{guard::read_marker, marker_is_armed};
22
23/// Marker file (under the staged skills dir) that arms the guard.
24pub const GUARD_MARKER: &str = ".slow-powers-eval-guard.json";
25/// Manifest recording what install changed, so teardown can restore it.
26pub const GUARD_MANIFEST: &str = ".slow-powers-eval-guard-manifest.json";
27
28/// Default lifetime of an armed guard. Bounds how long a crashed run's hook can
29/// linger before it is treated as expired (see `super::decide`).
30const GUARD_TTL: Duration = Duration::from_secs(6 * 60 * 60); // 6h
31
32/// Tool names the Claude Code PreToolUse hook fires on.
33const CLAUDE_HOOK_MATCHER: &str = "Write|Edit|MultiEdit|NotebookEdit|Bash";
34/// Tool names the Codex PreToolUse hook fires on.
35const CODEX_HOOK_MATCHER: &str = "^Bash$|^apply_patch$|^Edit$|^Write$";
36
37/// Restoration record written beside the marker. The field names are the
38/// on-disk manifest format — keep them stable so older manifests stay readable.
39#[derive(Debug, Serialize, Deserialize)]
40struct GuardManifest {
41    created_at: String,
42    settings_path: String,
43    settings_existed: bool,
44    settings_backup: Option<String>,
45    marker_path: String,
46}
47
48/// Format epoch milliseconds as `2026-06-08T12:00:00.000Z` — RFC 3339 with
49/// millisecond precision, the timestamp format every artifact uses.
50fn iso_millis(ms: i64) -> String {
51    DateTime::from_timestamp_millis(ms)
52        .unwrap_or_default()
53        .to_rfc3339_opts(SecondsFormat::Millis, true)
54}
55
56/// Lexically absolutize a path (no disk access, no symlink resolution) for the
57/// allowed-roots list.
58fn absolutize(p: &Path) -> PathBuf {
59    std::path::absolute(p).unwrap_or_else(|_| p.to_path_buf())
60}
61
62/// Write `value` as 2-space-pretty JSON with a trailing newline — the stable
63/// on-disk format for every artifact this binary writes.
64fn write_json(path: &Path, value: &Value) -> io::Result<()> {
65    let mut text = serde_json::to_string_pretty(value)
66        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
67    text.push('\n');
68    fs::write(path, text)
69}
70
71/// The guard's allowed write roots: the isolated env (`stage_root`, the
72/// agent-under-test's cwd) and the OS temp dir. The staged skills dir
73/// (`stage_root/.claude/skills` or `.agents/skills`) and the per-task outputs dir
74/// both live *inside* `stage_root`, so a single env root covers every legitimate
75/// agent write. Scoping to the env — not the parent `.eval-magic/` — keeps the
76/// guard boundary identical to the isolation boundary: the agent can't reach a
77/// sibling iteration or the `iteration-N/` meta tree above its cwd. eval-magic's own
78/// above-env writes (e.g. `benchmark.json`) are not gated here: they run as
79/// non-mutating `eval-magic` subprocesses the guard's Bash classifier passes.
80fn marker_allowed_roots(stage_root: &Path) -> Vec<String> {
81    vec![
82        absolutize(stage_root).display().to_string(),
83        absolutize(&std::env::temp_dir()).display().to_string(),
84    ]
85}
86
87fn write_marker(marker_path: &Path, stage_root: &Path, ttl: Option<Duration>) -> io::Result<()> {
88    let expires_ms = now_ms() + ttl.unwrap_or(GUARD_TTL).as_millis() as i64;
89    write_json(
90        marker_path,
91        &json!({
92            "active": true,
93            "allowedRoots": marker_allowed_roots(stage_root),
94            "expiresAt": iso_millis(expires_ms),
95        }),
96    )
97}
98
99fn write_manifest(
100    manifest_path: &Path,
101    settings_path: &Path,
102    settings_existed: bool,
103    settings_backup: Option<String>,
104    marker_path: &Path,
105) -> io::Result<()> {
106    let manifest = GuardManifest {
107        created_at: iso_millis(now_ms()),
108        settings_path: settings_path.display().to_string(),
109        settings_existed,
110        settings_backup,
111        marker_path: marker_path.display().to_string(),
112    };
113    write_json(
114        manifest_path,
115        &serde_json::to_value(&manifest)
116            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?,
117    )
118}
119
120/// Arm the write guard for an eval run. Returns the marker path. The guard is a
121/// no-op until this marker exists and is unexpired, so the hook is inert outside
122/// an active run. `guard_exe` is the path the hook invokes (normally
123/// `std::env::current_exe()`); `ttl` overrides the default 6h lifetime.
124pub fn install_guard(
125    stage_root: &Path,
126    guard_exe: &Path,
127    ttl: Option<Duration>,
128) -> io::Result<PathBuf> {
129    install_claude_guard(stage_root, guard_exe, ttl)
130}
131
132pub(crate) fn install_claude_guard(
133    stage_root: &Path,
134    guard_exe: &Path,
135    ttl: Option<Duration>,
136) -> io::Result<PathBuf> {
137    let skills_dir = stage_root.join(".claude").join("skills");
138    fs::create_dir_all(&skills_dir)?;
139
140    let marker_path = skills_dir.join(GUARD_MARKER);
141    write_marker(&marker_path, stage_root, ttl)?;
142
143    let settings_path = stage_root.join(".claude").join("settings.local.json");
144    let settings_existed = settings_path.exists();
145    let backup = if settings_existed {
146        Some(fs::read_to_string(&settings_path)?)
147    } else {
148        None
149    };
150
151    // Start from the existing settings (or an empty object), preserving key
152    // order, then append the PreToolUse hook entry.
153    let mut settings: Value = backup
154        .as_deref()
155        .and_then(|s| serde_json::from_str(s).ok())
156        .unwrap_or_else(|| json!({}));
157    let hooks = settings
158        .as_object_mut()
159        .expect("settings is a JSON object")
160        .entry("hooks")
161        .or_insert_with(|| json!({}));
162    let pre = hooks
163        .as_object_mut()
164        .expect("hooks is a JSON object")
165        .entry("PreToolUse")
166        .or_insert_with(|| json!([]));
167    let command = format!(
168        "\"{}\" guard \"{}\"",
169        guard_exe.display(),
170        marker_path.display()
171    );
172    pre.as_array_mut()
173        .expect("PreToolUse is an array")
174        .push(json!({
175            "matcher": CLAUDE_HOOK_MATCHER,
176            "hooks": [ { "type": "command", "command": command } ],
177        }));
178    write_json(&settings_path, &settings)?;
179
180    write_manifest(
181        &skills_dir.join(GUARD_MANIFEST),
182        &settings_path,
183        settings_existed,
184        backup,
185        &marker_path,
186    )?;
187
188    Ok(marker_path)
189}
190
191pub(crate) fn install_codex_guard(
192    stage_root: &Path,
193    guard_exe: &Path,
194    ttl: Option<Duration>,
195) -> io::Result<PathBuf> {
196    let skills_dir = stage_root.join(".agents").join("skills");
197    fs::create_dir_all(&skills_dir)?;
198
199    let marker_path = skills_dir.join(GUARD_MARKER);
200    write_marker(&marker_path, stage_root, ttl)?;
201
202    let hooks_path = stage_root.join(".codex").join("hooks.json");
203    if let Some(parent) = hooks_path.parent() {
204        fs::create_dir_all(parent)?;
205    }
206    let hooks_existed = hooks_path.exists();
207    let backup = if hooks_existed {
208        Some(fs::read_to_string(&hooks_path)?)
209    } else {
210        None
211    };
212
213    let mut hooks: Value = backup
214        .as_deref()
215        .and_then(|s| serde_json::from_str(s).ok())
216        .unwrap_or_else(|| json!({}));
217    let hooks_obj = hooks
218        .as_object_mut()
219        .expect("hooks.json root is a JSON object")
220        .entry("hooks")
221        .or_insert_with(|| json!({}));
222    let pre = hooks_obj
223        .as_object_mut()
224        .expect("hooks is a JSON object")
225        .entry("PreToolUse")
226        .or_insert_with(|| json!([]));
227    let command = format!(
228        "\"{}\" guard-codex \"{}\"",
229        guard_exe.display(),
230        marker_path.display()
231    );
232    pre.as_array_mut()
233        .expect("PreToolUse is an array")
234        .push(json!({
235            "matcher": CODEX_HOOK_MATCHER,
236            "hooks": [
237                {
238                    "type": "command",
239                    "command": command,
240                    "timeout": 30,
241                    "statusMessage": "Checking eval write boundary",
242                }
243            ],
244        }));
245    write_json(&hooks_path, &hooks)?;
246
247    write_manifest(
248        &skills_dir.join(GUARD_MANIFEST),
249        &hooks_path,
250        hooks_existed,
251        backup,
252        &marker_path,
253    )?;
254
255    Ok(marker_path)
256}
257
258/// Disarm the guard: restore the original harness hook file (or delete it if we
259/// created it) and remove the marker + manifest. Safe to call when no guard is
260/// installed. Returns true if a guard was found and torn down.
261pub fn teardown_guard(stage_root: &Path) -> bool {
262    let torn_claude = teardown_guard_from_skills_dir(&stage_root.join(".claude").join("skills"));
263    let torn_codex = teardown_guard_from_skills_dir(&stage_root.join(".agents").join("skills"));
264    let _ = prune_if_empty(&stage_root.join(".codex"));
265    torn_claude || torn_codex
266}
267
268/// True when either harness has a live guard marker under `stage_root`.
269pub(crate) fn guard_is_armed(stage_root: &Path) -> bool {
270    let now = now_ms();
271    [
272        stage_root.join(".claude").join("skills").join(GUARD_MARKER),
273        stage_root.join(".agents").join("skills").join(GUARD_MARKER),
274    ]
275    .iter()
276    .any(|path| marker_is_armed(read_marker(path).as_ref(), now))
277}
278
279fn teardown_guard_from_skills_dir(skills_dir: &Path) -> bool {
280    let manifest_path = skills_dir.join(GUARD_MANIFEST);
281    let marker_path = skills_dir.join(GUARD_MARKER);
282
283    let Ok(manifest_text) = fs::read_to_string(&manifest_path) else {
284        // No manifest — still sweep a stray marker so the guard can't stay armed.
285        if marker_path.exists() {
286            let _ = fs::remove_file(&marker_path);
287            return true;
288        }
289        return false;
290    };
291
292    let Ok(manifest) = serde_json::from_str::<GuardManifest>(&manifest_text) else {
293        // Corrupt manifest: drop both files and report a teardown.
294        let _ = fs::remove_file(&manifest_path);
295        let _ = fs::remove_file(&marker_path);
296        return true;
297    };
298
299    match (manifest.settings_existed, &manifest.settings_backup) {
300        (true, Some(backup)) => {
301            let _ = fs::write(&manifest.settings_path, backup);
302        }
303        _ => {
304            let _ = fs::remove_file(&manifest.settings_path);
305        }
306    }
307    let _ = fs::remove_file(&manifest.marker_path);
308    let _ = fs::remove_file(&manifest_path);
309    true
310}
311
312fn prune_if_empty(dir: &Path) -> io::Result<()> {
313    if dir.exists() && fs::read_dir(dir)?.next().is_none() {
314        fs::remove_dir_all(dir)?;
315    }
316    Ok(())
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322    use tempfile::TempDir;
323
324    struct Case {
325        _tmp: TempDir,
326        stage_root: PathBuf,
327    }
328
329    fn setup() -> Case {
330        let tmp = TempDir::new().unwrap();
331        let stage_root = tmp.path().join("stage");
332        fs::create_dir_all(&stage_root).unwrap();
333        Case {
334            _tmp: tmp,
335            stage_root,
336        }
337    }
338
339    fn skills_dir(stage_root: &Path) -> PathBuf {
340        stage_root.join(".claude").join("skills")
341    }
342
343    fn settings_path(stage_root: &Path) -> PathBuf {
344        stage_root.join(".claude").join("settings.local.json")
345    }
346
347    fn codex_hooks_path(stage_root: &Path) -> PathBuf {
348        stage_root.join(".codex").join("hooks.json")
349    }
350
351    fn read_json(path: &Path) -> Value {
352        serde_json::from_str(&fs::read_to_string(path).unwrap()).unwrap()
353    }
354
355    #[test]
356    fn install_writes_an_active_marker_hook_and_manifest() {
357        let c = setup();
358        let exe = Path::new("/g/eval-magic");
359        install_guard(&c.stage_root, exe, None).unwrap();
360
361        let marker = read_json(&skills_dir(&c.stage_root).join(GUARD_MARKER));
362        assert_eq!(marker["active"], json!(true));
363        let expires = marker["expiresAt"].as_str().unwrap();
364        let exp_ms = DateTime::parse_from_rfc3339(expires)
365            .unwrap()
366            .timestamp_millis();
367        assert!(exp_ms > now_ms());
368        let env = absolutize(&c.stage_root).display().to_string();
369        assert!(
370            marker["allowedRoots"]
371                .as_array()
372                .unwrap()
373                .iter()
374                .any(|r| r.as_str().unwrap() == env)
375        );
376
377        let settings = read_json(&settings_path(&c.stage_root));
378        let hook = &settings["hooks"]["PreToolUse"][0];
379        assert!(hook["matcher"].as_str().unwrap().contains("Write"));
380        assert!(
381            hook["hooks"][0]["command"]
382                .as_str()
383                .unwrap()
384                .contains("guard")
385        );
386
387        assert!(skills_dir(&c.stage_root).join(GUARD_MANIFEST).exists());
388    }
389
390    #[test]
391    fn marker_scopes_allowed_roots_to_the_env_and_temp_only() {
392        let c = setup();
393        let exe = Path::new("/g/eval-magic");
394        install_guard(&c.stage_root, exe, None).unwrap();
395
396        let marker = read_json(&skills_dir(&c.stage_root).join(GUARD_MARKER));
397        let roots: Vec<String> = marker["allowedRoots"]
398            .as_array()
399            .unwrap()
400            .iter()
401            .map(|r| r.as_str().unwrap().to_string())
402            .collect();
403
404        // The guard boundary is the isolated env (stage_root) plus temp — nothing
405        // above it. The parent workspace tree must NOT be an allowed root, or the
406        // agent could write into sibling iterations / the meta dir above `env/`.
407        let env = absolutize(&c.stage_root).display().to_string();
408        let temp = absolutize(&std::env::temp_dir()).display().to_string();
409        assert_eq!(roots, vec![env, temp]);
410        assert!(
411            !roots.iter().any(|r| r.ends_with(".eval-magic")),
412            "workspace_root must not be an allowed root: {roots:?}"
413        );
414    }
415
416    #[test]
417    fn hook_command_invokes_the_binary_guard_subcommand() {
418        let c = setup();
419        let exe = Path::new("/g/eval-magic");
420        let marker = install_guard(&c.stage_root, exe, None).unwrap();
421        let settings = read_json(&settings_path(&c.stage_root));
422        let command = settings["hooks"]["PreToolUse"][0]["hooks"][0]["command"]
423            .as_str()
424            .unwrap()
425            .to_string();
426        assert_eq!(
427            command,
428            format!("\"/g/eval-magic\" guard \"{}\"", marker.display())
429        );
430    }
431
432    #[test]
433    fn teardown_deletes_settings_it_created() {
434        let c = setup();
435        let exe = Path::new("/g/eval-magic");
436        install_guard(&c.stage_root, exe, None).unwrap();
437        assert!(settings_path(&c.stage_root).exists());
438
439        assert!(teardown_guard(&c.stage_root));
440        assert!(!settings_path(&c.stage_root).exists());
441        assert!(!skills_dir(&c.stage_root).join(GUARD_MARKER).exists());
442        assert!(!skills_dir(&c.stage_root).join(GUARD_MANIFEST).exists());
443    }
444
445    #[test]
446    fn teardown_restores_a_pre_existing_settings_verbatim() {
447        let c = setup();
448        fs::create_dir_all(c.stage_root.join(".claude")).unwrap();
449        let original = format!(
450            "{}\n",
451            serde_json::to_string_pretty(&json!({
452                "permissions": { "allow": ["Bash(ls)"] }
453            }))
454            .unwrap()
455        );
456        fs::write(settings_path(&c.stage_root), &original).unwrap();
457
458        let exe = Path::new("/g/eval-magic");
459        install_guard(&c.stage_root, exe, None).unwrap();
460        // hook present while armed
461        assert!(
462            fs::read_to_string(settings_path(&c.stage_root))
463                .unwrap()
464                .contains("PreToolUse")
465        );
466
467        teardown_guard(&c.stage_root);
468        assert_eq!(
469            fs::read_to_string(settings_path(&c.stage_root)).unwrap(),
470            original
471        );
472    }
473
474    #[test]
475    fn teardown_is_a_safe_no_op_when_nothing_is_installed() {
476        let c = setup();
477        assert!(!teardown_guard(&c.stage_root));
478    }
479
480    #[test]
481    fn guard_is_armed_detects_claude_or_codex_marker() {
482        let c = setup();
483        install_guard(&c.stage_root, Path::new("/g/eval-magic"), None).unwrap();
484        assert!(guard_is_armed(&c.stage_root));
485        teardown_guard(&c.stage_root);
486        assert!(!guard_is_armed(&c.stage_root));
487
488        install_codex_guard(&c.stage_root, Path::new("/g/eval-magic"), None).unwrap();
489        assert!(guard_is_armed(&c.stage_root));
490    }
491
492    #[test]
493    fn guard_is_armed_ignores_missing_inactive_expired_and_malformed_markers() {
494        let c = setup();
495        let marker_path = skills_dir(&c.stage_root).join(GUARD_MARKER);
496        fs::create_dir_all(skills_dir(&c.stage_root)).unwrap();
497
498        assert!(!guard_is_armed(&c.stage_root));
499
500        fs::write(
501            &marker_path,
502            serde_json::to_string(&json!({ "active": false })).unwrap(),
503        )
504        .unwrap();
505        assert!(!guard_is_armed(&c.stage_root));
506
507        fs::write(
508            &marker_path,
509            serde_json::to_string(&json!({
510                "active": true,
511                "expiresAt": iso_millis(now_ms() - 60_000),
512            }))
513            .unwrap(),
514        )
515        .unwrap();
516        assert!(!guard_is_armed(&c.stage_root));
517
518        fs::write(&marker_path, "not json").unwrap();
519        assert!(!guard_is_armed(&c.stage_root));
520    }
521
522    #[test]
523    fn teardown_sweeps_a_stray_marker_even_without_a_manifest() {
524        let c = setup();
525        fs::create_dir_all(skills_dir(&c.stage_root)).unwrap();
526        fs::write(skills_dir(&c.stage_root).join(GUARD_MARKER), "{}").unwrap();
527        assert!(teardown_guard(&c.stage_root));
528        assert!(!skills_dir(&c.stage_root).join(GUARD_MARKER).exists());
529    }
530
531    #[test]
532    fn codex_install_writes_project_hook_marker_and_manifest() {
533        let c = setup();
534        let exe = Path::new("/g/eval-magic");
535        install_codex_guard(&c.stage_root, exe, None).unwrap();
536
537        let marker = read_json(
538            &c.stage_root
539                .join(".agents")
540                .join("skills")
541                .join(GUARD_MARKER),
542        );
543        assert_eq!(marker["active"], json!(true));
544        // The Codex guard shares the env-scoped roots: the staged `.agents/skills`
545        // dir lives inside `stage_root`, so the single env root already covers it.
546        let env = absolutize(&c.stage_root).display().to_string();
547        assert!(
548            marker["allowedRoots"]
549                .as_array()
550                .unwrap()
551                .iter()
552                .any(|r| r.as_str().unwrap() == env)
553        );
554
555        let hooks = read_json(&codex_hooks_path(&c.stage_root));
556        let hook = &hooks["hooks"]["PreToolUse"][0];
557        assert!(hook["matcher"].as_str().unwrap().contains("apply_patch"));
558        assert!(
559            hook["hooks"][0]["command"]
560                .as_str()
561                .unwrap()
562                .contains("guard-codex")
563        );
564        assert!(
565            c.stage_root
566                .join(".agents")
567                .join("skills")
568                .join(GUARD_MANIFEST)
569                .exists()
570        );
571    }
572
573    #[test]
574    fn codex_teardown_restores_pre_existing_hooks_json_verbatim() {
575        let c = setup();
576        fs::create_dir_all(c.stage_root.join(".codex")).unwrap();
577        let original = format!(
578            "{}\n",
579            serde_json::to_string_pretty(&json!({
580                "hooks": {
581                    "PostToolUse": [
582                        {
583                            "matcher": "Bash",
584                            "hooks": [{ "type": "command", "command": "echo ok" }]
585                        }
586                    ]
587                }
588            }))
589            .unwrap()
590        );
591        fs::write(codex_hooks_path(&c.stage_root), &original).unwrap();
592
593        install_codex_guard(&c.stage_root, Path::new("/g/eval-magic"), None).unwrap();
594        assert!(
595            fs::read_to_string(codex_hooks_path(&c.stage_root))
596                .unwrap()
597                .contains("guard-codex")
598        );
599
600        teardown_guard(&c.stage_root);
601        assert_eq!(
602            fs::read_to_string(codex_hooks_path(&c.stage_root)).unwrap(),
603            original
604        );
605    }
606}