use std::path::Path;
use std::sync::LazyLock;
use regex::Regex;
use serde_json::Value;
use crate::adapters::all_tool_vocabulary;
pub fn is_write_tool(tool_name: &str) -> bool {
all_tool_vocabulary()
.write_tools
.iter()
.any(|t| t == tool_name)
}
pub fn is_patch_tool(tool_name: &str) -> bool {
all_tool_vocabulary()
.patch_tools
.iter()
.any(|t| t == tool_name)
}
pub fn is_shell_tool(tool_name: &str) -> bool {
all_tool_vocabulary()
.shell_tools
.iter()
.any(|t| t == tool_name)
}
static BASH_MUTATION_PATTERNS: LazyLock<Vec<(Regex, &'static str)>> = LazyLock::new(|| {
let config_dirs = crate::adapters::all_config_dir_names()
.iter()
.map(|d| regex::escape(d))
.collect::<Vec<_>>()
.join("|");
[
(
r"\b(npm|pnpm|yarn|bun)\s+(install|add|ci|i)\b".to_string(),
"package install/add",
),
(r"\bpip3?\s+install\b".to_string(), "pip install"),
(r"\bsed\s+-i\b".to_string(), "in-place file edit (sed -i)"),
(
r"\bgit\s+(commit|add|push|checkout|reset|restore|merge|rebase)\b".to_string(),
"git mutation",
),
(
r"\bgit\s+worktree\s+add\b".to_string(),
"git worktree add (working tree outside the sandbox)",
),
(
format!(r"\b(cp|mv|mkdir|touch|ln|rsync|install)\b[^|;&\n]*({config_dirs})(/|\b)"),
"path under a harness config dir",
),
(
r#"\b(cp|mv|mkdir|touch|ln|rsync)\b[^|;&\n]*[\s'"=/]\.{0,2}/?skills(/|\s|$)"#
.to_string(),
"creates a bare skills/ dir",
),
(
r"(^|\s)(>>?|tee)\s".to_string(),
"output redirection to a file",
),
]
.into_iter()
.map(|(re, reason)| {
(
Regex::new(&re)
.unwrap_or_else(|e| panic!("bundled bash pattern {re:?} is invalid: {e}")),
reason,
)
})
.collect()
});
pub fn path_arg(args: &Value) -> Option<&str> {
let obj = args.as_object()?;
["file_path", "notebook_path", "path"]
.iter()
.find_map(|k| obj.get(*k).and_then(Value::as_str))
}
pub fn apply_patch_paths(args: &Value) -> Vec<String> {
let mut out = Vec::new();
let Some(obj) = args.as_object() else {
return out;
};
if let Some(files) = obj.get("files") {
collect_file_values(files, &mut out);
}
for key in ["patch", "input", "content"] {
if let Some(text) = obj.get(key).and_then(Value::as_str) {
collect_patch_header_paths(text, &mut out);
}
}
out.sort();
out.dedup();
out
}
fn collect_file_values(value: &Value, out: &mut Vec<String>) {
match value {
Value::String(path) => out.push(path.to_string()),
Value::Array(items) => {
for item in items {
collect_file_values(item, out);
}
}
Value::Object(obj) => {
for key in ["file_path", "path", "absolute_file_path", "move_path"] {
if let Some(path) = obj.get(key).and_then(Value::as_str) {
out.push(path.to_string());
}
}
}
_ => {}
}
}
fn collect_patch_header_paths(text: &str, out: &mut Vec<String>) {
for line in text.lines() {
for prefix in [
"*** Add File: ",
"*** Update File: ",
"*** Delete File: ",
"*** Move to: ",
] {
if let Some(path) = line.strip_prefix(prefix) {
let path = path.trim();
if !path.is_empty() {
out.push(path.to_string());
}
}
}
}
}
fn absolutize(target: &str, repo_root: &Path) -> std::path::PathBuf {
let joined = if Path::new(target).is_absolute() {
std::path::PathBuf::from(target)
} else {
repo_root.join(target)
};
std::path::absolute(&joined).unwrap_or(joined)
}
pub fn is_under(target: &str, dir: &str, repo_root: &Path) -> bool {
let base = absolutize(dir, repo_root);
let abs = absolutize(target, repo_root);
abs.starts_with(&base)
}
pub fn is_under_any(target: &str, dirs: &[String], repo_root: &Path) -> bool {
dirs.iter().any(|d| is_under(target, d, repo_root))
}
pub fn classify_bash(command: &str, allowed_roots: &[String]) -> Option<&'static str> {
if command.is_empty() {
return None;
}
if allowed_roots.iter().any(|r| command.contains(r)) {
return None;
}
BASH_MUTATION_PATTERNS
.iter()
.find(|(re, _)| re.is_match(command))
.map(|(_, reason)| *reason)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
const ROOTS: [&str; 2] = ["/work/.eval-magic", "/work/.claude/skills"];
fn roots() -> Vec<String> {
ROOTS.iter().map(|s| s.to_string()).collect()
}
#[test]
fn is_write_tool_matches_every_harness_write_tool() {
for t in ["Write", "Edit", "MultiEdit", "NotebookEdit", "file_change"] {
assert!(is_write_tool(t), "{t} should be a write tool");
}
for t in ["Read", "Bash", "Grep", "apply_patch", ""] {
assert!(!is_write_tool(t), "{t} should not be a write tool");
}
}
#[test]
fn is_patch_tool_matches_apply_patch_style_tools_only() {
assert!(is_patch_tool("apply_patch"));
for t in ["Write", "Bash", "file_change", ""] {
assert!(!is_patch_tool(t), "{t} should not be a patch tool");
}
}
#[test]
fn is_shell_tool_matches_every_harness_shell_tool() {
for t in ["Bash", "command_execution"] {
assert!(is_shell_tool(t), "{t} should be a shell tool");
}
for t in ["Write", "apply_patch", ""] {
assert!(!is_shell_tool(t), "{t} should not be a shell tool");
}
}
#[test]
fn path_arg_prefers_file_path_then_notebook_then_path() {
assert_eq!(path_arg(&json!({ "file_path": "/a" })), Some("/a"));
assert_eq!(path_arg(&json!({ "notebook_path": "/b" })), Some("/b"));
assert_eq!(path_arg(&json!({ "path": "/c" })), Some("/c"));
assert_eq!(
path_arg(&json!({ "file_path": "/a", "path": "/c" })),
Some("/a")
);
assert_eq!(path_arg(&json!({ "command": "ls" })), None);
assert_eq!(path_arg(&json!("not an object")), None);
}
#[test]
fn apply_patch_paths_collects_structured_and_freeform_targets() {
let paths = apply_patch_paths(&json!({
"files": [
"/tmp/out.md",
{ "path": "src/lib.rs" },
{ "move_path": "src/new.rs" }
],
"patch": "*** Begin Patch\n*** Update File: docs/a.md\n*** Move to: docs/b.md\n*** End Patch\n"
}));
assert_eq!(
paths,
vec![
"/tmp/out.md".to_string(),
"docs/a.md".to_string(),
"docs/b.md".to_string(),
"src/lib.rs".to_string(),
"src/new.rs".to_string(),
]
);
}
#[test]
fn is_under_matches_dir_and_descendants() {
let repo = Path::new("/work");
assert!(is_under("/work/.eval-magic", "/work/.eval-magic", repo));
assert!(is_under(
"/work/.eval-magic/x/out.md",
"/work/.eval-magic",
repo
));
assert!(!is_under("/work/runner/run.ts", "/work/.eval-magic", repo));
assert!(!is_under("/work/.eval-magic2/x", "/work/.eval-magic", repo));
}
#[test]
fn is_under_resolves_relative_targets_against_repo_root() {
let repo = Path::new("/work");
assert!(is_under(".eval-magic/x", "/work/.eval-magic", repo));
}
#[test]
fn is_under_any_checks_every_root() {
let repo = Path::new("/work");
assert!(is_under_any("/work/.claude/skills/s", &roots(), repo));
assert!(!is_under_any("/etc/passwd", &roots(), repo));
}
#[test]
fn classify_bash_flags_install_and_git_mutations() {
assert_eq!(
classify_bash("npm install left-pad", &roots()),
Some("package install/add")
);
assert_eq!(
classify_bash("git worktree add ../wt -b scratch", &roots()),
Some("git worktree add (working tree outside the sandbox)")
);
assert_eq!(
classify_bash("echo hi > out.log", &roots()),
Some("output redirection to a file")
);
}
#[test]
fn classify_bash_flags_creates_under_every_harness_config_dir_but_allows_reads() {
for dir in crate::adapters::all_config_dir_names() {
assert_eq!(
classify_bash(&format!("mkdir -p {dir}/x"), &[]),
Some("path under a harness config dir"),
"mkdir under {dir} should be flagged"
);
assert_eq!(
classify_bash(&format!("cp evil.json {dir}/hooks.json"), &[]),
Some("path under a harness config dir"),
"cp into {dir} should be flagged"
);
assert_eq!(
classify_bash(&format!("cat {dir}/settings.json"), &[]),
None,
"read of {dir} should stay allowed"
);
assert_eq!(classify_bash(&format!("ls {dir}"), &[]), None);
}
}
#[test]
fn classify_bash_allows_scoped_and_readonly_commands() {
assert_eq!(
classify_bash("echo hi > /work/.eval-magic/x/log", &roots()),
None
);
assert_eq!(classify_bash("ls -la /", &roots()), None);
assert_eq!(classify_bash("", &roots()), None);
}
}