use serde_json::Value;
use super::normalize_tool_name;
use super::tool_execution::string_fields;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ToolKind {
Read,
Glob,
Grep,
Search,
Codesearch,
Ls,
Lsp,
WebSearch,
WebFetch,
Write,
Edit,
Delete,
ApplyPatch,
Shell,
Mcp,
Other,
}
pub(crate) fn classify(tool: &str) -> ToolKind {
let normalized = normalize_tool_name(tool);
if normalized == "mcp_list" || normalized.starts_with("mcp.") {
return ToolKind::Mcp;
}
match normalized.as_str() {
"read" => ToolKind::Read,
"glob" => ToolKind::Glob,
"grep" => ToolKind::Grep,
"search" => ToolKind::Search,
"codesearch" => ToolKind::Codesearch,
"list" | "ls" => ToolKind::Ls,
"lsp" => ToolKind::Lsp,
"websearch" => ToolKind::WebSearch,
"webfetch" | "webfetch_html" => ToolKind::WebFetch,
"write" => ToolKind::Write,
"edit" => ToolKind::Edit,
"delete" | "delete_file" => ToolKind::Delete,
"apply_patch" => ToolKind::ApplyPatch,
"bash" | "shell" => ToolKind::Shell,
_ => ToolKind::Other,
}
}
pub(crate) fn paths(tool: &str, args: &Value) -> Vec<String> {
let mut out = match classify(tool) {
ToolKind::Read
| ToolKind::Glob
| ToolKind::Grep
| ToolKind::Search
| ToolKind::Codesearch
| ToolKind::Ls
| ToolKind::Lsp
| ToolKind::WebSearch
| ToolKind::WebFetch
| ToolKind::Mcp
| ToolKind::Other => Vec::new(),
ToolKind::Write | ToolKind::Edit | ToolKind::Delete => string_fields(
args,
&[
"path",
"file_path",
"filePath",
"filepath",
"target_path",
"output_path",
"file",
],
),
ToolKind::ApplyPatch => args
.get("patchText")
.or_else(|| args.get("patch"))
.and_then(Value::as_str)
.map(extract_apply_patch_paths)
.unwrap_or_default(),
ToolKind::Shell => extract_shell_redirect_targets(
args.get("command")
.or_else(|| args.get("cmd"))
.and_then(Value::as_str)
.unwrap_or_default(),
),
};
out.sort();
out.dedup();
out
}
pub(crate) fn requires_concrete(tool: &str, args: &Value) -> bool {
match classify(tool) {
ToolKind::Read
| ToolKind::Glob
| ToolKind::Grep
| ToolKind::Search
| ToolKind::Codesearch
| ToolKind::Ls
| ToolKind::Lsp
| ToolKind::WebSearch
| ToolKind::WebFetch
| ToolKind::Mcp
| ToolKind::Other => false,
ToolKind::Write | ToolKind::Edit | ToolKind::Delete | ToolKind::ApplyPatch => true,
ToolKind::Shell => args
.get("command")
.or_else(|| args.get("cmd"))
.and_then(Value::as_str)
.is_some_and(shell_command_appears_mutating),
}
}
fn extract_apply_patch_paths(patch: &str) -> Vec<String> {
use std::collections::HashSet;
let mut paths = HashSet::new();
for line in patch.lines() {
let trimmed = line.trim();
let marker = trimmed
.strip_prefix("*** Add File: ")
.or_else(|| trimmed.strip_prefix("*** Update File: "))
.or_else(|| trimmed.strip_prefix("*** Delete File: "));
if let Some(path) = marker.map(str::trim).filter(|value| !value.is_empty()) {
paths.insert(path.to_string());
}
}
let mut paths = paths.into_iter().collect::<Vec<_>>();
paths.sort();
paths
}
fn extract_shell_redirect_targets(command: &str) -> Vec<String> {
let mut targets = Vec::new();
for part in command.split(">>").flat_map(|value| value.split('>')) {
let candidate = part.trim().split_whitespace().next().unwrap_or("").trim();
if candidate.starts_with('/')
|| candidate.starts_with("./")
|| candidate.starts_with("../")
|| candidate.starts_with("~/")
|| candidate.starts_with(".tandem/")
{
targets.push(candidate.to_string());
}
}
targets.sort();
targets.dedup();
targets
}
fn shell_command_appears_mutating(command: &str) -> bool {
let lowered = command.to_ascii_lowercase();
lowered.contains(" >")
|| lowered.contains(">>")
|| lowered.contains(" tee ")
|| lowered.starts_with("tee ")
|| lowered.contains(" sed -i")
|| lowered.starts_with("sed -i")
|| lowered.contains(" perl -pi")
|| lowered.starts_with("perl -pi")
|| lowered.contains(" rm ")
|| lowered.starts_with("rm ")
|| lowered.contains(" mv ")
|| lowered.starts_with("mv ")
|| lowered.contains(" cp ")
|| lowered.starts_with("cp ")
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn read_only_kinds_never_write() {
let read_only_tools = [
"read",
"glob",
"grep",
"search",
"codesearch",
"list",
"ls",
"lsp",
"websearch",
"webfetch",
"webfetch_html",
];
let arg_shapes = [
json!({}),
json!({"path": "src/foo.rs"}),
json!({"file_path": "./bar"}),
json!({"pattern": "**/*.ts"}),
json!({"command": "rm -rf /"}),
json!({"patchText": "*** Update File: x\n"}),
];
for tool in read_only_tools {
assert!(
matches!(
classify(tool),
ToolKind::Read
| ToolKind::Glob
| ToolKind::Grep
| ToolKind::Search
| ToolKind::Codesearch
| ToolKind::Ls
| ToolKind::Lsp
| ToolKind::WebSearch
| ToolKind::WebFetch
),
"tool {tool} should classify as a read-only ToolKind"
);
for args in &arg_shapes {
assert!(
paths(tool, args).is_empty(),
"read-only tool {tool} produced write paths for {args}"
);
assert!(
!requires_concrete(tool, args),
"read-only tool {tool} required concrete target for {args}"
);
}
}
}
#[test]
fn write_extracts_path_field() {
assert_eq!(
paths("write", &json!({"path": "artifacts/report.md"})),
vec!["artifacts/report.md".to_string()]
);
}
#[test]
fn write_extracts_alternative_field_aliases() {
assert_eq!(
paths("edit", &json!({"file_path": "src/lib.rs"})),
vec!["src/lib.rs".to_string()]
);
assert_eq!(
paths("delete", &json!({"target_path": "tmp/old.txt"})),
vec!["tmp/old.txt".to_string()]
);
}
#[test]
fn apply_patch_extracts_files_from_patch_text() {
let args = json!({
"patchText": "*** Begin Patch\n*** Update File: packages/app/src/main.ts\n@@\n old\n*** End Patch\n"
});
assert_eq!(
paths("apply_patch", &args),
vec!["packages/app/src/main.ts".to_string()]
);
}
#[test]
fn shell_extracts_redirect_targets() {
assert_eq!(
paths("bash", &json!({"command": "echo hi > ./out.txt"})),
vec!["./out.txt".to_string()]
);
}
#[test]
fn mcp_tools_route_to_their_own_gate() {
assert_eq!(classify("mcp.fs.write"), ToolKind::Mcp);
assert_eq!(classify("mcp_list"), ToolKind::Mcp);
assert!(paths("mcp.fs.write", &json!({"path": "/tmp/x"})).is_empty());
assert!(!requires_concrete(
"mcp.fs.write",
&json!({"path": "/tmp/x"})
));
}
#[test]
fn requires_concrete_for_workspace_writes() {
assert!(requires_concrete("write", &json!({})));
assert!(requires_concrete("edit", &json!({})));
assert!(requires_concrete("delete", &json!({})));
assert!(requires_concrete("apply_patch", &json!({})));
}
#[test]
fn requires_concrete_for_mutating_shell_only() {
assert!(requires_concrete(
"bash",
&json!({"command": "cat <<'EOF' > packages/app/src/main.ts\nbroken\nEOF"})
));
assert!(!requires_concrete(
"bash",
&json!({"command": "rg \"needle\" packages/app/src"})
));
}
}