| 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::tools::canonical_action::canonical_action_alias; |
| 8 | use codewhale_execpolicy::command_safety::is_parallel_readonly_command; |
| 9 | use serde_json::Value; |
| 10 | |
| 11 | // Tool categorization is runtime policy (the extension host and auto-review |
| 12 | // both consult it), so it lives in `core::authority`; re-exported here for the |
| 13 | // approval views. |
| 14 | pub use crate::core::authority::{ToolCategory, get_tool_category_for_call}; |
| 15 | |
| 16 | /// Stakes-based variant for the takeover modal. |
| 17 | /// |
| 18 | /// `RiskLevel::Benign` lets a single keystroke commit the approval. |
| 19 | /// `RiskLevel::Destructive` keeps stronger warning copy and styling |
| 20 | /// around approvals that can touch files, shell, or remote state. |
| 21 | /// |
| 22 | /// Routing rules live in [`classify_risk`] - when in doubt, route to |
| 23 | /// `Destructive`. |
| 24 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 25 | pub enum RiskLevel { |
| 26 | Benign, |
| 27 | Destructive, |
| 28 | } |
| 29 | |
| 30 | /// Presentation-level stakes for the approval prompt (#3883 follow-up). |
| 31 | /// |
| 32 | /// `RiskLevel` drives keymaps and stays conservative ("not provably |
| 33 | /// read-only" is `Destructive`), but rendering everything in that bucket |
| 34 | /// as a red DESTRUCTIVE takeover made routine file edits and build |
| 35 | /// commands read like emergencies. Stakes split presentation three ways: |
| 36 | /// |
| 37 | /// - `Routine` - provably read-only; minimal chrome. |
| 38 | /// - `Elevated` - ordinary state-touching work (edits, builds, MCP |
| 39 | /// actions); a calm approval, not a warning. |
| 40 | /// - `Critical` - genuinely destructive, publish-like, or |
| 41 | /// secret-touching per `ToolActionKind`; keeps the strong styling and |
| 42 | /// the policy semantics lines. |
| 43 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 44 | pub enum ApprovalStakes { |
| 45 | Routine, |
| 46 | Elevated, |
| 47 | Critical, |
| 48 | } |
| 49 | |
| 50 | #[must_use] |
| 51 | pub fn classify_stakes( |
| 52 | tool_name: &str, |
| 53 | category: ToolCategory, |
| 54 | risk: RiskLevel, |
| 55 | params: &Value, |
| 56 | ) -> ApprovalStakes { |
| 57 | if matches!(risk, RiskLevel::Benign) { |
| 58 | return ApprovalStakes::Routine; |
| 59 | } |
| 60 | let semantic_name = canonical_action_alias(tool_name, params); |
| 61 | match crate::tui::auto_review::ToolActionKind::from_tool_call( |
| 62 | semantic_name, |
| 63 | params, |
| 64 | category, |
| 65 | None, |
| 66 | ) { |
| 67 | crate::tui::auto_review::ToolActionKind::Publish |
| 68 | | crate::tui::auto_review::ToolActionKind::Destructive => ApprovalStakes::Critical, |
| 69 | _ => ApprovalStakes::Elevated, |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | /// Decide the stakes variant for an approval request. |
| 74 | /// |
| 75 | /// The bias is conservative: a category we don't recognise routes to |
| 76 | /// `Destructive`, and any shell command that `command_safety` flags as |
| 77 | /// `Dangerous` is forced to `Destructive` even when the rest of the |
| 78 | /// request looks calm. The split lets the modal render stronger warning |
| 79 | /// copy on anything that can touch state outside this turn. |
| 80 | #[must_use] |
| 81 | pub fn classify_risk(tool_name: &str, category: ToolCategory, params: &Value) -> RiskLevel { |
| 82 | let tool_name = canonical_action_alias(tool_name, params); |
| 83 | match category { |
| 84 | // Read paths and discovery. |
| 85 | ToolCategory::Safe | ToolCategory::McpRead => RiskLevel::Benign, |
| 86 | // Query-only network is benign; opening a URL pulls arbitrary |
| 87 | // remote content, so it stays destructive. |
| 88 | ToolCategory::Network => match tool_name { |
| 89 | "web_search" | "wait_for_dev_server" | "registry_sync" => RiskLevel::Benign, |
| 90 | // web_run is benign for search/query, but its `open`/`click` |
| 91 | // actions fetch model-supplied URLs (arbitrary remote content) - |
| 92 | // destructive, consistent with fetch_url. |
| 93 | "web_run" => { |
| 94 | let fetches_url = params |
| 95 | .get("open") |
| 96 | .and_then(Value::as_array) |
| 97 | .is_some_and(|a| !a.is_empty()) |
| 98 | || params |
| 99 | .get("click") |
| 100 | .and_then(Value::as_array) |
| 101 | .is_some_and(|a| !a.is_empty()); |
| 102 | if fetches_url { |
| 103 | RiskLevel::Destructive |
| 104 | } else { |
| 105 | RiskLevel::Benign |
| 106 | } |
| 107 | } |
| 108 | _ => RiskLevel::Destructive, |
| 109 | }, |
| 110 | // Shell stays destructive unless the existing command-safety analyzer |
| 111 | // can prove the concrete command is read-only. |
| 112 | ToolCategory::Shell => { |
| 113 | if let Some(cmd) = params.get("command").and_then(Value::as_str) |
| 114 | && is_parallel_readonly_command(cmd) |
| 115 | { |
| 116 | return RiskLevel::Benign; |
| 117 | } |
| 118 | RiskLevel::Destructive |
| 119 | } |
| 120 | // Sub-agent lifecycle: status/peek are inspection-only. Starts and |
| 121 | // other actions keep the explicit-options keymap (the child's own |
| 122 | // gates govern what it may do once running). |
| 123 | ToolCategory::Agent => match params.get("action").and_then(Value::as_str) { |
| 124 | Some("status" | "peek" | "list") => RiskLevel::Benign, |
| 125 | _ => RiskLevel::Destructive, |
| 126 | }, |
| 127 | // File writes, MCP actions, unclassified surfaces - all require |
| 128 | // explicit confirmation. |
| 129 | ToolCategory::FileWrite | ToolCategory::McpAction | ToolCategory::Unknown => { |
| 130 | RiskLevel::Destructive |
| 131 | } |
| 132 | } |
| 133 | } |
| 134 | |
| 135 | #[cfg(test)] |
| 136 | mod tests { |
| 137 | use super::*; |
| 138 | use crate::core::authority::get_tool_category; |
| 139 | use serde_json::json; |
| 140 | |
| 141 | #[test] |
| 142 | fn classifies_read_only_surfaces_as_benign() { |
| 143 | for name in ["read_file", "list_dir", "list_mcp_tools", "web_search"] { |
| 144 | let category = get_tool_category(name); |
| 145 | assert_eq!( |
| 146 | classify_risk(name, category, &json!({})), |
| 147 | RiskLevel::Benign, |
| 148 | "{name}" |
| 149 | ); |
| 150 | } |
| 151 | } |
| 152 | |
| 153 | #[test] |
| 154 | fn classifies_stateful_or_unknown_surfaces_as_destructive() { |
| 155 | for name in [ |
| 156 | "write_file", |
| 157 | "edit_file", |
| 158 | "apply_patch", |
| 159 | "mcp_linear_save_issue", |
| 160 | "fetch_url", |
| 161 | "unknown_tool", |
| 162 | ] { |
| 163 | let category = get_tool_category(name); |
| 164 | assert_eq!( |
| 165 | classify_risk(name, category, &json!({})), |
| 166 | RiskLevel::Destructive, |
| 167 | "{name}" |
| 168 | ); |
| 169 | } |
| 170 | } |
| 171 | |
| 172 | #[test] |
| 173 | fn shell_risk_uses_command_safety_analysis() { |
| 174 | let category = get_tool_category("exec_shell"); |
| 175 | assert_eq!( |
| 176 | classify_risk( |
| 177 | "exec_shell", |
| 178 | category, |
| 179 | &json!({"command": "git status --short"}) |
| 180 | ), |
| 181 | RiskLevel::Benign |
| 182 | ); |
| 183 | assert_eq!( |
| 184 | classify_risk( |
| 185 | "exec_shell", |
| 186 | category, |
| 187 | &json!({"command": "rm -rf /tmp/example"}) |
| 188 | ), |
| 189 | RiskLevel::Destructive |
| 190 | ); |
| 191 | } |
| 192 | |
| 193 | #[test] |
| 194 | fn shell_exec_flags_are_not_benign() { |
| 195 | let category = get_tool_category("exec_shell"); |
| 196 | for command in [ |
| 197 | "fd -x ./pwn.sh", |
| 198 | "fd -uHtx ./pwn.sh", |
| 199 | "rg --pre /tmp/evil.sh needle .", |
| 200 | "git grep -O needle", |
| 201 | "git grep -nO needle", |
| 202 | ] { |
| 203 | assert_eq!( |
| 204 | classify_risk("exec_shell", category, &json!({"command": command})), |
| 205 | RiskLevel::Destructive, |
| 206 | "{command} should not be classified as benign" |
| 207 | ); |
| 208 | } |
| 209 | |
| 210 | for command in [ |
| 211 | "fd -e rs .", |
| 212 | "fd -H --type f src", |
| 213 | "rg needle crates/", |
| 214 | "git grep needle crates/", |
| 215 | "git grep -n needle crates/", |
| 216 | ] { |
| 217 | assert_eq!( |
| 218 | classify_risk("exec_shell", category, &json!({"command": command})), |
| 219 | RiskLevel::Benign, |
| 220 | "{command} should remain benign" |
| 221 | ); |
| 222 | } |
| 223 | } |
| 224 | |
| 225 | #[test] |
| 226 | fn web_run_open_and_click_fetch_remote_content() { |
| 227 | let category = get_tool_category("web_run"); |
| 228 | assert_eq!( |
| 229 | classify_risk( |
| 230 | "web_run", |
| 231 | category, |
| 232 | &json!({"search_query": [{"q": "rust"}]}) |
| 233 | ), |
| 234 | RiskLevel::Benign |
| 235 | ); |
| 236 | assert_eq!( |
| 237 | classify_risk("web_run", category, &json!({"open": [{"ref_id": "x"}]})), |
| 238 | RiskLevel::Destructive |
| 239 | ); |
| 240 | assert_eq!( |
| 241 | classify_risk( |
| 242 | "web_run", |
| 243 | category, |
| 244 | &json!({"click": [{"ref_id": "x", "id": 1}]}) |
| 245 | ), |
| 246 | RiskLevel::Destructive |
| 247 | ); |
| 248 | } |
| 249 | |
| 250 | #[test] |
| 251 | fn canonical_actions_keep_legacy_approval_categories_and_risk() { |
| 252 | let cases = [ |
| 253 | ("Bash", "run", ToolCategory::Shell, RiskLevel::Destructive), |
| 254 | ("Bash", "wait", ToolCategory::Shell, RiskLevel::Destructive), |
| 255 | ( |
| 256 | "Bash", |
| 257 | "interact", |
| 258 | ToolCategory::Shell, |
| 259 | RiskLevel::Destructive, |
| 260 | ), |
| 261 | ( |
| 262 | "Bash", |
| 263 | "cancel", |
| 264 | ToolCategory::Shell, |
| 265 | RiskLevel::Destructive, |
| 266 | ), |
| 267 | ("File", "read", ToolCategory::Safe, RiskLevel::Benign), |
| 268 | ("File", "list", ToolCategory::Safe, RiskLevel::Benign), |
| 269 | ("File", "search_name", ToolCategory::Safe, RiskLevel::Benign), |
| 270 | ( |
| 271 | "File", |
| 272 | "search_content", |
| 273 | ToolCategory::Safe, |
| 274 | RiskLevel::Benign, |
| 275 | ), |
| 276 | ( |
| 277 | "File", |
| 278 | "write", |
| 279 | ToolCategory::FileWrite, |
| 280 | RiskLevel::Destructive, |
| 281 | ), |
| 282 | ( |
| 283 | "File", |
| 284 | "edit", |
| 285 | ToolCategory::FileWrite, |
| 286 | RiskLevel::Destructive, |
| 287 | ), |
| 288 | ( |
| 289 | "File", |
| 290 | "patch", |
| 291 | ToolCategory::FileWrite, |
| 292 | RiskLevel::Destructive, |
| 293 | ), |
| 294 | ("Git", "status", ToolCategory::Safe, RiskLevel::Benign), |
| 295 | ("Git", "diff", ToolCategory::Safe, RiskLevel::Benign), |
| 296 | ("Git", "log", ToolCategory::Safe, RiskLevel::Benign), |
| 297 | ("Git", "show", ToolCategory::Safe, RiskLevel::Benign), |
| 298 | ("Git", "blame", ToolCategory::Safe, RiskLevel::Benign), |
| 299 | ("Git", "commit_plan", ToolCategory::Safe, RiskLevel::Benign), |
| 300 | ( |
| 301 | "Run", |
| 302 | "tests", |
| 303 | ToolCategory::Unknown, |
| 304 | RiskLevel::Destructive, |
| 305 | ), |
| 306 | ( |
| 307 | "Run", |
| 308 | "verifiers", |
| 309 | ToolCategory::Unknown, |
| 310 | RiskLevel::Destructive, |
| 311 | ), |
| 312 | ("Web", "search", ToolCategory::Network, RiskLevel::Benign), |
| 313 | ( |
| 314 | "Web", |
| 315 | "fetch", |
| 316 | ToolCategory::Network, |
| 317 | RiskLevel::Destructive, |
| 318 | ), |
| 319 | ("Web", "wait", ToolCategory::Network, RiskLevel::Benign), |
| 320 | ]; |
| 321 | |
| 322 | for (family, action, expected_category, expected_risk) in cases { |
| 323 | let params = json!({"action": action}); |
| 324 | let category = get_tool_category_for_call(family, ¶ms); |
| 325 | assert_eq!(category, expected_category, "{family}.{action}"); |
| 326 | assert_eq!( |
| 327 | classify_risk(family, category, ¶ms), |
| 328 | expected_risk, |
| 329 | "{family}.{action}" |
| 330 | ); |
| 331 | } |
| 332 | } |
| 333 | } |
| 334 |