1use 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#[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#[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
53pub(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 _ => {}
68 }
69 }
70 true
71}
72
73pub 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 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 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}