返回 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 // 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, &params);
325 assert_eq!(category, expected_category, "{family}.{action}");
326 assert_eq!(
327 classify_risk(family, category, &params),
328 expected_risk,
329 "{family}.{action}"
330 );
331 }
332 }
333 }
334
334 lines RUST