1use chrono::DateTime;
10use serde::Deserialize;
11use serde_json::Value;
12
13use super::policy::{
14 apply_patch_paths, classify_bash, is_patch_tool, is_shell_tool, is_under_any, is_write_tool,
15 path_arg,
16};
17
18#[derive(Debug, Clone, Default, Deserialize)]
23#[serde(rename_all = "camelCase")]
24pub struct GuardMarker {
25 #[serde(default)]
26 pub active: Option<bool>,
27 #[serde(default)]
28 pub allowed_roots: Option<Vec<String>>,
29 #[serde(default)]
30 pub expires_at: Option<String>,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct GuardDecision {
36 pub allow: bool,
37 pub reason: Option<String>,
38}
39
40impl GuardDecision {
41 fn allow() -> Self {
42 Self {
43 allow: true,
44 reason: None,
45 }
46 }
47
48 fn deny(reason: String) -> Self {
49 Self {
50 allow: false,
51 reason: Some(reason),
52 }
53 }
54}
55
56pub(crate) fn marker_is_armed(marker: Option<&GuardMarker>, now_ms: i64) -> bool {
58 let Some(marker) = marker else {
59 return false;
60 };
61 if marker.active != Some(true) {
62 return false;
63 }
64 if let Some(expires_at) = &marker.expires_at {
65 match DateTime::parse_from_rfc3339(expires_at) {
66 Ok(exp) if exp.timestamp_millis() <= now_ms => return false,
67 _ => {}
71 }
72 }
73 true
74}
75
76pub fn decide(
82 tool_name: &str,
83 tool_input: &Value,
84 marker: Option<&GuardMarker>,
85 now_ms: i64,
86) -> GuardDecision {
87 if !marker_is_armed(marker, now_ms) {
88 return GuardDecision::allow();
89 }
90 let roots = marker
91 .and_then(|m| m.allowed_roots.clone())
92 .unwrap_or_default();
93 let repo_root = std::env::current_dir().unwrap_or_default();
94
95 if is_write_tool(tool_name) {
96 if let Some(p) = path_arg(tool_input)
97 && !is_under_any(p, &roots, &repo_root)
98 {
99 return GuardDecision::deny(format!(
100 "eval guard: {tool_name} to {p} is outside the eval sandbox (allowed: {})",
101 roots.join(", ")
102 ));
103 }
104 return GuardDecision::allow();
105 }
106
107 if is_patch_tool(tool_name) {
108 let paths = apply_patch_paths(tool_input);
109 if paths.is_empty() {
110 return GuardDecision::deny(format!(
111 "eval guard: blocked {tool_name} because no patch target path could be determined"
112 ));
113 }
114 if let Some(path) = paths.iter().find(|p| !is_under_any(p, &roots, &repo_root)) {
115 return GuardDecision::deny(format!(
116 "eval guard: {tool_name} target {path} is outside the eval sandbox (allowed: {})",
117 roots.join(", ")
118 ));
119 }
120 return GuardDecision::allow();
121 }
122
123 if is_shell_tool(tool_name) {
124 let command = tool_input
125 .get("command")
126 .and_then(Value::as_str)
127 .unwrap_or("");
128 if let Some(reason) = classify_bash(command, &roots) {
129 return GuardDecision::deny(format!(
130 "eval guard: blocked {tool_name} ({reason}) — runs outside the eval sandbox"
131 ));
132 }
133 }
134
135 GuardDecision::allow()
136}
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141 use crate::sandbox::now_ms;
142 use serde_json::json;
143
144 const ROOTS: [&str; 2] = ["/work/.eval-magic", "/work/.claude/skills"];
145
146 fn rfc3339(offset_ms: i64) -> String {
149 DateTime::from_timestamp_millis(now_ms() + offset_ms)
150 .unwrap()
151 .to_rfc3339()
152 }
153
154 fn future() -> String {
155 rfc3339(60_000)
156 }
157
158 fn past() -> String {
159 rfc3339(-60_000)
160 }
161
162 fn marker() -> GuardMarker {
164 GuardMarker {
165 active: Some(true),
166 allowed_roots: Some(ROOTS.iter().map(|s| s.to_string()).collect()),
167 expires_at: Some(future()),
168 }
169 }
170
171 fn decide_now(tool: &str, input: Value, m: Option<&GuardMarker>) -> GuardDecision {
172 decide(tool, &input, m, now_ms())
173 }
174
175 #[test]
176 fn allows_everything_when_marker_is_null() {
177 let d = decide_now("Write", json!({ "file_path": "/etc/passwd" }), None);
178 assert!(d.allow);
179 }
180
181 #[test]
182 fn allows_everything_when_marker_is_inactive_or_expired() {
183 let inactive = GuardMarker {
184 active: Some(false),
185 ..marker()
186 };
187 assert!(
188 decide_now(
189 "Write",
190 json!({ "file_path": "/etc/passwd" }),
191 Some(&inactive)
192 )
193 .allow
194 );
195
196 let expired = GuardMarker {
197 expires_at: Some(past()),
198 ..marker()
199 };
200 assert!(
201 decide_now(
202 "Write",
203 json!({ "file_path": "/etc/passwd" }),
204 Some(&expired)
205 )
206 .allow
207 );
208 }
209
210 #[test]
211 fn allows_a_write_under_an_allowed_root() {
212 let d = decide_now(
213 "Write",
214 json!({ "file_path": "/work/.eval-magic/x/outputs/a.md" }),
215 Some(&marker()),
216 );
217 assert!(d.allow);
218 }
219
220 #[test]
221 fn denies_a_write_outside_all_allowed_roots() {
222 let d = decide_now(
223 "Edit",
224 json!({ "file_path": "/work/runner/run.ts" }),
225 Some(&marker()),
226 );
227 assert!(!d.allow);
228 assert!(d.reason.unwrap().to_lowercase().contains("outside"));
229 }
230
231 #[test]
232 fn denies_an_install_command() {
233 let d = decide_now(
234 "Bash",
235 json!({ "command": "npm install left-pad" }),
236 Some(&marker()),
237 );
238 assert!(!d.allow);
239 assert!(d.reason.unwrap().to_lowercase().contains("install"));
240 }
241
242 #[test]
243 fn allows_a_bash_command_scoped_to_an_allowed_root() {
244 let d = decide_now(
245 "Bash",
246 json!({ "command": "echo hi > /work/.eval-magic/x/outputs/log" }),
247 Some(&marker()),
248 );
249 assert!(d.allow);
250 }
251
252 #[test]
253 fn allows_non_mutating_bash_and_read_tools() {
254 assert!(decide_now("Bash", json!({ "command": "ls -la /" }), Some(&marker())).allow);
255 assert!(
256 decide_now(
257 "Read",
258 json!({ "file_path": "/etc/passwd" }),
259 Some(&marker())
260 )
261 .allow
262 );
263 }
264
265 #[test]
266 fn denies_git_worktree_add() {
267 let d = decide_now(
268 "Bash",
269 json!({ "command": "git worktree add ../wt -b scratch" }),
270 Some(&marker()),
271 );
272 assert!(!d.allow);
273 assert!(d.reason.unwrap().to_lowercase().contains("worktree"));
274 }
275
276 #[test]
277 fn denies_apply_patch_outside_allowed_roots() {
278 let d = decide_now(
279 "apply_patch",
280 json!({ "files": ["/work/runner/src/lib.rs"] }),
281 Some(&marker()),
282 );
283 assert!(!d.allow);
284 assert!(d.reason.unwrap().contains("apply_patch"));
285 }
286
287 #[test]
288 fn allows_apply_patch_inside_allowed_roots() {
289 let d = decide_now(
290 "apply_patch",
291 json!({ "files": ["/work/.eval-magic/eval/outputs/out.md"] }),
292 Some(&marker()),
293 );
294 assert!(d.allow);
295 }
296
297 #[test]
298 fn denies_apply_patch_without_a_known_target() {
299 let d = decide_now("apply_patch", json!({}), Some(&marker()));
300 assert!(!d.allow);
301 assert!(d.reason.unwrap().contains("no patch target"));
302 }
303
304 #[test]
305 fn denies_bash_that_creates_a_path_under_dot_claude_via_non_redirect_verb() {
306 assert!(
307 !decide_now(
308 "Bash",
309 json!({ "command": "mkdir -p .claude/foo" }),
310 Some(&marker())
311 )
312 .allow
313 );
314 assert!(
315 !decide_now(
316 "Bash",
317 json!({ "command": "cp out.txt .claude/bar" }),
318 Some(&marker())
319 )
320 .allow
321 );
322 }
323
324 #[test]
325 fn denies_bash_that_creates_a_bare_skills_dir() {
326 assert!(
327 !decide_now(
328 "Bash",
329 json!({ "command": "mkdir skills" }),
330 Some(&marker())
331 )
332 .allow
333 );
334 assert!(
335 !decide_now(
336 "Bash",
337 json!({ "command": "cp -r src ./skills" }),
338 Some(&marker())
339 )
340 .allow
341 );
342 }
343
344 #[test]
345 fn still_allows_reads_of_dot_claude_with_no_create_verb() {
346 assert!(
347 decide_now(
348 "Bash",
349 json!({ "command": "cat .claude/settings.json" }),
350 Some(&marker())
351 )
352 .allow
353 );
354 assert!(decide_now("Bash", json!({ "command": "ls .claude" }), Some(&marker())).allow);
355 }
356
357 #[test]
358 fn allows_a_create_scoped_to_the_dot_claude_skills_staging_root() {
359 let d = decide_now(
360 "Bash",
361 json!({ "command": "mkdir -p /work/.claude/skills/staged-x" }),
362 Some(&marker()),
363 );
364 assert!(d.allow);
365 }
366
367 #[test]
368 fn denies_bash_that_creates_a_path_under_dot_codex_via_non_redirect_verb() {
369 assert!(
370 !decide_now(
371 "Bash",
372 json!({ "command": "mkdir -p .codex/foo" }),
373 Some(&marker())
374 )
375 .allow
376 );
377 assert!(
378 !decide_now(
379 "Bash",
380 json!({ "command": "cp evil.json .codex/hooks.json" }),
381 Some(&marker())
382 )
383 .allow
384 );
385 }
386
387 #[test]
388 fn denies_bash_that_creates_a_path_under_dot_agents_via_non_redirect_verb() {
389 assert!(
390 !decide_now(
391 "Bash",
392 json!({ "command": "mkdir -p .agents/foo" }),
393 Some(&marker())
394 )
395 .allow
396 );
397 }
398
399 #[test]
400 fn denies_bash_that_creates_a_path_under_dot_opencode_via_non_redirect_verb() {
401 assert!(
402 !decide_now(
403 "Bash",
404 json!({ "command": "touch .opencode/opencode.json" }),
405 Some(&marker())
406 )
407 .allow
408 );
409 }
410
411 #[test]
412 fn still_allows_reads_of_other_harness_config_dirs_with_no_create_verb() {
413 for command in [
414 "cat .codex/hooks.json",
415 "ls .agents",
416 "cat .opencode/skills/x/SKILL.md",
417 ] {
418 assert!(
419 decide_now("Bash", json!({ "command": command }), Some(&marker())).allow,
420 "{command} should stay allowed"
421 );
422 }
423 }
424
425 #[test]
426 fn allows_a_create_scoped_to_a_codex_skills_staging_root() {
427 let codex_marker = GuardMarker {
428 allowed_roots: Some(vec![
429 "/work/.eval-magic".to_string(),
430 "/work/.agents/skills".to_string(),
431 ]),
432 ..marker()
433 };
434 let d = decide_now(
435 "Bash",
436 json!({ "command": "mkdir -p /work/.agents/skills/staged-x" }),
437 Some(&codex_marker),
438 );
439 assert!(d.allow);
440 }
441
442 #[test]
443 fn does_not_flag_a_skills_prefixed_dir_as_a_bare_skills_write() {
444 let d = decide_now(
448 "Bash",
449 json!({ "command": "mkdir -p /work/skills-data/x/outputs" }),
450 Some(&marker()),
451 );
452 assert!(d.allow);
453 }
454}