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
71fn marker_allowed_roots(workspace_root: &Path, skills_dir: &Path) -> Vec<String> {
72    vec![
73        absolutize(workspace_root).display().to_string(),
74        absolutize(skills_dir).display().to_string(),
75        absolutize(&std::env::temp_dir()).display().to_string(),
76    ]
77}
78
79fn write_marker(
80    marker_path: &Path,
81    workspace_root: &Path,
82    skills_dir: &Path,
83    ttl: Option<Duration>,
84) -> io::Result<()> {
85    let expires_ms = now_ms() + ttl.unwrap_or(GUARD_TTL).as_millis() as i64;
86    write_json(
87        marker_path,
88        &json!({
89            "active": true,
90            "allowedRoots": marker_allowed_roots(workspace_root, skills_dir),
91            "expiresAt": iso_millis(expires_ms),
92        }),
93    )
94}
95
96fn write_manifest(
97    manifest_path: &Path,
98    settings_path: &Path,
99    settings_existed: bool,
100    settings_backup: Option<String>,
101    marker_path: &Path,
102) -> io::Result<()> {
103    let manifest = GuardManifest {
104        created_at: iso_millis(now_ms()),
105        settings_path: settings_path.display().to_string(),
106        settings_existed,
107        settings_backup,
108        marker_path: marker_path.display().to_string(),
109    };
110    write_json(
111        manifest_path,
112        &serde_json::to_value(&manifest)
113            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?,
114    )
115}
116
117/// Arm the write guard for an eval run. Returns the marker path. The guard is a
118/// no-op until this marker exists and is unexpired, so the hook is inert outside
119/// an active run. `guard_exe` is the path the hook invokes (normally
120/// `std::env::current_exe()`); `ttl` overrides the default 6h lifetime.
121pub fn install_guard(
122    stage_root: &Path,
123    workspace_root: &Path,
124    guard_exe: &Path,
125    ttl: Option<Duration>,
126) -> io::Result<PathBuf> {
127    install_claude_guard(stage_root, workspace_root, guard_exe, ttl)
128}
129
130pub(crate) fn install_claude_guard(
131    stage_root: &Path,
132    workspace_root: &Path,
133    guard_exe: &Path,
134    ttl: Option<Duration>,
135) -> io::Result<PathBuf> {
136    let skills_dir = stage_root.join(".claude").join("skills");
137    fs::create_dir_all(&skills_dir)?;
138
139    let marker_path = skills_dir.join(GUARD_MARKER);
140    write_marker(&marker_path, workspace_root, &skills_dir, ttl)?;
141
142    let settings_path = stage_root.join(".claude").join("settings.local.json");
143    let settings_existed = settings_path.exists();
144    let backup = if settings_existed {
145        Some(fs::read_to_string(&settings_path)?)
146    } else {
147        None
148    };
149
150    // Start from the existing settings (or an empty object), preserving key
151    // order, then append the PreToolUse hook entry.
152    let mut settings: Value = backup
153        .as_deref()
154        .and_then(|s| serde_json::from_str(s).ok())
155        .unwrap_or_else(|| json!({}));
156    let hooks = settings
157        .as_object_mut()
158        .expect("settings is a JSON object")
159        .entry("hooks")
160        .or_insert_with(|| json!({}));
161    let pre = hooks
162        .as_object_mut()
163        .expect("hooks is a JSON object")
164        .entry("PreToolUse")
165        .or_insert_with(|| json!([]));
166    let command = format!(
167        "\"{}\" guard \"{}\"",
168        guard_exe.display(),
169        marker_path.display()
170    );
171    pre.as_array_mut()
172        .expect("PreToolUse is an array")
173        .push(json!({
174            "matcher": CLAUDE_HOOK_MATCHER,
175            "hooks": [ { "type": "command", "command": command } ],
176        }));
177    write_json(&settings_path, &settings)?;
178
179    write_manifest(
180        &skills_dir.join(GUARD_MANIFEST),
181        &settings_path,
182        settings_existed,
183        backup,
184        &marker_path,
185    )?;
186
187    Ok(marker_path)
188}
189
190pub(crate) fn install_codex_guard(
191    stage_root: &Path,
192    workspace_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, workspace_root, &skills_dir, 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        workspace_root: PathBuf,
328    }
329
330    fn setup() -> Case {
331        let tmp = TempDir::new().unwrap();
332        let stage_root = tmp.path().join("stage");
333        fs::create_dir_all(&stage_root).unwrap();
334        let workspace_root = stage_root.join("skills-workspace");
335        Case {
336            _tmp: tmp,
337            stage_root,
338            workspace_root,
339        }
340    }
341
342    fn skills_dir(stage_root: &Path) -> PathBuf {
343        stage_root.join(".claude").join("skills")
344    }
345
346    fn settings_path(stage_root: &Path) -> PathBuf {
347        stage_root.join(".claude").join("settings.local.json")
348    }
349
350    fn codex_hooks_path(stage_root: &Path) -> PathBuf {
351        stage_root.join(".codex").join("hooks.json")
352    }
353
354    fn read_json(path: &Path) -> Value {
355        serde_json::from_str(&fs::read_to_string(path).unwrap()).unwrap()
356    }
357
358    #[test]
359    fn install_writes_an_active_marker_hook_and_manifest() {
360        let c = setup();
361        let exe = Path::new("/g/eval-magic");
362        install_guard(&c.stage_root, &c.workspace_root, exe, None).unwrap();
363
364        let marker = read_json(&skills_dir(&c.stage_root).join(GUARD_MARKER));
365        assert_eq!(marker["active"], json!(true));
366        let expires = marker["expiresAt"].as_str().unwrap();
367        let exp_ms = DateTime::parse_from_rfc3339(expires)
368            .unwrap()
369            .timestamp_millis();
370        assert!(exp_ms > now_ms());
371        assert!(
372            marker["allowedRoots"]
373                .as_array()
374                .unwrap()
375                .iter()
376                .any(|r| r.as_str().unwrap().contains("skills-workspace"))
377        );
378
379        let settings = read_json(&settings_path(&c.stage_root));
380        let hook = &settings["hooks"]["PreToolUse"][0];
381        assert!(hook["matcher"].as_str().unwrap().contains("Write"));
382        assert!(
383            hook["hooks"][0]["command"]
384                .as_str()
385                .unwrap()
386                .contains("guard")
387        );
388
389        assert!(skills_dir(&c.stage_root).join(GUARD_MANIFEST).exists());
390    }
391
392    #[test]
393    fn hook_command_invokes_the_binary_guard_subcommand() {
394        let c = setup();
395        let exe = Path::new("/g/eval-magic");
396        let marker = install_guard(&c.stage_root, &c.workspace_root, exe, None).unwrap();
397        let settings = read_json(&settings_path(&c.stage_root));
398        let command = settings["hooks"]["PreToolUse"][0]["hooks"][0]["command"]
399            .as_str()
400            .unwrap()
401            .to_string();
402        assert_eq!(
403            command,
404            format!("\"/g/eval-magic\" guard \"{}\"", marker.display())
405        );
406    }
407
408    #[test]
409    fn teardown_deletes_settings_it_created() {
410        let c = setup();
411        let exe = Path::new("/g/eval-magic");
412        install_guard(&c.stage_root, &c.workspace_root, exe, None).unwrap();
413        assert!(settings_path(&c.stage_root).exists());
414
415        assert!(teardown_guard(&c.stage_root));
416        assert!(!settings_path(&c.stage_root).exists());
417        assert!(!skills_dir(&c.stage_root).join(GUARD_MARKER).exists());
418        assert!(!skills_dir(&c.stage_root).join(GUARD_MANIFEST).exists());
419    }
420
421    #[test]
422    fn teardown_restores_a_pre_existing_settings_verbatim() {
423        let c = setup();
424        fs::create_dir_all(c.stage_root.join(".claude")).unwrap();
425        let original = format!(
426            "{}\n",
427            serde_json::to_string_pretty(&json!({
428                "permissions": { "allow": ["Bash(ls)"] }
429            }))
430            .unwrap()
431        );
432        fs::write(settings_path(&c.stage_root), &original).unwrap();
433
434        let exe = Path::new("/g/eval-magic");
435        install_guard(&c.stage_root, &c.workspace_root, exe, None).unwrap();
436        // hook present while armed
437        assert!(
438            fs::read_to_string(settings_path(&c.stage_root))
439                .unwrap()
440                .contains("PreToolUse")
441        );
442
443        teardown_guard(&c.stage_root);
444        assert_eq!(
445            fs::read_to_string(settings_path(&c.stage_root)).unwrap(),
446            original
447        );
448    }
449
450    #[test]
451    fn teardown_is_a_safe_no_op_when_nothing_is_installed() {
452        let c = setup();
453        assert!(!teardown_guard(&c.stage_root));
454    }
455
456    #[test]
457    fn guard_is_armed_detects_claude_or_codex_marker() {
458        let c = setup();
459        install_guard(
460            &c.stage_root,
461            &c.workspace_root,
462            Path::new("/g/eval-magic"),
463            None,
464        )
465        .unwrap();
466        assert!(guard_is_armed(&c.stage_root));
467        teardown_guard(&c.stage_root);
468        assert!(!guard_is_armed(&c.stage_root));
469
470        install_codex_guard(
471            &c.stage_root,
472            &c.workspace_root,
473            Path::new("/g/eval-magic"),
474            None,
475        )
476        .unwrap();
477        assert!(guard_is_armed(&c.stage_root));
478    }
479
480    #[test]
481    fn guard_is_armed_ignores_missing_inactive_expired_and_malformed_markers() {
482        let c = setup();
483        let marker_path = skills_dir(&c.stage_root).join(GUARD_MARKER);
484        fs::create_dir_all(skills_dir(&c.stage_root)).unwrap();
485
486        assert!(!guard_is_armed(&c.stage_root));
487
488        fs::write(
489            &marker_path,
490            serde_json::to_string(&json!({ "active": false })).unwrap(),
491        )
492        .unwrap();
493        assert!(!guard_is_armed(&c.stage_root));
494
495        fs::write(
496            &marker_path,
497            serde_json::to_string(&json!({
498                "active": true,
499                "expiresAt": iso_millis(now_ms() - 60_000),
500            }))
501            .unwrap(),
502        )
503        .unwrap();
504        assert!(!guard_is_armed(&c.stage_root));
505
506        fs::write(&marker_path, "not json").unwrap();
507        assert!(!guard_is_armed(&c.stage_root));
508    }
509
510    #[test]
511    fn teardown_sweeps_a_stray_marker_even_without_a_manifest() {
512        let c = setup();
513        fs::create_dir_all(skills_dir(&c.stage_root)).unwrap();
514        fs::write(skills_dir(&c.stage_root).join(GUARD_MARKER), "{}").unwrap();
515        assert!(teardown_guard(&c.stage_root));
516        assert!(!skills_dir(&c.stage_root).join(GUARD_MARKER).exists());
517    }
518
519    #[test]
520    fn codex_install_writes_project_hook_marker_and_manifest() {
521        let c = setup();
522        let exe = Path::new("/g/eval-magic");
523        install_codex_guard(&c.stage_root, &c.workspace_root, exe, None).unwrap();
524
525        let marker = read_json(
526            &c.stage_root
527                .join(".agents")
528                .join("skills")
529                .join(GUARD_MARKER),
530        );
531        assert_eq!(marker["active"], json!(true));
532        assert!(
533            marker["allowedRoots"]
534                .as_array()
535                .unwrap()
536                .iter()
537                .any(|r| r.as_str().unwrap().contains(".agents/skills"))
538        );
539
540        let hooks = read_json(&codex_hooks_path(&c.stage_root));
541        let hook = &hooks["hooks"]["PreToolUse"][0];
542        assert!(hook["matcher"].as_str().unwrap().contains("apply_patch"));
543        assert!(
544            hook["hooks"][0]["command"]
545                .as_str()
546                .unwrap()
547                .contains("guard-codex")
548        );
549        assert!(
550            c.stage_root
551                .join(".agents")
552                .join("skills")
553                .join(GUARD_MANIFEST)
554                .exists()
555        );
556    }
557
558    #[test]
559    fn codex_teardown_restores_pre_existing_hooks_json_verbatim() {
560        let c = setup();
561        fs::create_dir_all(c.stage_root.join(".codex")).unwrap();
562        let original = format!(
563            "{}\n",
564            serde_json::to_string_pretty(&json!({
565                "hooks": {
566                    "PostToolUse": [
567                        {
568                            "matcher": "Bash",
569                            "hooks": [{ "type": "command", "command": "echo ok" }]
570                        }
571                    ]
572                }
573            }))
574            .unwrap()
575        );
576        fs::write(codex_hooks_path(&c.stage_root), &original).unwrap();
577
578        install_codex_guard(
579            &c.stage_root,
580            &c.workspace_root,
581            Path::new("/g/eval-magic"),
582            None,
583        )
584        .unwrap();
585        assert!(
586            fs::read_to_string(codex_hooks_path(&c.stage_root))
587                .unwrap()
588                .contains("guard-codex")
589        );
590
591        teardown_guard(&c.stage_root);
592        assert_eq!(
593            fs::read_to_string(codex_hooks_path(&c.stage_root)).unwrap(),
594            original
595        );
596    }
597}