返回 CodeWhale
policy.rs
根目录 / crates / tui / src / tui / approval / policy.rs
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 /// 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!(
74 name,
75 "write" | "edit" | "write_file" | "edit_file" | "apply_patch"
76 ) {
77 ToolCategory::FileWrite
78 } else if matches!(
79 name,
80 "web_run" | "web_search" | "fetch_url" | "wait_for_dev_server" | "registry_sync"
81 ) {
82 ToolCategory::Network
83 } else if matches!(
84 name,
85 "bash"
86 | "Bash"
87 | "exec_shell"
88 | "task_shell_start"
89 | "task_shell_wait"
90 | "exec_shell_wait"
91 | "exec_shell_interact"
92 | "exec_shell_cancel"
93 | "exec_wait"
94 | "exec_interact"
95 ) {
96 ToolCategory::Shell
97 } else if name.starts_with("list_mcp_")
98 || name.starts_with("read_mcp_")
99 || name.starts_with("get_mcp_")
100 {
101 ToolCategory::McpRead
102 } else if name.starts_with("mcp_") {
103 ToolCategory::McpAction
104 } else if matches!(
105 name,
106 "read"
107 | "read_file"
108 | "list_dir"
109 | "work_update"
110 | "todo_write"
111 | "todo_read"
112 | "checklist_write"
113 | "note"
114 | "update_plan"
115 | "search"
116 | "file_search"
117 | "grep_files"
118 | "git_status"
119 | "git_diff"
120 | "git_log"
121 | "git_show"
122 | "git_blame"
123 | "git_commit_plan"
124 | "project"
125 | "diagnostics"
126 ) || name.starts_with("read_")
127 || name.starts_with("list_")
128 || name.starts_with("get_")
129 {
130 ToolCategory::Safe
131 } else if matches!(name, "start_mcp_server" | "start_registry_mcp_server") {
132 // Starting an MCP server spawns child processes or opens network
133 // connections — classify as McpAction to trigger appropriate
134 // approval prompts.
135 ToolCategory::McpAction
136 } else {
137 ToolCategory::Unknown
138 }
139 }
140
141 /// Categorize a concrete call after resolving an action-based canonical tool.
142 #[must_use]
143 pub fn get_tool_category_for_call(name: &str, params: &Value) -> ToolCategory {
144 get_tool_category(canonical_action_alias(name, params))
145 }
146
147 #[must_use]
148 pub fn classify_stakes(
149 tool_name: &str,
150 category: ToolCategory,
151 risk: RiskLevel,
152 params: &Value,
153 ) -> ApprovalStakes {
154 if matches!(risk, RiskLevel::Benign) {
155 return ApprovalStakes::Routine;
156 }
157 let semantic_name = canonical_action_alias(tool_name, params);
158 match crate::tui::auto_review::ToolActionKind::from_tool_call(semantic_name, params, category) {
159 crate::tui::auto_review::ToolActionKind::Publish
160 | crate::tui::auto_review::ToolActionKind::Destructive => ApprovalStakes::Critical,
161 _ => ApprovalStakes::Elevated,
162 }
163 }
164
165 /// Decide the stakes variant for an approval request.
166 ///
167 /// The bias is conservative: a category we don't recognise routes to
168 /// `Destructive`, and any shell command that `command_safety` flags as
169 /// `Dangerous` is forced to `Destructive` even when the rest of the
170 /// request looks calm. The split lets the modal render stronger warning
171 /// copy on anything that can touch state outside this turn.
172 #[must_use]
173 pub fn classify_risk(tool_name: &str, category: ToolCategory, params: &Value) -> RiskLevel {
174 let tool_name = canonical_action_alias(tool_name, params);
175 match category {
176 // Read paths and discovery.
177 ToolCategory::Safe | ToolCategory::McpRead => RiskLevel::Benign,
178 // Query-only network is benign; opening a URL pulls arbitrary
179 // remote content, so it stays destructive.
180 ToolCategory::Network => match tool_name {
181 "web_search" | "wait_for_dev_server" | "registry_sync" => RiskLevel::Benign,
182 // web_run is benign for search/query, but its `open`/`click`
183 // actions fetch model-supplied URLs (arbitrary remote content) -
184 // destructive, consistent with fetch_url.
185 "web_run" => {
186 let fetches_url = params
187 .get("open")
188 .and_then(Value::as_array)
189 .is_some_and(|a| !a.is_empty())
190 || params
191 .get("click")
192 .and_then(Value::as_array)
193 .is_some_and(|a| !a.is_empty());
194 if fetches_url {
195 RiskLevel::Destructive
196 } else {
197 RiskLevel::Benign
198 }
199 }
200 _ => RiskLevel::Destructive,
201 },
202 // Shell stays destructive unless the existing command-safety analyzer
203 // can prove the concrete command is read-only.
204 ToolCategory::Shell => {
205 if let Some(cmd) = params.get("command").and_then(Value::as_str)
206 && is_parallel_readonly_command(cmd)
207 {
208 return RiskLevel::Benign;
209 }
210 RiskLevel::Destructive
211 }
212 // Sub-agent lifecycle: status/peek are inspection-only. Starts and
213 // other actions keep the explicit-options keymap (the child's own
214 // gates govern what it may do once running).
215 ToolCategory::Agent => match params.get("action").and_then(Value::as_str) {
216 Some("status" | "peek" | "list") => RiskLevel::Benign,
217 _ => RiskLevel::Destructive,
218 },
219 // File writes, MCP actions, unclassified surfaces - all require
220 // explicit confirmation.
221 ToolCategory::FileWrite | ToolCategory::McpAction | ToolCategory::Unknown => {
222 RiskLevel::Destructive
223 }
224 }
225 }
226
227 #[cfg(test)]
228 mod tests {
229 use super::*;
230 use serde_json::json;
231
232 #[test]
233 fn classifies_read_only_surfaces_as_benign() {
234 for name in ["read_file", "list_dir", "list_mcp_tools", "web_search"] {
235 let category = get_tool_category(name);
236 assert_eq!(
237 classify_risk(name, category, &json!({})),
238 RiskLevel::Benign,
239 "{name}"
240 );
241 }
242 }
243
244 #[test]
245 fn classifies_stateful_or_unknown_surfaces_as_destructive() {
246 for name in [
247 "write_file",
248 "edit_file",
249 "apply_patch",
250 "mcp_linear_save_issue",
251 "fetch_url",
252 "unknown_tool",
253 ] {
254 let category = get_tool_category(name);
255 assert_eq!(
256 classify_risk(name, category, &json!({})),
257 RiskLevel::Destructive,
258 "{name}"
259 );
260 }
261 }
262
263 #[test]
264 fn shell_risk_uses_command_safety_analysis() {
265 let category = get_tool_category("exec_shell");
266 assert_eq!(
267 classify_risk(
268 "exec_shell",
269 category,
270 &json!({"command": "git status --short"})
271 ),
272 RiskLevel::Benign
273 );
274 assert_eq!(
275 classify_risk(
276 "exec_shell",
277 category,
278 &json!({"command": "rm -rf /tmp/example"})
279 ),
280 RiskLevel::Destructive
281 );
282 }
283
284 #[test]
285 fn shell_exec_flags_are_not_benign() {
286 let category = get_tool_category("exec_shell");
287 for command in [
288 "fd -x ./pwn.sh",
289 "fd -uHtx ./pwn.sh",
290 "rg --pre /tmp/evil.sh needle .",
291 "git grep -O needle",
292 "git grep -nO needle",
293 ] {
294 assert_eq!(
295 classify_risk("exec_shell", category, &json!({"command": command})),
296 RiskLevel::Destructive,
297 "{command} should not be classified as benign"
298 );
299 }
300
301 for command in [
302 "fd -e rs .",
303 "fd -H --type f src",
304 "rg needle crates/",
305 "git grep needle crates/",
306 "git grep -n needle crates/",
307 ] {
308 assert_eq!(
309 classify_risk("exec_shell", category, &json!({"command": command})),
310 RiskLevel::Benign,
311 "{command} should remain benign"
312 );
313 }
314 }
315
316 #[test]
317 fn web_run_open_and_click_fetch_remote_content() {
318 let category = get_tool_category("web_run");
319 assert_eq!(
320 classify_risk(
321 "web_run",
322 category,
323 &json!({"search_query": [{"q": "rust"}]})
324 ),
325 RiskLevel::Benign
326 );
327 assert_eq!(
328 classify_risk("web_run", category, &json!({"open": [{"ref_id": "x"}]})),
329 RiskLevel::Destructive
330 );
331 assert_eq!(
332 classify_risk(
333 "web_run",
334 category,
335 &json!({"click": [{"ref_id": "x", "id": 1}]})
336 ),
337 RiskLevel::Destructive
338 );
339 }
340
341 #[test]
342 fn canonical_actions_keep_legacy_approval_categories_and_risk() {
343 let cases = [
344 ("Bash", "run", ToolCategory::Shell, RiskLevel::Destructive),
345 ("Bash", "wait", ToolCategory::Shell, RiskLevel::Destructive),
346 (
347 "Bash",
348 "interact",
349 ToolCategory::Shell,
350 RiskLevel::Destructive,
351 ),
352 (
353 "Bash",
354 "cancel",
355 ToolCategory::Shell,
356 RiskLevel::Destructive,
357 ),
358 ("File", "read", ToolCategory::Safe, RiskLevel::Benign),
359 ("File", "list", ToolCategory::Safe, RiskLevel::Benign),
360 ("File", "search_name", ToolCategory::Safe, RiskLevel::Benign),
361 (
362 "File",
363 "search_content",
364 ToolCategory::Safe,
365 RiskLevel::Benign,
366 ),
367 (
368 "File",
369 "write",
370 ToolCategory::FileWrite,
371 RiskLevel::Destructive,
372 ),
373 (
374 "File",
375 "edit",
376 ToolCategory::FileWrite,
377 RiskLevel::Destructive,
378 ),
379 (
380 "File",
381 "patch",
382 ToolCategory::FileWrite,
383 RiskLevel::Destructive,
384 ),
385 ("Git", "status", ToolCategory::Safe, RiskLevel::Benign),
386 ("Git", "diff", ToolCategory::Safe, RiskLevel::Benign),
387 ("Git", "log", ToolCategory::Safe, RiskLevel::Benign),
388 ("Git", "show", ToolCategory::Safe, RiskLevel::Benign),
389 ("Git", "blame", ToolCategory::Safe, RiskLevel::Benign),
390 ("Git", "commit_plan", ToolCategory::Safe, RiskLevel::Benign),
391 (
392 "Run",
393 "tests",
394 ToolCategory::Unknown,
395 RiskLevel::Destructive,
396 ),
397 (
398 "Run",
399 "verifiers",
400 ToolCategory::Unknown,
401 RiskLevel::Destructive,
402 ),
403 ("Web", "search", ToolCategory::Network, RiskLevel::Benign),
404 (
405 "Web",
406 "fetch",
407 ToolCategory::Network,
408 RiskLevel::Destructive,
409 ),
410 ("Web", "wait", ToolCategory::Network, RiskLevel::Benign),
411 ];
412
413 for (family, action, expected_category, expected_risk) in cases {
414 let params = json!({"action": action});
415 let category = get_tool_category_for_call(family, &params);
416 assert_eq!(category, expected_category, "{family}.{action}");
417 assert_eq!(
418 classify_risk(family, category, &params),
419 expected_risk,
420 "{family}.{action}"
421 );
422 }
423 }
424 }
425
425 lines RUST