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}