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