Skip to main content

eval_magic/sandbox/
decide.rs

1//! The guard arbiter.
2//!
3//! [`decide`] is the single decision point the armed PreToolUse hook consults:
4//! given a tool call and the on-disk guard marker, it allows or denies. Writes
5//! outside every allowed root and un-scoped Bash mutations are denied; everything
6//! else — all read tools, and the orchestrator's own in-sandbox writes — is
7//! allowed. When the guard is not armed, every call is allowed.
8
9use chrono::DateTime;
10use serde::Deserialize;
11use serde_json::Value;
12
13use super::policy::{
14    apply_patch_paths, classify_bash, is_patch_tool, is_shell_tool, is_under_any, is_write_tool,
15    path_arg,
16};
17
18/// The staged marker file that arms the guard. The guard is a no-op unless this
19/// file exists, is active, and has not expired — so a crashed run that never tore
20/// the hook down can't silently block writes in the user's next interactive
21/// session.
22#[derive(Debug, Clone, Default, Deserialize)]
23#[serde(rename_all = "camelCase")]
24pub struct GuardMarker {
25    #[serde(default)]
26    pub active: Option<bool>,
27    #[serde(default)]
28    pub allowed_roots: Option<Vec<String>>,
29    #[serde(default)]
30    pub expires_at: Option<String>,
31}
32
33/// The outcome of [`decide`]: allow, or deny with a human-readable reason.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct GuardDecision {
36    pub allow: bool,
37    pub reason: Option<String>,
38}
39
40impl GuardDecision {
41    fn allow() -> Self {
42        Self {
43            allow: true,
44            reason: None,
45        }
46    }
47
48    fn deny(reason: String) -> Self {
49        Self {
50            allow: false,
51            reason: Some(reason),
52        }
53    }
54}
55
56/// True when the marker is active and unexpired at `now_ms` (epoch milliseconds).
57pub(crate) fn marker_is_armed(marker: Option<&GuardMarker>, now_ms: i64) -> bool {
58    let Some(marker) = marker else {
59        return false;
60    };
61    if marker.active != Some(true) {
62        return false;
63    }
64    if let Some(expires_at) = &marker.expires_at {
65        match DateTime::parse_from_rfc3339(expires_at) {
66            Ok(exp) if exp.timestamp_millis() <= now_ms => return false,
67            // An unparseable timestamp can't prove expiry; treat as unexpired,
68            // matching TS where `Date.parse` of a present-but-bad value is NaN
69            // and `NaN <= now` is false.
70            _ => {}
71        }
72    }
73    true
74}
75
76/// Decide whether a tool call should be allowed while the eval guard is armed.
77///
78/// `tool_input` is the harness-supplied argument object. `now_ms` is the current
79/// time in epoch milliseconds (parameterized for testability; callers pass the
80/// real clock).
81pub fn decide(
82    tool_name: &str,
83    tool_input: &Value,
84    marker: Option<&GuardMarker>,
85    now_ms: i64,
86) -> GuardDecision {
87    if !marker_is_armed(marker, now_ms) {
88        return GuardDecision::allow();
89    }
90    let roots = marker
91        .and_then(|m| m.allowed_roots.clone())
92        .unwrap_or_default();
93    let repo_root = std::env::current_dir().unwrap_or_default();
94
95    if is_write_tool(tool_name) {
96        if let Some(p) = path_arg(tool_input)
97            && !is_under_any(p, &roots, &repo_root)
98        {
99            return GuardDecision::deny(format!(
100                "eval guard: {tool_name} to {p} is outside the eval sandbox (allowed: {})",
101                roots.join(", ")
102            ));
103        }
104        return GuardDecision::allow();
105    }
106
107    if is_patch_tool(tool_name) {
108        let paths = apply_patch_paths(tool_input);
109        if paths.is_empty() {
110            return GuardDecision::deny(format!(
111                "eval guard: blocked {tool_name} because no patch target path could be determined"
112            ));
113        }
114        if let Some(path) = paths.iter().find(|p| !is_under_any(p, &roots, &repo_root)) {
115            return GuardDecision::deny(format!(
116                "eval guard: {tool_name} target {path} is outside the eval sandbox (allowed: {})",
117                roots.join(", ")
118            ));
119        }
120        return GuardDecision::allow();
121    }
122
123    if is_shell_tool(tool_name) {
124        let command = tool_input
125            .get("command")
126            .and_then(Value::as_str)
127            .unwrap_or("");
128        if let Some(reason) = classify_bash(command, &roots) {
129            return GuardDecision::deny(format!(
130                "eval guard: blocked {tool_name} ({reason}) — runs outside the eval sandbox"
131            ));
132        }
133    }
134
135    GuardDecision::allow()
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use crate::sandbox::now_ms;
142    use serde_json::json;
143
144    const ROOTS: [&str; 2] = ["/work/.eval-magic", "/work/.claude/skills"];
145
146    /// An RFC3339 timestamp `offset_ms` from now — `future`/`past` bracket the
147    /// current wall clock used by `decide`.
148    fn rfc3339(offset_ms: i64) -> String {
149        DateTime::from_timestamp_millis(now_ms() + offset_ms)
150            .unwrap()
151            .to_rfc3339()
152    }
153
154    fn future() -> String {
155        rfc3339(60_000)
156    }
157
158    fn past() -> String {
159        rfc3339(-60_000)
160    }
161
162    /// A live marker (active, unexpired, the standard roots), overridable per field.
163    fn marker() -> GuardMarker {
164        GuardMarker {
165            active: Some(true),
166            allowed_roots: Some(ROOTS.iter().map(|s| s.to_string()).collect()),
167            expires_at: Some(future()),
168        }
169    }
170
171    fn decide_now(tool: &str, input: Value, m: Option<&GuardMarker>) -> GuardDecision {
172        decide(tool, &input, m, now_ms())
173    }
174
175    #[test]
176    fn allows_everything_when_marker_is_null() {
177        let d = decide_now("Write", json!({ "file_path": "/etc/passwd" }), None);
178        assert!(d.allow);
179    }
180
181    #[test]
182    fn allows_everything_when_marker_is_inactive_or_expired() {
183        let inactive = GuardMarker {
184            active: Some(false),
185            ..marker()
186        };
187        assert!(
188            decide_now(
189                "Write",
190                json!({ "file_path": "/etc/passwd" }),
191                Some(&inactive)
192            )
193            .allow
194        );
195
196        let expired = GuardMarker {
197            expires_at: Some(past()),
198            ..marker()
199        };
200        assert!(
201            decide_now(
202                "Write",
203                json!({ "file_path": "/etc/passwd" }),
204                Some(&expired)
205            )
206            .allow
207        );
208    }
209
210    #[test]
211    fn allows_a_write_under_an_allowed_root() {
212        let d = decide_now(
213            "Write",
214            json!({ "file_path": "/work/.eval-magic/x/outputs/a.md" }),
215            Some(&marker()),
216        );
217        assert!(d.allow);
218    }
219
220    #[test]
221    fn denies_a_write_outside_all_allowed_roots() {
222        let d = decide_now(
223            "Edit",
224            json!({ "file_path": "/work/runner/run.ts" }),
225            Some(&marker()),
226        );
227        assert!(!d.allow);
228        assert!(d.reason.unwrap().to_lowercase().contains("outside"));
229    }
230
231    #[test]
232    fn denies_an_install_command() {
233        let d = decide_now(
234            "Bash",
235            json!({ "command": "npm install left-pad" }),
236            Some(&marker()),
237        );
238        assert!(!d.allow);
239        assert!(d.reason.unwrap().to_lowercase().contains("install"));
240    }
241
242    #[test]
243    fn allows_a_bash_command_scoped_to_an_allowed_root() {
244        let d = decide_now(
245            "Bash",
246            json!({ "command": "echo hi > /work/.eval-magic/x/outputs/log" }),
247            Some(&marker()),
248        );
249        assert!(d.allow);
250    }
251
252    #[test]
253    fn allows_non_mutating_bash_and_read_tools() {
254        assert!(decide_now("Bash", json!({ "command": "ls -la /" }), Some(&marker())).allow);
255        assert!(
256            decide_now(
257                "Read",
258                json!({ "file_path": "/etc/passwd" }),
259                Some(&marker())
260            )
261            .allow
262        );
263    }
264
265    #[test]
266    fn denies_git_worktree_add() {
267        let d = decide_now(
268            "Bash",
269            json!({ "command": "git worktree add ../wt -b scratch" }),
270            Some(&marker()),
271        );
272        assert!(!d.allow);
273        assert!(d.reason.unwrap().to_lowercase().contains("worktree"));
274    }
275
276    #[test]
277    fn denies_apply_patch_outside_allowed_roots() {
278        let d = decide_now(
279            "apply_patch",
280            json!({ "files": ["/work/runner/src/lib.rs"] }),
281            Some(&marker()),
282        );
283        assert!(!d.allow);
284        assert!(d.reason.unwrap().contains("apply_patch"));
285    }
286
287    #[test]
288    fn allows_apply_patch_inside_allowed_roots() {
289        let d = decide_now(
290            "apply_patch",
291            json!({ "files": ["/work/.eval-magic/eval/outputs/out.md"] }),
292            Some(&marker()),
293        );
294        assert!(d.allow);
295    }
296
297    #[test]
298    fn denies_apply_patch_without_a_known_target() {
299        let d = decide_now("apply_patch", json!({}), Some(&marker()));
300        assert!(!d.allow);
301        assert!(d.reason.unwrap().contains("no patch target"));
302    }
303
304    #[test]
305    fn denies_bash_that_creates_a_path_under_dot_claude_via_non_redirect_verb() {
306        assert!(
307            !decide_now(
308                "Bash",
309                json!({ "command": "mkdir -p .claude/foo" }),
310                Some(&marker())
311            )
312            .allow
313        );
314        assert!(
315            !decide_now(
316                "Bash",
317                json!({ "command": "cp out.txt .claude/bar" }),
318                Some(&marker())
319            )
320            .allow
321        );
322    }
323
324    #[test]
325    fn denies_bash_that_creates_a_bare_skills_dir() {
326        assert!(
327            !decide_now(
328                "Bash",
329                json!({ "command": "mkdir skills" }),
330                Some(&marker())
331            )
332            .allow
333        );
334        assert!(
335            !decide_now(
336                "Bash",
337                json!({ "command": "cp -r src ./skills" }),
338                Some(&marker())
339            )
340            .allow
341        );
342    }
343
344    #[test]
345    fn still_allows_reads_of_dot_claude_with_no_create_verb() {
346        assert!(
347            decide_now(
348                "Bash",
349                json!({ "command": "cat .claude/settings.json" }),
350                Some(&marker())
351            )
352            .allow
353        );
354        assert!(decide_now("Bash", json!({ "command": "ls .claude" }), Some(&marker())).allow);
355    }
356
357    #[test]
358    fn allows_a_create_scoped_to_the_dot_claude_skills_staging_root() {
359        let d = decide_now(
360            "Bash",
361            json!({ "command": "mkdir -p /work/.claude/skills/staged-x" }),
362            Some(&marker()),
363        );
364        assert!(d.allow);
365    }
366
367    #[test]
368    fn denies_bash_that_creates_a_path_under_dot_codex_via_non_redirect_verb() {
369        assert!(
370            !decide_now(
371                "Bash",
372                json!({ "command": "mkdir -p .codex/foo" }),
373                Some(&marker())
374            )
375            .allow
376        );
377        assert!(
378            !decide_now(
379                "Bash",
380                json!({ "command": "cp evil.json .codex/hooks.json" }),
381                Some(&marker())
382            )
383            .allow
384        );
385    }
386
387    #[test]
388    fn denies_bash_that_creates_a_path_under_dot_agents_via_non_redirect_verb() {
389        assert!(
390            !decide_now(
391                "Bash",
392                json!({ "command": "mkdir -p .agents/foo" }),
393                Some(&marker())
394            )
395            .allow
396        );
397    }
398
399    #[test]
400    fn denies_bash_that_creates_a_path_under_dot_opencode_via_non_redirect_verb() {
401        assert!(
402            !decide_now(
403                "Bash",
404                json!({ "command": "touch .opencode/opencode.json" }),
405                Some(&marker())
406            )
407            .allow
408        );
409    }
410
411    #[test]
412    fn still_allows_reads_of_other_harness_config_dirs_with_no_create_verb() {
413        for command in [
414            "cat .codex/hooks.json",
415            "ls .agents",
416            "cat .opencode/skills/x/SKILL.md",
417        ] {
418            assert!(
419                decide_now("Bash", json!({ "command": command }), Some(&marker())).allow,
420                "{command} should stay allowed"
421            );
422        }
423    }
424
425    #[test]
426    fn allows_a_create_scoped_to_a_codex_skills_staging_root() {
427        let codex_marker = GuardMarker {
428            allowed_roots: Some(vec![
429                "/work/.eval-magic".to_string(),
430                "/work/.agents/skills".to_string(),
431            ]),
432            ..marker()
433        };
434        let d = decide_now(
435            "Bash",
436            json!({ "command": "mkdir -p /work/.agents/skills/staged-x" }),
437            Some(&codex_marker),
438        );
439        assert!(d.allow);
440    }
441
442    #[test]
443    fn does_not_flag_a_skills_prefixed_dir_as_a_bare_skills_write() {
444        // A `skills`-prefixed path that is NOT an allowed root: the bare-`skills/`
445        // heuristic only fires on a bare `skills` at a path boundary, so a
446        // `skills-`-prefixed dir must not be flagged and the write is allowed.
447        let d = decide_now(
448            "Bash",
449            json!({ "command": "mkdir -p /work/skills-data/x/outputs" }),
450            Some(&marker()),
451        );
452        assert!(d.allow);
453    }
454}