seisin 0.6.4 → 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.
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++;
package/ui/index.html CHANGED
@@ -2088,9 +2088,9 @@ table.runs tr.sys td{color:var(--dim)}
2088
2088
  * that was gone or had a new token. After two failed polls in a row the page
2089
2089
  * says which of the two it is, since when, and turns the writes off. */
2090
2090
  var FAILS = 0, OFFLINE = null;
2091
- function setOffline(kind){
2092
- if (kind && OFFLINE && OFFLINE.kind === kind) return;
2093
- OFFLINE = kind ? { kind: kind, since: (OFFLINE && OFFLINE.since) || new Date() } : null;
2091
+ function setOffline(kind, why){
2092
+ if (kind && OFFLINE && OFFLINE.kind === kind && OFFLINE.why === why) return;
2093
+ OFFLINE = kind ? { kind: kind, why: why || null, since: (OFFLINE && OFFLINE.since) || new Date() } : null;
2094
2094
  var label = document.getElementById("source");
2095
2095
  var ban = document.getElementById("off-banner");
2096
2096
  if (!OFFLINE) {
@@ -2099,7 +2099,11 @@ table.runs tr.sys td{color:var(--dim)}
2099
2099
  } else {
2100
2100
  var t = pad(OFFLINE.since.getHours()) + ":" + pad(OFFLINE.since.getMinutes());
2101
2101
  label.style.color = "var(--amber)";
2102
- if (kind === "expired") {
2102
+ if (kind === "policy") {
2103
+ label.textContent = "seisin.toml cannot be read";
2104
+ ban.innerHTML = "<b>seisin.toml cannot be read</b> (since " + t + "): " + esc(OFFLINE.why) + " " +
2105
+ "What is on screen is the last policy that loaded. Decisions and saves are off until the file is fixed.";
2106
+ } else if (kind === "expired") {
2103
2107
  label.textContent = "link expired — open the new one";
2104
2108
  ban.innerHTML = "<b>This tab's link has expired</b> (since " + t + "): the console was restarted with a new one. " +
2105
2109
  "Open the new link — run <code>seisin ui --link</code> in the repository. Decisions and saves are off in this tab.";
@@ -2184,7 +2188,13 @@ table.runs tr.sys td{color:var(--dim)}
2184
2188
  if (!fresh && LAST.etag && LAST.url === url) h["if-none-match"] = LAST.etag;
2185
2189
  var r = await fetch(url, { cache: "no-store", headers: h });
2186
2190
  if (r.status === 304) return { same: true };
2187
- if (!r.ok) return { status: r.status };
2191
+ // A refusal carries the server's reason (a seisin.toml that no longer
2192
+ // parses): kept, so it is shown instead of reading as the server gone.
2193
+ if (!r.ok) {
2194
+ var why = null;
2195
+ try { why = (await r.json()).error || null; } catch (_) { /* no body */ }
2196
+ return { status: r.status, error: why };
2197
+ }
2188
2198
  var s = await r.json();
2189
2199
  if (s.error) return { status: r.status, error: s.error };
2190
2200
  LAST = { url: url, etag: r.headers.get("etag") };
@@ -2311,6 +2321,16 @@ table.runs tr.sys td{color:var(--dim)}
2311
2321
  'passes the example policy off as yours.</p></div>';
2312
2322
  return;
2313
2323
  }
2324
+ if (!s && got && got.error) {
2325
+ label.textContent = "seisin.toml cannot be read";
2326
+ label.style.color = "var(--amber)";
2327
+ document.querySelector("main").innerHTML =
2328
+ '<div class="box" style="padding:22px 24px;max-width:640px">' +
2329
+ '<h2 style="margin:0 0 8px;font-size:17px">The console cannot read this repository\'s policy</h2>' +
2330
+ '<p style="margin:0 0 8px" class="err">' + esc(got.error) + '</p>' +
2331
+ '<p style="margin:0;color:var(--mid)">Fix the file and reload. <code>seisin check</code> says the same in a terminal.</p></div>';
2332
+ return;
2333
+ }
2314
2334
  if (!s) {
2315
2335
  label.textContent = location.protocol.startsWith("http") && !TOKEN
2316
2336
  ? "no token — showing an example"
@@ -2632,6 +2652,8 @@ table.runs tr.sys td{color:var(--dim)}
2632
2652
  if (got.s) applyState(got.s);
2633
2653
  return;
2634
2654
  }
2655
+ // The server answered, with the reason it cannot: said at once, not as an outage.
2656
+ if (got.error && got.status !== 401 && got.status !== 403) { FAILS = 0; setOffline("policy", got.error); return; }
2635
2657
  FAILS++;
2636
2658
  if (FAILS >= 2) setOffline(got.status === 401 || got.status === 403 ? "expired" : "down");
2637
2659
  }