1use std::path::Path;
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(|| {
52 let config_dirs = crate::adapters::all_config_dir_names()
53 .iter()
54 .map(|d| regex::escape(d))
55 .collect::<Vec<_>>()
56 .join("|");
57 [
58 (
59 r"\b(npm|pnpm|yarn|bun)\s+(install|add|ci|i)\b".to_string(),
60 "package install/add",
61 ),
62 (r"\bpip3?\s+install\b".to_string(), "pip install"),
63 (r"\bsed\s+-i\b".to_string(), "in-place file edit (sed -i)"),
64 (
65 r"\bgit\s+(commit|add|push|checkout|reset|restore|merge|rebase)\b".to_string(),
66 "git mutation",
67 ),
68 (
69 r"\bgit\s+worktree\s+add\b".to_string(),
70 "git worktree add (working tree outside the sandbox)",
71 ),
72 (
78 format!(r"\b(cp|mv|mkdir|touch|ln|rsync|install)\b[^|;&\n]*({config_dirs})(/|\b)"),
79 "path under a harness config dir",
80 ),
81 (
86 r#"\b(cp|mv|mkdir|touch|ln|rsync)\b[^|;&\n]*[\s'"=/]\.{0,2}/?skills(/|\s|$)"#
87 .to_string(),
88 "creates a bare skills/ dir",
89 ),
90 (
91 r"(^|\s)(>>?|tee)\s".to_string(),
92 "output redirection to a file",
93 ),
94 ]
95 .into_iter()
96 .map(|(re, reason)| {
97 (
98 Regex::new(&re)
99 .unwrap_or_else(|e| panic!("bundled bash pattern {re:?} is invalid: {e}")),
100 reason,
101 )
102 })
103 .collect()
104});
105
106pub fn path_arg(args: &Value) -> Option<&str> {
111 let obj = args.as_object()?;
112 ["file_path", "notebook_path", "path", "filePath"]
113 .iter()
114 .find_map(|k| obj.get(*k).and_then(Value::as_str))
115}
116
117pub fn apply_patch_paths(args: &Value) -> Vec<String> {
122 let mut out = Vec::new();
123 let Some(obj) = args.as_object() else {
124 return out;
125 };
126
127 if let Some(files) = obj.get("files") {
128 collect_file_values(files, &mut out);
129 }
130
131 for key in ["patch", "input", "content", "patchText"] {
132 if let Some(text) = obj.get(key).and_then(Value::as_str) {
133 collect_patch_header_paths(text, &mut out);
134 }
135 }
136
137 out.sort();
138 out.dedup();
139 out
140}
141
142fn collect_file_values(value: &Value, out: &mut Vec<String>) {
143 match value {
144 Value::String(path) => out.push(path.to_string()),
145 Value::Array(items) => {
146 for item in items {
147 collect_file_values(item, out);
148 }
149 }
150 Value::Object(obj) => {
151 for key in ["file_path", "path", "absolute_file_path", "move_path"] {
152 if let Some(path) = obj.get(key).and_then(Value::as_str) {
153 out.push(path.to_string());
154 }
155 }
156 }
157 _ => {}
158 }
159}
160
161fn collect_patch_header_paths(text: &str, out: &mut Vec<String>) {
162 for line in text.lines() {
163 for prefix in [
164 "*** Add File: ",
165 "*** Update File: ",
166 "*** Delete File: ",
167 "*** Move to: ",
168 ] {
169 if let Some(path) = line.strip_prefix(prefix) {
170 let path = path.trim();
171 if !path.is_empty() {
172 out.push(path.to_string());
173 }
174 }
175 }
176 }
177}
178
179fn absolutize(target: &str, repo_root: &Path) -> std::path::PathBuf {
182 let joined = if Path::new(target).is_absolute() {
183 std::path::PathBuf::from(target)
184 } else {
185 repo_root.join(target)
186 };
187 std::path::absolute(&joined).unwrap_or(joined)
189}
190
191pub fn is_under(target: &str, dir: &str, repo_root: &Path) -> bool {
195 let base = absolutize(dir, repo_root);
196 let abs = absolutize(target, repo_root);
197 abs.starts_with(&base)
198}
199
200pub fn is_under_any(target: &str, dirs: &[String], repo_root: &Path) -> bool {
202 dirs.iter().any(|d| is_under(target, d, repo_root))
203}
204
205pub fn classify_bash(command: &str, allowed_roots: &[String]) -> Option<&'static str> {
209 if command.is_empty() {
210 return None;
211 }
212 if allowed_roots.iter().any(|r| command.contains(r)) {
213 return None;
214 }
215 BASH_MUTATION_PATTERNS
216 .iter()
217 .find(|(re, _)| re.is_match(command))
218 .map(|(_, reason)| *reason)
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224 use serde_json::json;
225
226 const ROOTS: [&str; 2] = ["/work/.eval-magic", "/work/.claude/skills"];
227
228 fn roots() -> Vec<String> {
229 ROOTS.iter().map(|s| s.to_string()).collect()
230 }
231
232 #[test]
233 fn is_write_tool_matches_every_harness_write_tool() {
234 for t in ["Write", "Edit", "MultiEdit", "NotebookEdit", "file_change"] {
235 assert!(is_write_tool(t), "{t} should be a write tool");
236 }
237 for t in ["Read", "Bash", "Grep", "apply_patch", ""] {
238 assert!(!is_write_tool(t), "{t} should not be a write tool");
239 }
240 }
241
242 #[test]
243 fn is_patch_tool_matches_apply_patch_style_tools_only() {
244 assert!(is_patch_tool("apply_patch"));
245 for t in ["Write", "Bash", "file_change", ""] {
246 assert!(!is_patch_tool(t), "{t} should not be a patch tool");
247 }
248 }
249
250 #[test]
251 fn is_shell_tool_matches_every_harness_shell_tool() {
252 for t in ["Bash", "command_execution"] {
253 assert!(is_shell_tool(t), "{t} should be a shell tool");
254 }
255 for t in ["Write", "apply_patch", ""] {
256 assert!(!is_shell_tool(t), "{t} should not be a shell tool");
257 }
258 }
259
260 #[test]
261 fn path_arg_prefers_file_path_then_notebook_then_path() {
262 assert_eq!(path_arg(&json!({ "file_path": "/a" })), Some("/a"));
263 assert_eq!(path_arg(&json!({ "notebook_path": "/b" })), Some("/b"));
264 assert_eq!(path_arg(&json!({ "path": "/c" })), Some("/c"));
265 assert_eq!(
266 path_arg(&json!({ "file_path": "/a", "path": "/c" })),
267 Some("/a")
268 );
269 assert_eq!(path_arg(&json!({ "command": "ls" })), None);
270 assert_eq!(path_arg(&json!("not an object")), None);
271 }
272
273 #[test]
274 fn path_arg_recognizes_opencode_camel_case_file_path() {
275 assert_eq!(path_arg(&json!({ "filePath": "/op" })), Some("/op"));
277 assert_eq!(
279 path_arg(&json!({ "file_path": "/a", "filePath": "/op" })),
280 Some("/a")
281 );
282 }
283
284 #[test]
285 fn apply_patch_paths_reads_opencode_patch_text() {
286 let paths = apply_patch_paths(&json!({
288 "patchText": "*** Begin Patch\n*** Add File: docs/new.md\n*** End Patch\n"
289 }));
290 assert_eq!(paths, vec!["docs/new.md".to_string()]);
291 }
292
293 #[test]
294 fn apply_patch_paths_collects_structured_and_freeform_targets() {
295 let paths = apply_patch_paths(&json!({
296 "files": [
297 "/tmp/out.md",
298 { "path": "src/lib.rs" },
299 { "move_path": "src/new.rs" }
300 ],
301 "patch": "*** Begin Patch\n*** Update File: docs/a.md\n*** Move to: docs/b.md\n*** End Patch\n"
302 }));
303 assert_eq!(
304 paths,
305 vec![
306 "/tmp/out.md".to_string(),
307 "docs/a.md".to_string(),
308 "docs/b.md".to_string(),
309 "src/lib.rs".to_string(),
310 "src/new.rs".to_string(),
311 ]
312 );
313 }
314
315 #[test]
316 fn is_under_matches_dir_and_descendants() {
317 let repo = Path::new("/work");
318 assert!(is_under("/work/.eval-magic", "/work/.eval-magic", repo));
319 assert!(is_under(
320 "/work/.eval-magic/x/out.md",
321 "/work/.eval-magic",
322 repo
323 ));
324 assert!(!is_under("/work/runner/run.ts", "/work/.eval-magic", repo));
325 assert!(!is_under("/work/.eval-magic2/x", "/work/.eval-magic", repo));
327 }
328
329 #[test]
330 fn is_under_resolves_relative_targets_against_repo_root() {
331 let repo = Path::new("/work");
332 assert!(is_under(".eval-magic/x", "/work/.eval-magic", repo));
333 }
334
335 #[test]
336 fn is_under_any_checks_every_root() {
337 let repo = Path::new("/work");
338 assert!(is_under_any("/work/.claude/skills/s", &roots(), repo));
339 assert!(!is_under_any("/etc/passwd", &roots(), repo));
340 }
341
342 #[test]
343 fn classify_bash_flags_install_and_git_mutations() {
344 assert_eq!(
345 classify_bash("npm install left-pad", &roots()),
346 Some("package install/add")
347 );
348 assert_eq!(
349 classify_bash("git worktree add ../wt -b scratch", &roots()),
350 Some("git worktree add (working tree outside the sandbox)")
351 );
352 assert_eq!(
353 classify_bash("echo hi > out.log", &roots()),
354 Some("output redirection to a file")
355 );
356 }
357
358 #[test]
359 fn classify_bash_flags_creates_under_every_harness_config_dir_but_allows_reads() {
360 for dir in crate::adapters::all_config_dir_names() {
361 assert_eq!(
362 classify_bash(&format!("mkdir -p {dir}/x"), &[]),
363 Some("path under a harness config dir"),
364 "mkdir under {dir} should be flagged"
365 );
366 assert_eq!(
367 classify_bash(&format!("cp evil.json {dir}/hooks.json"), &[]),
368 Some("path under a harness config dir"),
369 "cp into {dir} should be flagged"
370 );
371 assert_eq!(
372 classify_bash(&format!("cat {dir}/settings.json"), &[]),
373 None,
374 "read of {dir} should stay allowed"
375 );
376 assert_eq!(classify_bash(&format!("ls {dir}"), &[]), None);
377 }
378 }
379
380 #[test]
381 fn classify_bash_allows_scoped_and_readonly_commands() {
382 assert_eq!(
384 classify_bash("echo hi > /work/.eval-magic/x/log", &roots()),
385 None
386 );
387 assert_eq!(classify_bash("ls -la /", &roots()), None);
388 assert_eq!(classify_bash("", &roots()), None);
389 }
390}