| 1 | //! Approval risk and stakes policy. |
| 2 | //! |
| 3 | //! This module is intentionally UI-free: it classifies tool calls so the |
| 4 | //! approval and elevation views can render the decision without owning the |
| 5 | //! policy itself. |
| 6 | |
| 7 | use crate::command_safety::is_parallel_readonly_command; |
| 8 | use crate::tools::canonical_action::canonical_action_alias; |
| 9 | use serde_json::Value; |
| 10 | |
| 11 | /// Categorizes tools by cost/risk level. |
| 12 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 13 | pub enum ToolCategory { |
| 14 | /// Free, read-only operations (`list_dir`, `read_file`, todo_*) |
| 15 | Safe, |
| 16 | /// File modifications (`write_file`, `edit_file`) |
| 17 | FileWrite, |
| 18 | /// Shell execution (`exec_shell`) |
| 19 | Shell, |
| 20 | /// Network-oriented built-in tools |
| 21 | Network, |
| 22 | /// Read-only MCP discovery and resource access |
| 23 | McpRead, |
| 24 | /// MCP actions that may change remote state |
| 25 | McpAction, |
| 26 | /// Sub-agent lifecycle (`agent` start/status/peek/cancel); the child's |
| 27 | /// own tool gates govern what it may actually do. |
| 28 | Agent, |
| 29 | /// Unknown or unclassified tool surface |
| 30 | Unknown, |
| 31 | } |
| 32 | |
| 33 | /// Stakes-based variant for the takeover modal. |
| 34 | /// |
| 35 | /// `RiskLevel::Benign` lets a single keystroke commit the approval. |
| 36 | /// `RiskLevel::Destructive` keeps stronger warning copy and styling |
| 37 | /// around approvals that can touch files, shell, or remote state. |
| 38 | /// |
| 39 | /// Routing rules live in [`classify_risk`] - when in doubt, route to |
| 40 | /// `Destructive`. |
| 41 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 42 | pub enum RiskLevel { |
| 43 | Benign, |
| 44 | Destructive, |
| 45 | } |
| 46 | |
| 47 | /// Presentation-level stakes for the approval prompt (#3883 follow-up). |
| 48 | /// |
| 49 | /// `RiskLevel` drives keymaps and stays conservative ("not provably |
| 50 | /// read-only" is `Destructive`), but rendering everything in that bucket |
| 51 | /// as a red DESTRUCTIVE takeover made routine file edits and build |
| 52 | /// commands read like emergencies. Stakes split presentation three ways: |
| 53 | /// |
| 54 | /// - `Routine` - provably read-only; minimal chrome. |
| 55 | /// - `Elevated` - ordinary state-touching work (edits, builds, MCP |
| 56 | /// actions); a calm approval, not a warning. |
| 57 | /// - `Critical` - genuinely destructive, publish-like, or |
| 58 | /// secret-touching per `ToolActionKind`; keeps the strong styling and |
| 59 | /// the policy semantics lines. |
| 60 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 61 | pub enum ApprovalStakes { |
| 62 | Routine, |
| 63 | Elevated, |
| 64 | Critical, |
| 65 | } |
| 66 | |
| 67 | /// Get the category for a tool by name. |
| 68 | pub fn get_tool_category(name: &str) -> ToolCategory { |
| 69 | if name == "agent" || name == "workflow" { |
| 70 | // Workflow is multi-agent orchestration; reuse Agent stakes/routing |
| 71 | // and specialize the impact card via build_impact_summary (#4126). |
| 72 | ToolCategory::Agent |
| 73 | } else if matches!(name, "write_file" | "edit_file" | "apply_patch") { |
| 74 | ToolCategory::FileWrite |
| 75 | } else if matches!( |
| 76 | name, |
| 77 | "web_run" | "web_search" | "fetch_url" | "wait_for_dev_server" | "registry_sync" |
| 78 | ) { |
| 79 | ToolCategory::Network |
| 80 | } else if matches!( |
| 81 | name, |
| 82 | "exec_shell" |
| 83 | | "task_shell_start" |
| 84 | | "task_shell_wait" |
| 85 | | "exec_shell_wait" |
| 86 | | "exec_shell_interact" |
| 87 | | "exec_shell_cancel" |
| 88 | | "exec_wait" |
| 89 | | "exec_interact" |
| 90 | ) { |
| 91 | ToolCategory::Shell |
| 92 | } else if name.starts_with("list_mcp_") |
| 93 | || name.starts_with("read_mcp_") |
| 94 | || name.starts_with("get_mcp_") |
| 95 | { |
| 96 | ToolCategory::McpRead |
| 97 | } else if name.starts_with("mcp_") { |
| 98 | ToolCategory::McpAction |
| 99 | } else if matches!( |
| 100 | name, |
| 101 | "read_file" |
| 102 | | "list_dir" |
| 103 | | "work_update" |
| 104 | | "todo_write" |
| 105 | | "todo_read" |
| 106 | | "checklist_write" |
| 107 | | "note" |
| 108 | | "update_plan" |
| 109 | | "search" |
| 110 | | "file_search" |
| 111 | | "grep_files" |
| 112 | | "git_status" |
| 113 | | "git_diff" |
| 114 | | "git_log" |
| 115 | | "git_show" |
| 116 | | "git_blame" |
| 117 | | "project" |
| 118 | | "diagnostics" |
| 119 | ) || name.starts_with("read_") |
| 120 | || name.starts_with("list_") |
| 121 | || name.starts_with("get_") |
| 122 | { |
| 123 | ToolCategory::Safe |
| 124 | } else if matches!(name, "start_mcp_server" | "start_registry_mcp_server") { |
| 125 | // Starting an MCP server spawns child processes or opens network |
| 126 | // connections — classify as McpAction to trigger appropriate |
| 127 | // approval prompts. |
| 128 | ToolCategory::McpAction |
| 129 | } else { |
| 130 | ToolCategory::Unknown |
| 131 | } |
| 132 | } |
| 133 | |
| 134 | /// Categorize a concrete call after resolving an action-based canonical tool. |
| 135 | #[must_use] |
| 136 | pub fn get_tool_category_for_call(name: &str, params: &Value) -> ToolCategory { |
| 137 | get_tool_category(canonical_action_alias(name, params)) |
| 138 | } |
| 139 | |
| 140 | #[must_use] |
| 141 | pub fn classify_stakes( |
| 142 | tool_name: &str, |
| 143 | category: ToolCategory, |
| 144 | risk: RiskLevel, |
| 145 | params: &Value, |
| 146 | ) -> ApprovalStakes { |
| 147 | if matches!(risk, RiskLevel::Benign) { |
| 148 | return ApprovalStakes::Routine; |
| 149 | } |
| 150 | let semantic_name = canonical_action_alias(tool_name, params); |
| 151 | match crate::tui::auto_review::ToolActionKind::from_tool_call(semantic_name, params, category) { |
| 152 | crate::tui::auto_review::ToolActionKind::Publish |
| 153 | | crate::tui::auto_review::ToolActionKind::Destructive |
| 154 | | crate::tui::auto_review::ToolActionKind::Secret => ApprovalStakes::Critical, |
| 155 | _ => ApprovalStakes::Elevated, |
| 156 | } |
| 157 | } |
| 158 | |
| 159 | /// Decide the stakes variant for an approval request. |
| 160 | /// |
| 161 | /// The bias is conservative: a category we don't recognise routes to |
| 162 | /// `Destructive`, and any shell command that `command_safety` flags as |
| 163 | /// `Dangerous` is forced to `Destructive` even when the rest of the |
| 164 | /// request looks calm. The split lets the modal render stronger warning |
| 165 | /// copy on anything that can touch state outside this turn. |
| 166 | #[must_use] |
| 167 | pub fn classify_risk(tool_name: &str, category: ToolCategory, params: &Value) -> RiskLevel { |
| 168 | let tool_name = canonical_action_alias(tool_name, params); |
| 169 | match category { |
| 170 | // Read paths and discovery. |
| 171 | ToolCategory::Safe | ToolCategory::McpRead => RiskLevel::Benign, |
| 172 | // Query-only network is benign; opening a URL pulls arbitrary |
| 173 | // remote content, so it stays destructive. |
| 174 | ToolCategory::Network => match tool_name { |
| 175 | "web_search" | "wait_for_dev_server" | "registry_sync" => RiskLevel::Benign, |
| 176 | // web_run is benign for search/query, but its `open`/`click` |
| 177 | // actions fetch model-supplied URLs (arbitrary remote content) - |
| 178 | // destructive, consistent with fetch_url. |
| 179 | "web_run" => { |
| 180 | let fetches_url = params |
| 181 | .get("open") |
| 182 | .and_then(Value::as_array) |
| 183 | .is_some_and(|a| !a.is_empty()) |
| 184 | || params |
| 185 | .get("click") |
| 186 | .and_then(Value::as_array) |
| 187 | .is_some_and(|a| !a.is_empty()); |
| 188 | if fetches_url { |
| 189 | RiskLevel::Destructive |
| 190 | } else { |
| 191 | RiskLevel::Benign |
| 192 | } |
| 193 | } |
| 194 | _ => RiskLevel::Destructive, |
| 195 | }, |
| 196 | // Shell stays destructive unless the existing command-safety analyzer |
| 197 | // can prove the concrete command is read-only. |
| 198 | ToolCategory::Shell => { |
| 199 | if let Some(cmd) = params.get("command").and_then(Value::as_str) |
| 200 | && is_parallel_readonly_command(cmd) |
| 201 | { |
| 202 | return RiskLevel::Benign; |
| 203 | } |
| 204 | RiskLevel::Destructive |
| 205 | } |
| 206 | // Sub-agent lifecycle: status/peek are inspection-only. Starts and |
| 207 | // other actions keep the explicit-options keymap (the child's own |
| 208 | // gates govern what it may do once running). |
| 209 | ToolCategory::Agent => match params.get("action").and_then(Value::as_str) { |
| 210 | Some("status" | "peek" | "list") => RiskLevel::Benign, |
| 211 | _ => RiskLevel::Destructive, |
| 212 | }, |
| 213 | // File writes, MCP actions, unclassified surfaces - all require |
| 214 | // explicit confirmation. |
| 215 | ToolCategory::FileWrite | ToolCategory::McpAction | ToolCategory::Unknown => { |
| 216 | RiskLevel::Destructive |
| 217 | } |
| 218 | } |
| 219 | } |
| 220 | |
| 221 | #[cfg(test)] |
| 222 | mod tests { |
| 223 | use super::*; |
| 224 | use serde_json::json; |
| 225 | |
| 226 | #[test] |
| 227 | fn classifies_read_only_surfaces_as_benign() { |
| 228 | for name in ["read_file", "list_dir", "list_mcp_tools", "web_search"] { |
| 229 | let category = get_tool_category(name); |
| 230 | assert_eq!( |
| 231 | classify_risk(name, category, &json!({})), |
| 232 | RiskLevel::Benign, |
| 233 | "{name}" |
| 234 | ); |
| 235 | } |
| 236 | } |
| 237 | |
| 238 | #[test] |
| 239 | fn classifies_stateful_or_unknown_surfaces_as_destructive() { |
| 240 | for name in [ |
| 241 | "write_file", |
| 242 | "edit_file", |
| 243 | "apply_patch", |
| 244 | "mcp_linear_save_issue", |
| 245 | "fetch_url", |
| 246 | "unknown_tool", |
| 247 | ] { |
| 248 | let category = get_tool_category(name); |
| 249 | assert_eq!( |
| 250 | classify_risk(name, category, &json!({})), |
| 251 | RiskLevel::Destructive, |
| 252 | "{name}" |
| 253 | ); |
| 254 | } |
| 255 | } |
| 256 | |
| 257 | #[test] |
| 258 | fn shell_risk_uses_command_safety_analysis() { |
| 259 | let category = get_tool_category("exec_shell"); |
| 260 | assert_eq!( |
| 261 | classify_risk( |
| 262 | "exec_shell", |
| 263 | category, |
| 264 | &json!({"command": "git status --short"}) |
| 265 | ), |
| 266 | RiskLevel::Benign |
| 267 | ); |
| 268 | assert_eq!( |
| 269 | classify_risk( |
| 270 | "exec_shell", |
| 271 | category, |
| 272 | &json!({"command": "rm -rf /tmp/example"}) |
| 273 | ), |
| 274 | RiskLevel::Destructive |
| 275 | ); |
| 276 | } |
| 277 | |
| 278 | #[test] |
| 279 | fn shell_exec_flags_are_not_benign() { |
| 280 | let category = get_tool_category("exec_shell"); |
| 281 | for command in [ |
| 282 | "fd -x ./pwn.sh", |
| 283 | "fd -uHtx ./pwn.sh", |
| 284 | "rg --pre /tmp/evil.sh needle .", |
| 285 | "git grep -O needle", |
| 286 | "git grep -nO needle", |
| 287 | ] { |
| 288 | assert_eq!( |
| 289 | classify_risk("exec_shell", category, &json!({"command": command})), |
| 290 | RiskLevel::Destructive, |
| 291 | "{command} should not be classified as benign" |
| 292 | ); |
| 293 | } |
| 294 | |
| 295 | for command in [ |
| 296 | "fd -e rs .", |
| 297 | "fd -H --type f src", |
| 298 | "rg needle crates/", |
| 299 | "git grep needle crates/", |
| 300 | "git grep -n needle crates/", |
| 301 | ] { |
| 302 | assert_eq!( |
| 303 | classify_risk("exec_shell", category, &json!({"command": command})), |
| 304 | RiskLevel::Benign, |
| 305 | "{command} should remain benign" |
| 306 | ); |
| 307 | } |
| 308 | } |
| 309 | |
| 310 | #[test] |
| 311 | fn web_run_open_and_click_fetch_remote_content() { |
| 312 | let category = get_tool_category("web_run"); |
| 313 | assert_eq!( |
| 314 | classify_risk( |
| 315 | "web_run", |
| 316 | category, |
| 317 | &json!({"search_query": [{"q": "rust"}]}) |
| 318 | ), |
| 319 | RiskLevel::Benign |
| 320 | ); |
| 321 | assert_eq!( |
| 322 | classify_risk("web_run", category, &json!({"open": [{"ref_id": "x"}]})), |
| 323 | RiskLevel::Destructive |
| 324 | ); |
| 325 | assert_eq!( |
| 326 | classify_risk( |
| 327 | "web_run", |
| 328 | category, |
| 329 | &json!({"click": [{"ref_id": "x", "id": 1}]}) |
| 330 | ), |
| 331 | RiskLevel::Destructive |
| 332 | ); |
| 333 | } |
| 334 | |
| 335 | #[test] |
| 336 | fn canonical_actions_keep_legacy_approval_categories_and_risk() { |
| 337 | let cases = [ |
| 338 | ("Bash", "run", ToolCategory::Shell, RiskLevel::Destructive), |
| 339 | ("Bash", "wait", ToolCategory::Shell, RiskLevel::Destructive), |
| 340 | ( |
| 341 | "Bash", |
| 342 | "interact", |
| 343 | ToolCategory::Shell, |
| 344 | RiskLevel::Destructive, |
| 345 | ), |
| 346 | ( |
| 347 | "Bash", |
| 348 | "cancel", |
| 349 | ToolCategory::Shell, |
| 350 | RiskLevel::Destructive, |
| 351 | ), |
| 352 | ("File", "read", ToolCategory::Safe, RiskLevel::Benign), |
| 353 | ("File", "list", ToolCategory::Safe, RiskLevel::Benign), |
| 354 | ("File", "search_name", ToolCategory::Safe, RiskLevel::Benign), |
| 355 | ( |
| 356 | "File", |
| 357 | "search_content", |
| 358 | ToolCategory::Safe, |
| 359 | RiskLevel::Benign, |
| 360 | ), |
| 361 | ( |
| 362 | "File", |
| 363 | "write", |
| 364 | ToolCategory::FileWrite, |
| 365 | RiskLevel::Destructive, |
| 366 | ), |
| 367 | ( |
| 368 | "File", |
| 369 | "edit", |
| 370 | ToolCategory::FileWrite, |
| 371 | RiskLevel::Destructive, |
| 372 | ), |
| 373 | ( |
| 374 | "File", |
| 375 | "patch", |
| 376 | ToolCategory::FileWrite, |
| 377 | RiskLevel::Destructive, |
| 378 | ), |
| 379 | ("Git", "status", ToolCategory::Safe, RiskLevel::Benign), |
| 380 | ("Git", "diff", ToolCategory::Safe, RiskLevel::Benign), |
| 381 | ("Git", "log", ToolCategory::Safe, RiskLevel::Benign), |
| 382 | ("Git", "show", ToolCategory::Safe, RiskLevel::Benign), |
| 383 | ("Git", "blame", ToolCategory::Safe, RiskLevel::Benign), |
| 384 | ( |
| 385 | "Run", |
| 386 | "tests", |
| 387 | ToolCategory::Unknown, |
| 388 | RiskLevel::Destructive, |
| 389 | ), |
| 390 | ( |
| 391 | "Run", |
| 392 | "verifiers", |
| 393 | ToolCategory::Unknown, |
| 394 | RiskLevel::Destructive, |
| 395 | ), |
| 396 | ("Web", "search", ToolCategory::Network, RiskLevel::Benign), |
| 397 | ( |
| 398 | "Web", |
| 399 | "fetch", |
| 400 | ToolCategory::Network, |
| 401 | RiskLevel::Destructive, |
| 402 | ), |
| 403 | ("Web", "wait", ToolCategory::Network, RiskLevel::Benign), |
| 404 | ]; |
| 405 | |
| 406 | for (family, action, expected_category, expected_risk) in cases { |
| 407 | let params = json!({"action": action}); |
| 408 | let category = get_tool_category_for_call(family, ¶ms); |
| 409 | assert_eq!(category, expected_category, "{family}.{action}"); |
| 410 | assert_eq!( |
| 411 | classify_risk(family, category, ¶ms), |
| 412 | expected_risk, |
| 413 | "{family}.{action}" |
| 414 | ); |
| 415 | } |
| 416 | } |
| 417 | } |
| 418 |