1use 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#[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#[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
59pub(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
82pub(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 _ => {}
97 }
98 }
99 true
100}
101
102pub 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
117pub(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 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 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 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}