1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
//! P5-1 (COMPOSABLE-HARNESS-DESIGN.md §2 module 10, §2.10): approval policy
//! plumbing — the POLICY + session-scoped CACHE + a non-interactive decision
//! path. The INTERACTIVE ask-UI itself is the `tui` module (P5 row 4, not
//! this unit) — [`PermissionsApprovalHandler`] is the seam a CLI/TUI/SDK
//! embedder implements to plug an interactive (or scripted/headless) prompt
//! into `crate::agent::Agent`'s tool-dispatch gate, mirroring the existing
//! `crate::reduce::summarize::SpanSummarizer`/`crate::session_title::SessionTitler`
//! "installing one alone changes nothing, the `Config` gate is what turns it
//! on" pattern (`Agent::set_span_summarizer`/`Agent::set_session_titler`).
use HashSet;
use ;
use Mutex;
use Decision;
/// What a [`PermissionsApprovalHandler`] decides for one `Ask`-tier request.
/// One `Ask`-tier request handed to a [`PermissionsApprovalHandler`] — enough
/// context for an interactive prompt (or a scripted policy) to render a
/// decision without needing back-references into `Agent`'s private state.
/// The non-interactive decision seam a CLI/TUI/SDK embedder implements. The
/// engine (`crate::agent::Agent`'s gate) calls [`Self::ask`] ONLY when the
/// rule engine has already resolved a call to [`Decision::Ask`] — `Deny`
/// short-circuits before ever reaching a handler (a hard floor, never
/// consulted), and `Allow` never needs one. No handler installed (the
/// default) denies every `Ask` — fail-closed, the same posture
/// `Config::approval_handler`'s doc comment already documents for the
/// pre-P5-1 gate ("absent handler denies, so an OnRequest/Untrusted policy
/// is fail-closed" — agent.rs).
/// Session-scoped "approve for session" decision cache (§2.10). Keyed by
/// [`Self::key`] — `(tool, subject)` — so a repeated identical call (the
/// SAME canonical command, or the same path) skips re-prompting for the rest
/// of this agent's lifetime, exactly like CC's "don't ask again"/oc's
/// "always" (cc§4, oc§4). Cheap and unconditional to construct — an agent
/// that never enables `capabilities.permissions` simply never populates or
/// consults it (§1.13-style "zero cost when off").
///
/// BP-10 (catalog row "Session approval caching (\"don't ask again\")",
/// semantics "Approvals persisted per session/project/**prefix**"): the
/// cache can be BACKED BY A FILE, so a grant outlives the process the way
/// CC's per-project "don't ask again" and cx's saved prefix rules both do.
/// [`Self::new`] (no store) is the pre-BP-10 in-memory cache, unchanged and
/// still the default for any embedder that never asks for persistence.
/// BP-10: read a persisted grant set. Any failure (absent file, unreadable,
/// not a JSON string array) yields an EMPTY set — see
/// [`ApprovalCache::persistent`]'s doc comment for why that is the
/// fail-closed direction.
/// BP-10: write the grant set back. A failure is silent-but-degrading (the
/// run keeps its in-memory grants, they are simply not remembered) rather
/// than a panic in a tool-dispatch path.
/// BP-10: the DEFAULT per-project approval store for `cwd` —
/// `$SUPERCODE_HOME/approvals/<project_tag>.json`. Transcribes
/// `crate::checkpoint`'s own `default_shadow_root` method exactly (same
/// `$SUPERCODE_HOME` resolver, same `crate::checkpoint::project_tag`), so a
/// remembered approval sits beside the session's other per-project records
/// instead of inventing a third layout.
///
/// Per PROJECT, not per session: cc§4 records "don't ask again" per
/// project+command and cx saves prefix rules the same way — a grant a user
/// gave once should not evaporate because they started a new session in the
/// same repo. It is still scoped: another project never sees it.
/// BP-10: the [`ApprovalCache`] a fresh `crate::agent::Agent` should carry,
/// given a resolved config. `capabilities.permissions.approvals.persist`
/// (`Config::permissions_approvals_persist`, `false` by default) is the ONE
/// gate: off returns the pre-BP-10 in-memory cache without touching the
/// filesystem at all.
/// D-3 (Fable-5 delta review — LOW hardening, "unlengthed separator can
/// collide two distinct (tool, subject, args) triples"): join `parts` into
/// one unambiguous string by prefixing EACH component with its own byte
/// length (netstring-style: `"<decimal-length>:<bytes>"`, repeated back to
/// back, no trailing separator) — every [`ApprovalCache`] key this module
/// produces (both [`ApprovalCache::key`]'s `Some`/`None` branches and
/// [`ApprovalCache::key_for_request`]'s args-digest branch) is built from
/// this ONE function, so the whole key space shares one encoding rather
/// than two ad-hoc ones that could disagree.
///
/// **Why this actually closes the collision** (the review's own repro:
/// `ApprovalCache::key("bash", Some("x\0args:{}"))` rendered
/// byte-identical to `key_for_request` on a tool literally named
/// `"bash:x"` with no subject and empty args — both collapsed to
/// `"bash:x\0args:{}"` under the old bare `:`/`\u{0}` separators, which
/// weren't guarded against a component embedding those exact bytes
/// itself). A decoder here would parse strictly left to right: read
/// decimal digits up to the first `:` to learn a component's TRUE length,
/// then consume exactly that many bytes as its content, with no scanning
/// for a delimiter INSIDE that content — so no byte sequence a component
/// carries (a colon, a NUL, digits, anything) can ever be mistaken for a
/// length prefix or a boundary. And because the prefix is always the
/// REAL length of what follows (computed here via `p.len()`, never a
/// value a caller can pick independently of the content), the encoding's
/// TOTAL byte length is pinned to its true component count and their true
/// lengths — which is what additionally rules out a `(tool, subject)`
/// pair (2 components) ever colliding with a `(tool, "args", digest)`
/// triple (3 components): every extra component contributes at least 2
/// more bytes (`"0:"` at minimum), so encodings built from a different
/// number of parts can never even have equal total length, let alone
/// equal bytes. Deterministic (a pure function of `parts`) and
/// order-independent in the one sense that matters here — the args digest
/// fed in as one already-canonicalized (recursively key-sorted, F2)
/// string, so two calls with the same args in a different JSON key order
/// still produce the same component and thus the same key.
/// F2: a canonical (recursively key-sorted) rendering of `value` — see
/// [`ApprovalCache::key_for_request`]'s doc comment for why this doesn't
/// just lean on `serde_json::Value`'s own `Display`. Rebuilding every
/// object from an already-sorted `BTreeMap` and re-serializing is correct
/// regardless of whether `serde_json`'s `Map` is itself `BTreeMap`- or
/// insertion-order (`indexmap`)-backed in a given build: either way, the
/// values get inserted into the output `Value::Object` in sorted order,
/// so `to_string()` renders them in that order.
/// Resolve one `Ask`-tier request against the cache + an optional handler:
/// cache hit → `true` (no handler call); no handler → `false` (fail-closed);
/// handler `Deny`/`Allow`/`AllowForSession` → `false`/`true`/`true`
/// (recording the grant in `cache` for the last case). This is the single
/// call site `crate::agent::Agent`'s gate uses, factored out so it's unit-
/// testable without a full `Agent`.
/// Convenience: fold a [`Decision`] into the boolean "may this call proceed"
/// the tool-dispatch gate needs, given a `resolve_ask`-style callback for the
/// `Ask` case. `Deny` never reaches `ask_fn` (hard floor); `Allow` never
/// needs it either.