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