seisin 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,540 @@
1
+ /**
2
+ * The other half of the record: what the KERNEL refused, not what the hook saw.
3
+ *
4
+ * ── Why this exists ──
5
+ * The hook runs one layer above the boundary and reports the attempt before it
6
+ * happens. That is most of the log, and it is deliberately imperfect — a hook
7
+ * that misreads a shell command costs an explanation, never a boundary. But it
8
+ * means the log only ever contains what the hook understood. Everything that
9
+ * escaped it went **unexplained**, and an escape looks exactly like nothing
10
+ * happening: the agent gets a bare `Operation not permitted` on its stderr, with
11
+ * no path and no reason, and the record does not grow.
12
+ *
13
+ * That failure mode has a shape, and it has cost real time. Four defects found
14
+ * while wrapping a live cell all presented the same way — a file that stopped
15
+ * growing — because nothing anywhere said which path had been refused.
16
+ *
17
+ * macOS already writes the missing line. Every Seatbelt denial lands in the
18
+ * system log with the operation, the absolute path, and the sandbox runtime's
19
+ * own attribution tag attached:
20
+ *
21
+ * Sandbox: bash(65518) deny(1) file-write-create /repo/src/api/orders.ts
22
+ * CMD64_c2ggLWMgJ2VjaG8g…_END__qlvxb5sax_SBX
23
+ *
24
+ * This module reads that stream and hands each denial to the parent, which
25
+ * already owns the log file. Nothing here enforces anything: the boundary held
26
+ * before this existed and holds the same afterwards. It is an instrument.
27
+ *
28
+ * ── Why it does not go through the runtime ──
29
+ * The runtime collects these itself — `startMacOSSandboxLogMonitor` — but only
30
+ * for a caller that embeds the library AND passes `enableLogMonitor: true`. The
31
+ * `srt` binary omits that argument, so on the CLI path the collector is never
32
+ * constructed. Filed upstream: docs/upstream/cli-violations.md.
33
+ *
34
+ * ── Why macOS only, stated rather than papered over ──
35
+ * There is no Linux equivalent to read. bubblewrap does not log refusals; the
36
+ * runtime synthesises them by observing write-intent syscalls through its own
37
+ * `apply-seccomp` stub, which reports over a socket the runtime creates and
38
+ * reads paths out of the traced process's memory — its own comment calls those
39
+ * events attacker-controlled and racy. That is not a stream an outside process
40
+ * can attach to, and it is not one to reimplement. On Linux this returns an
41
+ * unavailable watcher with the reason, and `seisin run` says so once.
42
+ */
43
+ import { spawn, execFileSync } from "node:child_process";
44
+ import { statSync } from "node:fs";
45
+
46
+ /** Only sandbox-runtime tags its violations this way. Everything else on the
47
+ * machine — Safari, mDNSResponder, Spotlight — is filtered out by the OS
48
+ * before a byte reaches us, which is why the predicate is not a grep. */
49
+ const PREDICATE = 'eventMessage ENDSWITH "_SBX"';
50
+
51
+ /** `Sandbox: <proc>(<pid>) deny(<n>) <operation> <detail>` */
52
+ const DENY = /Sandbox:\s+(\S+)\((\d+)\)\s+deny\(\d+\)\s+(\S+)\s+(.+?)\s*$/;
53
+
54
+ /** `CMD64_<base64 of the first 100 chars>_END_<per-srt-process suffix>` */
55
+ const TAG = /CMD64_(.*?)_END_(\S*_SBX)\s*$/;
56
+
57
+ /**
58
+ * A Seatbelt operation, as one of the two verbs the log speaks.
59
+ *
60
+ * Anything that is not a file operation — `mach-lookup`, `network-outbound`,
61
+ * `sysctl-read` — returns null and is dropped. Those are the sandbox doing its
62
+ * job against the machine, not a role reaching into someone else's territory,
63
+ * and the network half already has its own path through the proxy.
64
+ *
65
+ * **`file-read-metadata` is dropped too, and that one was learned the hard
66
+ * way.** It is a refused `stat()`, which is what any directory walk produces
67
+ * against a denied path — `rg`, `find`, a glob, a tool listing the repo. It is
68
+ * not an attempt to read a secret and recording it as one is actively harmful:
69
+ * the first real run against a policy with a populated key directory wrote
70
+ * **721 of them in two runs**, against 4 genuine `file-read-data` attempts. That
71
+ * buried the nine lines that mattered, told `review` that the policy was
72
+ * catastrophically wrong about a directory where nothing had happened, and filed
73
+ * a separate permission request per file — 327 of them — in a queue whose whole
74
+ * promise is that many refusals in one place are one question.
75
+ *
76
+ * `file-read-data` is the operation that means content was reached for. That is
77
+ * the one worth a line.
78
+ */
79
+ export function actionOf(operation) {
80
+ if (operation.startsWith("file-write")) return "write";
81
+ if (operation === "file-read-data") return "read";
82
+ return null;
83
+ }
84
+
85
+ /**
86
+ * One chunk of `log stream` output into a denial, or null.
87
+ *
88
+ * Kept pure and exported so the parsing is testable without a kernel: every
89
+ * fixture in the test suite is a real chunk captured from a real refusal.
90
+ */
91
+ export function parseChunk(chunk) {
92
+ let deny = null;
93
+ let tag = null;
94
+ for (const line of chunk.split("\n")) {
95
+ // "3 duplicate reports for Sandbox: …" is the OS collapsing a repeat. The
96
+ // first one was already reported on its own line, so counting it again
97
+ // would inflate `review`, which exists to do arithmetic over this file.
98
+ if (!deny && !/\bduplicate reports? for\b/.test(line)) {
99
+ const m = line.match(DENY);
100
+ if (m) deny = { proc: m[1], pid: Number(m[2]), operation: m[3], detail: m[4] };
101
+ }
102
+ if (!tag) {
103
+ const m = line.match(TAG);
104
+ if (m) tag = { command: decode(m[1]), suffix: m[2] };
105
+ }
106
+ }
107
+ if (!deny) return null;
108
+ const action = actionOf(deny.operation);
109
+ if (!action) return null;
110
+ // Only an absolute path is a territory question. A relative or malformed
111
+ // detail means the line was not what it looked like; dropping beats guessing.
112
+ if (!deny.detail.startsWith("/")) return null;
113
+ return { ...deny, action, path: deny.detail, suffix: tag?.suffix ?? null, command: tag?.command ?? null };
114
+ }
115
+
116
+ function decode(b64) {
117
+ try {
118
+ return Buffer.from(b64, "base64").toString("utf8");
119
+ } catch {
120
+ return null;
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Split a POSIX shell word list, the way a shell would.
126
+ *
127
+ * This reads the runtime's attribution tag, which carries the wrapped command
128
+ * re-quoted for `sh -c` and truncated to 100 characters. Parsing it beats
129
+ * re-generating it: any correct quoting of the same argument list parses back to
130
+ * that list, so this keeps matching if the runtime changes how it quotes, which
131
+ * re-implementing its `quote()` would not.
132
+ *
133
+ * Truncation means the last word is usually a fragment and the closing quote is
134
+ * usually missing. That is expected, not an error — an unterminated word is
135
+ * returned as far as it got, and {@link isOurs} treats the final word as a
136
+ * prefix.
137
+ */
138
+ export function shellSplit(line) {
139
+ const words = [];
140
+ let word = null;
141
+ let quote = null;
142
+ for (let i = 0; i < line.length; i++) {
143
+ const c = line[i];
144
+ if (quote) {
145
+ if (c === quote) quote = null;
146
+ else word += c;
147
+ continue;
148
+ }
149
+ if (c === "'" || c === '"') { quote = c; word ??= ""; continue; }
150
+ if (c === "\\" && i + 1 < line.length) { word = (word ?? "") + line[++i]; continue; }
151
+ if (/\s/.test(c)) { if (word !== null) { words.push(word); word = null; } continue; }
152
+ word = (word ?? "") + c;
153
+ }
154
+ if (word !== null) words.push(word);
155
+ return words;
156
+ }
157
+
158
+ /**
159
+ * Was this tagged command the one this run launched?
160
+ *
161
+ * The second of two anchors, and it exists because the first one is racy: a
162
+ * refused `sh -c` can be dead before `ps` runs, and then a process tree proves
163
+ * nothing. This one does not depend on the process still existing.
164
+ *
165
+ * Compared as a *prefix*, because the tag holds only the first 100 characters:
166
+ * every complete word must match exactly, and the last word — the one the
167
+ * truncation cut — only has to start the same way. A command that agrees with
168
+ * ours for 100 characters and then differs would be credited to us, which is a
169
+ * wrong attribution we accept: it requires another sandbox on the same machine
170
+ * launching a command that matches ours to the byte for 100 bytes.
171
+ */
172
+ export function isOurs(tagged, argv) {
173
+ if (!tagged) return false;
174
+ const words = shellSplit(tagged);
175
+ if (words.length === 0 || words.length > argv.length) return false;
176
+ for (let i = 0; i < words.length - 1; i++) if (words[i] !== argv[i]) return false;
177
+ const last = words[words.length - 1];
178
+ return argv[words.length - 1]?.startsWith(last) ?? false;
179
+ }
180
+
181
+ /**
182
+ * Is `pid` a descendant of `root`?
183
+ *
184
+ * This is the anchor for attribution, and it is the reason a denial from
185
+ * somebody else's sandbox never lands in this repo's log. The alternative was
186
+ * matching the decoded command against the one we launched, which would mean
187
+ * reimplementing the runtime's shell quoting and inheriting its bugs; a process
188
+ * tree is a fact the OS already holds.
189
+ *
190
+ * One `ps` per unseen pid, and only until the session suffix is learned — after
191
+ * that the suffix alone is exact, because it is generated once per `srt`
192
+ * process. In practice that is one `ps` per run.
193
+ */
194
+ export function descends(pid, root, snapshot) {
195
+ const parent = new Map(snapshot);
196
+ const seen = new Set();
197
+ let at = pid;
198
+ while (at && at !== 1 && !seen.has(at)) {
199
+ if (at === root) return true;
200
+ seen.add(at);
201
+ at = parent.get(at);
202
+ }
203
+ return false;
204
+ }
205
+
206
+ /** pid → ppid for every process this user can see. */
207
+ export function processTree(run = execFileSync) {
208
+ const tree = new Map();
209
+ try {
210
+ const out = run("/bin/ps", ["-Ao", "pid=,ppid="], { encoding: "utf8" });
211
+ for (const line of out.split("\n")) {
212
+ const m = line.trim().match(/^(\d+)\s+(\d+)$/);
213
+ if (m) tree.set(Number(m[1]), Number(m[2]));
214
+ }
215
+ } catch {
216
+ // No tree means nothing can be attributed, which the caller handles by
217
+ // recording nothing. A permission tool does not invent an owner.
218
+ }
219
+ return tree;
220
+ }
221
+
222
+ /**
223
+ * Did this refusal reach for content, or just walk past the door?
224
+ *
225
+ * `file-read-data` on a *file* is a read. On a *directory* it is `opendir` —
226
+ * what any recursive search produces when it reaches a denied path. `rg`, `find`
227
+ * and `grep -r` from the repo root hit every key directory on every invocation,
228
+ * and the kernel refuses each one.
229
+ *
230
+ * This is the same lesson as `file-read-metadata`, one layer up, and it was
231
+ * missed the first time because the operation name is identical: the difference
232
+ * is not in what the kernel said, it is in what was on the other end. Measured
233
+ * on the real log after the metadata fix: **78 of 116 lines** were directory
234
+ * opens of a key directory, and **not one of them named a file inside it**. A
235
+ * role going for a secret names the file.
236
+ *
237
+ * It matters more than volume. Those lines carry no owner — a key directory is
238
+ * closed to everyone, not held by someone — so they arrive at `review` as
239
+ * repeated friction with nobody to hand the work to, and its advice for
240
+ * repeated friction is *grant it, or move the territory*. Left alone, the
241
+ * strongest recommendation the tool made about a real repository was to grant
242
+ * two roles the credential directory.
243
+ *
244
+ * Writes are not filtered this way. A refused `file-write-create` names
245
+ * something that does not exist yet, and there is nothing to stat.
246
+ */
247
+ export function reachedForContent(denial, stat = statSync) {
248
+ if (denial.action !== "read") return true;
249
+ try {
250
+ return !stat(denial.path).isDirectory();
251
+ } catch {
252
+ // Gone, or never there. A read of something that is not on disk is not a
253
+ // directory walk, so it keeps its line rather than being filtered on a
254
+ // guess.
255
+ return true;
256
+ }
257
+ }
258
+
259
+ /**
260
+ * Is this path one the policy has anything to say about?
261
+ *
262
+ * The kernel refuses plenty that is not a territory question: `/dev/tty` on
263
+ * every command a CLI runs, a font cache, a dtrace helper. Those are the
264
+ * sandbox holding the line against the machine, they repeat on every single
265
+ * invocation, and recording them would bury the one line that matters under
266
+ * thousands that never do — in a file whose whole purpose is that `review` can
267
+ * do arithmetic over it.
268
+ *
269
+ * So a denial is recorded when it lands inside the repo, or inside a path this
270
+ * role's policy actually named: its territory, its scratch space, its key
271
+ * directories, the state directory, the denied credential locations under
272
+ * `isolate`. Everything else is counted and reported as a number when the run
273
+ * ends, so that filtering them is visible rather than silent.
274
+ *
275
+ * This is a reporting filter and nothing else. It cannot widen or narrow a
276
+ * boundary; by the time a line exists here the kernel has already refused.
277
+ */
278
+ export function inScope(path, prefixes) {
279
+ return prefixes.some((p) => path === p || path.startsWith(p.endsWith("/") ? p : p + "/"));
280
+ }
281
+
282
+ /**
283
+ * Every path the role's settings mention, plus the repo itself.
284
+ *
285
+ * Read off the generated settings rather than off the config, so it covers
286
+ * exactly what was handed to the kernel — including the paths seisin adds on
287
+ * the role's behalf, which are the ones a reader would not think to look for.
288
+ */
289
+ export function scopeOf(settings, root) {
290
+ const fs = settings.filesystem ?? {};
291
+ return [root, ...(fs.allowWrite ?? []), ...(fs.denyWrite ?? []),
292
+ ...(fs.allowRead ?? []), ...(fs.denyRead ?? [])];
293
+ }
294
+
295
+ /**
296
+ * Follows the kernel's refusals for one `seisin run`.
297
+ *
298
+ * `onDeny` receives `{ action, path, operation, proc, pid }` for each denial
299
+ * attributable to this run. `pid` is the pid of the `srt` process; denials from
300
+ * anything that is not its descendant belong to someone else and are dropped.
301
+ *
302
+ * Returns `{ available, reason, close, stats }`. `available: false` is a normal
303
+ * outcome, not a failure — it is what Linux looks like, and what a machine
304
+ * without `log` looks like — and the caller says so once rather than silently
305
+ * logging less than it used to.
306
+ */
307
+ export function watchDenials(onDeny, { pid = null, argv = null, platform = process.platform, spawnFn = spawn, treeFn = processTree } = {}) {
308
+ const stats = { attributed: 0, foreign: 0, unattributed: 0 };
309
+
310
+ /**
311
+ * Unavailable is a watcher that does nothing, NOT a different shape.
312
+ *
313
+ * This returned an object without `attributeTo`, and `seisin run` calls that
314
+ * unconditionally — so on Linux, where this branch is always taken, every
315
+ * single run died with `denials.attributeTo is not a function` before the
316
+ * agent started. The tool did not degrade on the platform it cannot watch;
317
+ * **it stopped working there.**
318
+ *
319
+ * It went unnoticed for a day because the suite could not see it either: the
320
+ * fourteen tests that run real commands decided whether the runtime was
321
+ * present with `command -v srt`, which misses the bundled one, so they
322
+ * skipped and the run came back green. Two defects covering for each other,
323
+ * and it took running the suite on the other platform to part them.
324
+ *
325
+ * So the contract is the object, not the flag. Everything a caller may invoke
326
+ * exists at both ends; `available` says whether it will find anything.
327
+ */
328
+ if (platform !== "darwin")
329
+ return {
330
+ available: false,
331
+ reason: platform === "linux"
332
+ ? "kernel denials are not readable from outside the runtime on Linux — see docs/upstream/cli-violations.md"
333
+ : `no kernel denial stream on ${platform}`,
334
+ stats,
335
+ attributeTo() {},
336
+ close: () => Promise.resolve(stats),
337
+ };
338
+
339
+ let suffix = null; // learned once, exact from then on
340
+ let tree = null; // refreshed only while still learning
341
+ let root = pid; // the srt pid; not known until after spawn
342
+ const mine = new Set(); // pids already proven to be ours
343
+ const held = []; // denials seen before they could be attributed
344
+
345
+ /**
346
+ * Two anchors, and either one is enough.
347
+ *
348
+ * Neither works alone. The process tree is exact while the process exists and
349
+ * proves nothing once it has exited — which, for the short commands that get
350
+ * refused most, is by the time the line arrives. The command tag survives the
351
+ * process but depends on the runtime's quoting staying recognisable.
352
+ *
353
+ * Whichever answers first hands over the session suffix, and from that point
354
+ * attribution is one string comparison: the suffix is generated once per `srt`
355
+ * process, so it separates this run from every other sandbox on the machine
356
+ * exactly. In practice the tag settles it on the first denial and the tree is
357
+ * never consulted.
358
+ */
359
+ const attribute = (d) => {
360
+ if (suffix) return d.suffix === suffix;
361
+ if (argv && isOurs(d.command, argv)) {
362
+ if (d.suffix) suffix = d.suffix;
363
+ return true;
364
+ }
365
+ if (root === null) return false; // nothing to descend from yet
366
+ if (mine.has(d.pid)) return true;
367
+ if (tree === null) tree = treeFn();
368
+ if (!descends(d.pid, root, tree)) {
369
+ // A pid we have never seen may simply be younger than the snapshot.
370
+ // Refresh once, then believe the answer.
371
+ tree = treeFn();
372
+ if (!descends(d.pid, root, tree)) return false;
373
+ }
374
+ mine.add(d.pid);
375
+ // The first proven denial hands us the suffix for every later one.
376
+ if (d.suffix) suffix = d.suffix;
377
+ return true;
378
+ };
379
+
380
+ /**
381
+ * The same refusal, reported twice.
382
+ *
383
+ * macOS emits a violation from `kernel` and, for some of them, a second
384
+ * extended report from `sandboxd` carrying the identical `Sandbox:` line.
385
+ * They are one event and the log has to say so — `review` counts lines, and a
386
+ * denial that shows as two is a policy that looks twice as wrong as it is.
387
+ * Measured on the first real run: 31 doubled events out of 730.
388
+ *
389
+ * Keyed on what the kernel said, not on when we read it: two deliveries of one
390
+ * event agree on pid, operation and path and disagree on arrival time.
391
+ */
392
+ const recent = new Map();
393
+ const isRepeat = (d) => {
394
+ const key = `${d.pid}|${d.operation}|${d.path}`;
395
+ const now = Date.now();
396
+ for (const [k, t] of recent) if (now - t > REPEAT_MS) recent.delete(k); else break;
397
+ if (recent.has(key)) return true;
398
+ recent.set(key, now);
399
+ return false;
400
+ };
401
+
402
+ const consider = (d) => {
403
+ if (attribute(d)) {
404
+ stats.attributed++;
405
+ onDeny(d);
406
+ // Learning the suffix makes every held line decidable. Without this,
407
+ // denials that arrived while the first process was being identified —
408
+ // which on a fast command is all of them — are lost.
409
+ if (suffix && held.length) revisit();
410
+ return;
411
+ }
412
+ // Not ours *yet*. Two reasons, both temporary: the run has not spawned the
413
+ // child whose pid anchors attribution, or the process died before `ps`
414
+ // could see it. Either way a foreign sandbox and one of ours look the same
415
+ // from here, so hold the line rather than credit or discard it.
416
+ if (!suffix && d.suffix) {
417
+ stats.unattributed++;
418
+ held.push(d);
419
+ // Bounded, because the common case on a busy machine is a run that is
420
+ // never refused anything: it never learns its own suffix, so every
421
+ // denial from every other sandbox lands here and nothing ever drains it.
422
+ // Several cells running at once for hours is exactly that shape, and an
423
+ // unbounded array is how an instrument becomes the leak.
424
+ //
425
+ // Dropping the oldest is the right end to drop. What is still worth
426
+ // replaying is whatever arrived near the run's own first refusal; a
427
+ // denial from an hour ago that is still unattributed is not ours.
428
+ while (held.length > HELD_MAX) { held.shift(); stats.unattributed--; stats.foreign++; }
429
+ return;
430
+ }
431
+ stats.foreign++;
432
+ };
433
+
434
+ /** Re-examine what was held, now that something new is known. */
435
+ const revisit = () => {
436
+ for (const h of held.splice(0)) { stats.unattributed--; consider(h); }
437
+ };
438
+
439
+ let child;
440
+ try {
441
+ child = spawnFn("/usr/bin/log", ["stream", "--predicate", PREDICATE, "--style", "compact"],
442
+ { stdio: ["ignore", "pipe", "ignore"] });
443
+ } catch (e) {
444
+ return { available: false, reason: `could not start the log stream: ${e.message}`, stats, close() {} };
445
+ }
446
+
447
+ let buf = "";
448
+ child.stdout?.on("data", (data) => {
449
+ buf += data.toString();
450
+ // The OS emits one event per chunk, but a chunk can split across reads.
451
+ // Split on the tag, which always terminates an event.
452
+ let cut;
453
+ while ((cut = buf.indexOf("_SBX")) !== -1) {
454
+ const chunk = buf.slice(0, cut + 4);
455
+ buf = buf.slice(cut + 4);
456
+ const d = parseChunk(chunk);
457
+ // Deduplicated here and not in consider(), because a held denial goes
458
+ // through consider() a second time when it is finally attributed. Doing
459
+ // this there would drop every line that arrived before the child had a
460
+ // pid — which, on a short command, is all of them.
461
+ if (d && !isRepeat(d)) consider(d);
462
+ }
463
+ if (buf.length > 64_000) buf = ""; // a stream we cannot parse is not a leak
464
+ });
465
+ // A monitor that cannot run must never take the run down with it. Same rule
466
+ // as append(): the instrument is allowed to fail, the agent is not.
467
+ child.on("error", () => {});
468
+
469
+ return {
470
+ available: true,
471
+ reason: null,
472
+ stats,
473
+
474
+ /**
475
+ * Name the process whose descendants count as ours.
476
+ *
477
+ * Separate from the constructor because the stream has to be listening
478
+ * *before* the child starts. A short command — the kind an agent runs
479
+ * hundreds of times — is refused and gone in single-digit milliseconds, so
480
+ * a monitor started after the spawn misses precisely the denials it exists
481
+ * to catch. Measured: with the stream started afterwards, a one-line `sh -c`
482
+ * refusal recorded nothing at all.
483
+ */
484
+ attributeTo(childPid) {
485
+ root = childPid;
486
+ if (held.length) revisit();
487
+ },
488
+
489
+ /**
490
+ * Stop, after giving the kernel time to finish talking.
491
+ *
492
+ * `log stream` is a separate process and the last denial of a run can still
493
+ * be in flight when the child exits. The wait is per run and it is the only
494
+ * latency this whole mechanism adds; nothing here sits on the path of a tool
495
+ * call. Resolves early once the stream goes quiet, so the common case —
496
+ * nothing pending — costs one tick rather than the full window.
497
+ */
498
+ close({ drain = 0 } = {}) {
499
+ const stop = () => { try { child.kill("SIGTERM"); } catch { /* already gone */ } };
500
+ if (drain <= 0) { stop(); return Promise.resolve(stats); }
501
+ return new Promise((resolve) => {
502
+ let quiet = null;
503
+ const settle = () => { clearTimeout(quiet); clearTimeout(cap); stop(); resolve(stats); };
504
+ const cap = setTimeout(settle, drain);
505
+ const bump = () => { clearTimeout(quiet); quiet = setTimeout(settle, QUIET_MS); };
506
+ child.stdout?.on("data", bump);
507
+ bump();
508
+ });
509
+ },
510
+ };
511
+ }
512
+
513
+ /**
514
+ * How long the stream has to stay silent before a drain gives up on it.
515
+ *
516
+ * Short, because the measured lag between a refusal and its line arriving is
517
+ * under a millisecond — the line had already landed before `srt` finished
518
+ * exiting. This is a floor against scheduling noise, not a guess at the lag.
519
+ */
520
+ const QUIET_MS = 60;
521
+
522
+ /**
523
+ * How many undecided denials to keep while waiting to learn our own suffix.
524
+ *
525
+ * Generous enough to cover the startup window — where every denial arrives
526
+ * before there is anything to attribute it to — and small enough that a run
527
+ * which is never refused anything cannot accumulate another sandbox's traffic
528
+ * for the length of an agent session.
529
+ */
530
+ const HELD_MAX = 500;
531
+
532
+ /**
533
+ * How long two reports of one refusal can be apart and still be one event.
534
+ *
535
+ * The second copy — `sandboxd`'s extended report — follows the kernel's within
536
+ * milliseconds. Kept short so that a role genuinely retrying the same path a
537
+ * second later is still recorded as a second attempt, which is what makes
538
+ * `asked 3×` mean anything.
539
+ */
540
+ const REPEAT_MS = 1000;