返回 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 crate::command_safety::classify_command;
40 use crate::tools::apply_patch::{NormalizedApplyPatchInput, normalize_apply_patch_input};
41
42 /// The fingerprint of a tool call — stable enough to match repeated
43 /// calls but specific enough to avoid privilege confusion.
44 #[derive(Debug, Clone, PartialEq, Eq, Hash)]
45 pub struct ApprovalKey(pub String);
46
47 /// Build the approval‑cache key for a tool call.
48 ///
49 /// The key incorporates the tool name and a canonical digest of the
50 /// arguments so that denying one call suppresses exact retries, not later
51 /// invocations of the same tool with different parameters.
52 #[must_use]
53 pub fn build_approval_key(tool_name: &str, input: &serde_json::Value) -> ApprovalKey {
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 fingerprint = match tool_name {
84 "apply_patch" => {
85 let paths_hash = hash_patch_paths(input);
86 format!("patch:{paths_hash}")
87 }
88 "exec_shell"
89 | "task_shell_start"
90 | "exec_shell_wait"
91 | "exec_shell_interact"
92 | "exec_wait"
93 | "exec_interact" => {
94 let prefix = command_prefix(input);
95 format!("shell:{prefix}")
96 }
97 "fetch_url" | "web.fetch" | "web_fetch" => {
98 let host = parse_host(input);
99 format!("net:{host}")
100 }
101 _ => format!("tool:{tool_name}:{}", hash_json_value(input)),
102 };
103 ApprovalKey(fingerprint)
104 }
105
106 /// Return the canonical command prefix for the shell command in `input`.
107 ///
108 /// Uses [`classify_command`] from the arity dictionary so that approving
109 /// `git status` also covers `git status -s` / `git status --porcelain`
110 /// without also covering `git push`.
111 fn command_prefix(input: &serde_json::Value) -> String {
112 let cmd = input.get("command").and_then(|v| v.as_str()).unwrap_or("");
113 let tokens: Vec<&str> = cmd.split_whitespace().collect();
114 if tokens.is_empty() {
115 return "<empty>".to_string();
116 }
117 classify_command(&tokens)
118 }
119
120 /// Hash the sorted set of file paths referenced by a patch input.
121 fn hash_patch_paths(input: &serde_json::Value) -> String {
122 use std::collections::hash_map::DefaultHasher;
123 use std::hash::{Hash, Hasher};
124
125 let mut paths: Vec<&str> = Vec::new();
126
127 match normalize_apply_patch_input(input) {
128 Ok(NormalizedApplyPatchInput::Replacement { entries, .. }) => {
129 for change in entries {
130 if let Some(path) = change.get("path").and_then(|v| v.as_str()) {
131 paths.push(path);
132 }
133 }
134 }
135 Ok(NormalizedApplyPatchInput::Patch(patch_text)) => {
136 for line in patch_text.lines() {
137 if let Some(rest) = line.strip_prefix("+++ b/") {
138 paths.push(rest.trim());
139 }
140 }
141 }
142 Err(_) => {}
143 }
144
145 paths.sort();
146 paths.dedup();
147
148 if paths.is_empty() {
149 return "no_files".to_string();
150 }
151
152 let mut hasher = DefaultHasher::new();
153 for path in &paths {
154 path.hash(&mut hasher);
155 }
156 format!("{:x}", hasher.finish())
157 }
158
159 /// Parse the host portion from a URL input.
160 fn parse_host(input: &serde_json::Value) -> String {
161 let url = input.get("url").and_then(|v| v.as_str()).unwrap_or("");
162
163 if let Ok(parsed) = reqwest::Url::parse(url) {
164 parsed.host_str().unwrap_or(url).to_string()
165 } else {
166 url.to_string()
167 }
168 }
169
170 fn hash_json_value(value: &Value) -> String {
171 let mut canonical = String::new();
172 push_canonical_json(value, &mut canonical);
173
174 let digest = Sha256::digest(canonical.as_bytes());
175 let mut short = String::with_capacity(16);
176 for byte in &digest[..8] {
177 write!(&mut short, "{byte:02x}").expect("writing to String cannot fail");
178 }
179 short
180 }
181
182 fn push_canonical_json(value: &Value, out: &mut String) {
183 match value {
184 Value::Null => out.push_str("null"),
185 Value::Bool(value) => {
186 out.push_str("bool:");
187 out.push_str(if *value { "true" } else { "false" });
188 }
189 Value::Number(value) => {
190 out.push_str("number:");
191 // Avoid allocating via value.to_string().
192 if let Some(n) = value.as_f64() {
193 let _ = write!(out, "{n}");
194 } else if let Some(n) = value.as_i64() {
195 let _ = write!(out, "{n}");
196 } else if let Some(n) = value.as_u64() {
197 let _ = write!(out, "{n}");
198 } else {
199 out.push_str(&value.to_string());
200 }
201 }
202 Value::String(value) => {
203 out.push_str("string:");
204 // Emit JSON-encoded string without an intermediate allocation.
205 out.push('"');
206 for ch in value.chars() {
207 match ch {
208 '"' => out.push_str("\\\""),
209 '\\' => out.push_str("\\\\"),
210 '\n' => out.push_str("\\n"),
211 '\r' => out.push_str("\\r"),
212 '\t' => out.push_str("\\t"),
213 c if c.is_control() => {
214 let _ = write!(out, "\\u{:04x}", c as u32);
215 }
216 c => out.push(c),
217 }
218 }
219 out.push('"');
220 }
221 Value::Array(items) => {
222 out.push('[');
223 for (index, item) in items.iter().enumerate() {
224 if index > 0 {
225 out.push(',');
226 }
227 push_canonical_json(item, out);
228 }
229 out.push(']');
230 }
231 Value::Object(map) => {
232 let mut entries = map.iter().collect::<Vec<_>>();
233 entries.sort_by_key(|(key, _)| *key);
234
235 out.push('{');
236 for (index, (key, value)) in entries.into_iter().enumerate() {
237 if index > 0 {
238 out.push(',');
239 }
240 let encoded_key =
241 serde_json::to_string(key).expect("serializing an object key cannot fail");
242 out.push_str(&encoded_key);
243 out.push(':');
244 push_canonical_json(value, out);
245 }
246 out.push('}');
247 }
248 }
249 }
250
251 #[cfg(test)]
252 mod tests {
253 use super::*;
254 use serde_json::json;
255
256 #[test]
257 fn different_commands_different_keys() {
258 let key_a = build_approval_key("exec_shell", &json!({"command": "ls"}));
259 let key_b = build_approval_key("exec_shell", &json!({"command": "rm -rf /tmp"}));
260 assert_ne!(key_a, key_b);
261 }
262
263 #[test]
264 fn same_command_same_key() {
265 let key_a = build_approval_key("exec_shell", &json!({"command": "cargo build --release"}));
266 let key_b = build_approval_key("exec_shell", &json!({"command": "cargo build --release"}));
267 assert_eq!(key_a, key_b);
268 }
269
270 #[test]
271 fn shell_keys_include_full_command_arguments() {
272 let key_a = build_approval_key("exec_shell", &json!({"command": "cargo build"}));
273 let key_b = build_approval_key("exec_shell", &json!({"command": "cargo build --release"}));
274 assert_ne!(key_a, key_b);
275 }
276
277 #[test]
278 fn grouping_key_collapses_shell_flag_variants() {
279 let key_a = build_approval_grouping_key("exec_shell", &json!({"command": "cargo build"}));
280 let key_b =
281 build_approval_grouping_key("exec_shell", &json!({"command": "cargo build --release"}));
282 assert_eq!(
283 key_a, key_b,
284 "approving a command family must cover later flag variants"
285 );
286 }
287
288 #[test]
289 fn grouping_key_still_separates_distinct_commands() {
290 let key_a = build_approval_grouping_key("exec_shell", &json!({"command": "git status"}));
291 let key_b = build_approval_grouping_key("exec_shell", &json!({"command": "git push"}));
292 assert_ne!(key_a, key_b);
293 }
294
295 #[test]
296 fn grouping_key_collapses_patch_body_for_same_path() {
297 let key_a = build_approval_grouping_key(
298 "apply_patch",
299 &json!({"replace": [{"path": "a.rs", "content": "x"}]}),
300 );
301 let key_b = build_approval_grouping_key(
302 "apply_patch",
303 &json!({"replace": [{"path": "a.rs", "content": "y"}]}),
304 );
305 assert_eq!(
306 key_a, key_b,
307 "approving a patch family must cover later edits to the same path"
308 );
309 }
310
311 #[test]
312 fn grouping_key_treats_replace_and_legacy_changes_as_the_same_path_set() {
313 let canonical = build_approval_grouping_key(
314 "apply_patch",
315 &json!({"replace": [{"path": "a.rs", "content": "new"}]}),
316 );
317 let legacy = build_approval_grouping_key(
318 "apply_patch",
319 &json!({"changes": [{"path": "a.rs", "content": "new"}]}),
320 );
321
322 assert_eq!(canonical, legacy);
323 }
324
325 #[test]
326 fn denial_key_stays_exact_while_grouping_key_collapses() {
327 let exact_a = build_approval_key("exec_shell", &json!({"command": "cargo build"}));
328 let exact_b =
329 build_approval_key("exec_shell", &json!({"command": "cargo build --release"}));
330 assert_ne!(exact_a, exact_b, "denials must remain exact-call scoped");
331
332 let group_a = build_approval_grouping_key("exec_shell", &json!({"command": "cargo build"}));
333 let group_b =
334 build_approval_grouping_key("exec_shell", &json!({"command": "cargo build --release"}));
335 assert_eq!(group_a, group_b, "approvals must group by command family");
336 }
337
338 #[test]
339 fn patch_keys_differ_by_path() {
340 let key_a = build_approval_key(
341 "apply_patch",
342 &json!({"replace": [{"path": "a.rs", "content": "x"}]}),
343 );
344 let key_b = build_approval_key(
345 "apply_patch",
346 &json!({"replace": [{"path": "b.rs", "content": "x"}]}),
347 );
348 assert_ne!(key_a, key_b);
349 }
350
351 #[test]
352 fn patch_keys_differ_by_body_for_same_path() {
353 let key_a = build_approval_key(
354 "apply_patch",
355 &json!({"replace": [{"path": "a.rs", "content": "x"}]}),
356 );
357 let key_b = build_approval_key(
358 "apply_patch",
359 &json!({"replace": [{"path": "a.rs", "content": "y"}]}),
360 );
361 assert_ne!(key_a, key_b);
362 }
363
364 #[test]
365 fn net_keys_differ_by_host() {
366 let key_a = build_approval_key("fetch_url", &json!({"url": "https://example.com"}));
367 let key_b = build_approval_key("fetch_url", &json!({"url": "https://other.org"}));
368 assert_ne!(key_a, key_b);
369 }
370
371 #[test]
372 fn generic_tool_keys_include_arguments() {
373 let key_a = build_approval_key("read_file", &json!({"path": "a.txt"}));
374 let key_b = build_approval_key("read_file", &json!({"path": "b.txt"}));
375 assert_ne!(key_a, key_b);
376 assert!(key_a.0.starts_with("tool:read_file:"));
377 }
378
379 #[test]
380 fn generic_tool_same_arguments_reuse_key() {
381 let input = json!({"path": "a.txt"});
382 let key_a = build_approval_key("edit_file", &input);
383 let key_b = build_approval_key("edit_file", &input);
384 assert_eq!(key_a, key_b);
385 }
386
387 #[test]
388 fn input_hash_is_stable_across_object_key_order() {
389 let key_a = build_approval_key("write_file", &json!({"path": "a.txt", "content": "x"}));
390 let key_b = build_approval_key("write_file", &json!({"content": "x", "path": "a.txt"}));
391 assert_eq!(key_a, key_b);
392 }
393
394 #[test]
395 fn canonical_json_omits_trailing_commas() {
396 let mut canonical = String::new();
397 push_canonical_json(&json!({"b": [true, false], "a": {"x": 1}}), &mut canonical);
398
399 assert_eq!(
400 canonical,
401 r#"{"a":{"x":number:1},"b":[bool:true,bool:false]}"#
402 );
403 assert!(!canonical.contains(",]"));
404 assert!(!canonical.contains(",}"));
405 }
406 }
407
407 lines RUST