Skip to main content

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}