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