supercode_harness/permissions/approval.rs
1//! P5-1 (COMPOSABLE-HARNESS-DESIGN.md §2 module 10, §2.10): approval policy
2//! plumbing — the POLICY + session-scoped CACHE + a non-interactive decision
3//! path. The INTERACTIVE ask-UI itself is the `tui` module (P5 row 4, not
4//! this unit) — [`PermissionsApprovalHandler`] is the seam a CLI/TUI/SDK
5//! embedder implements to plug an interactive (or scripted/headless) prompt
6//! into `crate::agent::Agent`'s tool-dispatch gate, mirroring the existing
7//! `crate::reduce::summarize::SpanSummarizer`/`crate::session_title::SessionTitler`
8//! "installing one alone changes nothing, the `Config` gate is what turns it
9//! on" pattern (`Agent::set_span_summarizer`/`Agent::set_session_titler`).
10
11use std::collections::HashSet;
12use std::sync::Mutex;
13
14use super::rules::Decision;
15
16/// What a [`PermissionsApprovalHandler`] decides for one `Ask`-tier request.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum ApprovalOutcome {
19 /// Refuse this one call.
20 Deny,
21 /// Allow this one call only.
22 Allow,
23 /// Allow this call AND cache the decision for the rest of this agent's
24 /// session — CC's "don't ask again" / oc's "always" reply (cc§4, oc§4
25 /// "Ask/approve flow"). Subsequent calls whose
26 /// [`ApprovalCache::key`] matches skip the handler entirely.
27 AllowForSession,
28}
29
30/// One `Ask`-tier request handed to a [`PermissionsApprovalHandler`] — enough
31/// context for an interactive prompt (or a scripted policy) to render a
32/// decision without needing back-references into `Agent`'s private state.
33#[derive(Debug, Clone)]
34pub struct ApprovalRequest<'a> {
35 /// The tool being called (`"bash"`, `"write_file"`, …).
36 pub tool: &'a str,
37 /// The canonicalized command text (bash-family tools) or resolved path
38 /// (file tools), if this call has one — `None` for a tool with no
39 /// richer subject (e.g. `update_plan`).
40 pub subject: Option<&'a str>,
41 /// The raw, model-supplied arguments (for a handler that wants to show
42 /// the user the exact call, not just the canonical summary).
43 pub raw_args: &'a serde_json::Value,
44}
45
46/// The non-interactive decision seam a CLI/TUI/SDK embedder implements. The
47/// engine (`crate::agent::Agent`'s gate) calls [`Self::ask`] ONLY when the
48/// rule engine has already resolved a call to [`Decision::Ask`] — `Deny`
49/// short-circuits before ever reaching a handler (a hard floor, never
50/// consulted), and `Allow` never needs one. No handler installed (the
51/// default) denies every `Ask` — fail-closed, the same posture
52/// `Config::approval_handler`'s doc comment already documents for the
53/// pre-P5-1 gate ("absent handler denies, so an OnRequest/Untrusted policy
54/// is fail-closed" — agent.rs).
55pub trait PermissionsApprovalHandler: Send + Sync {
56 /// Render a decision for `req`. Implementations that need to block on
57 /// user input (a real TUI prompt) do so here; a scripted/headless
58 /// implementation (tests, CI, an `--auto-approve` flag) returns
59 /// immediately.
60 fn ask(&self, req: &ApprovalRequest) -> ApprovalOutcome;
61}
62
63/// Session-scoped "approve for session" decision cache (§2.10). Keyed by
64/// [`Self::key`] — `(tool, subject)` — so a repeated identical call (the
65/// SAME canonical command, or the same path) skips re-prompting for the rest
66/// of this agent's lifetime, exactly like CC's "don't ask again"/oc's
67/// "always" (cc§4, oc§4). Cheap and unconditional to construct — an agent
68/// that never enables `capabilities.permissions` simply never populates or
69/// consults it (§1.13-style "zero cost when off").
70#[derive(Debug, Default)]
71pub struct ApprovalCache {
72 granted: Mutex<HashSet<String>>,
73}
74
75impl ApprovalCache {
76 /// A fresh, empty cache.
77 pub fn new() -> Self {
78 ApprovalCache::default()
79 }
80
81 /// The cache key for a `(tool, subject)` pair — no hashing, so it
82 /// stays legible-ish in logs/debug output (see `length_prefixed`'s
83 /// doc comment for D-3's length-prefixed encoding, which keeps this
84 /// readable while still being provably unambiguous); a session cache
85 /// has no untrusted-input DoS surface a hash would need to guard
86 /// against (bounded by how many distinct calls one session can make).
87 ///
88 /// **Only use this directly for a request that HAS a `subject`** (bash's
89 /// `command`, a file tool's resolved `path`, `apply_patch`'s `patch`).
90 /// For the general case — including a request with NO subject — use
91 /// [`Self::key_for_request`], which falls back to this exact function
92 /// when `subject` is `Some` (so every existing bash/file-tool caller
93 /// is unaffected) but does something different when it's `None` — see
94 /// that method's doc comment for why (F2, Fable-5 adversarial review).
95 pub fn key(tool: &str, subject: Option<&str>) -> String {
96 match subject {
97 Some(s) => length_prefixed(&[tool, s]),
98 None => length_prefixed(&[tool]),
99 }
100 }
101
102 /// F2 (Fable-5 adversarial review — HIGH, "'allow for session'
103 /// over-grants tool-wide for subject-less tools"): the cache key
104 /// `resolve_ask` actually uses, for ANY request shape.
105 ///
106 /// `ApprovalRequest::subject` is `command.or(path).or(patch)`
107 /// (`Agent::permissions_gate_denial`) — `None` for every MCP tool call
108 /// and any tool whose interesting content lives in richer JSON args
109 /// rather than a single command/path/patch string (e.g.
110 /// `mcp_db_query {"sql": "…"}`). Before this fix, [`Self::key`] alone
111 /// collapsed a subject-less request down to the bare tool name, so an
112 /// `AllowForSession` granted for ONE call's args
113 /// (`{"sql":"SELECT 1"}`) silently auto-allowed EVERY later call to
114 /// that tool regardless of args (`{"sql":"DROP TABLE users"}`) — an
115 /// over-grant the user never saw, let alone approved.
116 ///
117 /// The fix: when there's no `subject`, fold a canonical digest of
118 /// `raw_args` into the key too, so a session grant only ever
119 /// auto-allows the exact SAME args again — a call with different args
120 /// still reaches the handler. "Canonical" here means
121 /// `canonical_json_string`'s recursively-key-sorted rendering, NOT
122 /// `Value`'s own `Display`/`to_string()` — this crate's own build
123 /// happens to render `Value`'s keys already-sorted (`serde_json`'s
124 /// default `Map` backing is a `BTreeMap` unless some dependency's
125 /// build pulls in the `preserve_order` feature and Cargo's feature
126 /// resolver unifies it into this target too), but a SECURITY-relevant
127 /// cache key has no business depending on an indirect, easily-
128 /// disturbed fact like that — so this re-sorts explicitly and is
129 /// correct regardless.
130 ///
131 /// When `subject` IS `Some` (bash/file tools/`apply_patch`), this is
132 /// byte-identical to [`Self::key`] — those callers' session-grant
133 /// breadth is completely unchanged.
134 ///
135 /// D-3 (Fable-5 delta review — LOW hardening): the `None` branch used
136 /// to join `tool` and the args digest with a bare `\u{0}args:`
137 /// separator that wasn't itself length-guarded — see
138 /// `length_prefixed`'s doc comment for the exact collision the
139 /// review proved constructible against [`Self::key`]'s `Some` branch,
140 /// and why length-prefixing every component (rather than trusting an
141 /// unlengthed separator no caller-controlled byte could ever
142 /// reproduce) closes it for good.
143 pub fn key_for_request(req: &ApprovalRequest) -> String {
144 match req.subject {
145 Some(s) => Self::key(req.tool, Some(s)),
146 None => length_prefixed(&[req.tool, "args", &canonical_json_string(req.raw_args)]),
147 }
148 }
149
150 /// Has `key` previously been granted "for session"?
151 pub fn is_approved(&self, key: &str) -> bool {
152 self.granted
153 .lock()
154 .map(|g| g.contains(key))
155 .unwrap_or(false)
156 }
157
158 /// Record `key` as approved for the rest of this session. A poisoned
159 /// lock (a prior panic while held) is treated as "cache unavailable" —
160 /// silently drops the grant rather than panicking the caller; the next
161 /// identical call simply re-prompts, which is the fail-closed direction
162 /// (a lost cache entry costs an extra prompt, never a skipped one).
163 pub fn approve(&self, key: &str) {
164 if let Ok(mut g) = self.granted.lock() {
165 g.insert(key.to_string());
166 }
167 }
168}
169
170/// D-3 (Fable-5 delta review — LOW hardening, "unlengthed separator can
171/// collide two distinct (tool, subject, args) triples"): join `parts` into
172/// one unambiguous string by prefixing EACH component with its own byte
173/// length (netstring-style: `"<decimal-length>:<bytes>"`, repeated back to
174/// back, no trailing separator) — every [`ApprovalCache`] key this module
175/// produces (both [`ApprovalCache::key`]'s `Some`/`None` branches and
176/// [`ApprovalCache::key_for_request`]'s args-digest branch) is built from
177/// this ONE function, so the whole key space shares one encoding rather
178/// than two ad-hoc ones that could disagree.
179///
180/// **Why this actually closes the collision** (the review's own repro:
181/// `ApprovalCache::key("bash", Some("x\0args:{}"))` rendered
182/// byte-identical to `key_for_request` on a tool literally named
183/// `"bash:x"` with no subject and empty args — both collapsed to
184/// `"bash:x\0args:{}"` under the old bare `:`/`\u{0}` separators, which
185/// weren't guarded against a component embedding those exact bytes
186/// itself). A decoder here would parse strictly left to right: read
187/// decimal digits up to the first `:` to learn a component's TRUE length,
188/// then consume exactly that many bytes as its content, with no scanning
189/// for a delimiter INSIDE that content — so no byte sequence a component
190/// carries (a colon, a NUL, digits, anything) can ever be mistaken for a
191/// length prefix or a boundary. And because the prefix is always the
192/// REAL length of what follows (computed here via `p.len()`, never a
193/// value a caller can pick independently of the content), the encoding's
194/// TOTAL byte length is pinned to its true component count and their true
195/// lengths — which is what additionally rules out a `(tool, subject)`
196/// pair (2 components) ever colliding with a `(tool, "args", digest)`
197/// triple (3 components): every extra component contributes at least 2
198/// more bytes (`"0:"` at minimum), so encodings built from a different
199/// number of parts can never even have equal total length, let alone
200/// equal bytes. Deterministic (a pure function of `parts`) and
201/// order-independent in the one sense that matters here — the args digest
202/// fed in as one already-canonicalized (recursively key-sorted, F2)
203/// string, so two calls with the same args in a different JSON key order
204/// still produce the same component and thus the same key.
205fn length_prefixed(parts: &[&str]) -> String {
206 let mut out = String::new();
207 for p in parts {
208 out.push_str(&p.len().to_string());
209 out.push(':');
210 out.push_str(p);
211 }
212 out
213}
214
215/// F2: a canonical (recursively key-sorted) rendering of `value` — see
216/// [`ApprovalCache::key_for_request`]'s doc comment for why this doesn't
217/// just lean on `serde_json::Value`'s own `Display`. Rebuilding every
218/// object from an already-sorted `BTreeMap` and re-serializing is correct
219/// regardless of whether `serde_json`'s `Map` is itself `BTreeMap`- or
220/// insertion-order (`indexmap`)-backed in a given build: either way, the
221/// values get inserted into the output `Value::Object` in sorted order,
222/// so `to_string()` renders them in that order.
223fn canonical_json_string(value: &serde_json::Value) -> String {
224 fn sorted(value: &serde_json::Value) -> serde_json::Value {
225 match value {
226 serde_json::Value::Object(map) => {
227 let ordered: std::collections::BTreeMap<&String, &serde_json::Value> =
228 map.iter().collect();
229 serde_json::Value::Object(
230 ordered
231 .into_iter()
232 .map(|(k, v)| (k.clone(), sorted(v)))
233 .collect(),
234 )
235 }
236 serde_json::Value::Array(items) => {
237 serde_json::Value::Array(items.iter().map(sorted).collect())
238 }
239 other => other.clone(),
240 }
241 }
242 sorted(value).to_string()
243}
244
245/// Resolve one `Ask`-tier request against the cache + an optional handler:
246/// cache hit → `true` (no handler call); no handler → `false` (fail-closed);
247/// handler `Deny`/`Allow`/`AllowForSession` → `false`/`true`/`true`
248/// (recording the grant in `cache` for the last case). This is the single
249/// call site `crate::agent::Agent`'s gate uses, factored out so it's unit-
250/// testable without a full `Agent`.
251pub fn resolve_ask(
252 cache: &ApprovalCache,
253 handler: Option<&dyn PermissionsApprovalHandler>,
254 req: &ApprovalRequest,
255) -> bool {
256 // F2 (Fable-5 adversarial review — HIGH): was `ApprovalCache::key`
257 // alone, which collapses to the bare tool name for a subject-less
258 // request — see `ApprovalCache::key_for_request`'s doc comment for
259 // the over-grant that let a caller's `AllowForSession` on one MCP
260 // call's args silently auto-allow a later, DIFFERENT call to the
261 // same tool.
262 let key = ApprovalCache::key_for_request(req);
263 if cache.is_approved(&key) {
264 return true;
265 }
266 match handler {
267 None => false,
268 Some(h) => match h.ask(req) {
269 ApprovalOutcome::Deny => false,
270 ApprovalOutcome::Allow => true,
271 ApprovalOutcome::AllowForSession => {
272 cache.approve(&key);
273 true
274 }
275 },
276 }
277}
278
279/// Convenience: fold a [`Decision`] into the boolean "may this call proceed"
280/// the tool-dispatch gate needs, given a `resolve_ask`-style callback for the
281/// `Ask` case. `Deny` never reaches `ask_fn` (hard floor); `Allow` never
282/// needs it either.
283pub fn decision_to_approved(decision: Decision, ask_fn: impl FnOnce() -> bool) -> bool {
284 match decision {
285 Decision::Deny => false,
286 Decision::Allow => true,
287 Decision::Ask => ask_fn(),
288 }
289}
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294
295 struct AlwaysAllow;
296 impl PermissionsApprovalHandler for AlwaysAllow {
297 fn ask(&self, _req: &ApprovalRequest) -> ApprovalOutcome {
298 ApprovalOutcome::Allow
299 }
300 }
301 struct AlwaysDeny;
302 impl PermissionsApprovalHandler for AlwaysDeny {
303 fn ask(&self, _req: &ApprovalRequest) -> ApprovalOutcome {
304 ApprovalOutcome::Deny
305 }
306 }
307 struct AlwaysAllowForSession;
308 impl PermissionsApprovalHandler for AlwaysAllowForSession {
309 fn ask(&self, _req: &ApprovalRequest) -> ApprovalOutcome {
310 ApprovalOutcome::AllowForSession
311 }
312 }
313
314 fn req<'a>(
315 tool: &'a str,
316 subject: Option<&'a str>,
317 args: &'a serde_json::Value,
318 ) -> ApprovalRequest<'a> {
319 ApprovalRequest {
320 tool,
321 subject,
322 raw_args: args,
323 }
324 }
325
326 #[test]
327 fn no_handler_denies_fail_closed() {
328 let cache = ApprovalCache::new();
329 let args = serde_json::json!({});
330 assert!(!resolve_ask(
331 &cache,
332 None,
333 &req("bash", Some("rm -rf /"), &args)
334 ));
335 }
336
337 #[test]
338 fn handler_deny_is_denied_and_not_cached() {
339 let cache = ApprovalCache::new();
340 let args = serde_json::json!({});
341 let h = AlwaysDeny;
342 assert!(!resolve_ask(
343 &cache,
344 Some(&h),
345 &req("bash", Some("ls"), &args)
346 ));
347 assert!(!cache.is_approved(&ApprovalCache::key("bash", Some("ls"))));
348 }
349
350 #[test]
351 fn handler_allow_once_is_not_cached() {
352 let cache = ApprovalCache::new();
353 let args = serde_json::json!({});
354 let h = AlwaysAllow;
355 assert!(resolve_ask(
356 &cache,
357 Some(&h),
358 &req("bash", Some("ls"), &args)
359 ));
360 assert!(!cache.is_approved(&ApprovalCache::key("bash", Some("ls"))));
361 }
362
363 #[test]
364 fn allow_for_session_is_cached_and_skips_handler_next_time() {
365 let cache = ApprovalCache::new();
366 let args = serde_json::json!({});
367 let h = AlwaysAllowForSession;
368 assert!(resolve_ask(
369 &cache,
370 Some(&h),
371 &req("bash", Some("ls -la"), &args)
372 ));
373 assert!(cache.is_approved(&ApprovalCache::key("bash", Some("ls -la"))));
374 // Second call: even a deny-everything handler is never consulted,
375 // because the cache short-circuits first.
376 let deny = AlwaysDeny;
377 assert!(resolve_ask(
378 &cache,
379 Some(&deny),
380 &req("bash", Some("ls -la"), &args)
381 ));
382 }
383
384 #[test]
385 fn cache_key_distinguishes_subjects() {
386 let cache = ApprovalCache::new();
387 cache.approve(&ApprovalCache::key("bash", Some("ls -la")));
388 assert!(cache.is_approved(&ApprovalCache::key("bash", Some("ls -la"))));
389 assert!(!cache.is_approved(&ApprovalCache::key("bash", Some("rm -rf /"))));
390 assert!(!cache.is_approved(&ApprovalCache::key("write_file", Some("ls -la"))));
391 }
392
393 #[test]
394 fn decision_to_approved_short_circuits_deny_and_allow() {
395 assert!(!decision_to_approved(Decision::Deny, || panic!(
396 "must not call"
397 )));
398 assert!(decision_to_approved(Decision::Allow, || panic!(
399 "must not call"
400 )));
401 assert!(decision_to_approved(Decision::Ask, || true));
402 assert!(!decision_to_approved(Decision::Ask, || false));
403 }
404
405 // ---- F2 (Fable-5 adversarial review — HIGH): "allow for session"
406 // must be per-args, not per-tool, for a subject-less request ----
407
408 /// THE regression test for the HIGH finding: an `AllowForSession` on
409 /// `mcp_db_query {"sql":"SELECT 1"}` must NOT auto-allow
410 /// `{"sql":"DROP TABLE users"}` — before this fix, both collapsed to
411 /// the same bare-tool-name cache key.
412 #[test]
413 fn allow_for_session_on_a_subject_less_tool_does_not_leak_to_different_args() {
414 let cache = ApprovalCache::new();
415 let handler = AlwaysAllowForSession;
416 let safe_args = serde_json::json!({"sql": "SELECT 1"});
417 let safe = req("mcp_db_query", None, &safe_args);
418 assert!(resolve_ask(&cache, Some(&handler), &safe));
419 // Same tool, same args again: cache hit, no handler needed (would
420 // still return true even without a handler if this weren't
421 // cached — assert the handler path directly below instead).
422 assert!(resolve_ask(&cache, Some(&handler), &safe));
423
424 // Same tool, DIFFERENT args: must NOT be auto-allowed by the
425 // grant above — prove it by using `AlwaysDeny` as the handler
426 // here, so a pass is only possible if the cache did NOT
427 // short-circuit (i.e. the handler really was consulted and really
428 // denied).
429 let deny = AlwaysDeny;
430 let dangerous_args = serde_json::json!({"sql": "DROP TABLE users"});
431 let dangerous = req("mcp_db_query", None, &dangerous_args);
432 assert!(
433 !resolve_ask(&cache, Some(&deny), &dangerous),
434 "a session grant for one arg set must not leak to a different one"
435 );
436 }
437
438 #[test]
439 fn key_for_request_is_per_args_when_subject_is_none() {
440 let a = serde_json::json!({"sql": "SELECT 1"});
441 let b = serde_json::json!({"sql": "DROP TABLE users"});
442 let key_a = ApprovalCache::key_for_request(&req("mcp_db_query", None, &a));
443 let key_b = ApprovalCache::key_for_request(&req("mcp_db_query", None, &b));
444 assert_ne!(key_a, key_b);
445
446 // The SAME args, keys given in a different order, must produce
447 // the SAME key (a call is "the same call" regardless of the
448 // model's own JSON key ordering).
449 let a_reordered = serde_json::json!({
450 "extra": "same",
451 "sql": "SELECT 1",
452 });
453 let a2 = serde_json::json!({
454 "sql": "SELECT 1",
455 "extra": "same",
456 });
457 let key_a_reordered =
458 ApprovalCache::key_for_request(&req("mcp_db_query", None, &a_reordered));
459 let key_a2 = ApprovalCache::key_for_request(&req("mcp_db_query", None, &a2));
460 assert_eq!(key_a_reordered, key_a2);
461 }
462
463 #[test]
464 fn key_for_request_is_unchanged_for_bash_and_file_tools() {
465 // Every existing subject-carrying caller (bash's `command`, a
466 // file tool's `path`, `apply_patch`'s `patch`) must see BYTE-
467 // IDENTICAL key behavior — `key_for_request` must not widen OR
468 // narrow their existing session-grant breadth.
469 let args = serde_json::json!({"command": "git status"});
470 let r = req("bash", Some("git status"), &args);
471 assert_eq!(
472 ApprovalCache::key_for_request(&r),
473 ApprovalCache::key("bash", Some("git status"))
474 );
475 }
476
477 /// The review's own worked example, end to end: a bash `AllowForSession`
478 /// for `git status` must not leak to `git push` — proves `key_for_request`
479 /// didn't accidentally change bash's existing exact-subject-match
480 /// behavior while fixing the subject-less case.
481 #[test]
482 fn bash_allow_for_session_still_does_not_leak_to_a_different_command() {
483 let cache = ApprovalCache::new();
484 let handler = AlwaysAllowForSession;
485 let status_args = serde_json::json!({"command": "git status"});
486 assert!(resolve_ask(
487 &cache,
488 Some(&handler),
489 &req("bash", Some("git status"), &status_args)
490 ));
491
492 let deny = AlwaysDeny;
493 let push_args = serde_json::json!({"command": "git push"});
494 assert!(!resolve_ask(
495 &cache,
496 Some(&deny),
497 &req("bash", Some("git push"), &push_args)
498 ));
499 }
500
501 #[test]
502 fn canonical_json_string_sorts_nested_objects_and_arrays() {
503 let a = serde_json::json!({"z": 1, "a": {"y": 2, "b": 3}, "list": [{"n": 2, "m": 1}]});
504 let b = serde_json::json!({"a": {"b": 3, "y": 2}, "z": 1, "list": [{"m": 1, "n": 2}]});
505 assert_eq!(canonical_json_string(&a), canonical_json_string(&b));
506 }
507
508 // ---- D-3 (Fable-5 delta review — LOW hardening): no separator
509 // injection can collide two distinct (tool, subject, args) triples ----
510
511 /// THE regression test for the review's own repro: `bash` with a
512 /// subject that embeds the OLD raw separator bytes
513 /// (`"x\0args:{}"`) must no longer render the same key as a
514 /// subject-less call to a tool literally named `"bash:x"` with empty
515 /// args — under the pre-D-3 bare `:`/`\u{0}` joins, both collapsed to
516 /// `"bash:x\0args:{}"`. Fail-on-revert: reverting `length_prefixed`
517 /// back to the old `format!("{tool}:{s}")` / `format!("{}\u{0}args:{}",
518 /// ...)` pair makes this fail.
519 #[test]
520 fn key_collision_probe_bash_subject_vs_colon_named_tool_now_distinct() {
521 let empty_args = serde_json::json!({});
522
523 let subject_key = ApprovalCache::key("bash", Some("x\0args:{}"));
524
525 let colon_named_tool_req = req("bash:x", None, &empty_args);
526 let args_digest_key = ApprovalCache::key_for_request(&colon_named_tool_req);
527
528 assert_ne!(
529 subject_key, args_digest_key,
530 "a subject embedding the old separator bytes must not collide with an \
531 unrelated colon-named tool's subject-less key"
532 );
533 }
534
535 /// The same probe via [`ApprovalCache::key_for_request`] end to end
536 /// (not just the raw [`ApprovalCache::key`] building block), proving
537 /// the two DIFFERENT call shapes (`bash` with a subject vs. a
538 /// subject-less `"bash:x"`) never share a cache entry: granting one
539 /// "for session" must not silently cover the other.
540 #[test]
541 fn key_collision_probe_does_not_let_one_grant_cover_the_other() {
542 let cache = ApprovalCache::new();
543 let empty_args = serde_json::json!({});
544
545 let bash_with_tricky_subject = req("bash", Some("x\0args:{}"), &empty_args);
546 cache.approve(&ApprovalCache::key_for_request(&bash_with_tricky_subject));
547
548 let colon_named_tool_req = req("bash:x", None, &empty_args);
549 assert!(
550 !cache.is_approved(&ApprovalCache::key_for_request(&colon_named_tool_req)),
551 "granting the tricky-subject bash call must not also grant the \
552 unrelated colon-named, subject-less tool call"
553 );
554 }
555
556 /// `length_prefixed` itself, directly: differing arity (2 parts vs
557 /// 3 parts) must never collide even when a component is crafted to
558 /// mimic the other encoding's bytes.
559 #[test]
560 fn length_prefixed_distinguishes_differing_arity_and_embedded_separators() {
561 assert_ne!(
562 length_prefixed(&["bash", "x\0args:{}"]),
563 length_prefixed(&["bash:x", "args", "{}"])
564 );
565 // Same components, called out explicitly: embedding a `:` or a
566 // NUL byte inside a component doesn't change how many bytes that
567 // component's own length prefix claims, so it can't be mistaken
568 // for a boundary.
569 assert_ne!(
570 length_prefixed(&["a:b", "c"]),
571 length_prefixed(&["a", "b:c"])
572 );
573 }
574}