Skip to main content

eval_magic/sandbox/
guard.rs

1//! Guard hook evaluation.
2//!
3//! Library logic behind the binary's hidden `guard` subcommand: the CLI handler
4//! reads the hook payload from stdin and the marker path from argv, calls
5//! [`guard_decision`], and writes any deny verdict to stdout. Both layers fail
6//! open — a malformed payload or unreadable marker yields "allow", so the guard
7//! can never brick a session.
8
9use std::path::Path;
10
11use serde_json::{Map, Value, json};
12
13use super::decide::{GuardMarker, decide};
14use super::now_ms;
15
16/// Read and parse the guard marker. Missing or unparseable → `None` (the guard
17/// then allows everything — fail open).
18pub fn read_marker(path: &Path) -> Option<GuardMarker> {
19    let text = std::fs::read_to_string(path).ok()?;
20    serde_json::from_str(&text).ok()
21}
22
23/// Evaluate a PreToolUse hook `payload` (the JSON the harness sends on stdin)
24/// against `marker`. Returns the serialized deny verdict to print on stdout when
25/// the call is blocked, or `None` to allow (print nothing). An empty or malformed
26/// payload is treated as allow.
27pub fn guard_decision(payload: &str, marker: Option<GuardMarker>) -> Option<String> {
28    let (tool_name, tool_input) = parse_tool_call(payload)?;
29
30    let decision = decide(&tool_name, &tool_input, marker.as_ref(), now_ms());
31    if decision.allow {
32        return None;
33    }
34    Some(
35        serde_json::to_string(&json!({
36            "hookSpecificOutput": {
37                "hookEventName": "PreToolUse",
38                "permissionDecision": "deny",
39                "permissionDecisionReason": decision.reason,
40            }
41        }))
42        .expect("deny verdict serializes"),
43    )
44}
45
46/// Codex's hook contract blocks by returning `{ "decision": "block", "reason":
47/// "..." }` on stdout. Keep this separate from Claude Code's
48/// `hookSpecificOutput` shape so both harnesses use their native conventions.
49pub fn codex_guard_decision(payload: &str, marker: Option<GuardMarker>) -> Option<String> {
50    let (tool_name, tool_input) = parse_tool_call(payload)?;
51    let decision = decide(&tool_name, &tool_input, marker.as_ref(), now_ms());
52    if decision.allow {
53        return None;
54    }
55    Some(
56        serde_json::to_string(&json!({
57            "decision": "block",
58            "reason": decision.reason,
59        }))
60        .expect("Codex block verdict serializes"),
61    )
62}
63
64fn parse_tool_call(payload: &str) -> Option<(String, Value)> {
65    let trimmed = payload.trim();
66    let parsed: Value =
67        serde_json::from_str(if trimmed.is_empty() { "{}" } else { trimmed }).ok()?;
68
69    let tool_name = parsed
70        .get("tool_name")
71        .and_then(Value::as_str)
72        .or_else(|| {
73            parsed
74                .get("tool")
75                .and_then(Value::as_object)
76                .and_then(|tool| tool.get("name"))
77                .and_then(Value::as_str)
78        })
79        .unwrap_or("")
80        .to_string();
81
82    let tool_input = merge_top_level_files(
83        parsed
84            .get("tool_input")
85            .or_else(|| parsed.get("input"))
86            .cloned()
87            .unwrap_or(Value::Null),
88        &parsed,
89    );
90
91    Some((tool_name, tool_input))
92}
93
94fn merge_top_level_files(input: Value, parsed: &Value) -> Value {
95    let Some(files) = parsed.get("files") else {
96        return input;
97    };
98
99    match input {
100        Value::Object(mut obj) => {
101            obj.entry("files").or_insert_with(|| files.clone());
102            Value::Object(obj)
103        }
104        Value::Null => {
105            let mut obj = Map::new();
106            obj.insert("files".to_string(), files.clone());
107            Value::Object(obj)
108        }
109        other => other,
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    /// A live marker (active, no expiry → unexpired) scoped to one root.
118    fn marker() -> GuardMarker {
119        GuardMarker {
120            active: Some(true),
121            allowed_roots: Some(vec!["/work/skills-workspace".to_string()]),
122            expires_at: None,
123        }
124    }
125
126    #[test]
127    fn allows_returns_none() {
128        let payload = r#"{ "tool_name": "Read", "tool_input": { "file_path": "/etc/passwd" } }"#;
129        assert_eq!(guard_decision(payload, Some(marker())), None);
130    }
131
132    #[test]
133    fn deny_returns_pretooluse_deny_json() {
134        let payload = r#"{ "tool_name": "Write", "tool_input": { "file_path": "/etc/passwd" } }"#;
135        let out = guard_decision(payload, Some(marker())).expect("should deny");
136        let v: Value = serde_json::from_str(&out).unwrap();
137        assert_eq!(v["hookSpecificOutput"]["hookEventName"], "PreToolUse");
138        assert_eq!(v["hookSpecificOutput"]["permissionDecision"], "deny");
139        assert!(
140            v["hookSpecificOutput"]["permissionDecisionReason"]
141                .as_str()
142                .unwrap()
143                .contains("outside")
144        );
145    }
146
147    #[test]
148    fn no_marker_allows_everything() {
149        let payload = r#"{ "tool_name": "Write", "tool_input": { "file_path": "/etc/passwd" } }"#;
150        assert_eq!(guard_decision(payload, None), None);
151    }
152
153    #[test]
154    fn codex_deny_returns_decision_block_json() {
155        let payload = r#"{ "hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": { "command": "npm install left-pad" } }"#;
156        let out = codex_guard_decision(payload, Some(marker())).expect("should block");
157        let v: Value = serde_json::from_str(&out).unwrap();
158        assert_eq!(v["decision"], "block");
159        assert!(v["reason"].as_str().unwrap().contains("blocked Bash"));
160    }
161
162    #[test]
163    fn codex_apply_patch_outside_allowed_roots_blocks() {
164        let payload = r#"{ "hook_event_name": "PreToolUse", "tool_name": "apply_patch", "tool_input": { "files": ["/etc/passwd"] } }"#;
165        let out = codex_guard_decision(payload, Some(marker())).expect("should block");
166        let v: Value = serde_json::from_str(&out).unwrap();
167        assert_eq!(v["decision"], "block");
168        assert!(v["reason"].as_str().unwrap().contains("apply_patch"));
169    }
170
171    #[test]
172    fn codex_apply_patch_inside_allowed_roots_allows() {
173        let payload = r#"{ "hook_event_name": "PreToolUse", "tool_name": "apply_patch", "tool_input": { "files": ["/work/skills-workspace/out.md"] } }"#;
174        assert_eq!(codex_guard_decision(payload, Some(marker())), None);
175    }
176
177    #[test]
178    fn empty_or_malformed_payload_fails_open() {
179        assert_eq!(guard_decision("", Some(marker())), None);
180        assert_eq!(guard_decision("not json", Some(marker())), None);
181    }
182}