返回 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::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, &params);
409 assert_eq!(category, expected_category, "{family}.{action}");
410 assert_eq!(
411 classify_risk(family, category, &params),
412 expected_risk,
413 "{family}.{action}"
414 );
415 }
416 }
417 }
418
418 lines RUST