use crate::command_safety::is_parallel_readonly_command;
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_file" | "edit_file" | "apply_patch") {
ToolCategory::FileWrite
} else if matches!(
name,
"web_run" | "web_search" | "fetch_url" | "wait_for_dev_server"
) {
ToolCategory::Network
} else if matches!(
name,
"exec_shell"
| "task_shell_start"
| "task_shell_wait"
| "exec_shell_wait"
| "exec_shell_interact"
| "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_file"
| "list_dir"
| "work_update"
| "todo_write"
| "todo_read"
| "checklist_write"
| "note"
| "update_plan"
| "search"
| "file_search"
| "project"
| "diagnostics"
) || name.starts_with("read_")
|| name.starts_with("list_")
|| name.starts_with("get_")
{
ToolCategory::Safe
} else if name == "start_mcp_server" {
ToolCategory::McpAction
} else {
ToolCategory::Unknown
}
}
#[must_use]
pub fn classify_stakes(
tool_name: &str,
category: ToolCategory,
risk: RiskLevel,
params: &Value,
) -> ApprovalStakes {
if matches!(risk, RiskLevel::Benign) {
return ApprovalStakes::Routine;
}
match crate::tui::auto_review::ToolActionKind::from_tool_call(tool_name, params, category) {
crate::tui::auto_review::ToolActionKind::Publish
| crate::tui::auto_review::ToolActionKind::Destructive
| crate::tui::auto_review::ToolActionKind::Secret => ApprovalStakes::Critical,
_ => ApprovalStakes::Elevated,
}
}
#[must_use]
pub fn classify_risk(tool_name: &str, category: ToolCategory, params: &Value) -> RiskLevel {
match category {
ToolCategory::Safe | ToolCategory::McpRead => RiskLevel::Benign,
ToolCategory::Network => match tool_name {
"web_search" | "wait_for_dev_server" => 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
);
}
}