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