1use std::path::{Component, Path, PathBuf};
10use std::sync::LazyLock;
11
12use regex::Regex;
13use serde_json::Value;
14
15use crate::adapters::all_tool_vocabulary;
16
17pub fn is_write_tool(tool_name: &str) -> bool {
20 all_tool_vocabulary()
21 .write_tools
22 .iter()
23 .any(|t| t == tool_name)
24}
25
26pub fn is_patch_tool(tool_name: &str) -> bool {
29 all_tool_vocabulary()
30 .patch_tools
31 .iter()
32 .any(|t| t == tool_name)
33}
34
35pub fn is_shell_tool(tool_name: &str) -> bool {
38 all_tool_vocabulary()
39 .shell_tools
40 .iter()
41 .any(|t| t == tool_name)
42}
43
44static BASH_MUTATION_PATTERNS: LazyLock<Vec<(Regex, &'static str)>> = LazyLock::new(|| {
56 let config_dirs = crate::adapters::all_config_dir_names()
57 .iter()
58 .map(|d| regex::escape(d))
59 .collect::<Vec<_>>()
60 .join("|");
61 [
62 (
63 r"\b(npm|pnpm|yarn|bun)\s+(install|add|ci|i)\b".to_string(),
64 "package install/add",
65 ),
66 (r"\bpip3?\s+install\b".to_string(), "pip install"),
67 (r"\bsed\s+-i\b".to_string(), "in-place file edit (sed -i)"),
68 (
74 format!(r"\b(cp|mv|mkdir|touch|ln|rsync|install)\b[^|;&\n]*({config_dirs})(/|\b)"),
75 "path under a harness config dir",
76 ),
77 (
82 r#"\b(cp|mv|mkdir|touch|ln|rsync)\b[^|;&\n]*[\s'"=/]\.{0,2}/?skills(/|\s|$)"#
83 .to_string(),
84 "creates a bare skills/ dir",
85 ),
86 ]
87 .into_iter()
88 .map(|(re, reason)| {
89 (
90 Regex::new(&re)
91 .unwrap_or_else(|e| panic!("bundled bash pattern {re:?} is invalid: {e}")),
92 reason,
93 )
94 })
95 .collect()
96});
97
98pub fn path_arg(args: &Value) -> Option<&str> {
103 let obj = args.as_object()?;
104 ["file_path", "notebook_path", "path", "filePath"]
105 .iter()
106 .find_map(|k| obj.get(*k).and_then(Value::as_str))
107}
108
109pub fn apply_patch_paths(args: &Value) -> Vec<String> {
115 let mut out = Vec::new();
116 let Some(obj) = args.as_object() else {
117 return out;
118 };
119
120 if let Some(files) = obj.get("files") {
121 collect_file_values(files, &mut out);
122 }
123
124 for key in ["command", "patch", "input", "content", "patchText"] {
125 if let Some(text) = obj.get(key).and_then(Value::as_str) {
126 collect_patch_header_paths(text, &mut out);
127 }
128 }
129
130 out.sort();
131 out.dedup();
132 out
133}
134
135fn collect_file_values(value: &Value, out: &mut Vec<String>) {
136 match value {
137 Value::String(path) => out.push(path.to_string()),
138 Value::Array(items) => {
139 for item in items {
140 collect_file_values(item, out);
141 }
142 }
143 Value::Object(obj) => {
144 for key in ["file_path", "path", "absolute_file_path", "move_path"] {
145 if let Some(path) = obj.get(key).and_then(Value::as_str) {
146 out.push(path.to_string());
147 }
148 }
149 }
150 _ => {}
151 }
152}
153
154fn collect_patch_header_paths(text: &str, out: &mut Vec<String>) {
155 for line in text.lines() {
156 for prefix in [
157 "*** Add File: ",
158 "*** Update File: ",
159 "*** Delete File: ",
160 "*** Move to: ",
161 ] {
162 if let Some(path) = line.strip_prefix(prefix) {
163 let path = path.trim();
164 if !path.is_empty() {
165 out.push(path.to_string());
166 }
167 }
168 }
169 }
170}
171
172pub(crate) fn resolve_path(target: &str, repo_root: &Path) -> PathBuf {
175 let joined = if Path::new(target).is_absolute() {
176 PathBuf::from(target)
177 } else {
178 repo_root.join(target)
179 };
180 let absolute = std::path::absolute(&joined).unwrap_or(joined);
181 let mut normalized = PathBuf::new();
182 for component in absolute.components() {
183 match component {
184 Component::CurDir => {}
185 Component::ParentDir => {
186 normalized.pop();
187 }
188 Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
189 Component::RootDir => normalized.push(component.as_os_str()),
190 Component::Normal(part) => normalized.push(part),
191 }
192 }
193 normalized
194}
195
196pub fn is_under(target: &str, dir: &str, repo_root: &Path) -> bool {
200 let base = resolve_path(dir, repo_root);
201 let abs = resolve_path(target, repo_root);
202 abs.starts_with(&base)
203}
204
205pub fn is_under_any(target: &str, dirs: &[String], repo_root: &Path) -> bool {
207 dirs.iter().any(|d| is_under(target, d, repo_root))
208}
209
210pub(super) const OUTPUT_REDIRECTION_REASON: &str = "output redirection to a file";
211
212#[derive(Debug, Clone, PartialEq, Eq)]
214pub(crate) struct BashClassification {
215 pub reason: &'static str,
216 pub resolved_targets: Vec<String>,
217}
218
219pub(crate) fn classify_bash_with_cwd(
221 command: &str,
222 allowed_roots: &[String],
223 invocation_cwd: &Path,
224) -> Option<BashClassification> {
225 if command.is_empty() {
226 return None;
227 }
228 if let Some(denial) =
229 super::shell_targets::classify_output_targets(command, allowed_roots, invocation_cwd)
230 {
231 return Some(denial);
232 }
233 if let Some(denial) =
234 super::git_command::classify_git_commands(command, allowed_roots, invocation_cwd)
235 {
236 return Some(denial);
237 }
238 if allowed_roots.iter().any(|r| command.contains(r)) {
239 return None;
240 }
241 BASH_MUTATION_PATTERNS
242 .iter()
243 .find(|(re, _)| re.is_match(command))
244 .map(|(_, reason)| BashClassification {
245 reason,
246 resolved_targets: Vec::new(),
247 })
248}
249
250pub fn classify_bash(command: &str, allowed_roots: &[String]) -> Option<&'static str> {
256 let cwd = std::env::current_dir().unwrap_or_default();
257 classify_bash_with_cwd(command, allowed_roots, &cwd).map(|result| result.reason)
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263 use serde_json::json;
264
265 const ROOTS: [&str; 2] = ["/work/.eval-magic", "/work/.claude/skills"];
266
267 fn roots() -> Vec<String> {
268 ROOTS.iter().map(|s| s.to_string()).collect()
269 }
270
271 #[test]
272 fn is_write_tool_matches_every_harness_write_tool() {
273 for t in ["Write", "Edit", "MultiEdit", "NotebookEdit", "file_change"] {
274 assert!(is_write_tool(t), "{t} should be a write tool");
275 }
276 for t in ["Read", "Bash", "Grep", "apply_patch", ""] {
277 assert!(!is_write_tool(t), "{t} should not be a write tool");
278 }
279 }
280
281 #[test]
282 fn is_patch_tool_matches_apply_patch_style_tools_only() {
283 assert!(is_patch_tool("apply_patch"));
284 for t in ["Write", "Bash", "file_change", ""] {
285 assert!(!is_patch_tool(t), "{t} should not be a patch tool");
286 }
287 }
288
289 #[test]
290 fn is_shell_tool_matches_every_harness_shell_tool() {
291 for t in ["Bash", "command_execution"] {
292 assert!(is_shell_tool(t), "{t} should be a shell tool");
293 }
294 for t in ["Write", "apply_patch", ""] {
295 assert!(!is_shell_tool(t), "{t} should not be a shell tool");
296 }
297 }
298
299 #[test]
300 fn path_arg_prefers_file_path_then_notebook_then_path() {
301 assert_eq!(path_arg(&json!({ "file_path": "/a" })), Some("/a"));
302 assert_eq!(path_arg(&json!({ "notebook_path": "/b" })), Some("/b"));
303 assert_eq!(path_arg(&json!({ "path": "/c" })), Some("/c"));
304 assert_eq!(
305 path_arg(&json!({ "file_path": "/a", "path": "/c" })),
306 Some("/a")
307 );
308 assert_eq!(path_arg(&json!({ "command": "ls" })), None);
309 assert_eq!(path_arg(&json!("not an object")), None);
310 }
311
312 #[test]
313 fn path_arg_recognizes_opencode_camel_case_file_path() {
314 assert_eq!(path_arg(&json!({ "filePath": "/op" })), Some("/op"));
316 assert_eq!(
318 path_arg(&json!({ "file_path": "/a", "filePath": "/op" })),
319 Some("/a")
320 );
321 }
322
323 #[test]
324 fn apply_patch_paths_reads_opencode_patch_text() {
325 let paths = apply_patch_paths(&json!({
327 "patchText": "*** Begin Patch\n*** Add File: docs/new.md\n*** End Patch\n"
328 }));
329 assert_eq!(paths, vec!["docs/new.md".to_string()]);
330 }
331
332 #[test]
333 fn apply_patch_paths_reads_codex_command_and_every_patch_target() {
334 let paths = apply_patch_paths(&json!({
335 "command": "\
336 *** Begin Patch\n\
337 *** Add File: fixtures/new.txt\n\
338 *** Update File: fixtures/source.txt\n\
339 *** Move to: fixtures/moved.txt\n\
340 *** Delete File: fixtures/old.txt\n\
341 *** End Patch\n"
342 }));
343 assert_eq!(
344 paths,
345 vec![
346 "fixtures/moved.txt".to_string(),
347 "fixtures/new.txt".to_string(),
348 "fixtures/old.txt".to_string(),
349 "fixtures/source.txt".to_string(),
350 ]
351 );
352 }
353
354 #[test]
355 fn apply_patch_paths_collects_structured_and_freeform_targets() {
356 let paths = apply_patch_paths(&json!({
357 "files": [
358 "/tmp/out.md",
359 { "path": "src/lib.rs" },
360 { "move_path": "src/new.rs" }
361 ],
362 "patch": "*** Begin Patch\n*** Update File: docs/a.md\n*** Move to: docs/b.md\n*** End Patch\n"
363 }));
364 assert_eq!(
365 paths,
366 vec![
367 "/tmp/out.md".to_string(),
368 "docs/a.md".to_string(),
369 "docs/b.md".to_string(),
370 "src/lib.rs".to_string(),
371 "src/new.rs".to_string(),
372 ]
373 );
374 }
375
376 #[test]
377 fn is_under_matches_dir_and_descendants() {
378 let repo = Path::new("/work");
379 assert!(is_under("/work/.eval-magic", "/work/.eval-magic", repo));
380 assert!(is_under(
381 "/work/.eval-magic/x/out.md",
382 "/work/.eval-magic",
383 repo
384 ));
385 assert!(!is_under("/work/runner/run.ts", "/work/.eval-magic", repo));
386 assert!(!is_under("/work/.eval-magic2/x", "/work/.eval-magic", repo));
388 }
389
390 #[test]
391 fn is_under_resolves_relative_targets_against_repo_root() {
392 let repo = Path::new("/work");
393 assert!(is_under(".eval-magic/x", "/work/.eval-magic", repo));
394 }
395
396 #[test]
397 fn is_under_any_checks_every_root() {
398 let repo = Path::new("/work");
399 assert!(is_under_any("/work/.claude/skills/s", &roots(), repo));
400 assert!(!is_under_any("/etc/passwd", &roots(), repo));
401 }
402
403 #[test]
404 fn classify_bash_flags_installs_and_git_worktree_escape() {
405 assert_eq!(
406 classify_bash("npm install left-pad", &roots()),
407 Some("package install/add")
408 );
409 assert_eq!(
410 classify_bash("git worktree add ../wt -b scratch", &roots()),
411 Some("git worktree add (working tree outside the sandbox)")
412 );
413 assert_eq!(
414 classify_bash("echo hi > out.log", &roots()),
415 Some("output redirection to a file")
416 );
417 }
418
419 #[test]
420 fn classify_bash_allows_local_git_workflows_inside_the_task_repository() {
421 let cwd = Path::new("/work/.eval-magic/task");
422 for command in [
423 "git status --short",
424 "git add . && git commit -m baseline",
425 "git switch -c scratch",
426 "git checkout -- src/lib.rs",
427 "git reset HEAD~1",
428 "git branch topic",
429 "git rev-parse --git-dir",
430 "git -C /work/.eval-magic/task status",
431 "GIT_WORK_TREE=/work/.eval-magic/task git status",
432 ] {
433 assert_eq!(
434 classify_bash_with_cwd(command, &roots(), cwd),
435 None,
436 "{command}"
437 );
438 }
439 }
440
441 #[test]
442 fn classify_bash_denies_git_repository_routing_escapes() {
443 let cwd = Path::new("/work/.eval-magic/task");
444 for command in [
445 "git -C /outside status",
446 "git --git-dir=/outside/repo.git status",
447 "git --work-tree /outside status",
448 "GIT_DIR=/outside/repo.git git status",
449 "env GIT_WORK_TREE=../../outside git status",
450 "git -C \"$TARGET\" status",
451 "git --git-dir='unterminated status",
452 ] {
453 let denial = classify_bash_with_cwd(command, &roots(), cwd)
454 .unwrap_or_else(|| panic!("expected Git routing denial for {command}"));
455 assert_eq!(denial.reason, "git repository routing escape", "{command}");
456 }
457 }
458
459 #[test]
460 fn classify_bash_denies_remote_git_operations_before_allowed_root_shortcut() {
461 let cwd = Path::new("/work/.eval-magic/task");
462 for command in [
463 "git clone https://example.com/repo.git",
464 "git fetch origin",
465 "git --no-pager push origin main",
466 "git -c color.ui=false push origin main",
467 "git pull",
468 "git push /work/.eval-magic/task",
469 "git ls-remote origin",
470 "git submodule update --init",
471 "git send-email patch",
472 "git svn fetch",
473 "git p4 sync",
474 "git archive --remote=origin HEAD",
475 "git remote add origin https://example.com/repo.git",
476 "git remote remove origin",
477 "git config remote.origin.url https://example.com/repo.git",
478 "git config --add url.ssh://git@example.com/.insteadOf gh:",
479 "git config set remote.origin.url https://example.com/repo.git",
480 "git config unset remote.origin.url",
481 "git config rename-section remote.origin remote.backup",
482 "git config remove-section remote.origin",
483 ] {
484 let denial = classify_bash_with_cwd(command, &roots(), cwd)
485 .unwrap_or_else(|| panic!("expected remote Git denial for {command}"));
486 assert_eq!(denial.reason, "git remote operation", "{command}");
487 }
488 }
489
490 #[test]
491 fn classify_bash_allows_remote_inspection_without_mutation() {
492 let cwd = Path::new("/work/.eval-magic/task");
493 for command in [
494 "git remote",
495 "git remote -v",
496 "git remote get-url origin",
497 "git config remote.origin.url",
498 "git config --get url.ssh://git@example.com/.insteadOf",
499 ] {
500 assert_eq!(
501 classify_bash_with_cwd(command, &roots(), cwd),
502 None,
503 "{command}"
504 );
505 }
506 }
507
508 #[test]
509 fn classify_bash_flags_creates_under_every_harness_config_dir_but_allows_reads() {
510 for dir in crate::adapters::all_config_dir_names() {
511 assert_eq!(
512 classify_bash(&format!("mkdir -p {dir}/x"), &[]),
513 Some("path under a harness config dir"),
514 "mkdir under {dir} should be flagged"
515 );
516 assert_eq!(
517 classify_bash(&format!("cp evil.json {dir}/hooks.json"), &[]),
518 Some("path under a harness config dir"),
519 "cp into {dir} should be flagged"
520 );
521 assert_eq!(
522 classify_bash(&format!("cat {dir}/settings.json"), &[]),
523 None,
524 "read of {dir} should stay allowed"
525 );
526 assert_eq!(classify_bash(&format!("ls {dir}"), &[]), None);
527 }
528 }
529
530 #[test]
531 fn classify_bash_allows_scoped_and_readonly_commands() {
532 assert_eq!(
534 classify_bash("echo hi > /work/.eval-magic/x/log", &roots()),
535 None
536 );
537 assert_eq!(classify_bash("ls -la /", &roots()), None);
538 assert_eq!(classify_bash("", &roots()), None);
539 }
540}