返回 CodeWhale
approval_cache.rs
根目录 / crates / tui / src / tools / approval_cache.rs
1 //! Approval fingerprint keys (§5.A).
2 //!
3 //! Instead of caching by tool name alone (which would let an approved
4 //! `exec_shell "cat foo"` silently pass `exec_shell "rm -rf /"`), the
5 //! approval flow uses a **call fingerprint** — a digest of the tool name
6 //! and the semantically‑relevant portion of its arguments.
7 //!
8 //! ## Two fingerprint shapes
9 //!
10 //! There are two key flavours, used for opposite sides of the decision:
11 //!
12 //! * [`build_approval_key`] — an **exact** digest of the full arguments.
13 //! Used to scope *denials* so that denying one call (e.g. `rm -rf /tmp/x`)
14 //! does not also suppress a later, different call to the same tool (#1617).
15 //!
16 //! | Tool | Exact key |
17 //! |---------------|------------------------------------------|
18 //! | file writes | `file:<tool_name>:<hash of args>` |
19 //! | shell tools | `shell:<tool_name>:<hash of args>` |
20 //! | `fetch_url` | `net:<hostname>` |
21 //! | everything else| `tool:<tool_name>:<hash of input>` |
22 //!
23 //! * [`build_approval_grouping_key`] — a **lossy / arity-aware** digest.
24 //! Used to scope *approvals* so that approving `cargo build` for the
25 //! session also covers `cargo build --release` (the v0.8.37 behaviour).
26 //!
27 //! | Tool | Grouping key |
28 //! |---------------|------------------------------------------|
29 //! | `apply_patch` | `patch:<hash of file paths>` |
30 //! | shell tools | `shell:<command prefix>` |
31 //! | `fetch_url` | `net:<hostname>` |
32 //! | everything else| `tool:<tool_name>:<hash of input>` |
33 //!
34 use std::fmt::Write as _;
35
36 use serde_json::Value;
37 use sha2::{Digest, Sha256};
38
39 use codewhale_execpolicy::command_safety::classify_command;
40
41 /// The fingerprint of a tool call — stable enough to match repeated
42 /// calls but specific enough to avoid privilege confusion.
43 #[derive(Debug, Clone, PartialEq, Eq, Hash)]
44 pub struct ApprovalKey(pub String);
45
46 /// Build the approval‑cache key for a tool call.
47 ///
48 /// The key incorporates the tool name and a canonical digest of the
49 /// arguments so that denying one call suppresses exact retries, not later
50 /// invocations of the same tool with different parameters.
51 #[must_use]
52 pub fn build_approval_key(tool_name: &str, input: &serde_json::Value) -> ApprovalKey {
53 let tool_name = crate::tools::canonical_action::canonical_action_alias(tool_name, input);
54 let fingerprint = match tool_name {
55 "apply_patch" | "write_file" | "edit_file" | "fim_edit" => {
56 format!("file:{tool_name}:{}", hash_json_value(input))
57 }
58 "exec_shell"
59 | "task_shell_start"
60 | "exec_shell_wait"
61 | "exec_shell_interact"
62 | "exec_wait"
63 | "exec_interact" => {
64 format!("shell:{tool_name}:{}", hash_json_value(input))
65 }
66 "fetch_url" | "web.fetch" | "web_fetch" => {
67 let host = parse_host(input);
68 format!("net:{host}")
69 }
70 _ => format!("tool:{tool_name}:{}", hash_json_value(input)),
71 };
72 ApprovalKey(fingerprint)
73 }
74
75 /// Build the **grouping** approval key for a tool call.
76 ///
77 /// Unlike [`build_approval_key`], this collapses argument variants of the
78 /// same command family onto one key (the v0.8.37 behaviour) so that an
79 /// "approve for session" decision covers later invocations that differ only
80 /// by flags. Denials must keep using the exact [`build_approval_key`].
81 #[must_use]
82 pub fn build_approval_grouping_key(tool_name: &str, input: &serde_json::Value) -> ApprovalKey {
83 let tool_name = crate::tools::canonical_action::canonical_action_alias(tool_name, input);
84 let fingerprint = match tool_name {
85 "apply_patch" => {
86 let paths_hash = hash_patch_paths(input);
87 format!("patch:{paths_hash}")
88 }
89 "exec_shell"
90 | "task_shell_start"
91 | "exec_shell_wait"
92 | "exec_shell_interact"
93 | "exec_wait"
94 | "exec_interact" => {
95 let prefix = command_prefix(input);
96 format!("shell:{prefix}")
97 }
98 "fetch_url" | "web.fetch" | "web_fetch" => {
99 let host = parse_host(input);
100 format!("net:{host}")
101 }
102 // MCP tools are reviewed as kinds: a trusted plugin bundle's MCP
103 // tools were human-reviewed at trust time, so the session grant the
104 // approval card offers (`2` — "approves for the session") is the
105 // reviewed kind, `mcp:<tool>`. Hashing the full params here would
106 // make every exact-argument variant its own family and silently
107 // narrow the granted kind into a one-call grant (the regression the
108 // plugin e2e acceptance catches). Shell keeps its command-family
109 // key (R2); this arm never widens shell or file tools.
110 name if crate::mcp::McpPool::is_mcp_tool(name) => format!("mcp:{name}"),
111 _ => format!("tool:{tool_name}:{}", hash_json_value(input)),
112 };
113 ApprovalKey(fingerprint)
114 }
115
116 /// Return the canonical command prefix for the shell command in `input`.
117 ///
118 /// Uses [`classify_command`] from the arity dictionary so that approving
119 /// `git status` also covers `git status -s` / `git status --porcelain`
120 /// without also covering `git push`.
121 fn command_prefix(input: &serde_json::Value) -> String {
122 let cmd = input.get("command").and_then(|v| v.as_str()).unwrap_or("");
123 let tokens: Vec<&str> = cmd.split_whitespace().collect();
124 if tokens.is_empty() {
125 return "<empty>".to_string();
126 }
127 classify_command(&tokens)
128 }
129
130 /// Hash the sorted set of file paths referenced by a patch input.
131 ///
132 /// The paths come from [`preflight_apply_patch`] — the same resolver the
133 /// executor, the permission path (`core/engine.rs`) and auto-review already
134 /// use — rather than from a second, weaker parser. That matters because this
135 /// string *is* the scope of an "approve for the session" grant: two patches
136 /// share a grant exactly when they share this key.
137 ///
138 /// The previous implementation read only `+++ b/` headers and the
139 /// `replace`/`changes` array, so it saw no paths at all for the documented
140 /// `apply_patch{path, patch}` override, for `--no-prefix` diffs, or for
141 /// delete-only diffs — and collapsed all of them to one shared constant.
142 /// Approving any one of those pre-approved every later one, to any file
143 /// (#6247).
144 ///
145 /// An input the resolver cannot parse gets a digest of the input itself, not
146 /// a shared constant: an unparseable patch is its own family and matches
147 /// nothing but a byte-identical repeat.
148 fn hash_patch_paths(input: &serde_json::Value) -> String {
149 use std::collections::hash_map::DefaultHasher;
150 use std::hash::{Hash, Hasher};
151
152 let Ok(preflight) = crate::tools::apply_patch::preflight_apply_patch(input) else {
153 return format!("unparsed_{}", hash_json_value(input));
154 };
155
156 let mut paths: Vec<&str> = preflight.touched_files.iter().map(String::as_str).collect();
157
158 paths.sort_unstable();
159 paths.dedup();
160
161 if paths.is_empty() {
162 // The resolver parsed the input but found no target. Fail closed for
163 // the same reason as the error arm above: a shared key here is a
164 // shared grant.
165 return format!("no_target_{}", hash_json_value(input));
166 }
167
168 let mut hasher = DefaultHasher::new();
169 for path in &paths {
170 path.hash(&mut hasher);
171 }
172 format!("{:x}", hasher.finish())
173 }
174
175 /// Parse the host portion from a URL input.
176 fn parse_host(input: &serde_json::Value) -> String {
177 let url = input.get("url").and_then(|v| v.as_str()).unwrap_or("");
178
179 if let Ok(parsed) = reqwest::Url::parse(url) {
180 parsed.host_str().unwrap_or(url).to_string()
181 } else {
182 url.to_string()
183 }
184 }
185
186 fn hash_json_value(value: &Value) -> String {
187 let mut canonical = String::new();
188 push_canonical_json(value, &mut canonical);
189
190 let digest = Sha256::digest(canonical.as_bytes());
191 let mut short = String::with_capacity(16);
192 for byte in &digest[..8] {
193 write!(&mut short, "{byte:02x}").expect("writing to String cannot fail");
194 }
195 short
196 }
197
198 /// Maximum nesting depth the canonical serializer descends. Aligned with
199 /// serde_json's own parse limit so parsed input never truncates; anything
200 /// deeper emits a fixed marker, keeping keys deterministic.
201 const MAX_CANONICAL_JSON_DEPTH: usize = 128;
202
203 fn push_canonical_json(value: &Value, out: &mut String) {
204 push_canonical_json_at(value, out, 0)
205 }
206
207 fn push_canonical_json_at(value: &Value, out: &mut String, depth: usize) {
208 if depth > MAX_CANONICAL_JSON_DEPTH {
209 out.push_str("maxdepth");
210 return;
211 }
212 match value {
213 Value::Null => out.push_str("null"),
214 Value::Bool(value) => {
215 out.push_str("bool:");
216 out.push_str(if *value { "true" } else { "false" });
217 }
218 Value::Number(value) => {
219 out.push_str("number:");
220 // Avoid allocating via value.to_string().
221 if let Some(n) = value.as_f64() {
222 let _ = write!(out, "{n}");
223 } else if let Some(n) = value.as_i64() {
224 let _ = write!(out, "{n}");
225 } else if let Some(n) = value.as_u64() {
226 let _ = write!(out, "{n}");
227 } else {
228 out.push_str(&value.to_string());
229 }
230 }
231 Value::String(value) => {
232 out.push_str("string:");
233 // Emit JSON-encoded string without an intermediate allocation.
234 out.push('"');
235 for ch in value.chars() {
236 match ch {
237 '"' => out.push_str("\\\""),
238 '\\' => out.push_str("\\\\"),
239 '\n' => out.push_str("\\n"),
240 '\r' => out.push_str("\\r"),
241 '\t' => out.push_str("\\t"),
242 c if c.is_control() => {
243 let _ = write!(out, "\\u{:04x}", c as u32);
244 }
245 c => out.push(c),
246 }
247 }
248 out.push('"');
249 }
250 Value::Array(items) => {
251 out.push('[');
252 for (index, item) in items.iter().enumerate() {
253 if index > 0 {
254 out.push(',');
255 }
256 push_canonical_json_at(item, out, depth + 1);
257 }
258 out.push(']');
259 }
260 Value::Object(map) => {
261 let mut entries = map.iter().collect::<Vec<_>>();
262 entries.sort_by_key(|(key, _)| *key);
263
264 out.push('{');
265 for (index, (key, value)) in entries.into_iter().enumerate() {
266 if index > 0 {
267 out.push(',');
268 }
269 let encoded_key =
270 serde_json::to_string(key).expect("serializing an object key cannot fail");
271 out.push_str(&encoded_key);
272 out.push(':');
273 push_canonical_json_at(value, out, depth + 1);
274 }
275 out.push('}');
276 }
277 }
278 }
279
280 #[cfg(test)]
281 mod tests {
282 use super::*;
283 use serde_json::json;
284
285 #[test]
286 fn different_commands_different_keys() {
287 let key_a = build_approval_key("exec_shell", &json!({"command": "ls"}));
288 let key_b = build_approval_key("exec_shell", &json!({"command": "rm -rf /tmp"}));
289 assert_ne!(key_a, key_b);
290 }
291
292 #[test]
293 fn same_command_same_key() {
294 let key_a = build_approval_key("exec_shell", &json!({"command": "cargo build --release"}));
295 let key_b = build_approval_key("exec_shell", &json!({"command": "cargo build --release"}));
296 assert_eq!(key_a, key_b);
297 }
298
299 #[test]
300 fn pathological_nesting_yields_a_stable_key() {
301 let mut value = Value::String("leaf".to_string());
302 for _ in 0..150 {
303 let mut map = serde_json::Map::new();
304 map.insert("t".to_string(), value);
305 value = Value::Object(map);
306 }
307 let key_a = build_approval_key("exec_shell", &value);
308 let key_b = build_approval_key("exec_shell", &value);
309 assert_eq!(key_a, key_b, "truncated keys must stay deterministic");
310 }
311
312 #[test]
313 fn shell_keys_include_full_command_arguments() {
314 let key_a = build_approval_key("exec_shell", &json!({"command": "cargo build"}));
315 let key_b = build_approval_key("exec_shell", &json!({"command": "cargo build --release"}));
316 assert_ne!(key_a, key_b);
317 }
318
319 #[test]
320 fn grouping_key_collapses_shell_flag_variants() {
321 let key_a = build_approval_grouping_key("exec_shell", &json!({"command": "cargo build"}));
322 let key_b =
323 build_approval_grouping_key("exec_shell", &json!({"command": "cargo build --release"}));
324 assert_eq!(
325 key_a, key_b,
326 "approving a command family must cover later flag variants"
327 );
328 }
329
330 #[test]
331 fn grouping_key_grants_mcp_tools_as_reviewed_kinds() {
332 // A session grant for a reviewed plugin MCP tool is the kind
333 // (`mcp:<tool>`), not the exact arguments: the plugin e2e acceptance
334 // approves the echo kind once and later variants of the same reviewed
335 // tool must not re-prompt. R2's shell command-family scoping is
336 // untouched — this is the MCP arm only.
337 let key_a = build_approval_grouping_key(
338 "mcp_plugin-4-demo-local_echo",
339 &json!({"text": "acceptance", "hang": false}),
340 );
341 let key_b = build_approval_grouping_key(
342 "mcp_plugin-4-demo-local_echo",
343 &json!({"text": "acceptance", "hang": true}),
344 );
345 assert_eq!(
346 key_a, key_b,
347 "a reviewed MCP kind grant covers argument variants of that tool"
348 );
349 let key_c = build_approval_grouping_key("mcp_plugin-4-demo-local_kick", &json!({"x": 1}));
350 assert_ne!(key_a, key_c, "a different MCP tool is a different kind");
351 // The exact-call key stays per-arguments so denials still suppress
352 // only exact retries.
353 let exact_a = build_approval_key(
354 "mcp_plugin-4-demo-local_echo",
355 &json!({"text": "acceptance", "hang": false}),
356 );
357 let exact_b = build_approval_key(
358 "mcp_plugin-4-demo-local_echo",
359 &json!({"text": "acceptance", "hang": true}),
360 );
361 assert_ne!(exact_a, exact_b, "denial keys remain argument-exact");
362 }
363
364 #[test]
365 fn grouping_key_still_separates_distinct_commands() {
366 let key_a = build_approval_grouping_key("exec_shell", &json!({"command": "git status"}));
367 let key_b = build_approval_grouping_key("exec_shell", &json!({"command": "git push"}));
368 assert_ne!(key_a, key_b);
369 }
370
371 /// #6247. The `path` override is the documented way to patch without
372 /// diff headers (`apply_patch.rs` tells the model "Ensure the patch
373 /// includes ---/+++ headers or provide `path`"), and a bare hunk has no
374 /// `+++` line at all. Before the fix both of these produced the constant
375 /// `patch:no_files`, so one session grant covered every later one.
376 #[test]
377 fn grouping_key_scopes_a_path_override_to_its_own_file() {
378 let hunk = "@@ -1 +1 @@\n-old\n+new\n";
379 let benign = build_approval_grouping_key(
380 "apply_patch",
381 &json!({"path": ".env.example", "patch": hunk}),
382 );
383 let sensitive = build_approval_grouping_key(
384 "apply_patch",
385 &json!({"path": ".codewhale/settings.json", "patch": hunk}),
386 );
387 assert_ne!(
388 benign, sensitive,
389 "approving a patch to one file must never cover a patch to another"
390 );
391 assert!(
392 !format!("{benign:?}").contains("no_files"),
393 "a resolvable target must never collapse to the shared constant"
394 );
395 }
396
397 /// The executor's `normalize_diff_path` accepts a prefix-less header, so
398 /// the fingerprint must too — otherwise a `--no-prefix` diff is a second
399 /// route to the shared key.
400 #[test]
401 fn grouping_key_reads_prefix_less_diff_headers() {
402 let prefixed = build_approval_grouping_key(
403 "apply_patch",
404 &json!({"patch": "--- a/src/auth.rs\n+++ b/src/auth.rs\n@@ -1 +1 @@\n-a\n+b\n"}),
405 );
406 let bare = build_approval_grouping_key(
407 "apply_patch",
408 &json!({"patch": "--- src/auth.rs\n+++ src/auth.rs\n@@ -1 +1 @@\n-a\n+b\n"}),
409 );
410 assert_eq!(
411 prefixed, bare,
412 "the same target written two legal ways is one approval family"
413 );
414 let other = build_approval_grouping_key(
415 "apply_patch",
416 &json!({"patch": "--- src/billing.rs\n+++ src/billing.rs\n@@ -1 +1 @@\n-a\n+b\n"}),
417 );
418 assert_ne!(bare, other, "different targets are different families");
419 }
420
421 /// Fail closed: an input the resolver cannot parse is its own family, not
422 /// a member of a shared one. Two different unparseable inputs must not
423 /// share a grant.
424 #[test]
425 fn grouping_key_fails_closed_on_an_unresolvable_patch() {
426 let a = build_approval_grouping_key("apply_patch", &json!({"patch": "not a diff at all"}));
427 let b = build_approval_grouping_key("apply_patch", &json!({"patch": "also not a diff"}));
428 assert_ne!(a, b, "unparseable inputs must not share an approval family");
429 for key in [&a, &b] {
430 let rendered = format!("{key:?}");
431 assert!(
432 !rendered.contains("no_files"),
433 "the shared constant must not survive anywhere: {rendered}"
434 );
435 }
436 }
437
438 #[test]
439 fn grouping_key_collapses_patch_body_for_same_path() {
440 let key_a = build_approval_grouping_key(
441 "apply_patch",
442 &json!({"replace": [{"path": "a.rs", "content": "x"}]}),
443 );
444 let key_b = build_approval_grouping_key(
445 "apply_patch",
446 &json!({"replace": [{"path": "a.rs", "content": "y"}]}),
447 );
448 assert_eq!(
449 key_a, key_b,
450 "approving a patch family must cover later edits to the same path"
451 );
452 }
453
454 #[test]
455 fn grouping_key_treats_replace_and_legacy_changes_as_the_same_path_set() {
456 let canonical = build_approval_grouping_key(
457 "apply_patch",
458 &json!({"replace": [{"path": "a.rs", "content": "new"}]}),
459 );
460 let legacy = build_approval_grouping_key(
461 "apply_patch",
462 &json!({"changes": [{"path": "a.rs", "content": "new"}]}),
463 );
464
465 assert_eq!(canonical, legacy);
466 }
467
468 #[test]
469 fn denial_key_stays_exact_while_grouping_key_collapses() {
470 let exact_a = build_approval_key("exec_shell", &json!({"command": "cargo build"}));
471 let exact_b =
472 build_approval_key("exec_shell", &json!({"command": "cargo build --release"}));
473 assert_ne!(exact_a, exact_b, "denials must remain exact-call scoped");
474
475 let group_a = build_approval_grouping_key("exec_shell", &json!({"command": "cargo build"}));
476 let group_b =
477 build_approval_grouping_key("exec_shell", &json!({"command": "cargo build --release"}));
478 assert_eq!(group_a, group_b, "approvals must group by command family");
479 }
480
481 #[test]
482 fn patch_keys_differ_by_path() {
483 let key_a = build_approval_key(
484 "apply_patch",
485 &json!({"replace": [{"path": "a.rs", "content": "x"}]}),
486 );
487 let key_b = build_approval_key(
488 "apply_patch",
489 &json!({"replace": [{"path": "b.rs", "content": "x"}]}),
490 );
491 assert_ne!(key_a, key_b);
492 }
493
494 #[test]
495 fn patch_keys_differ_by_body_for_same_path() {
496 let key_a = build_approval_key(
497 "apply_patch",
498 &json!({"replace": [{"path": "a.rs", "content": "x"}]}),
499 );
500 let key_b = build_approval_key(
501 "apply_patch",
502 &json!({"replace": [{"path": "a.rs", "content": "y"}]}),
503 );
504 assert_ne!(key_a, key_b);
505 }
506
507 #[test]
508 fn net_keys_differ_by_host() {
509 let key_a = build_approval_key("fetch_url", &json!({"url": "https://example.com"}));
510 let key_b = build_approval_key("fetch_url", &json!({"url": "https://other.org"}));
511 assert_ne!(key_a, key_b);
512 }
513
514 #[test]
515 fn generic_tool_keys_include_arguments() {
516 let key_a = build_approval_key("read_file", &json!({"path": "a.txt"}));
517 let key_b = build_approval_key("read_file", &json!({"path": "b.txt"}));
518 assert_ne!(key_a, key_b);
519 assert!(key_a.0.starts_with("tool:read_file:"));
520 }
521
522 #[test]
523 fn generic_tool_same_arguments_reuse_key() {
524 let input = json!({"path": "a.txt"});
525 let key_a = build_approval_key("edit_file", &input);
526 let key_b = build_approval_key("edit_file", &input);
527 assert_eq!(key_a, key_b);
528 }
529
530 #[test]
531 fn input_hash_is_stable_across_object_key_order() {
532 let key_a = build_approval_key("write_file", &json!({"path": "a.txt", "content": "x"}));
533 let key_b = build_approval_key("write_file", &json!({"content": "x", "path": "a.txt"}));
534 assert_eq!(key_a, key_b);
535 }
536
537 #[test]
538 fn lowercase_primitives_share_legacy_approval_keys() {
539 let shell = json!({"command": "cargo test"});
540 assert_eq!(
541 build_approval_key("bash", &shell),
542 build_approval_key("exec_shell", &shell)
543 );
544 let write = json!({"path": "a.txt", "content": "x"});
545 assert_eq!(
546 build_approval_key("write", &write),
547 build_approval_key("write_file", &write)
548 );
549 let edit = json!({
550 "path": "a.txt",
551 "edits": [{"oldText": "x", "newText": "y"}]
552 });
553 assert_eq!(
554 build_approval_key("edit", &edit),
555 build_approval_key("edit_file", &edit)
556 );
557 }
558
559 #[test]
560 fn canonical_json_omits_trailing_commas() {
561 let mut canonical = String::new();
562 push_canonical_json(&json!({"b": [true, false], "a": {"x": 1}}), &mut canonical);
563
564 assert_eq!(
565 canonical,
566 r#"{"a":{"x":number:1},"b":[bool:true,bool:false]}"#
567 );
568 assert!(!canonical.contains(",]"));
569 assert!(!canonical.contains(",}"));
570 }
571 }
572
572 lines RUST