eval_magic/sandbox/
guard.rs1use std::path::Path;
10
11use serde_json::{Map, Value, json};
12
13use super::decide::{GuardMarker, decide};
14use super::now_ms;
15
16pub 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
23pub 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
46pub 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 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}