返回 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 family>` for a simple, known command; `shell:cmd:<full normalized command>` otherwise |
31 //! | shell interact / wait | `shell:<tool_name>:<hash of args>` |
32 //! | `fetch_url` | `net:<hostname>` |
33 //! | Computer Use consent / `app_script` | `cu:<tool_name>:<hash of input>` |
34 //! | other MCP tools| `mcp:<tool_name>` (the reviewed kind) |
35 //! | everything else| `tool:<tool_name>:<hash of input>` |
36 //!
37 //! ## Computer Use calls that need a human (K1 / K2)
38 //!
39 //! [`computer_use_user_gate`] names the Computer Use calls whose approval must
40 //! come from a person: granting or revoking per-app consent (which includes the
41 //! shared-pointer `scope: "foreground"` decision) and `app_script`, an
42 //! unsandboxed osascript. Those calls are never covered by the MCP kind grant:
43 //! their session grant is the exact call, so allowing app X never allows app Y
44 //! and approving one script never approves a changed one. Engine preparation
45 //! also refuses them in any posture that cannot open a human approval card,
46 //! and refuses a `run_actions` batch that carries one as a step
47 //! ([`computer_use_batch_hidden_gate`]).
48 //!
49 //! Known limits of this stopgap: the calls are matched by MCP tool-name suffix
50 //! (`_consent`, `_consent_allow`, `_consent_revoke`, `_app_script`), so a
51 //! different MCP server exposing a tool with one of those names is gated the
52 //! same way (fail closed). Consent, script and computer registration/spawn
53 //! calls force an exact human card. Only its human decision can be attested
54 //! to the reviewed built-in plugin; grants and autonomous modes cannot mint it.
55 use std::fmt::Write as _;
56
57 use serde_json::Value;
58 use sha2::{Digest, Sha256};
59
60 use codewhale_execpolicy::command_safety::{canonical_prefix_is_leading, classify_command};
61
62 /// The fingerprint of a tool call — stable enough to match repeated
63 /// calls but specific enough to avoid privilege confusion.
64 #[derive(Debug, Clone, PartialEq, Eq, Hash)]
65 pub struct ApprovalKey(pub String);
66
67 /// Build the approval‑cache key for a tool call.
68 ///
69 /// The key incorporates the tool name and a canonical digest of the
70 /// arguments so that denying one call suppresses exact retries, not later
71 /// invocations of the same tool with different parameters.
72 #[must_use]
73 pub fn build_approval_key(tool_name: &str, input: &serde_json::Value) -> ApprovalKey {
74 let tool_name = crate::tools::canonical_action::canonical_action_alias(tool_name, input);
75 let fingerprint = match tool_name {
76 "apply_patch" | "write_file" | "edit_file" | "fim_edit" => {
77 format!("file:{tool_name}:{}", hash_json_value(input))
78 }
79 "exec_shell"
80 | "task_shell_start"
81 | "exec_shell_wait"
82 | "exec_shell_interact"
83 | "exec_wait"
84 | "exec_interact" => {
85 format!("shell:{tool_name}:{}", hash_json_value(input))
86 }
87 "fetch_url" | "web.fetch" | "web_fetch" => {
88 let host = parse_host(input);
89 format!("net:{host}")
90 }
91 _ => format!("tool:{tool_name}:{}", hash_json_value(input)),
92 };
93 ApprovalKey(fingerprint)
94 }
95
96 /// Build the **grouping** approval key for a tool call.
97 ///
98 /// Unlike [`build_approval_key`], this collapses argument variants of the
99 /// same command family onto one key (the v0.8.37 behaviour) so that an
100 /// "approve for session" decision covers later invocations that differ only
101 /// by flags. Denials must keep using the exact [`build_approval_key`].
102 #[must_use]
103 pub fn build_approval_grouping_key(tool_name: &str, input: &serde_json::Value) -> ApprovalKey {
104 let tool_name = crate::tools::canonical_action::canonical_action_alias(tool_name, input);
105 let fingerprint = match tool_name {
106 "apply_patch" => {
107 let paths_hash = hash_patch_paths(input);
108 format!("patch:{paths_hash}")
109 }
110 "exec_shell" | "task_shell_start" => shell_command_grant_scope(input),
111 // Interact and wait calls carry no command, only input for a live
112 // session. Keying them on the (empty) command prefix gave every one
113 // of them the same grant; a grant covers the exact call only.
114 "exec_shell_wait" | "exec_shell_interact" | "exec_wait" | "exec_interact" => {
115 format!("shell:{tool_name}:{}", hash_json_value(input))
116 }
117 "fetch_url" | "web.fetch" | "web_fetch" => {
118 let host = parse_host(input);
119 format!("net:{host}")
120 }
121 // MCP tools are reviewed as kinds: a trusted plugin bundle's MCP
122 // tools were human-reviewed at trust time, so the session grant the
123 // approval card offers (`2` — "approves for the session") is the
124 // reviewed kind, `mcp:<tool>`. Hashing the full params here would
125 // make every exact-argument variant its own family and silently
126 // narrow the granted kind into a one-call grant (the regression the
127 // plugin e2e acceptance catches). Shell keeps its command-family
128 // key (R2); this arm never widens shell or file tools.
129 //
130 // Computer Use consent and `app_script` are the exception (K1/K2):
131 // a kind grant there would let one approval cover every app or every
132 // script, so their session grant is the exact call.
133 name if computer_use_user_gate(name, input).is_some() => {
134 format!("cu:{name}:{}", hash_json_value(input))
135 }
136 name if crate::mcp::McpPool::is_mcp_tool(name) => format!("mcp:{name}"),
137 // E1: a session grant for web browsing covers the argument class the
138 // person approved (search, open, …), not the one exact query.
139 "web.run" => format!("web:{tool_name}:{}", web_run_action_class(input)),
140 "web_search" => format!("web:{tool_name}"),
141 _ => format!("tool:{tool_name}:{}", hash_json_value(input)),
142 };
143 ApprovalKey(fingerprint)
144 }
145
146 /// Exact and grouping keys for one call, as the engine puts them on an
147 /// approval request. A tool with an [`approval_scope`] (extension tools) is
148 /// keyed `<scope>:<tool_name>:<hash of input>` for both, so its grants are
149 /// bound to the reviewed plugin build and never widened to a family; every
150 /// other tool keeps [`build_approval_key`] / [`build_approval_grouping_key`].
151 /// The registry's captured owning agent prefixes both keys with `agent:<id>:`,
152 /// so its grants and denials never cover a parent or sibling's call.
153 ///
154 /// [`approval_scope`]: crate::tools::spec::ToolSpec::approval_scope
155 #[must_use]
156 pub fn approval_keys_for_call(
157 registry: Option<&crate::tools::ToolRegistry>,
158 tool_name: &str,
159 input: &serde_json::Value,
160 ) -> (ApprovalKey, ApprovalKey) {
161 let scope = registry
162 .and_then(|registry| registry.get(tool_name))
163 .and_then(|tool| tool.approval_scope());
164 let keys = match scope {
165 Some(scope) => {
166 let key = ApprovalKey(format!("{scope}:{tool_name}:{}", hash_json_value(input)));
167 (key.clone(), key)
168 }
169 None => (
170 build_approval_key(tool_name, input),
171 build_approval_grouping_key(tool_name, input),
172 ),
173 };
174 if let Some(owner) = registry.and_then(|registry| registry.context().owner_agent_id.as_deref())
175 {
176 let scoped = |key: ApprovalKey| ApprovalKey(format!("agent:{owner}:{}", key.0));
177 (scoped(keys.0), scoped(keys.1))
178 } else {
179 keys
180 }
181 }
182
183 /// [`approval_keys_for_call`] for a call an extension made through `core/call`
184 /// (`scope` is the extension plugin build's [`approval_scope`]): both keys are
185 /// prefixed `extcall:<scope>:`, so a session grant or a denial recorded for the
186 /// model's call of a tool never matches the extension's call of it, and the
187 /// reverse, and neither crosses to another plugin build.
188 ///
189 /// [`approval_scope`]: crate::tools::spec::ToolSpec::approval_scope
190 #[must_use]
191 pub fn extension_origin_approval_keys(
192 scope: &str,
193 registry: Option<&crate::tools::ToolRegistry>,
194 tool_name: &str,
195 input: &serde_json::Value,
196 ) -> (ApprovalKey, ApprovalKey) {
197 let (exact, grouping) = approval_keys_for_call(registry, tool_name, input);
198 let scoped = |key: ApprovalKey| ApprovalKey(format!("extcall:{scope}:{}", key.0));
199 (scoped(exact), scoped(grouping))
200 }
201
202 /// The sorted `web.run` action kinds present in `input`, e.g. `open+search_query`.
203 fn web_run_action_class(input: &Value) -> String {
204 const ACTIONS: [&str; 6] = [
205 "click",
206 "find",
207 "image_query",
208 "open",
209 "screenshot",
210 "search_query",
211 ];
212 let present: Vec<String> = ACTIONS
213 .into_iter()
214 .filter(|action| input.get(*action).is_some_and(|value| !value.is_null()))
215 .map(|action| {
216 if action == "open" {
217 format!("open({})", web_run_open_targets(input))
218 } else {
219 action.to_string()
220 }
221 })
222 .collect();
223 if present.is_empty() {
224 "none".to_string()
225 } else {
226 present.join("+")
227 }
228 }
229
230 /// The sorted target set of a `web.run` `open`: the host of each raw URL, or
231 /// `ref` for a result reference. `open` fetches any raw URL it is given, and a
232 /// URL can carry local data out in its path or query, so an "open" grant
233 /// covers the hosts the person approved — as `fetch_url` grants do — never
234 /// every host.
235 fn web_run_open_targets(input: &Value) -> String {
236 let mut targets: Vec<String> = input
237 .get("open")
238 .and_then(Value::as_array)
239 .into_iter()
240 .flatten()
241 .map(|item| {
242 let ref_id = item.get("ref_id").and_then(Value::as_str).unwrap_or("");
243 if ref_id.starts_with("http://") || ref_id.starts_with("https://") {
244 reqwest::Url::parse(ref_id)
245 .ok()
246 .and_then(|url| url.host_str().map(str::to_ascii_lowercase))
247 .unwrap_or_else(|| format!("url:{}", hash_json_value(item)))
248 } else {
249 "ref".to_string()
250 }
251 })
252 .collect();
253 targets.sort_unstable();
254 targets.dedup();
255 targets.join(",")
256 }
257
258 /// A Computer Use call whose approval must come from a person (K1 / K2).
259 #[derive(Debug, Clone, PartialEq, Eq)]
260 pub(crate) enum ComputerUseUserGate {
261 /// A consent ledger write that widens what the model may do: `allow`, or
262 /// `revoke` (which can clear a persisted deny).
263 Consent {
264 action: &'static str,
265 app: Option<String>,
266 bundle_id: Option<String>,
267 scope: &'static str,
268 remember: bool,
269 /// An `allow` carrying a plugin `confirm` token: the person is
270 /// confirming an irreversible action (pay, buy, send, transfer,
271 /// delete) the plugin paused on, not consenting to an app.
272 confirm: bool,
273 },
274 /// `app_script`: arbitrary AppleScript/JXA through osascript.
275 AppScript {
276 language: &'static str,
277 script_sha256: String,
278 first_line: String,
279 /// Non-empty lines in the script, so the card can say how much is
280 /// not shown by `first_line`.
281 line_count: usize,
282 },
283 /// Registering or spawning a computer the plugin will then drive (and,
284 /// for ssh, push an agent to).
285 Computer {
286 action: &'static str,
287 transport: Option<String>,
288 /// `user@host:port` for ssh, the target or image otherwise.
289 destination: Option<String>,
290 },
291 }
292
293 /// Classify an MCP tool call as a Computer Use call that needs a human
294 /// decision. See the module docs for the matching rule and its limits.
295 #[must_use]
296 pub(crate) fn computer_use_user_gate(
297 tool_name: &str,
298 input: &Value,
299 ) -> Option<ComputerUseUserGate> {
300 if !tool_name.starts_with("mcp_") {
301 return None;
302 }
303 let text = |key: &str| {
304 input
305 .get(key)
306 .and_then(Value::as_str)
307 .map(str::trim)
308 .filter(|value| !value.is_empty())
309 .map(str::to_string)
310 };
311 let computer_action = if tool_name.ends_with("_computer_register") {
312 Some("register")
313 } else if tool_name.ends_with("_computer_spawn") {
314 Some("spawn")
315 } else if tool_name.ends_with("_computer") {
316 match input.get("action").and_then(Value::as_str) {
317 Some("register") => Some("register"),
318 Some("spawn") => Some("spawn"),
319 _ => None,
320 }
321 } else {
322 None
323 };
324 if let Some(action) = computer_action {
325 let host = text("host");
326 let destination = match host {
327 Some(host) => {
328 let user = text("user")
329 .map(|user| format!("{user}@"))
330 .unwrap_or_default();
331 let port = input
332 .get("port")
333 .and_then(Value::as_u64)
334 .map(|port| format!(":{port}"))
335 .unwrap_or_default();
336 Some(format!("{user}{host}{port}"))
337 }
338 None => text("target").or_else(|| text("image")),
339 };
340 return Some(ComputerUseUserGate::Computer {
341 action,
342 transport: text("transport"),
343 destination,
344 });
345 }
346 let action = if tool_name.ends_with("_consent_allow") {
347 "allow"
348 } else if tool_name.ends_with("_consent_revoke") {
349 "revoke"
350 } else if tool_name.ends_with("_consent") {
351 match input.get("action").and_then(Value::as_str) {
352 Some("allow") => "allow",
353 Some("revoke") => "revoke",
354 // `status` reads; `deny` only narrows what the model may do.
355 _ => return None,
356 }
357 } else if tool_name.ends_with("_app_script") {
358 let script = input.get("script").and_then(Value::as_str).unwrap_or("");
359 let language = match input.get("language").and_then(Value::as_str) {
360 Some("javascript") => "JXA",
361 _ => "AppleScript",
362 };
363 let digest = Sha256::digest(script.as_bytes());
364 let mut script_sha256 = String::with_capacity(64);
365 for byte in digest {
366 write!(&mut script_sha256, "{byte:02x}").expect("writing to String cannot fail");
367 }
368 let first_line = script
369 .lines()
370 .map(str::trim)
371 .find(|line| !line.is_empty())
372 .unwrap_or("")
373 .chars()
374 .take(120)
375 .collect();
376 let line_count = script
377 .lines()
378 .filter(|line| !line.trim().is_empty())
379 .count();
380 return Some(ComputerUseUserGate::AppScript {
381 language,
382 script_sha256,
383 first_line,
384 line_count,
385 });
386 } else {
387 return None;
388 };
389 let pid = input
390 .get("pid")
391 .and_then(Value::as_i64)
392 .map(|pid| format!("pid:{pid}"));
393 Some(ComputerUseUserGate::Consent {
394 action,
395 app: text("app").or_else(|| text("name")).or(pid),
396 bundle_id: text("bundle_id"),
397 scope: if input.get("scope").and_then(Value::as_str) == Some("foreground") {
398 "foreground"
399 } else {
400 "app"
401 },
402 remember: input.get("remember").and_then(Value::as_bool) == Some(true),
403 confirm: action == "allow" && text("confirm").is_some(),
404 })
405 }
406
407 /// The inner tool of the first `run_actions` step that would need a human
408 /// decision (K1 / K2). Computer Use `run_actions` accepts any plugin tool name
409 /// as a step — including the unlisted `consent_allow` / `consent_revoke` and
410 /// `app_script` — so a batch would otherwise carry a consent grant or a
411 /// script past the per-call card. Engine preparation refuses such a batch.
412 #[must_use]
413 pub(crate) fn computer_use_batch_hidden_gate(tool_name: &str, input: &Value) -> Option<String> {
414 if !tool_name.starts_with("mcp_") || !tool_name.ends_with("_run_actions") {
415 return None;
416 }
417 input
418 .get("steps")
419 .and_then(Value::as_array)?
420 .iter()
421 .find_map(|step| {
422 let inner = step.get("tool").and_then(Value::as_str)?;
423 let arguments = step.get("arguments").cloned().unwrap_or(Value::Null);
424 computer_use_user_gate(&format!("mcp_{inner}"), &arguments).map(|_| inner.to_string())
425 })
426 }
427
428 /// The session-grant scope for a shell command.
429 ///
430 /// A simple command whose family is in the arity dictionary keeps the
431 /// family grant, so approving `git status` also covers `git status -s`
432 /// without covering `git push`. Everything else fails closed to the full
433 /// normalized command:
434 ///
435 /// * compound commands (`;`, `&&`, `|`, redirects, substitutions, `$VAR`):
436 /// a grant for `cd` must not extend to whatever is chained after it;
437 /// * wrappers and interpreters (`bash -c`, `env`, `sudo`, `xargs`,
438 /// `python -c`, `docker run`, …): the first word says nothing about what
439 /// runs;
440 /// * commands the dictionary does not know, which used to collapse to their
441 /// first word, so one approval covered every use of that program.
442 fn shell_command_grant_scope(input: &serde_json::Value) -> String {
443 let cmd = input.get("command").and_then(|v| v.as_str()).unwrap_or("");
444 let tokens: Vec<&str> = cmd.split_whitespace().collect();
445 if tokens.is_empty() {
446 return "shell:<empty>".to_string();
447 }
448 if !shell_command_is_compound(cmd)
449 && !shell_command_is_wrapper(&tokens)
450 && !shell_command_has_code_option(&tokens)
451 {
452 let family = classify_command(&tokens);
453 // Options wedged before a subcommand (`git -c k=v status`), a chain,
454 // or code that runs nested or resolves only at run time also keep
455 // the grant to this exact command (#6675).
456 let expansion = codewhale_execpolicy::shell_expand::expand_command(cmd);
457 if command_family_is_known(&family)
458 && !family_arguments_are_config(&family)
459 && !family_arguments_are_code(&family)
460 && !expansion.dynamic
461 && !expansion.nested
462 && expansion.commands.len() == 1
463 && canonical_prefix_is_leading(&tokens, &family)
464 {
465 return format!("shell:{family}");
466 }
467 }
468 format!("shell:cmd:{}", normalize_shell_command(cmd))
469 }
470
471 /// Whether any option could change what a known command runs, reads or
472 /// writes. The family ignores flags, so a family grant is kept only when
473 /// every option is on [`INERT_OPTIONS`]; anything else keys the grant on the
474 /// full command.
475 ///
476 /// An allow-list, not a deny-list: options take values in too many spellings
477 /// (`-C../x`, `-f/tmp/x`, `--output out.patch`, `-x "cmd"`, `-exec ./x`,
478 /// `-O<cmd>`) for a list of dangerous ones to be complete.
479 fn shell_command_has_code_option(tokens: &[&str]) -> bool {
480 tokens.iter().skip(1).any(|token| {
481 token.contains('=') || (token.starts_with('-') && !INERT_OPTIONS.contains(token))
482 })
483 }
484
485 /// Value-free options that only change how much a command prints or which of
486 /// its own outputs it builds, never what it runs or where it writes.
487 const INERT_OPTIONS: &[&str] = &[
488 "-h",
489 "--help",
490 "-V",
491 "--version",
492 "-v",
493 "-vv",
494 "--verbose",
495 "-q",
496 "--quiet",
497 "-s",
498 "--short",
499 "--porcelain",
500 "--oneline",
501 "--stat",
502 "--name-only",
503 "--name-status",
504 "--cached",
505 "--staged",
506 "--no-color",
507 "--release",
508 "-p",
509 "--package",
510 "--workspace",
511 "--all-targets",
512 "--all-features",
513 "--lib",
514 "--bins",
515 "--tests",
516 "--locked",
517 "--frozen",
518 "--offline",
519 "--no-fail-fast",
520 "--dry-run",
521 ];
522
523 /// Families whose positional arguments beyond the family are themselves what
524 /// runs or gets installed (`go run <file>`, `git bisect run <cmd>`,
525 /// `make <target>`, `npm install <pkg>`), so one grant would cover any of
526 /// them.
527 fn family_arguments_are_code(family: &str) -> bool {
528 const FAMILIES: &[&str] = &[
529 "make",
530 "go run",
531 "go install",
532 "go get",
533 "go generate",
534 "deno run",
535 "bun run",
536 "cargo run",
537 "cargo install",
538 "cargo add",
539 "git bisect",
540 "git submodule",
541 "npm install",
542 "yarn add",
543 "pnpm add",
544 "bun add",
545 "pip install",
546 "pip3 install",
547 "docker compose run",
548 "docker compose exec",
549 "docker container run",
550 "docker container exec",
551 ];
552 FAMILIES.contains(&family)
553 }
554
555 /// Families whose arguments are settings, so one grant would cover every
556 /// setting (`git config user.name x` covering `git config core.fsmonitor`).
557 fn family_arguments_are_config(family: &str) -> bool {
558 matches!(family.split(' ').nth(1), Some("config" | "set" | "remote"))
559 }
560
561 /// Whether the dictionary recognised `family`, rather than falling back to
562 /// the bare first word.
563 fn command_family_is_known(family: &str) -> bool {
564 use codewhale_execpolicy::command_safety::COMMAND_ARITY;
565 COMMAND_ARITY
566 .iter()
567 .any(|(key, _)| family == *key || family.starts_with(&format!("{key} ")))
568 }
569
570 /// Any shell syntax that chains, redirects, substitutes, or expands.
571 fn shell_command_is_compound(cmd: &str) -> bool {
572 cmd.contains(|c: char| {
573 matches!(
574 c,
575 ';' | '&' | '|' | '<' | '>' | '`' | '$' | '(' | ')' | '{' | '}' | '\n' | '\r'
576 )
577 })
578 }
579
580 /// Commands whose first word runs something else the grant cannot see.
581 fn shell_command_is_wrapper(tokens: &[&str]) -> bool {
582 const WRAPPERS: &[&str] = &[
583 "bash",
584 "sh",
585 "zsh",
586 "dash",
587 "ksh",
588 "fish",
589 "csh",
590 "tcsh",
591 "env",
592 "sudo",
593 "doas",
594 "su",
595 "xargs",
596 "nohup",
597 "time",
598 "timeout",
599 "nice",
600 "ionice",
601 "exec",
602 "eval",
603 "command",
604 "builtin",
605 "stdbuf",
606 "script",
607 "watch",
608 "parallel",
609 "chroot",
610 "nsenter",
611 "unshare",
612 "setsid",
613 "caffeinate",
614 "strace",
615 "ltrace",
616 "gdb",
617 "lldb",
618 "osascript",
619 "pwsh",
620 "powershell",
621 "cmd",
622 "npx",
623 "pnpx",
624 "bunx",
625 "uvx",
626 "node",
627 "perl",
628 "ruby",
629 "php",
630 "python",
631 "python2",
632 "python3",
633 "find",
634 "ssh",
635 ];
636 const RUNNERS: &[&str] = &[
637 "docker run",
638 "docker exec",
639 "kubectl exec",
640 "npm exec",
641 "pnpm exec",
642 "pnpm dlx",
643 "yarn dlx",
644 "uv run",
645 "poetry run",
646 ];
647 let first = tokens[0];
648 // `FOO=1 cmd`: an environment assignment can change what `cmd` does.
649 if first.contains('=') {
650 return true;
651 }
652 let program = first.rsplit('/').next().unwrap_or(first);
653 if WRAPPERS.contains(&program) {
654 return true;
655 }
656 let lead = tokens
657 .iter()
658 .take(2)
659 .map(|token| token.rsplit('/').next().unwrap_or(token))
660 .collect::<Vec<_>>()
661 .join(" ");
662 RUNNERS.contains(&lead.as_str())
663 }
664
665 /// Collapse insignificant whitespace. Quoted text keeps its exact spacing:
666 /// `echo "a b"` and `echo "a b"` are different commands.
667 fn normalize_shell_command(cmd: &str) -> String {
668 if cmd.contains(['"', '\'', '\\']) {
669 cmd.trim().to_string()
670 } else {
671 cmd.split_whitespace().collect::<Vec<_>>().join(" ")
672 }
673 }
674
675 /// Hash the sorted set of file paths referenced by a patch input.
676 ///
677 /// The paths come from [`preflight_apply_patch`] — the same resolver the
678 /// executor, the permission path (`core/engine.rs`) and auto-review already
679 /// use — rather than from a second, weaker parser. That matters because this
680 /// string *is* the scope of an "approve for the session" grant: two patches
681 /// share a grant exactly when they share this key.
682 ///
683 /// The previous implementation read only `+++ b/` headers and the
684 /// `replace`/`changes` array, so it saw no paths at all for the documented
685 /// `apply_patch{path, patch}` override, for `--no-prefix` diffs, or for
686 /// delete-only diffs — and collapsed all of them to one shared constant.
687 /// Approving any one of those pre-approved every later one, to any file
688 /// (#6247).
689 ///
690 /// An input the resolver cannot parse gets a digest of the input itself, not
691 /// a shared constant: an unparseable patch is its own family and matches
692 /// nothing but a byte-identical repeat.
693 fn hash_patch_paths(input: &serde_json::Value) -> String {
694 use std::collections::hash_map::DefaultHasher;
695 use std::hash::{Hash, Hasher};
696
697 let Ok(preflight) = crate::tools::apply_patch::preflight_apply_patch(input) else {
698 return format!("unparsed_{}", hash_json_value(input));
699 };
700
701 let mut paths: Vec<&str> = preflight.touched_files.iter().map(String::as_str).collect();
702
703 paths.sort_unstable();
704 paths.dedup();
705
706 if paths.is_empty() {
707 // The resolver parsed the input but found no target. Fail closed for
708 // the same reason as the error arm above: a shared key here is a
709 // shared grant.
710 return format!("no_target_{}", hash_json_value(input));
711 }
712
713 let mut hasher = DefaultHasher::new();
714 for path in &paths {
715 path.hash(&mut hasher);
716 }
717 format!("{:x}", hasher.finish())
718 }
719
720 /// Parse the host portion from a URL input.
721 fn parse_host(input: &serde_json::Value) -> String {
722 let url = input.get("url").and_then(|v| v.as_str()).unwrap_or("");
723
724 if let Ok(parsed) = reqwest::Url::parse(url) {
725 parsed.host_str().unwrap_or(url).to_string()
726 } else {
727 url.to_string()
728 }
729 }
730
731 fn hash_json_value(value: &Value) -> String {
732 let mut canonical = String::new();
733 push_canonical_json(value, &mut canonical);
734
735 let digest = Sha256::digest(canonical.as_bytes());
736 let mut short = String::with_capacity(16);
737 for byte in &digest[..8] {
738 write!(&mut short, "{byte:02x}").expect("writing to String cannot fail");
739 }
740 short
741 }
742
743 /// Maximum nesting depth the canonical serializer descends. Aligned with
744 /// serde_json's own parse limit so parsed input never truncates; anything
745 /// deeper emits a fixed marker, keeping keys deterministic.
746 const MAX_CANONICAL_JSON_DEPTH: usize = 128;
747
748 fn push_canonical_json(value: &Value, out: &mut String) {
749 push_canonical_json_at(value, out, 0)
750 }
751
752 fn push_canonical_json_at(value: &Value, out: &mut String, depth: usize) {
753 if depth > MAX_CANONICAL_JSON_DEPTH {
754 out.push_str("maxdepth");
755 return;
756 }
757 match value {
758 Value::Null => out.push_str("null"),
759 Value::Bool(value) => {
760 out.push_str("bool:");
761 out.push_str(if *value { "true" } else { "false" });
762 }
763 Value::Number(value) => {
764 out.push_str("number:");
765 // Avoid allocating via value.to_string().
766 if let Some(n) = value.as_f64() {
767 let _ = write!(out, "{n}");
768 } else if let Some(n) = value.as_i64() {
769 let _ = write!(out, "{n}");
770 } else if let Some(n) = value.as_u64() {
771 let _ = write!(out, "{n}");
772 } else {
773 out.push_str(&value.to_string());
774 }
775 }
776 Value::String(value) => {
777 out.push_str("string:");
778 // Emit JSON-encoded string without an intermediate allocation.
779 out.push('"');
780 for ch in value.chars() {
781 match ch {
782 '"' => out.push_str("\\\""),
783 '\\' => out.push_str("\\\\"),
784 '\n' => out.push_str("\\n"),
785 '\r' => out.push_str("\\r"),
786 '\t' => out.push_str("\\t"),
787 c if c.is_control() => {
788 let _ = write!(out, "\\u{:04x}", c as u32);
789 }
790 c => out.push(c),
791 }
792 }
793 out.push('"');
794 }
795 Value::Array(items) => {
796 out.push('[');
797 for (index, item) in items.iter().enumerate() {
798 if index > 0 {
799 out.push(',');
800 }
801 push_canonical_json_at(item, out, depth + 1);
802 }
803 out.push(']');
804 }
805 Value::Object(map) => {
806 let mut entries = map.iter().collect::<Vec<_>>();
807 entries.sort_by_key(|(key, _)| *key);
808
809 out.push('{');
810 for (index, (key, value)) in entries.into_iter().enumerate() {
811 if index > 0 {
812 out.push(',');
813 }
814 let encoded_key =
815 serde_json::to_string(key).expect("serializing an object key cannot fail");
816 out.push_str(&encoded_key);
817 out.push(':');
818 push_canonical_json_at(value, out, depth + 1);
819 }
820 out.push('}');
821 }
822 }
823 }
824
825 #[cfg(test)]
826 mod tests {
827 use super::*;
828 use serde_json::json;
829
830 #[test]
831 fn different_commands_different_keys() {
832 let key_a = build_approval_key("exec_shell", &json!({"command": "ls"}));
833 let key_b = build_approval_key("exec_shell", &json!({"command": "rm -rf /tmp"}));
834 assert_ne!(key_a, key_b);
835 }
836
837 #[test]
838 fn same_command_same_key() {
839 let key_a = build_approval_key("exec_shell", &json!({"command": "cargo build --release"}));
840 let key_b = build_approval_key("exec_shell", &json!({"command": "cargo build --release"}));
841 assert_eq!(key_a, key_b);
842 }
843
844 #[test]
845 fn pathological_nesting_yields_a_stable_key() {
846 let mut value = Value::String("leaf".to_string());
847 for _ in 0..150 {
848 let mut map = serde_json::Map::new();
849 map.insert("t".to_string(), value);
850 value = Value::Object(map);
851 }
852 let key_a = build_approval_key("exec_shell", &value);
853 let key_b = build_approval_key("exec_shell", &value);
854 assert_eq!(key_a, key_b, "truncated keys must stay deterministic");
855 }
856
857 #[test]
858 fn shell_keys_include_full_command_arguments() {
859 let key_a = build_approval_key("exec_shell", &json!({"command": "cargo build"}));
860 let key_b = build_approval_key("exec_shell", &json!({"command": "cargo build --release"}));
861 assert_ne!(key_a, key_b);
862 }
863
864 #[test]
865 fn grouping_key_collapses_shell_flag_variants() {
866 let key_a = build_approval_grouping_key("exec_shell", &json!({"command": "cargo build"}));
867 let key_b =
868 build_approval_grouping_key("exec_shell", &json!({"command": "cargo build --release"}));
869 assert_eq!(
870 key_a, key_b,
871 "approving a command family must cover later flag variants"
872 );
873 }
874
875 #[test]
876 fn shell_grants_fail_closed_on_compound_wrapper_and_unknown_commands() {
877 let key = |cmd: &str| build_approval_grouping_key("exec_shell", &json!({"command": cmd}));
878 // Compound: a grant for `cd` never covers what is chained after it.
879 assert_ne!(key("cd src"), key("cd src && make clean"));
880 assert_ne!(key("cargo build"), key("cargo build; make install"));
881 assert_ne!(key("cargo build"), key("cargo build > out.log"));
882 assert_ne!(key("git status"), key("git status $(pwd)"));
883 // Wrappers and interpreters are keyed by the whole command.
884 assert_ne!(key("bash build.sh"), key("bash -c 'make clean'"));
885 assert_ne!(key("python3 script.py"), key("python3 -c 'import os'"));
886 assert_ne!(key("env FOO=1 make"), key("env FOO=1 make clean"));
887 assert_ne!(key("FOO=1 make"), key("FOO=1 make install"));
888 assert_ne!(
889 key("docker run alpine ls"),
890 key("docker run alpine touch x")
891 );
892 assert_ne!(key("/usr/bin/sudo ls"), key("/usr/bin/sudo make install"));
893 // Unknown commands no longer collapse to their first word.
894 assert_ne!(key("rm tmp/x"), key("rm -r build"));
895 assert_ne!(key("cat README.md"), key("cat notes/other.txt"));
896 // The full-command key still matches an exact repeat, modulo
897 // insignificant whitespace, but not a quoted-spacing change.
898 assert_eq!(key("cd src && make"), key(" cd src && make "));
899 assert_ne!(key("echo \"a b\""), key("echo \"a b\""));
900 assert!(key("rm -r build").0.starts_with("shell:cmd:"));
901 // Options and config arguments that can run code key the full
902 // command, though the family ignores flags.
903 assert_ne!(key("git status"), key("git -ccore.fsmonitor=./hook status"));
904 assert_ne!(key("git status"), key("git -C ../other status"));
905 assert_ne!(
906 key("cargo build"),
907 key("cargo build --config build.rustc-wrapper=./x")
908 );
909 assert_ne!(key("git diff"), key("git diff --output=out.patch"));
910 assert_ne!(key("make"), key("make -f /tmp/x"));
911 assert_ne!(
912 key("git config user.name x"),
913 key("git config core.fsmonitor ./x.sh")
914 );
915 // A known, simple command keeps its family grant.
916 assert_eq!(key("git status"), key("git status --porcelain"));
917 assert_ne!(key("git status"), key("git push"));
918 }
919
920 #[test]
921 fn shell_family_grants_survive_only_inert_options() {
922 let key = |cmd: &str| build_approval_grouping_key("exec_shell", &json!({"command": cmd}));
923 // Options with an attached or separate value, and single-dash long
924 // options, key the full command.
925 for (granted, other) in [
926 ("git status", "git -C../other status"),
927 ("make", "make -f/tmp/x"),
928 ("git diff", "git diff --output out.patch"),
929 ("git rebase HEAD~1", "git rebase -x \"touch x\" HEAD~1"),
930 ("go test ./...", "go test -exec ./x ./..."),
931 ("go build ./...", "go build -toolexec ./x ./..."),
932 ("git clone ./repo", "git clone -u \"cmd\" ./repo"),
933 ("git grep pat", "git grep -Ocmd pat"),
934 ("git log", "git log --git-dir x"),
935 ("cargo test", "cargo test -- --nocapture"),
936 ] {
937 assert_ne!(key(granted), key(other), "{other}");
938 assert!(key(other).0.starts_with("shell:cmd:"), "{other}");
939 }
940 // Every option once listed as able to run code still keys the full
941 // command.
942 for option in [
943 "-c",
944 "-C",
945 "--config",
946 "--exec",
947 "--exec-path",
948 "--script-shell",
949 "-f",
950 "--file",
951 "--makefile",
952 "-e",
953 "--eval",
954 "--require",
955 "--upload-pack",
956 "--receive-pack",
957 "--manifest-path",
958 ] {
959 assert!(!INERT_OPTIONS.contains(&option), "{option}");
960 }
961 // Families whose arguments are the code that runs or is installed
962 // key the full command.
963 for (granted, other) in [
964 ("go run ./cmd/tool", "go run /tmp/other.go"),
965 ("deno run main.ts", "deno run https://host/x.ts"),
966 ("cargo run", "cargo run --bin other"),
967 ("git bisect start", "git bisect run ./x"),
968 ("git submodule update", "git submodule foreach ./x"),
969 ("make build", "make clean"),
970 ("npm install", "npm install left-pad"),
971 ("pip install requests", "pip install other"),
972 ("cargo install ripgrep", "cargo install other"),
973 ] {
974 assert_ne!(key(granted), key(other), "{other}");
975 }
976 // Inert options keep the family grant.
977 assert_eq!(key("cargo build"), key("cargo build --release --locked"));
978 assert_eq!(key("git status"), key("git status -s"));
979 assert_eq!(key("git diff"), key("git diff --stat --cached"));
980 }
981
982 #[test]
983 fn shell_interact_grants_are_per_exact_call() {
984 for tool in [
985 "exec_shell_interact",
986 "exec_interact",
987 "exec_shell_wait",
988 "exec_wait",
989 ] {
990 let a = build_approval_grouping_key(tool, &json!({"task_id": "t1", "input": "y\n"}));
991 let b = build_approval_grouping_key(tool, &json!({"task_id": "t1", "input": "n\n"}));
992 let c = build_approval_grouping_key(tool, &json!({"task_id": "t2", "input": "y\n"}));
993 assert_ne!(a, b, "{tool}: different input must not share a grant");
994 assert_ne!(a, c, "{tool}: a different session must not share a grant");
995 assert_eq!(
996 a,
997 build_approval_grouping_key(tool, &json!({"task_id": "t1", "input": "y\n"}))
998 );
999 }
1000 }
1001
1002 #[test]
1003 fn grouping_key_grants_mcp_tools_as_reviewed_kinds() {
1004 // A session grant for a reviewed plugin MCP tool is the kind
1005 // (`mcp:<tool>`), not the exact arguments: the plugin e2e acceptance
1006 // approves the echo kind once and later variants of the same reviewed
1007 // tool must not re-prompt. R2's shell command-family scoping is
1008 // untouched — this is the MCP arm only.
1009 let key_a = build_approval_grouping_key(
1010 "mcp_plugin-4-demo-local_echo",
1011 &json!({"text": "acceptance", "hang": false}),
1012 );
1013 let key_b = build_approval_grouping_key(
1014 "mcp_plugin-4-demo-local_echo",
1015 &json!({"text": "acceptance", "hang": true}),
1016 );
1017 assert_eq!(
1018 key_a, key_b,
1019 "a reviewed MCP kind grant covers argument variants of that tool"
1020 );
1021 let key_c = build_approval_grouping_key("mcp_plugin-4-demo-local_kick", &json!({"x": 1}));
1022 assert_ne!(key_a, key_c, "a different MCP tool is a different kind");
1023 // The exact-call key stays per-arguments so denials still suppress
1024 // only exact retries.
1025 let exact_a = build_approval_key(
1026 "mcp_plugin-4-demo-local_echo",
1027 &json!({"text": "acceptance", "hang": false}),
1028 );
1029 let exact_b = build_approval_key(
1030 "mcp_plugin-4-demo-local_echo",
1031 &json!({"text": "acceptance", "hang": true}),
1032 );
1033 assert_ne!(exact_a, exact_b, "denial keys remain argument-exact");
1034 }
1035
1036 /// K1: one session grant for Computer Use consent must not cover a
1037 /// consent request for a different app, a different scope, or a
1038 /// persisted (`remember`) variant — the MCP kind grant is not used here.
1039 #[test]
1040 fn computer_use_consent_grants_are_per_exact_call_not_per_kind() {
1041 let tool = "mcp_plugin-12-computer-use-computer_consent";
1042 let safari = build_approval_grouping_key(
1043 tool,
1044 &json!({"action": "allow", "app": "Safari", "bundle_id": "com.apple.Safari"}),
1045 );
1046 let terminal = build_approval_grouping_key(
1047 tool,
1048 &json!({"action": "allow", "app": "Terminal", "bundle_id": "com.apple.Terminal"}),
1049 );
1050 let foreground =
1051 build_approval_grouping_key(tool, &json!({"action": "allow", "scope": "foreground"}));
1052 let persisted = build_approval_grouping_key(
1053 tool,
1054 &json!({"action": "allow", "app": "Safari", "bundle_id": "com.apple.Safari", "remember": true}),
1055 );
1056 assert_ne!(safari, terminal, "allowing app X must never allow app Y");
1057 assert_ne!(safari, foreground);
1058 assert_ne!(safari, persisted);
1059 assert!(safari.0.starts_with("cu:"), "{safari:?}");
1060 for name in [
1061 "mcp_codewhale-cu_consent_allow",
1062 "mcp_codewhale-cu_consent_revoke",
1063 ] {
1064 let a = build_approval_grouping_key(name, &json!({"app": "Safari"}));
1065 let b = build_approval_grouping_key(name, &json!({"app": "Terminal"}));
1066 assert_ne!(a, b, "{name}");
1067 }
1068 // Reads and self-narrowing decisions keep the ordinary kind grant.
1069 assert_eq!(
1070 build_approval_grouping_key(tool, &json!({"action": "status"})).0,
1071 format!("mcp:{tool}")
1072 );
1073 assert!(
1074 computer_use_user_gate(tool, &json!({"action": "deny", "app": "Safari"})).is_none()
1075 );
1076 assert!(computer_use_user_gate("mcp_codewhale-cu_consent_status", &json!({})).is_none());
1077 assert!(computer_use_user_gate("consent_allow", &json!({"app": "Safari"})).is_none());
1078 }
1079
1080 /// K2: an `app_script` session grant is the exact script; a changed
1081 /// script is a new approval.
1082 #[test]
1083 fn app_script_grants_are_per_exact_script() {
1084 let tool = "mcp_plugin-12-computer-use-computer_app_script";
1085 let a = build_approval_grouping_key(
1086 tool,
1087 &json!({"script": "tell application \"Finder\" to get name of front window"}),
1088 );
1089 let same = build_approval_grouping_key(
1090 tool,
1091 &json!({"script": "tell application \"Finder\" to get name of front window"}),
1092 );
1093 let changed =
1094 build_approval_grouping_key(tool, &json!({"script": "do shell script \"id\""}));
1095 assert_eq!(a, same);
1096 assert_ne!(a, changed, "a changed script must prompt again");
1097 let Some(ComputerUseUserGate::AppScript {
1098 language,
1099 script_sha256,
1100 first_line,
1101 line_count,
1102 }) = computer_use_user_gate(
1103 tool,
1104 &json!({"script": "\n ObjC.import('Foundation')\nrest", "language": "javascript"}),
1105 )
1106 else {
1107 panic!("app_script must be gated");
1108 };
1109 assert_eq!(language, "JXA");
1110 assert_eq!(script_sha256.len(), 64);
1111 assert_eq!(first_line, "ObjC.import('Foundation')");
1112 assert_eq!(line_count, 2);
1113 }
1114
1115 #[test]
1116 fn grouping_key_still_separates_distinct_commands() {
1117 let key_a = build_approval_grouping_key("exec_shell", &json!({"command": "git status"}));
1118 let key_b = build_approval_grouping_key("exec_shell", &json!({"command": "git push"}));
1119 assert_ne!(key_a, key_b);
1120 }
1121
1122 #[test]
1123 fn grouping_key_does_not_cover_interposed_options_chains_or_nested_code() {
1124 let key =
1125 |command: &str| build_approval_grouping_key("exec_shell", &json!({"command": command}));
1126 let granted = key("git status");
1127 assert_eq!(granted, key("git status -s"));
1128 for command in [
1129 "git --git-dir=/tmp/e/.git status",
1130 "git --exec-path=/x status",
1131 "git status $(touch p)",
1132 "git status && rm x",
1133 ] {
1134 assert_ne!(granted, key(command), "{command}");
1135 }
1136 // Such a command still matches an identical repeat, and only that.
1137 assert_eq!(key("git status && ls"), key("git status && ls"));
1138 assert_ne!(key("git status && ls"), key("git status && pwd"));
1139 }
1140
1141 /// #6247. The `path` override is the documented way to patch without
1142 /// diff headers (`apply_patch.rs` tells the model "Ensure the patch
1143 /// includes ---/+++ headers or provide `path`"), and a bare hunk has no
1144 /// `+++` line at all. Before the fix both of these produced the constant
1145 /// `patch:no_files`, so one session grant covered every later one.
1146 #[test]
1147 fn grouping_key_scopes_a_path_override_to_its_own_file() {
1148 let hunk = "@@ -1 +1 @@\n-old\n+new\n";
1149 let benign = build_approval_grouping_key(
1150 "apply_patch",
1151 &json!({"path": ".env.example", "patch": hunk}),
1152 );
1153 let sensitive = build_approval_grouping_key(
1154 "apply_patch",
1155 &json!({"path": ".codewhale/settings.json", "patch": hunk}),
1156 );
1157 assert_ne!(
1158 benign, sensitive,
1159 "approving a patch to one file must never cover a patch to another"
1160 );
1161 assert!(
1162 !format!("{benign:?}").contains("no_files"),
1163 "a resolvable target must never collapse to the shared constant"
1164 );
1165 }
1166
1167 /// The executor's `normalize_diff_path` accepts a prefix-less header, so
1168 /// the fingerprint must too — otherwise a `--no-prefix` diff is a second
1169 /// route to the shared key.
1170 #[test]
1171 fn grouping_key_reads_prefix_less_diff_headers() {
1172 let prefixed = build_approval_grouping_key(
1173 "apply_patch",
1174 &json!({"patch": "--- a/src/auth.rs\n+++ b/src/auth.rs\n@@ -1 +1 @@\n-a\n+b\n"}),
1175 );
1176 let bare = build_approval_grouping_key(
1177 "apply_patch",
1178 &json!({"patch": "--- src/auth.rs\n+++ src/auth.rs\n@@ -1 +1 @@\n-a\n+b\n"}),
1179 );
1180 assert_eq!(
1181 prefixed, bare,
1182 "the same target written two legal ways is one approval family"
1183 );
1184 let other = build_approval_grouping_key(
1185 "apply_patch",
1186 &json!({"patch": "--- src/billing.rs\n+++ src/billing.rs\n@@ -1 +1 @@\n-a\n+b\n"}),
1187 );
1188 assert_ne!(bare, other, "different targets are different families");
1189 }
1190
1191 /// Fail closed: an input the resolver cannot parse is its own family, not
1192 /// a member of a shared one. Two different unparseable inputs must not
1193 /// share a grant.
1194 #[test]
1195 fn grouping_key_fails_closed_on_an_unresolvable_patch() {
1196 let a = build_approval_grouping_key("apply_patch", &json!({"patch": "not a diff at all"}));
1197 let b = build_approval_grouping_key("apply_patch", &json!({"patch": "also not a diff"}));
1198 assert_ne!(a, b, "unparseable inputs must not share an approval family");
1199 for key in [&a, &b] {
1200 let rendered = format!("{key:?}");
1201 assert!(
1202 !rendered.contains("no_files"),
1203 "the shared constant must not survive anywhere: {rendered}"
1204 );
1205 }
1206 }
1207
1208 #[test]
1209 fn grouping_key_collapses_patch_body_for_same_path() {
1210 let key_a = build_approval_grouping_key(
1211 "apply_patch",
1212 &json!({"replace": [{"path": "a.rs", "content": "x"}]}),
1213 );
1214 let key_b = build_approval_grouping_key(
1215 "apply_patch",
1216 &json!({"replace": [{"path": "a.rs", "content": "y"}]}),
1217 );
1218 assert_eq!(
1219 key_a, key_b,
1220 "approving a patch family must cover later edits to the same path"
1221 );
1222 }
1223
1224 #[test]
1225 fn grouping_key_treats_replace_and_legacy_changes_as_the_same_path_set() {
1226 let canonical = build_approval_grouping_key(
1227 "apply_patch",
1228 &json!({"replace": [{"path": "a.rs", "content": "new"}]}),
1229 );
1230 let legacy = build_approval_grouping_key(
1231 "apply_patch",
1232 &json!({"changes": [{"path": "a.rs", "content": "new"}]}),
1233 );
1234
1235 assert_eq!(canonical, legacy);
1236 }
1237
1238 #[test]
1239 fn denial_key_stays_exact_while_grouping_key_collapses() {
1240 let exact_a = build_approval_key("exec_shell", &json!({"command": "cargo build"}));
1241 let exact_b =
1242 build_approval_key("exec_shell", &json!({"command": "cargo build --release"}));
1243 assert_ne!(exact_a, exact_b, "denials must remain exact-call scoped");
1244
1245 let group_a = build_approval_grouping_key("exec_shell", &json!({"command": "cargo build"}));
1246 let group_b =
1247 build_approval_grouping_key("exec_shell", &json!({"command": "cargo build --release"}));
1248 assert_eq!(group_a, group_b, "approvals must group by command family");
1249 }
1250
1251 #[test]
1252 fn patch_keys_differ_by_path() {
1253 let key_a = build_approval_key(
1254 "apply_patch",
1255 &json!({"replace": [{"path": "a.rs", "content": "x"}]}),
1256 );
1257 let key_b = build_approval_key(
1258 "apply_patch",
1259 &json!({"replace": [{"path": "b.rs", "content": "x"}]}),
1260 );
1261 assert_ne!(key_a, key_b);
1262 }
1263
1264 #[test]
1265 fn patch_keys_differ_by_body_for_same_path() {
1266 let key_a = build_approval_key(
1267 "apply_patch",
1268 &json!({"replace": [{"path": "a.rs", "content": "x"}]}),
1269 );
1270 let key_b = build_approval_key(
1271 "apply_patch",
1272 &json!({"replace": [{"path": "a.rs", "content": "y"}]}),
1273 );
1274 assert_ne!(key_a, key_b);
1275 }
1276
1277 #[test]
1278 fn net_keys_differ_by_host() {
1279 let key_a = build_approval_key("fetch_url", &json!({"url": "https://example.com"}));
1280 let key_b = build_approval_key("fetch_url", &json!({"url": "https://other.org"}));
1281 assert_ne!(key_a, key_b);
1282 }
1283
1284 #[test]
1285 fn generic_tool_keys_include_arguments() {
1286 let key_a = build_approval_key("read_file", &json!({"path": "a.txt"}));
1287 let key_b = build_approval_key("read_file", &json!({"path": "b.txt"}));
1288 assert_ne!(key_a, key_b);
1289 assert!(key_a.0.starts_with("tool:read_file:"));
1290 }
1291
1292 #[test]
1293 fn generic_tool_same_arguments_reuse_key() {
1294 let input = json!({"path": "a.txt"});
1295 let key_a = build_approval_key("edit_file", &input);
1296 let key_b = build_approval_key("edit_file", &input);
1297 assert_eq!(key_a, key_b);
1298 }
1299
1300 #[test]
1301 fn input_hash_is_stable_across_object_key_order() {
1302 let key_a = build_approval_key("write_file", &json!({"path": "a.txt", "content": "x"}));
1303 let key_b = build_approval_key("write_file", &json!({"content": "x", "path": "a.txt"}));
1304 assert_eq!(key_a, key_b);
1305 }
1306
1307 #[test]
1308 fn lowercase_primitives_share_legacy_approval_keys() {
1309 let shell = json!({"command": "cargo test"});
1310 assert_eq!(
1311 build_approval_key("bash", &shell),
1312 build_approval_key("exec_shell", &shell)
1313 );
1314 let write = json!({"path": "a.txt", "content": "x"});
1315 assert_eq!(
1316 build_approval_key("write", &write),
1317 build_approval_key("write_file", &write)
1318 );
1319 let edit = json!({
1320 "path": "a.txt",
1321 "edits": [{"oldText": "x", "newText": "y"}]
1322 });
1323 assert_eq!(
1324 build_approval_key("edit", &edit),
1325 build_approval_key("edit_file", &edit)
1326 );
1327 }
1328
1329 #[test]
1330 fn canonical_json_omits_trailing_commas() {
1331 let mut canonical = String::new();
1332 push_canonical_json(&json!({"b": [true, false], "a": {"x": 1}}), &mut canonical);
1333
1334 assert_eq!(
1335 canonical,
1336 r#"{"a":{"x":number:1},"b":[bool:true,bool:false]}"#
1337 );
1338 assert!(!canonical.contains(",]"));
1339 assert!(!canonical.contains(",}"));
1340 }
1341
1342 #[test]
1343 fn web_run_session_grant_covers_its_argument_class_only() {
1344 let search = |q: &str| json!({"search_query": [{"q": q}]});
1345 assert_eq!(
1346 build_approval_grouping_key("web.run", &search("espresso")),
1347 build_approval_grouping_key("web.run", &search("grinders")),
1348 "approving one search covers later searches"
1349 );
1350 assert_ne!(
1351 build_approval_grouping_key("web.run", &search("espresso")),
1352 build_approval_grouping_key(
1353 "web.run",
1354 &json!({"open": [{"ref_id": "https://x.test"}]})
1355 ),
1356 "a search grant never covers opening a page"
1357 );
1358 let open = |url: &str| json!({"open": [{"ref_id": url}]});
1359 assert_eq!(
1360 build_approval_grouping_key("web.run", &open("https://docs.rs/a")),
1361 build_approval_grouping_key("web.run", &open("https://DOCS.rs/b?x=1")),
1362 "an open grant covers later pages on the approved host"
1363 );
1364 assert_ne!(
1365 build_approval_grouping_key("web.run", &open("https://docs.rs/a")),
1366 build_approval_grouping_key("web.run", &open("https://evil.test/?q=secret")),
1367 "an open grant never covers another host"
1368 );
1369 assert_ne!(
1370 build_approval_grouping_key("web.run", &open("turn0search0")),
1371 build_approval_grouping_key("web.run", &open("https://evil.test/")),
1372 "a result-reference open grant never covers a raw URL"
1373 );
1374 assert_ne!(
1375 build_approval_key("web.run", &search("espresso")),
1376 build_approval_key("web.run", &search("grinders")),
1377 "denials stay exact-call scoped"
1378 );
1379 }
1380 #[test]
1381 fn computer_register_and_spawn_need_a_human_card() {
1382 for (tool, input) in [
1383 (
1384 "mcp_codewhale-cu_computer_register",
1385 json!({"computer": "box", "transport": "ssh", "host": "box.example", "user": "me", "port": 2222}),
1386 ),
1387 (
1388 "mcp_codewhale-cu_computer",
1389 json!({"action": "register", "id": "box", "transport": "ssh", "host": "box.example"}),
1390 ),
1391 (
1392 "mcp_codewhale-cu_computer_spawn",
1393 json!({"image": "desktop"}),
1394 ),
1395 (
1396 "mcp_codewhale-cu_computer",
1397 json!({"action": "spawn", "id": "d"}),
1398 ),
1399 ] {
1400 assert!(
1401 matches!(
1402 computer_use_user_gate(tool, &input),
1403 Some(ComputerUseUserGate::Computer { .. })
1404 ),
1405 "{tool} {input}"
1406 );
1407 }
1408 assert_eq!(
1409 computer_use_user_gate(
1410 "mcp_codewhale-cu_computer_register",
1411 &json!({"transport": "ssh", "host": "box.example", "user": "me", "port": 2222}),
1412 ),
1413 Some(ComputerUseUserGate::Computer {
1414 action: "register",
1415 transport: Some("ssh".to_string()),
1416 destination: Some("me@box.example:2222".to_string()),
1417 })
1418 );
1419 for (tool, input) in [
1420 ("mcp_codewhale-cu_computer", json!({"action": "list"})),
1421 (
1422 "mcp_codewhale-cu_computer",
1423 json!({"action": "switch", "id": "box"}),
1424 ),
1425 (
1426 "mcp_codewhale-cu_computer_switch",
1427 json!({"computer": "box"}),
1428 ),
1429 ] {
1430 assert_eq!(computer_use_user_gate(tool, &input), None, "{tool}");
1431 }
1432 assert_eq!(
1433 computer_use_batch_hidden_gate(
1434 "mcp_codewhale-cu_run_actions",
1435 &json!({"steps": [{"tool": "codewhale-cu_computer_register", "arguments": {"host": "x"}}]}),
1436 )
1437 .as_deref(),
1438 Some("codewhale-cu_computer_register")
1439 );
1440 }
1441 }
1442
1442 lines RUST