seisin 0.6.3 → 0.6.5

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.
Files changed (50) hide show
  1. package/README.md +143 -686
  2. package/package.json +2 -2
  3. package/src/canary.js +1 -1
  4. package/src/cli.js +47 -3
  5. package/src/commands/check.js +68 -12
  6. package/src/commands/hook.js +1 -1
  7. package/src/commands/log.js +21 -14
  8. package/src/commands/requests.js +10 -15
  9. package/src/commands/review.js +2 -1
  10. package/src/commands/run.js +331 -245
  11. package/src/commands/walls.js +12 -5
  12. package/src/config-fields.js +305 -0
  13. package/src/config.js +114 -534
  14. package/src/controls.js +1 -1
  15. package/src/decide-request.js +37 -0
  16. package/src/denial-attribution.js +338 -0
  17. package/src/grants.js +1 -1
  18. package/src/help.js +6 -4
  19. package/src/index.js +1 -1
  20. package/src/inspect/enforce.js +1 -1
  21. package/src/inspect/files.js +1 -1
  22. package/src/inspect/home.js +1 -1
  23. package/src/inspect/inject.js +1 -1
  24. package/src/intake.js +3 -2
  25. package/src/keys.js +2 -2
  26. package/src/kinds.js +19 -0
  27. package/src/layout.js +1 -1
  28. package/src/log.js +3 -2
  29. package/src/mcp.js +1 -1
  30. package/src/notify-config.js +58 -0
  31. package/src/notify.js +4 -52
  32. package/src/owners.js +2 -2
  33. package/src/requests.js +9 -4
  34. package/src/rundir.js +2 -2
  35. package/src/runs.js +29 -10
  36. package/src/sandboxes/command.js +0 -2
  37. package/src/sandboxes/index.js +15 -2
  38. package/src/sandboxes/srt.js +1 -1
  39. package/src/sandboxes/tartaros.js +34 -6
  40. package/src/serve.js +11 -22
  41. package/src/spool.js +1 -1
  42. package/src/{srt.js → srt-settings.js} +5 -2
  43. package/src/state.js +25 -0
  44. package/src/suggest.js +26 -1
  45. package/src/surface.js +3 -2
  46. package/src/territory.js +20 -1
  47. package/src/toml.js +250 -0
  48. package/src/violations.js +16 -315
  49. package/src/walls.js +4 -2
  50. package/ui/index.html +35 -9
package/src/toml.js ADDED
@@ -0,0 +1,250 @@
1
+ /**
2
+ * The TOML subset seisin.toml is written in: `[table.name]` headers, and
3
+ * `key = ["a", "b"]` / `key = "a"` values, with a line number on every error.
4
+ *
5
+ * Kept in-house so installing seisin pulls in no parser. If a config outgrows
6
+ * this subset, the config is doing too much.
7
+ */
8
+ import { CONFIG_NAME } from "./layout.js";
9
+
10
+ const RE_TABLE = /^\[([A-Za-z0-9_.\-]+)\]$/;
11
+ const RE_PAIR = /^([A-Za-z0-9_\-]+)\s*=\s*(.+)$/;
12
+
13
+ /**
14
+ * Names a table or key may not have.
15
+ *
16
+ * `[roles.__proto__]` does not show up in `Object.keys(roles)`, so `check`
17
+ * prints the roles that exist and says nothing — while every other role
18
+ * inherits whatever it declared. A config reading `[roles.frontend]` with no
19
+ * `writes` came out of the parser owning the entire repo and holding
20
+ * GITHUB_TOKEN, and no amount of reading the file would show it.
21
+ *
22
+ * The tables below are made with a null prototype, which stops the inheritance
23
+ * on its own. The names are refused as well, because a config containing them
24
+ * is not a config with a mistake in it.
25
+ */
26
+ export const FORBIDDEN = new Set(["__proto__", "prototype", "constructor"]);
27
+
28
+ /** A table with no prototype, so nothing is inherited into it. */
29
+ export function dict() {
30
+ return Object.create(null);
31
+ }
32
+
33
+ export const isTable = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
34
+
35
+ /** Read a property only if the object actually has it. */
36
+ export function own(obj, key) {
37
+ return obj && Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : undefined;
38
+ }
39
+
40
+ /**
41
+ * Where each table and key of a parsed file was written, by dotted name.
42
+ *
43
+ * Kept beside the result rather than in it, so the object stays exactly the
44
+ * data that was written; loadConfig reads it to put a line number on a name it
45
+ * does not know.
46
+ */
47
+ export const LINES = new WeakMap();
48
+
49
+ /** Minimal TOML subset -> plain object. Throws with a line number on bad input. */
50
+ export function parseToml(text) {
51
+ const out = dict();
52
+ const where = new Map();
53
+ LINES.set(out, where);
54
+ let table = out;
55
+ let tableName = "";
56
+ // "Last one wins" is TOML-ish and wrong for a permission file. A policy can
57
+ // look restrictive at the top and be cancelled forty lines down, and the
58
+ // person reviewing it reads the first block. Both shapes are refused.
59
+ const seenTables = new Set();
60
+ const seenKeys = new Set();
61
+ const lines = text.split(/\r?\n/);
62
+
63
+ for (let i = 0; i < lines.length; i++) {
64
+ const raw = lines[i];
65
+ const line = stripComment(raw).trim();
66
+ if (!line) continue;
67
+
68
+ const header = RE_TABLE.exec(line);
69
+ if (header) {
70
+ tableName = header[1];
71
+ for (const part of tableName.split("."))
72
+ if (FORBIDDEN.has(part))
73
+ throw new Error(
74
+ `${CONFIG_NAME}:${i + 1}: "[${tableName}]" uses the reserved name "${part}". ` +
75
+ `A table named that would not appear as a role and would hand its settings to ` +
76
+ `every other one.`);
77
+ if (seenTables.has(tableName))
78
+ throw new Error(
79
+ `${CONFIG_NAME}:${i + 1}: [${tableName}] appears twice. ` +
80
+ `A later table would silently override the earlier one — put every setting for ` +
81
+ `${tableName} in one place.`);
82
+ seenTables.add(tableName);
83
+ tableName.split(".").forEach((_, k, parts) => {
84
+ const name = parts.slice(0, k + 1).join(".");
85
+ if (!where.has(name)) where.set(name, i + 1);
86
+ });
87
+ table = tableName.split(".").reduce((node, key, k, parts) => {
88
+ if (!Object.prototype.hasOwnProperty.call(node, key)) node[key] = dict();
89
+ else if (!isTable(node[key]))
90
+ throw new Error(
91
+ `${CONFIG_NAME}:${i + 1}: [${tableName}] — "${parts.slice(0, k + 1).join(".")}" is already ` +
92
+ `a value, not a table. One name cannot be both.`);
93
+ return node[key];
94
+ }, out);
95
+ continue;
96
+ }
97
+
98
+ const pair = RE_PAIR.exec(line);
99
+ if (!pair) throw new Error(`${CONFIG_NAME}:${i + 1}: cannot read "${raw.trim()}"`);
100
+
101
+ let [, key, value] = pair;
102
+ const at = i + 1;
103
+ // An array may span lines; keep pulling until the brackets balance. Only
104
+ // the new line is searched: searching the whole growing value made an
105
+ // unclosed array of 100k lines take 20 s to be refused. A `]` inside a
106
+ // quoted item does not close it — `["a]b",` used to end the array there.
107
+ if (value.startsWith("[") && !closesArray(value)) {
108
+ const parts = [value];
109
+ let closed = false;
110
+ while (!closed && i + 1 < lines.length) {
111
+ const next = stripComment(lines[++i]).trim();
112
+ parts.push(next);
113
+ closed = closesArray(next);
114
+ }
115
+ value = parts.join(" ");
116
+ }
117
+ if (FORBIDDEN.has(key))
118
+ throw new Error(`${CONFIG_NAME}:${i + 1}: "${key}" is a reserved name and cannot be a setting.`);
119
+ const seen = `${tableName}.${key}`;
120
+ if (seenKeys.has(seen))
121
+ throw new Error(
122
+ `${CONFIG_NAME}:${i + 1}: "${key}" is set twice in [${tableName || "the root"}]. ` +
123
+ `The second would win, which is not a thing a permission file should do quietly.`);
124
+ seenKeys.add(seen);
125
+ where.set(tableName ? `${tableName}.${key}` : key, at);
126
+ /**
127
+ * A key may not be a table that is declared elsewhere, in either order.
128
+ *
129
+ * `[roles.a]` with `network = []`, then `[roles]` and `a = true` forty lines
130
+ * down, used to replace the whole role with `true`: its `network = []` came
131
+ * back as "not set", which means the global allow list. `check` said
132
+ * nothing. It is the "last one wins" this parser refuses everywhere else,
133
+ * arriving by a different spelling.
134
+ */
135
+ if (Object.prototype.hasOwnProperty.call(table, key))
136
+ throw new Error(
137
+ `${CONFIG_NAME}:${i + 1}: "${key}" in [${tableName || "the root"}] is already the table ` +
138
+ `[${tableName ? tableName + "." : ""}${key}]. One name cannot be both — the value would replace ` +
139
+ `everything that table says.`);
140
+ table[key] = readValue(value, i + 1);
141
+ }
142
+ return out;
143
+ }
144
+
145
+ /** Is there a `]` on this line outside a quoted string? Strings do not span lines. */
146
+ export function closesArray(line) {
147
+ let quoted = false;
148
+ for (let i = 0; i < line.length; i++) {
149
+ const c = line[i];
150
+ if (c === '"') quoted = !quoted;
151
+ else if (c === "]" && !quoted) return true;
152
+ }
153
+ return false;
154
+ }
155
+
156
+ /** Strips a trailing `#` comment, but not one inside a quoted string. */
157
+ export function stripComment(line) {
158
+ let quoted = false;
159
+ for (let i = 0; i < line.length; i++) {
160
+ const c = line[i];
161
+ if (c === '"') quoted = !quoted;
162
+ else if (c === "#" && !quoted) return line.slice(0, i);
163
+ }
164
+ return line;
165
+ }
166
+
167
+ /**
168
+ * One value, read strictly.
169
+ *
170
+ * Strict is the whole point, and it was not before. The array reader used to
171
+ * pick quoted runs out of the line with a regex and ignore everything between
172
+ * them, so `["a" GARBAGE "b"]` parsed as `["a","b"]` and an unterminated string
173
+ * parsed as `""`. A permission file that is half-understood is worse than one
174
+ * that is rejected: the half that was dropped is the half you meant.
175
+ *
176
+ * So this scans rather than matches, and anything it does not recognise is an
177
+ * error with a line number. There are no escapes — a `"` ends the string. If a
178
+ * path of yours needs a quote in it, that is outside this subset and the
179
+ * message says so instead of guessing.
180
+ */
181
+ function readValue(value, lineNo) {
182
+ const v = value.trim();
183
+ const bad = (msg) => { throw new Error(`${CONFIG_NAME}:${lineNo}: ${msg}`); };
184
+
185
+ if (v === "true") return true;
186
+ if (v === "false") return false;
187
+
188
+ if (v.startsWith('"')) {
189
+ const end = v.indexOf('"', 1);
190
+ if (end === -1) bad(`unterminated string — no closing quote in ${v}`);
191
+ const rest = v.slice(end + 1).trim();
192
+ if (rest) bad(`unexpected ${JSON.stringify(rest)} after the string`);
193
+ return v.slice(1, end);
194
+ }
195
+
196
+ if (v.startsWith("[")) {
197
+ if (!v.endsWith("]")) bad("unterminated array — no closing bracket");
198
+ const items = [];
199
+ let i = 1;
200
+ const body = v.slice(0, -1); // everything up to the closing bracket
201
+ let expectItem = true; // a list alternates item, comma, item…
202
+
203
+ while (i < body.length) {
204
+ const c = body[i];
205
+ if (c === " " || c === "\t") { i++; continue; }
206
+
207
+ if (c === ",") {
208
+ if (expectItem) bad("empty array slot — two commas in a row, or a leading comma");
209
+ expectItem = true; i++; continue;
210
+ }
211
+ if (!expectItem) bad(`missing comma before ${JSON.stringify(body.slice(i, i + 12))}`);
212
+ // Whole numbers, for `local_ports`. Digits only: no sign, no decimal, no
213
+ // underscore — a port is none of those, and every other key still
214
+ // refuses a number through asArray.
215
+ const digits = /^\d+/.exec(body.slice(i));
216
+ if (digits) {
217
+ const after = body[i + digits[0].length];
218
+ if (after !== undefined && after !== "," && after !== " " && after !== "\t")
219
+ bad(`${JSON.stringify(body.slice(i, i + 12))} is not a whole number`);
220
+ items.push(Number(digits[0]));
221
+ i += digits[0].length;
222
+ expectItem = false;
223
+ continue;
224
+ }
225
+ if (c !== '"') bad(`array items must be double-quoted, found ${JSON.stringify(body.slice(i, i + 12))}`);
226
+
227
+ const end = body.indexOf('"', i + 1);
228
+ if (end === -1) bad("unterminated string inside the array");
229
+ // A backslash before the closing quote means somebody wrote `\"` and
230
+ // expected an escape. There are none — the quote just ended the string,
231
+ // and the error they get otherwise is about a missing comma somewhere in
232
+ // the middle of a shell pipeline, which names the symptom and hides the
233
+ // cause. Measured while writing the docs: every attempt at a one-line
234
+ // provider command landed here.
235
+ if (body[end - 1] === "\\")
236
+ bad(`there are no escapes in this format, so the \\" in ` +
237
+ `${JSON.stringify(body.slice(i, Math.min(end + 2, i + 40)))} ended the string early.\n` +
238
+ ` A value cannot contain a double quote. If this is a shell command, put it in a ` +
239
+ `script and name the script instead — which is also how it stops being unreadable.`);
240
+ items.push(body.slice(i + 1, end));
241
+ i = end + 1;
242
+ expectItem = false; // a trailing comma is fine; a trailing item is not
243
+ }
244
+ return items;
245
+ }
246
+
247
+ if (/^\d+$/.test(v)) return Number(v);
248
+
249
+ bad("value must be a string, an array of strings, a whole number, or a boolean");
250
+ }
package/src/violations.js CHANGED
@@ -42,9 +42,13 @@
42
42
  * per run is a line nobody can act on — and `seisin check` says it, with the
43
43
  * platform's other standing limits.
44
44
  */
45
- import { spawn, execFileSync } from "node:child_process";
45
+ import { spawn } from "node:child_process";
46
46
  import { statSync } from "node:fs";
47
47
  import { basename } from "node:path";
48
+ import { createDenialAttribution, processTree, TREE_MS } from "./denial-attribution.js";
49
+
50
+ // Keep the existing helper imports available to callers and tests.
51
+ export { shellSplit, isOurs, descends, processTree, underAnotherRun } from "./denial-attribution.js";
48
52
 
49
53
  /** Only sandbox-runtime tags its violations this way. Everything else on the
50
54
  * machine — Safari, mDNSResponder, Spotlight — is filtered out by the OS
@@ -197,134 +201,6 @@ function decode(b64) {
197
201
  }
198
202
  }
199
203
 
200
- /**
201
- * Split a POSIX shell word list, the way a shell would.
202
- *
203
- * This reads the runtime's attribution tag, which carries the wrapped command
204
- * re-quoted for `sh -c` and truncated to 100 characters. Parsing it beats
205
- * re-generating it: any correct quoting of the same argument list parses back to
206
- * that list, so this keeps matching if the runtime changes how it quotes, which
207
- * re-implementing its `quote()` would not.
208
- *
209
- * Truncation means the last word is usually a fragment and the closing quote is
210
- * usually missing. That is expected, not an error — an unterminated word is
211
- * returned as far as it got, and {@link isOurs} treats the final word as a
212
- * prefix.
213
- */
214
- export function shellSplit(line) {
215
- const words = [];
216
- let word = null;
217
- let quote = null;
218
- for (let i = 0; i < line.length; i++) {
219
- const c = line[i];
220
- if (quote) {
221
- if (c === quote) quote = null;
222
- else word += c;
223
- continue;
224
- }
225
- if (c === "'" || c === '"') { quote = c; word ??= ""; continue; }
226
- if (c === "\\" && i + 1 < line.length) { word = (word ?? "") + line[++i]; continue; }
227
- if (/\s/.test(c)) { if (word !== null) { words.push(word); word = null; } continue; }
228
- word = (word ?? "") + c;
229
- }
230
- if (word !== null) words.push(word);
231
- return words;
232
- }
233
-
234
- /**
235
- * Was this tagged command the one this run launched?
236
- *
237
- * The second of two anchors, and it exists because the first one is racy: a
238
- * refused `sh -c` can be dead before `ps` runs, and then a process tree proves
239
- * nothing. This one does not depend on the process still existing.
240
- *
241
- * Compared as a *prefix*, because the tag holds only the first 100 characters:
242
- * every complete word must match exactly, and the last word — the one the
243
- * truncation cut — only has to start the same way. A command that agrees with
244
- * ours for 100 characters and then differs would be credited to us, which is a
245
- * wrong attribution we accept: it requires another sandbox on the same machine
246
- * launching a command that matches ours to the byte for 100 bytes.
247
- */
248
- export function isOurs(tagged, argv) {
249
- if (!tagged) return false;
250
- const words = shellSplit(tagged);
251
- if (words.length === 0 || words.length > argv.length) return false;
252
- for (let i = 0; i < words.length - 1; i++) if (words[i] !== argv[i]) return false;
253
- const last = words[words.length - 1];
254
- return argv[words.length - 1]?.startsWith(last) ?? false;
255
- }
256
-
257
- /**
258
- * Is `pid` a descendant of `root`?
259
- *
260
- * This is the anchor for attribution, and it is the reason a denial from
261
- * somebody else's sandbox never lands in this repo's log. The alternative was
262
- * matching the decoded command against the one we launched, which would mean
263
- * reimplementing the runtime's shell quoting and inheriting its bugs; a process
264
- * tree is a fact the OS already holds.
265
- *
266
- * At most one `ps` per TREE_MS, and only until the session suffix is learned —
267
- * after that the suffix alone is exact, because it is generated once per `srt`
268
- * process. In practice that is one `ps` per run.
269
- */
270
- export function descends(pid, root, snapshot) {
271
- const parent = new Map(snapshot);
272
- const seen = new Set();
273
- let at = pid;
274
- while (at && at !== 1 && !seen.has(at)) {
275
- if (at === root) return true;
276
- seen.add(at);
277
- at = parent.get(at);
278
- }
279
- return false;
280
- }
281
-
282
- /**
283
- * pid → ppid for every process this user can see, and in `tree.roots` the
284
- * pids that are a sandbox run's root: an `srt`, or a `seisin run`.
285
- *
286
- * The roots are what makes "not ours" provable. A process outside our tree is
287
- * someone else's only if its ancestry reaches another run; one whose ancestry
288
- * ends at init was orphaned — `cmd &`, a build daemon, git's fsmonitor — and
289
- * may well be ours, reparented when the shell that started it exited.
290
- */
291
- export function processTree(run = execFileSync) {
292
- const tree = new Map();
293
- tree.roots = new Set();
294
- try {
295
- const out = run("/bin/ps", ["-Ao", "pid=,ppid=,args="], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
296
- for (const line of out.split("\n")) {
297
- const m = line.trim().match(/^(\d+)\s+(\d+)(?:\s+(.*))?$/);
298
- if (!m) continue;
299
- tree.set(Number(m[1]), Number(m[2]));
300
- if (m[3] && RUN_ROOT.test(m[3])) tree.roots.add(Number(m[1]));
301
- }
302
- } catch {
303
- // No tree means nothing can be attributed, which the caller handles by
304
- // recording nothing. A permission tool does not invent an owner.
305
- }
306
- return tree;
307
- }
308
-
309
- /** The command line of an `srt` (however it was installed) or of `seisin run`. */
310
- const RUN_ROOT = /(?:^|[\s/])srt(?:\s|$)|sandbox-runtime\/dist\/cli\.js(?:\s|$)|(?:^|[\s/])(?:seisin|cli\.js)\s+run\s/;
311
-
312
- /**
313
- * Does `pid` descend from a run root other than `root`?
314
- *
315
- * A table without command lines (`roots` absent) cannot tell an orphan from a
316
- * stranger, and answers as before: alive and not ours is someone else's.
317
- */
318
- export function underAnotherRun(pid, root, snapshot) {
319
- if (!snapshot.roots) return true;
320
- const seen = new Set();
321
- for (let at = pid; at && at !== 1 && !seen.has(at); at = snapshot.get(at)) {
322
- if (at !== root && snapshot.roots.has(at)) return true;
323
- seen.add(at);
324
- }
325
- return false;
326
- }
327
-
328
204
  /**
329
205
  * Did this refusal reach for content, or just walk past the door?
330
206
  *
@@ -442,143 +318,15 @@ export function watchDenials(onDeny, { pid = null, argv = null, platform = proce
442
318
  close: () => Promise.resolve(stats),
443
319
  };
444
320
 
445
- let suffix = null; // learned once, exact from then on
446
- let tree = null; // refreshed only while still learning
447
- let treeAt = -Infinity; // when `tree` was taken
448
- let root = pid; // the srt pid; not known until after spawn
449
- const mine = new Set(); // pids already proven to be ours
450
- const held = []; // denials seen before they could be attributed
451
- const foreignSuffixes = new Set(); // other sandboxes, proven while they were alive
452
321
  let recheck = null; // a deferred look, when a snapshot was refused
453
-
454
- /**
455
- * One `ps` per TREE_MS at most, and a later look instead of a skipped one.
456
- *
457
- * `ps` is synchronous and it was taken for every denial from another sandbox
458
- * while this run had not learned its own suffix — which, for a run that is
459
- * never refused anything, is the whole run. Several cells at once is exactly
460
- * that: 83 ms of blocked event loop per foreign line, measured. A line whose
461
- * process may be younger than the last snapshot is not decided on it; it is
462
- * held, and looked at again once the window has passed.
463
- */
464
- const snapshot = () => { tree = treeFn(); treeAt = now(); };
465
- const later = () => {
466
- if (recheck) return;
467
- recheck = setTimeout(() => { recheck = null; if (!suffix && held.length) revisit(); }, TREE_MS);
468
- recheck.unref?.();
469
- };
470
-
471
- /**
472
- * Two anchors, and either one is enough.
473
- *
474
- * Neither works alone. The process tree is exact while the process exists and
475
- * proves nothing once it has exited — which, for the short commands that get
476
- * refused most, is by the time the line arrives. The command tag survives the
477
- * process but depends on the runtime's quoting staying recognisable.
478
- *
479
- * Whichever answers first hands over the session suffix, and from that point
480
- * attribution is one string comparison: the suffix is generated once per `srt`
481
- * process, so it separates this run from every other sandbox on the machine
482
- * exactly. In practice the tag settles it on the first denial and the tree is
483
- * never consulted.
484
- */
485
- const attribute = (d) => {
486
- if (suffix) return d.suffix === suffix;
487
- if (argv && isOurs(d.command, argv)) {
488
- if (d.suffix) suffix = d.suffix;
489
- return true;
490
- }
491
- if (root === null) return false; // nothing to descend from yet
492
- if (mine.has(d.pid)) return true;
493
- // A suffix already proven foreign needs no `ps`: the suffix is per `srt`.
494
- if (d.suffix && foreignSuffixes.has(d.suffix)) return false;
495
- if (tree === null) snapshot();
496
- if (!descends(d.pid, root, tree)) {
497
- // A pid the snapshot does not have may simply be younger than it.
498
- // Refresh once, then believe the answer — unless the last refresh was
499
- // moments ago, and then look again later rather than refresh now. A pid
500
- // it does have is answered: its parents do not change into ours.
501
- if (treeAt < d.seen && !tree.has(d.pid)) {
502
- if (now() - treeAt < TREE_MS) { later(); return LATER; }
503
- snapshot();
504
- }
505
- if (!descends(d.pid, root, tree)) {
506
- // Alive, not ours by either anchor, and under another run — the tag
507
- // did not match above. Every later line from that `srt` is someone
508
- // else's without asking. Alive but under no run at all is an orphan
509
- // (`cmd &`, a daemon): possibly ours, so its suffix stays undecided
510
- // rather than turning this run's own later lines into strangers.
511
- if (d.suffix && tree.has(d.pid) && underAnotherRun(d.pid, root, tree)) foreignSuffixes.add(d.suffix);
512
- return false;
513
- }
514
- }
515
- mine.add(d.pid);
516
- // The first proven denial hands us the suffix for every later one.
517
- if (d.suffix) suffix = d.suffix;
518
- return true;
519
- };
520
-
521
- /**
522
- * The same refusal, reported twice.
523
- *
524
- * macOS emits a violation from `kernel` and, for some of them, a second
525
- * extended report from `sandboxd` carrying the identical `Sandbox:` line.
526
- * They are one event and the log has to say so — `review` counts lines, and a
527
- * denial that shows as two is a policy that looks twice as wrong as it is.
528
- * Measured on the first real run: 31 doubled events out of 730.
529
- *
530
- * Keyed on what the kernel said, not on when we read it: two deliveries of one
531
- * event agree on pid, operation and path and disagree on arrival time.
532
- */
533
- const recent = new Map();
534
- const isRepeat = (d) => {
535
- const key = `${d.pid}|${d.operation}|${d.path}`;
536
- const now = Date.now();
537
- for (const [k, t] of recent) if (now - t > REPEAT_MS) recent.delete(k); else break;
538
- if (recent.has(key)) return true;
539
- recent.set(key, now);
540
- return false;
541
- };
542
-
543
- const consider = (d) => {
544
- const verdict = attribute(d);
545
- if (verdict === true) {
546
- stats.attributed++;
547
- onDeny(d);
548
- // Learning the suffix makes every held line decidable. Without this,
549
- // denials that arrived while the first process was being identified —
550
- // which on a fast command is all of them — are lost.
551
- if (suffix && held.length) revisit();
552
- return;
553
- }
554
- // Not ours *yet*. Two reasons, both temporary: the run has not spawned the
555
- // child whose pid anchors attribution, or the process died before `ps`
556
- // could see it. Either way a foreign sandbox and one of ours look the same
557
- // from here, so hold the line rather than credit or discard it.
558
- // A line put off to the next snapshot is held with or without a suffix:
559
- // the look it is waiting for is the only thing that can decide it.
560
- if (verdict === LATER || (!suffix && d.suffix && !foreignSuffixes.has(d.suffix))) {
561
- stats.unattributed++;
562
- held.push(d);
563
- // Bounded, because the common case on a busy machine is a run that is
564
- // never refused anything: it never learns its own suffix, so every
565
- // denial from every other sandbox lands here and nothing ever drains it.
566
- // Several cells running at once for hours is exactly that shape, and an
567
- // unbounded array is how an instrument becomes the leak.
568
- //
569
- // Dropping the oldest is the right end to drop. What is still worth
570
- // replaying is whatever arrived near the run's own first refusal; a
571
- // denial from an hour ago that is still unattributed is not ours.
572
- while (held.length > HELD_MAX) { held.shift(); stats.unattributed--; stats.foreign++; }
573
- return;
574
- }
575
- stats.foreign++;
576
- };
577
-
578
- /** Re-examine what was held, now that something new is known. */
579
- const revisit = () => {
580
- for (const h of held.splice(0)) { stats.unattributed--; consider(h); }
581
- };
322
+ const attribution = createDenialAttribution(onDeny, {
323
+ pid, argv, treeFn, now, stats,
324
+ requestRecheck() {
325
+ if (recheck) return;
326
+ recheck = setTimeout(() => { recheck = null; attribution.revisitPending(); }, TREE_MS);
327
+ recheck.unref?.();
328
+ },
329
+ });
582
330
 
583
331
  let child;
584
332
  try {
@@ -607,12 +355,7 @@ export function watchDenials(onDeny, { pid = null, argv = null, platform = proce
607
355
  // in `buf`, which close() counts.
608
356
  stats.dropped += droppedIn(chunk);
609
357
  const d = parseChunk(chunk);
610
- if (d) d.seen = now();
611
- // Deduplicated here and not in consider(), because a held denial goes
612
- // through consider() a second time when it is finally attributed. Doing
613
- // this there would drop every line that arrived before the child had a
614
- // pid — which, on a short command, is all of them.
615
- if (d && !isRepeat(d)) consider(d);
358
+ if (d) attribution.accept(d);
616
359
  }
617
360
  // A stream we cannot parse is not a leak. A flooded one is mostly markers
618
361
  // with no tag between them, so they are counted before they go.
@@ -627,19 +370,8 @@ export function watchDenials(onDeny, { pid = null, argv = null, platform = proce
627
370
  reason: null,
628
371
  stats,
629
372
 
630
- /**
631
- * Name the process whose descendants count as ours.
632
- *
633
- * Separate from the constructor because the stream has to be listening
634
- * *before* the child starts. A short command — the kind an agent runs
635
- * hundreds of times — is refused and gone in single-digit milliseconds, so
636
- * a monitor started after the spawn misses precisely the denials it exists
637
- * to catch. Measured: with the stream started afterwards, a one-line `sh -c`
638
- * refusal recorded nothing at all.
639
- */
640
373
  attributeTo(childPid) {
641
- root = childPid;
642
- if (held.length) revisit();
374
+ attribution.attributeTo(childPid);
643
375
  },
644
376
 
645
377
  /**
@@ -652,6 +384,7 @@ export function watchDenials(onDeny, { pid = null, argv = null, platform = proce
652
384
  * nothing pending — costs one tick rather than the full window.
653
385
  */
654
386
  close({ drain = 0 } = {}) {
387
+ // Cancel before draining without resetting the handle: preserve replay scheduling.
655
388
  clearTimeout(recheck);
656
389
  const stop = () => ended(child).then(() => { stats.dropped += droppedIn(buf); buf = ""; return stats; });
657
390
  if (drain <= 0) return stop();
@@ -698,38 +431,6 @@ const END_MS = 1000;
698
431
  */
699
432
  const QUIET_MS = 60;
700
433
 
701
- /**
702
- * The shortest time between two `ps` snapshots while attribution is learning.
703
- *
704
- * Long enough that a burst of foreign denials costs one snapshot, not one
705
- * each; short enough that a line held for a younger process is decided before
706
- * anyone reads the log.
707
- */
708
- const TREE_MS = 250;
709
-
710
- /** What `attribute` answers for a line it has put off to the next snapshot. */
711
- const LATER = "later";
712
-
713
- /**
714
- * How many undecided denials to keep while waiting to learn our own suffix.
715
- *
716
- * Generous enough to cover the startup window — where every denial arrives
717
- * before there is anything to attribute it to — and small enough that a run
718
- * which is never refused anything cannot accumulate another sandbox's traffic
719
- * for the length of an agent session.
720
- */
721
- const HELD_MAX = 500;
722
-
723
- /**
724
- * How long two reports of one refusal can be apart and still be one event.
725
- *
726
- * The second copy — `sandboxd`'s extended report — follows the kernel's within
727
- * milliseconds. Kept short so that a role genuinely retrying the same path a
728
- * second later is still recorded as a second attempt, which is what makes
729
- * `asked 3×` mean anything.
730
- */
731
- const REPEAT_MS = 1000;
732
-
733
434
  /**
734
435
  * Is the system log dropping messages right now, and who is writing the most?
735
436
  *
package/src/walls.js CHANGED
@@ -138,10 +138,12 @@ export function hitsOf(file, role, action, target, run = null) {
138
138
  // the cut, so a cut on a boundary keeps it). Comparing the file's byte size
139
139
  // with the text's UTF-16 length used to decide that, and with any multibyte
140
140
  // character in the tail it kept the fragment of a line or dropped a whole one.
141
- const id = run ? String(run).slice(0, 8) : null;
141
+ // The canary carries the run's label: the launcher's --run-id whole, or the
142
+ // first 8 characters of seisin's own id (older runs exported the whole id).
143
+ const id = run ? String(run) : null;
142
144
  let total = 0, inRun = null;
143
145
  for (const e of readEntries(file, { tail: TAIL_BYTES })) {
144
- if (id && e.event === "canary" && e.role === role && e.run === id) inRun = 0;
146
+ if (id && e.event === "canary" && e.role === role && (e.run === id || e.run === id.slice(0, 8))) inRun = 0;
145
147
  if (e.role === role && e.verdict === "denied" && e.action === action && e.target === target) {
146
148
  total++;
147
149
  if (inRun !== null) inRun++;