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}