Skip to main content

eval_magic/sandbox/
install.rs

1//! Shared write-guard arm/disarm machinery.
2//!
3//! Each harness's installer (in its adapter module, e.g.
4//! `crate::adapters::claude_code::guard`) writes a marker listing the allowed
5//! roots and merges a `PreToolUse` hook into that harness's project config,
6//! using the marker/manifest helpers here. The original hook file is backed up
7//! verbatim in a manifest so [`teardown_guard`] restores it exactly.
8//!
9//! The hook command points at the running binary (`std::env::current_exe`), so
10//! there is no separate hook script to ship and no interpreter to select.
11
12use std::fs;
13use std::io;
14use std::path::{Path, PathBuf};
15use std::time::Duration;
16
17use chrono::{DateTime, SecondsFormat};
18use serde::{Deserialize, Serialize};
19use serde_json::Value;
20use serde_json::json;
21
22use crate::core::Harness;
23
24use super::now_ms;
25use super::{guard::read_marker, marker_is_armed};
26
27/// Marker file (under the staged skills dir) that arms the guard.
28pub const GUARD_MARKER: &str = ".slow-powers-eval-guard.json";
29/// Manifest recording what install changed, so teardown can restore it.
30pub const GUARD_MANIFEST: &str = ".slow-powers-eval-guard-manifest.json";
31
32/// Default lifetime of an armed guard. Bounds how long a crashed run's hook can
33/// linger before it is treated as expired (see `super::decide`).
34const GUARD_TTL: Duration = Duration::from_secs(6 * 60 * 60); // 6h
35
36/// Restoration record written beside the marker. The field names are the
37/// on-disk manifest format — keep them stable so older manifests stay readable.
38#[derive(Debug, Serialize, Deserialize)]
39struct GuardManifest {
40    created_at: String,
41    settings_path: String,
42    settings_existed: bool,
43    settings_backup: Option<String>,
44    marker_path: String,
45}
46
47/// Format epoch milliseconds as `2026-06-08T12:00:00.000Z` — RFC 3339 with
48/// millisecond precision, the timestamp format every artifact uses.
49pub(crate) fn iso_millis(ms: i64) -> String {
50    DateTime::from_timestamp_millis(ms)
51        .unwrap_or_default()
52        .to_rfc3339_opts(SecondsFormat::Millis, true)
53}
54
55/// Lexically absolutize a path (no disk access, no symlink resolution) for the
56/// allowed-roots list.
57fn absolutize(p: &Path) -> PathBuf {
58    std::path::absolute(p).unwrap_or_else(|_| p.to_path_buf())
59}
60
61/// Write `value` as 2-space-pretty JSON with a trailing newline — the stable
62/// on-disk format for every artifact this binary writes.
63pub(crate) fn write_json(path: &Path, value: &Value) -> io::Result<()> {
64    let mut text = serde_json::to_string_pretty(value)
65        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
66    text.push('\n');
67    fs::write(path, text)
68}
69
70/// The guard's allowed write roots: the isolated env (`stage_root`, the
71/// agent-under-test's cwd) and the OS temp dir. The staged skills dir and the
72/// per-task outputs dir both live *inside* `stage_root`, so a single env root
73/// covers every legitimate agent write. Scoping to the env — not the parent
74/// `.eval-magic/` — keeps the guard boundary identical to the isolation
75/// boundary: the agent can't reach a sibling iteration or the `iteration-N/`
76/// meta tree above its cwd. eval-magic's own above-env writes (e.g.
77/// `benchmark.json`) are not gated here: they run as non-mutating `eval-magic`
78/// subprocesses the guard's Bash classifier passes.
79fn marker_allowed_roots(stage_root: &Path) -> Vec<String> {
80    vec![
81        absolutize(stage_root).display().to_string(),
82        absolutize(&std::env::temp_dir()).display().to_string(),
83    ]
84}
85
86/// Write the guard marker that arms the hook for `stage_root`. The guard is a
87/// no-op until this marker exists and is unexpired, so the hook is inert
88/// outside an active run. `ttl` overrides the default 6h lifetime.
89pub(crate) fn write_marker(
90    marker_path: &Path,
91    stage_root: &Path,
92    ttl: Option<Duration>,
93) -> io::Result<()> {
94    let expires_ms = now_ms() + ttl.unwrap_or(GUARD_TTL).as_millis() as i64;
95    write_json(
96        marker_path,
97        &json!({
98            "active": true,
99            "allowedRoots": marker_allowed_roots(stage_root),
100            "expiresAt": iso_millis(expires_ms),
101        }),
102    )
103}
104
105/// Write the restoration manifest recording what install changed.
106pub(crate) fn write_manifest(
107    manifest_path: &Path,
108    settings_path: &Path,
109    settings_existed: bool,
110    settings_backup: Option<String>,
111    marker_path: &Path,
112) -> io::Result<()> {
113    let manifest = GuardManifest {
114        created_at: iso_millis(now_ms()),
115        settings_path: settings_path.display().to_string(),
116        settings_existed,
117        settings_backup,
118        marker_path: marker_path.display().to_string(),
119    };
120    write_json(
121        manifest_path,
122        &serde_json::to_value(&manifest)
123            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?,
124    )
125}
126
127/// Disarm the guard: restore the original harness hook file (or delete it if we
128/// created it) and remove the marker + manifest, for every harness's skills
129/// dir. Safe to call when no guard is installed. Returns true if a guard was
130/// found and torn down.
131pub fn teardown_guard(stage_root: &Path) -> bool {
132    let mut torn = false;
133    for harness in Harness::known() {
134        let adapter = crate::adapters::adapter_for(harness);
135        // A harness without a skills dir cannot host a guard (descriptor
136        // validation rejects the combination), so there is nothing to sweep.
137        if let Some(skills_dir) = adapter.skills_dir(stage_root) {
138            torn |= teardown_guard_from_skills_dir(&skills_dir);
139        }
140        if let Some(hook_dir) = adapter.guard_hook_cleanup_dir(stage_root) {
141            let _ = prune_if_empty(&hook_dir);
142        }
143    }
144    torn
145}
146
147/// True when any harness has a live guard marker under `stage_root`.
148pub(crate) fn guard_is_armed(stage_root: &Path) -> bool {
149    let now = now_ms();
150    Harness::known().any(|harness| {
151        crate::adapters::adapter_for(harness)
152            .skills_dir(stage_root)
153            .is_some_and(|skills_dir| {
154                let marker_path = skills_dir.join(GUARD_MARKER);
155                marker_is_armed(read_marker(&marker_path).as_ref(), now)
156            })
157    })
158}
159
160fn teardown_guard_from_skills_dir(skills_dir: &Path) -> bool {
161    let manifest_path = skills_dir.join(GUARD_MANIFEST);
162    let marker_path = skills_dir.join(GUARD_MARKER);
163
164    let Ok(manifest_text) = fs::read_to_string(&manifest_path) else {
165        // No manifest — still sweep a stray marker so the guard can't stay armed.
166        if marker_path.exists() {
167            let _ = fs::remove_file(&marker_path);
168            return true;
169        }
170        return false;
171    };
172
173    let Ok(manifest) = serde_json::from_str::<GuardManifest>(&manifest_text) else {
174        // Corrupt manifest: drop both files and report a teardown.
175        let _ = fs::remove_file(&manifest_path);
176        let _ = fs::remove_file(&marker_path);
177        return true;
178    };
179
180    match (manifest.settings_existed, &manifest.settings_backup) {
181        (true, Some(backup)) => {
182            let _ = fs::write(&manifest.settings_path, backup);
183        }
184        _ => {
185            let _ = fs::remove_file(&manifest.settings_path);
186        }
187    }
188    let _ = fs::remove_file(&manifest.marker_path);
189    let _ = fs::remove_file(&manifest_path);
190    true
191}
192
193fn prune_if_empty(dir: &Path) -> io::Result<()> {
194    if dir.exists() && fs::read_dir(dir)?.next().is_none() {
195        fs::remove_dir_all(dir)?;
196    }
197    Ok(())
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203    use tempfile::TempDir;
204
205    struct Case {
206        _tmp: TempDir,
207        stage_root: PathBuf,
208    }
209
210    fn setup() -> Case {
211        let tmp = TempDir::new().unwrap();
212        let stage_root = tmp.path().join("stage");
213        fs::create_dir_all(&stage_root).unwrap();
214        Case {
215            _tmp: tmp,
216            stage_root,
217        }
218    }
219
220    #[test]
221    fn teardown_is_a_safe_no_op_when_nothing_is_installed() {
222        let c = setup();
223        assert!(!teardown_guard(&c.stage_root));
224    }
225
226    #[test]
227    fn teardown_sweeps_stray_marker_without_manifest() {
228        let c = setup();
229        // A stray marker (no manifest) in any harness's skills dir must still
230        // be swept so the guard can't stay armed.
231        for skills_dir in [
232            c.stage_root.join(".claude").join("skills"),
233            c.stage_root.join(".opencode").join("skills"),
234        ] {
235            fs::create_dir_all(&skills_dir).unwrap();
236            let marker = skills_dir.join(GUARD_MARKER);
237            fs::write(&marker, "{}").unwrap();
238            assert!(teardown_guard(&c.stage_root));
239            assert!(!marker.exists(), "stray marker at {marker:?} was not swept");
240        }
241    }
242
243    /// Byte-pin of a fresh Claude hook file: armed envs and their backups are an
244    /// on-disk compatibility surface, so the merged settings must keep this exact
245    /// 2-space-pretty shape, key order, and trailing newline.
246    #[test]
247    fn claude_install_writes_this_exact_hook_file() {
248        let c = setup();
249        let adapter = crate::adapters::adapter_for(Harness::resolve("claude-code").unwrap());
250        let marker = adapter
251            .install_guard(&c.stage_root, Path::new("/g/eval-magic"), None)
252            .unwrap();
253
254        let settings =
255            fs::read_to_string(c.stage_root.join(".claude").join("settings.local.json")).unwrap();
256        let expected = format!(
257            r#"{{
258  "hooks": {{
259    "PreToolUse": [
260      {{
261        "matcher": "Write|Edit|MultiEdit|NotebookEdit|Bash",
262        "hooks": [
263          {{
264            "type": "command",
265            "command": "\"/g/eval-magic\" guard \"{marker}\""
266          }}
267        ]
268      }}
269    ]
270  }}
271}}
272"#,
273            marker = marker.display()
274        );
275        assert_eq!(settings, expected);
276    }
277
278    /// Byte-pin of a fresh Codex hook file — same compatibility contract as the
279    /// Claude pin, plus the Codex-only `timeout`/`statusMessage` keys.
280    #[test]
281    fn codex_install_writes_this_exact_hook_file() {
282        let c = setup();
283        let adapter = crate::adapters::adapter_for(Harness::resolve("codex").unwrap());
284        let marker = adapter
285            .install_guard(&c.stage_root, Path::new("/g/eval-magic"), None)
286            .unwrap();
287
288        let hooks = fs::read_to_string(c.stage_root.join(".codex").join("hooks.json")).unwrap();
289        let expected = format!(
290            r#"{{
291  "hooks": {{
292    "PreToolUse": [
293      {{
294        "matcher": "^Bash$|^apply_patch$|^Edit$|^Write$",
295        "hooks": [
296          {{
297            "type": "command",
298            "command": "\"/g/eval-magic\" guard-codex \"{marker}\"",
299            "timeout": 30,
300            "statusMessage": "Checking eval write boundary"
301          }}
302        ]
303      }}
304    ]
305  }}
306}}
307"#,
308            marker = marker.display()
309        );
310        assert_eq!(hooks, expected);
311    }
312
313    #[test]
314    fn guard_is_armed_detects_claude_or_codex_marker() {
315        let c = setup();
316        let exe = Path::new("/g/eval-magic");
317        let claude = crate::adapters::adapter_for(Harness::resolve("claude-code").unwrap());
318        claude.install_guard(&c.stage_root, exe, None).unwrap();
319        assert!(guard_is_armed(&c.stage_root));
320        teardown_guard(&c.stage_root);
321        assert!(!guard_is_armed(&c.stage_root));
322
323        let codex = crate::adapters::adapter_for(Harness::resolve("codex").unwrap());
324        codex.install_guard(&c.stage_root, exe, None).unwrap();
325        assert!(guard_is_armed(&c.stage_root));
326    }
327}