use crate::command_safety::is_parallel_readonly_command;
use crate::tools::canonical_action::canonical_action_alias;
use serde_json::Value;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolCategory {
Safe,
FileWrite,
Shell,
Network,
McpRead,
McpAction,
Agent,
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RiskLevel {
Benign,
Destructive,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ApprovalStakes {
Routine,
Elevated,
Critical,
}
pub fn get_tool_category(name: &str) -> ToolCategory {
if name == "agent" || name == "workflow" {
ToolCategory::Agent
} else if matches!(
name,
"write" | "edit" | "write_file" | "edit_file" | "apply_patch"
) {
ToolCategory::FileWrite
} else if matches!(
name,
"web_run" | "web_search" | "fetch_url" | "wait_for_dev_server" | "registry_sync"
) {
ToolCategory::Network
} else if matches!(
name,
"bash"
| "Bash"
| "exec_shell"
| "task_shell_start"
| "task_shell_wait"
| "exec_shell_wait"
| "exec_shell_interact"
| "exec_shell_cancel"
| "exec_wait"
| "exec_interact"
) {
ToolCategory::Shell
} else if name.starts_with("list_mcp_")
|| name.starts_with("read_mcp_")
|| name.starts_with("get_mcp_")
{
ToolCategory::McpRead
} else if name.starts_with("mcp_") {
ToolCategory::McpAction
} else if matches!(
name,
"read"
| "read_file"
| "list_dir"
| "work_update"
| "todo_write"
| "todo_read"
| "checklist_write"
| "note"
| "update_plan"
| "search"
| "file_search"
| "grep_files"
| "git_status"
| "git_diff"
| "git_log"
| "git_show"
| "git_blame"
| "project"
| "diagnostics"
) || name.starts_with("read_")
|| name.starts_with("list_")
|| name.starts_with("get_")
{
ToolCategory::Safe
} else if matches!(name, "start_mcp_server" | "start_registry_mcp_server") {
ToolCategory::McpAction
} else {
ToolCategory::Unknown
}
}
#[must_use]
pub fn get_tool_category_for_call(name: &str, params: &Value) -> ToolCategory {
get_tool_category(canonical_action_alias(name, params))
}
#[must_use]
pub fn classify_stakes(
tool_name: &str,
category: ToolCategory,
risk: RiskLevel,
params: &Value,
) -> ApprovalStakes {
if matches!(risk, RiskLevel::Benign) {
return ApprovalStakes::Routine;
}
let semantic_name = canonical_action_alias(tool_name, params);
match crate::tui::auto_review::ToolActionKind::from_tool_call(semantic_name, params, category) {
crate::tui::auto_review::ToolActionKind::Publish
| crate::tui::auto_review::ToolActionKind::Destructive => ApprovalStakes::Critical,
_ => ApprovalStakes::Elevated,
}
}
#[must_use]
pub fn classify_risk(tool_name: &str, category: ToolCategory, params: &Value) -> RiskLevel {
let tool_name = canonical_action_alias(tool_name, params);
match category {
ToolCategory::Safe | ToolCategory::McpRead => RiskLevel::Benign,
ToolCategory::Network => match tool_name {
"web_search" | "wait_for_dev_server" | "registry_sync" => RiskLevel::Benign,
"web_run" => {
let fetches_url = params
.get("open")
.and_then(Value::as_array)
.is_some_and(|a| !a.is_empty())
|| params
.get("click")
.and_then(Value::as_array)
.is_some_and(|a| !a.is_empty());
if fetches_url {
RiskLevel::Destructive
} else {
RiskLevel::Benign
}
}
_ => RiskLevel::Destructive,
},
ToolCategory::Shell => {
if let Some(cmd) = params.get("command").and_then(Value::as_str)
&& is_parallel_readonly_command(cmd)
{
return RiskLevel::Benign;
}
RiskLevel::Destructive
}
ToolCategory::Agent => match params.get("action").and_then(Value::as_str) {
Some("status" | "peek" | "list") => RiskLevel::Benign,
_ => RiskLevel::Destructive,
},
ToolCategory::FileWrite | ToolCategory::McpAction | ToolCategory::Unknown => {
RiskLevel::Destructive
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn classifies_read_only_surfaces_as_benign() {
for name in ["read_file", "list_dir", "list_mcp_tools", "web_search"] {
let category = get_tool_category(name);
assert_eq!(
classify_risk(name, category, &json!({})),
RiskLevel::Benign,
"{name}"
);
}
}
#[test]
fn classifies_stateful_or_unknown_surfaces_as_destructive() {
for name in [
"write_file",
"edit_file",
"apply_patch",
"mcp_linear_save_issue",
"fetch_url",
"unknown_tool",
] {
let category = get_tool_category(name);
assert_eq!(
classify_risk(name, category, &json!({})),
RiskLevel::Destructive,
"{name}"
);
}
}
#[test]
fn shell_risk_uses_command_safety_analysis() {
let category = get_tool_category("exec_shell");
assert_eq!(
classify_risk(
"exec_shell",
category,
&json!({"command": "git status --short"})
),
RiskLevel::Benign
);
assert_eq!(
classify_risk(
"exec_shell",
category,
&json!({"command": "rm -rf /tmp/example"})
),
RiskLevel::Destructive
);
}
#[test]
fn shell_exec_flags_are_not_benign() {
let category = get_tool_category("exec_shell");
for command in [
"fd -x ./pwn.sh",
"fd -uHtx ./pwn.sh",
"rg --pre /tmp/evil.sh needle .",
"git grep -O needle",
"git grep -nO needle",
] {
assert_eq!(
classify_risk("exec_shell", category, &json!({"command": command})),
RiskLevel::Destructive,
"{command} should not be classified as benign"
);
}
for command in [
"fd -e rs .",
"fd -H --type f src",
"rg needle crates/",
"git grep needle crates/",
"git grep -n needle crates/",
] {
assert_eq!(
classify_risk("exec_shell", category, &json!({"command": command})),
RiskLevel::Benign,
"{command} should remain benign"
);
}
}
#[test]
fn web_run_open_and_click_fetch_remote_content() {
let category = get_tool_category("web_run");
assert_eq!(
classify_risk(
"web_run",
category,
&json!({"search_query": [{"q": "rust"}]})
),
RiskLevel::Benign
);
assert_eq!(
classify_risk("web_run", category, &json!({"open": [{"ref_id": "x"}]})),
RiskLevel::Destructive
);
assert_eq!(
classify_risk(
"web_run",
category,
&json!({"click": [{"ref_id": "x", "id": 1}]})
),
RiskLevel::Destructive
);
}
#[test]
fn canonical_actions_keep_legacy_approval_categories_and_risk() {
let cases = [
("Bash", "run", ToolCategory::Shell, RiskLevel::Destructive),
("Bash", "wait", ToolCategory::Shell, RiskLevel::Destructive),
(
"Bash",
"interact",
ToolCategory::Shell,
RiskLevel::Destructive,
),
(
"Bash",
"cancel",
ToolCategory::Shell,
RiskLevel::Destructive,
),
("File", "read", ToolCategory::Safe, RiskLevel::Benign),
("File", "list", ToolCategory::Safe, RiskLevel::Benign),
("File", "search_name", ToolCategory::Safe, RiskLevel::Benign),
(
"File",
"search_content",
ToolCategory::Safe,
RiskLevel::Benign,
),
(
"File",
"write",
ToolCategory::FileWrite,
RiskLevel::Destructive,
),
(
"File",
"edit",
ToolCategory::FileWrite,
RiskLevel::Destructive,
),
(
"File",
"patch",
ToolCategory::FileWrite,
RiskLevel::Destructive,
),
("Git", "status", ToolCategory::Safe, RiskLevel::Benign),
("Git", "diff", ToolCategory::Safe, RiskLevel::Benign),
("Git", "log", ToolCategory::Safe, RiskLevel::Benign),
("Git", "show", ToolCategory::Safe, RiskLevel::Benign),
("Git", "blame", ToolCategory::Safe, RiskLevel::Benign),
(
"Run",
"tests",
ToolCategory::Unknown,
RiskLevel::Destructive,
),
(
"Run",
"verifiers",
ToolCategory::Unknown,
RiskLevel::Destructive,
),
("Web", "search", ToolCategory::Network, RiskLevel::Benign),
(
"Web",
"fetch",
ToolCategory::Network,
RiskLevel::Destructive,
),
("Web", "wait", ToolCategory::Network, RiskLevel::Benign),
];
for (family, action, expected_category, expected_risk) in cases {
let params = json!({"action": action});
let category = get_tool_category_for_call(family, ¶ms);
assert_eq!(category, expected_category, "{family}.{action}");
assert_eq!(
classify_risk(family, category, ¶ms),
expected_risk,
"{family}.{action}"
);
}
}
}