seisin 0.4.2 → 0.6.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.
Files changed (82) hide show
  1. package/README.md +506 -162
  2. package/package.json +14 -6
  3. package/src/browsers.js +52 -0
  4. package/src/canary.js +149 -0
  5. package/src/cli.js +175 -52
  6. package/src/commands/check.js +157 -5
  7. package/src/commands/explain.js +31 -13
  8. package/src/commands/hook.js +20 -6
  9. package/src/commands/init.js +183 -88
  10. package/src/commands/log.js +48 -9
  11. package/src/commands/requests.js +122 -70
  12. package/src/commands/review.js +73 -21
  13. package/src/commands/run.js +422 -78
  14. package/src/commands/scan.js +18 -3
  15. package/src/commands/ui.js +18 -5
  16. package/src/commands/walls.js +62 -11
  17. package/src/commands/whose.js +9 -7
  18. package/src/commands/wire.js +156 -17
  19. package/src/config.js +505 -37
  20. package/src/controls.js +244 -0
  21. package/src/diagnose.js +102 -49
  22. package/src/env.js +14 -8
  23. package/src/grants.js +122 -9
  24. package/src/handoff.js +285 -10
  25. package/src/help.js +179 -0
  26. package/src/hook.js +101 -42
  27. package/src/index.d.ts +217 -0
  28. package/src/index.js +24 -16
  29. package/src/inspect/codex.js +64 -0
  30. package/src/inspect/credentials.js +48 -0
  31. package/src/inspect/enforce.js +24 -0
  32. package/src/inspect/files.js +71 -0
  33. package/src/inspect/home.js +43 -0
  34. package/src/inspect/inject.js +68 -0
  35. package/src/inspect/keys.js +74 -0
  36. package/src/inspect/layout.js +258 -0
  37. package/src/inspect/limits.js +98 -0
  38. package/src/inspect/machine.js +107 -0
  39. package/src/inspect/network.js +37 -0
  40. package/src/inspect/providers.js +71 -0
  41. package/src/inspect/read-territory.js +105 -0
  42. package/src/inspect/role-keys.js +136 -0
  43. package/src/inspect/shared.js +37 -0
  44. package/src/inspect/warnings.js +58 -0
  45. package/src/inspect.js +20 -756
  46. package/src/intake.js +150 -35
  47. package/src/keys.js +231 -73
  48. package/src/kinds.js +249 -0
  49. package/src/lines.js +26 -0
  50. package/src/log.js +242 -52
  51. package/src/mcp.js +57 -58
  52. package/src/nested.js +12 -3
  53. package/src/notify.js +66 -20
  54. package/src/owners.js +358 -39
  55. package/src/paths.js +54 -2
  56. package/src/provider-run.js +81 -0
  57. package/src/redact.js +46 -13
  58. package/src/render.js +101 -17
  59. package/src/requests.js +506 -87
  60. package/src/review.js +50 -25
  61. package/src/rundir.js +33 -14
  62. package/src/sandboxes/command.js +93 -0
  63. package/src/sandboxes/index.js +113 -0
  64. package/src/sandboxes/mach-profile.js +31 -0
  65. package/src/sandboxes/srt-mach.mjs +43 -0
  66. package/src/sandboxes/srt.js +163 -0
  67. package/src/sandboxes/tartaros.js +80 -0
  68. package/src/scan.js +181 -19
  69. package/src/serve.js +396 -230
  70. package/src/spool.js +16 -45
  71. package/src/srt.js +296 -49
  72. package/src/suggest.js +58 -0
  73. package/src/surface.js +314 -54
  74. package/src/territory.js +261 -0
  75. package/src/trace.js +33 -0
  76. package/src/uilink.js +24 -9
  77. package/src/views.js +315 -0
  78. package/src/violations.js +247 -23
  79. package/src/walls.js +76 -46
  80. package/src/worktree.js +3 -11
  81. package/ui/index.html +1429 -538
  82. package/ui/favicon.svg +0 -1
package/src/violations.js CHANGED
@@ -38,16 +38,37 @@
38
38
  * reads paths out of the traced process's memory — its own comment calls those
39
39
  * events attacker-controlled and racy. That is not a stream an outside process
40
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.
41
+ * unavailable watcher with the reason. `seisin run` does not print it — once
42
+ * per run is a line nobody can act on — and `seisin check` says it, with the
43
+ * platform's other standing limits.
42
44
  */
43
45
  import { spawn, execFileSync } from "node:child_process";
44
46
  import { statSync } from "node:fs";
47
+ import { basename } from "node:path";
45
48
 
46
49
  /** Only sandbox-runtime tags its violations this way. Everything else on the
47
50
  * machine — Safari, mDNSResponder, Spotlight — is filtered out by the OS
48
51
  * before a byte reaches us, which is why the predicate is not a grep. */
49
52
  const PREDICATE = 'eventMessage ENDSWITH "_SBX"';
50
53
 
54
+ /**
55
+ * What `log stream` prints in place of the messages it could not keep up with.
56
+ *
57
+ * The one way this instrument loses a denial without anything else noticing:
58
+ * the kernel refuses, the agent sees EPERM, and the line never arrives. It
59
+ * happens when the unified log is flooded — measured, a network filter's
60
+ * kernel extension writing ~6,700 lines a second while it dropped two
61
+ * sessions' connections — and the predicate does not help, because the stream
62
+ * is dropped before it is filtered. The marker carries no count and no names:
63
+ * one line per batch lost, which is all there is to report.
64
+ */
65
+ const DROPPED = /^=== Messages dropped during live streaming\b/gm;
66
+
67
+ /** How many batches `log stream` says it dropped in this text. */
68
+ export function droppedIn(text) {
69
+ return (String(text).match(DROPPED) ?? []).length;
70
+ }
71
+
51
72
  /** `Sandbox: <proc>(<pid>) deny(<n>) <operation> <detail>` */
52
73
  const DENY = /Sandbox:\s+(\S+)\((\d+)\)\s+deny\(\d+\)\s+(\S+)\s+(.+?)\s*$/;
53
74
 
@@ -82,9 +103,25 @@ export function actionOf(operation) {
82
103
  if (operation.startsWith("file-write")) return "write";
83
104
  if (operation === "file-read-data") return "read";
84
105
  if (operation === "network-outbound") return "connect";
106
+ if (SYSTEM.test(operation)) return "system";
85
107
  return null;
86
108
  }
87
109
 
110
+ /**
111
+ * The operations that are neither a file nor a connection — `mach-register`,
112
+ * `mach-lookup`, `sysctl-read`, `iokit-open`… — and that used to be dropped.
113
+ *
114
+ * They were dropped as the sandbox doing its job against the machine, and most
115
+ * of them are: a browser asks for the window server and carries on. But one of
116
+ * them was the whole failure of a headless Chromium — a refused
117
+ * `mach-register` that killed the browser at start — and from seisin's record
118
+ * there was no way to know: five file reads in the log, and a crash. So they
119
+ * are recorded now, by operation and name, once per run each (intake.js), and
120
+ * never as a request: there is no territory to hand them to. What grants one
121
+ * is a preset like `browsers`, not `seisin grant`.
122
+ */
123
+ const SYSTEM = /^(mach|sysctl|iokit|ipc|signal|process|system|user-preference|darwin|nvram|lsopen|appleevent)-/;
124
+
88
125
  /**
89
126
  * The target of a refused connection, or null when it is not one worth a line.
90
127
  *
@@ -98,7 +135,7 @@ export function actionOf(operation) {
98
135
  * - `remote:*:<port>` — a TCP dial the profile refused. The kernel does not
99
136
  * name the host, so the target is the port and nothing more.
100
137
  * - a unix socket path — except under /var/run, where the system daemons
101
- * live: every DNS lookup inside the box is a refused connect to
138
+ * live: every DNS lookup inside the sandbox is a refused connect to
102
139
  * mDNSResponder, and recording those would bury everything else.
103
140
  * - anything else (an empty detail, which the kernel also writes) — dropped.
104
141
  */
@@ -139,6 +176,13 @@ export function parseChunk(chunk) {
139
176
  if (!target) return null;
140
177
  return { ...deny, action, path: target, suffix: tag?.suffix ?? null, command: tag?.command ?? null };
141
178
  }
179
+ // The name as the kernel gave it ("com.apple.tsm.portname (per-pid)"); an
180
+ // operation with no name is not a line anyone can act on.
181
+ if (action === "system") {
182
+ const name = deny.detail.trim();
183
+ if (!name) return null;
184
+ return { ...deny, action, path: name, suffix: tag?.suffix ?? null, command: tag?.command ?? null };
185
+ }
142
186
  // Only an absolute path is a territory question. A relative or malformed
143
187
  // detail means the line was not what it looked like; dropping beats guessing.
144
188
  if (!deny.detail.startsWith("/")) return null;
@@ -219,8 +263,8 @@ export function isOurs(tagged, argv) {
219
263
  * reimplementing the runtime's shell quoting and inheriting its bugs; a process
220
264
  * tree is a fact the OS already holds.
221
265
  *
222
- * One `ps` per unseen pid, and only until the session suffix is learned — after
223
- * that the suffix alone is exact, because it is generated once per `srt`
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`
224
268
  * process. In practice that is one `ps` per run.
225
269
  */
226
270
  export function descends(pid, root, snapshot) {
@@ -235,14 +279,25 @@ export function descends(pid, root, snapshot) {
235
279
  return false;
236
280
  }
237
281
 
238
- /** pid → ppid for every process this user can see. */
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
+ */
239
291
  export function processTree(run = execFileSync) {
240
292
  const tree = new Map();
293
+ tree.roots = new Set();
241
294
  try {
242
- const out = run("/bin/ps", ["-Ao", "pid=,ppid="], { encoding: "utf8" });
295
+ const out = run("/bin/ps", ["-Ao", "pid=,ppid=,args="], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
243
296
  for (const line of out.split("\n")) {
244
- const m = line.trim().match(/^(\d+)\s+(\d+)$/);
245
- if (m) tree.set(Number(m[1]), Number(m[2]));
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]));
246
301
  }
247
302
  } catch {
248
303
  // No tree means nothing can be attributed, which the caller handles by
@@ -251,6 +306,25 @@ export function processTree(run = execFileSync) {
251
306
  return tree;
252
307
  }
253
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
+
254
328
  /**
255
329
  * Did this refusal reach for content, or just walk past the door?
256
330
  *
@@ -336,8 +410,8 @@ export function scopeOf(settings, root) {
336
410
  * without `log` looks like — and the caller says so once rather than silently
337
411
  * logging less than it used to.
338
412
  */
339
- export function watchDenials(onDeny, { pid = null, argv = null, platform = process.platform, spawnFn = spawn, treeFn = processTree } = {}) {
340
- const stats = { attributed: 0, foreign: 0, unattributed: 0 };
413
+ export function watchDenials(onDeny, { pid = null, argv = null, platform = process.platform, spawnFn = spawn, treeFn = processTree, now = () => performance.now() } = {}) {
414
+ const stats = { attributed: 0, foreign: 0, unattributed: 0, dropped: 0 };
341
415
 
342
416
  /**
343
417
  * Unavailable is a watcher that does nothing, NOT a different shape.
@@ -361,7 +435,7 @@ export function watchDenials(onDeny, { pid = null, argv = null, platform = proce
361
435
  return {
362
436
  available: false,
363
437
  reason: platform === "linux"
364
- ? "kernel denials are not readable from outside the runtime on Linux — see docs/upstream/cli-violations.md"
438
+ ? "kernel denials are not readable from outside the runtime on Linux — see https://github.com/carlostapiacl/seisin/blob/main/docs/upstream/cli-violations.md"
365
439
  : `no kernel denial stream on ${platform}`,
366
440
  stats,
367
441
  attributeTo() {},
@@ -370,9 +444,29 @@ export function watchDenials(onDeny, { pid = null, argv = null, platform = proce
370
444
 
371
445
  let suffix = null; // learned once, exact from then on
372
446
  let tree = null; // refreshed only while still learning
447
+ let treeAt = -Infinity; // when `tree` was taken
373
448
  let root = pid; // the srt pid; not known until after spawn
374
449
  const mine = new Set(); // pids already proven to be ours
375
450
  const held = []; // denials seen before they could be attributed
451
+ const foreignSuffixes = new Set(); // other sandboxes, proven while they were alive
452
+ 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
+ };
376
470
 
377
471
  /**
378
472
  * Two anchors, and either one is enough.
@@ -396,12 +490,27 @@ export function watchDenials(onDeny, { pid = null, argv = null, platform = proce
396
490
  }
397
491
  if (root === null) return false; // nothing to descend from yet
398
492
  if (mine.has(d.pid)) return true;
399
- if (tree === null) tree = treeFn();
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();
400
496
  if (!descends(d.pid, root, tree)) {
401
- // A pid we have never seen may simply be younger than the snapshot.
402
- // Refresh once, then believe the answer.
403
- tree = treeFn();
404
- if (!descends(d.pid, root, tree)) return false;
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
+ }
405
514
  }
406
515
  mine.add(d.pid);
407
516
  // The first proven denial hands us the suffix for every later one.
@@ -432,7 +541,8 @@ export function watchDenials(onDeny, { pid = null, argv = null, platform = proce
432
541
  };
433
542
 
434
543
  const consider = (d) => {
435
- if (attribute(d)) {
544
+ const verdict = attribute(d);
545
+ if (verdict === true) {
436
546
  stats.attributed++;
437
547
  onDeny(d);
438
548
  // Learning the suffix makes every held line decidable. Without this,
@@ -445,7 +555,9 @@ export function watchDenials(onDeny, { pid = null, argv = null, platform = proce
445
555
  // child whose pid anchors attribution, or the process died before `ps`
446
556
  // could see it. Either way a foreign sandbox and one of ours look the same
447
557
  // from here, so hold the line rather than credit or discard it.
448
- if (!suffix && d.suffix) {
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))) {
449
561
  stats.unattributed++;
450
562
  held.push(d);
451
563
  // Bounded, because the common case on a busy machine is a run that is
@@ -473,7 +585,13 @@ export function watchDenials(onDeny, { pid = null, argv = null, platform = proce
473
585
  child = spawnFn("/usr/bin/log", ["stream", "--predicate", PREDICATE, "--style", "compact"],
474
586
  { stdio: ["ignore", "pipe", "ignore"] });
475
587
  } catch (e) {
476
- return { available: false, reason: `could not start the log stream: ${e.message}`, stats, close() {} };
588
+ // The same shape as the unavailable watcher above, for the same reason:
589
+ // `run` calls attributeTo() and awaits close() unconditionally.
590
+ return {
591
+ available: false, reason: `could not start the log stream: ${e.message}`, stats,
592
+ attributeTo() {},
593
+ close: () => Promise.resolve(stats),
594
+ };
477
595
  }
478
596
 
479
597
  let buf = "";
@@ -485,14 +603,20 @@ export function watchDenials(onDeny, { pid = null, argv = null, platform = proce
485
603
  while ((cut = buf.indexOf("_SBX")) !== -1) {
486
604
  const chunk = buf.slice(0, cut + 4);
487
605
  buf = buf.slice(cut + 4);
606
+ // A marker never contains the tag, so it is whole in one chunk or still
607
+ // in `buf`, which close() counts.
608
+ stats.dropped += droppedIn(chunk);
488
609
  const d = parseChunk(chunk);
610
+ if (d) d.seen = now();
489
611
  // Deduplicated here and not in consider(), because a held denial goes
490
612
  // through consider() a second time when it is finally attributed. Doing
491
613
  // this there would drop every line that arrived before the child had a
492
614
  // pid — which, on a short command, is all of them.
493
615
  if (d && !isRepeat(d)) consider(d);
494
616
  }
495
- if (buf.length > 64_000) buf = ""; // a stream we cannot parse is not a leak
617
+ // A stream we cannot parse is not a leak. A flooded one is mostly markers
618
+ // with no tag between them, so they are counted before they go.
619
+ if (buf.length > 64_000) { stats.dropped += droppedIn(buf); buf = ""; }
496
620
  });
497
621
  // A monitor that cannot run must never take the run down with it. Same rule
498
622
  // as append(): the instrument is allowed to fail, the agent is not.
@@ -528,11 +652,12 @@ export function watchDenials(onDeny, { pid = null, argv = null, platform = proce
528
652
  * nothing pending — costs one tick rather than the full window.
529
653
  */
530
654
  close({ drain = 0 } = {}) {
531
- const stop = () => { try { child.kill("SIGTERM"); } catch { /* already gone */ } };
532
- if (drain <= 0) { stop(); return Promise.resolve(stats); }
655
+ clearTimeout(recheck);
656
+ const stop = () => ended(child).then(() => { stats.dropped += droppedIn(buf); buf = ""; return stats; });
657
+ if (drain <= 0) return stop();
533
658
  return new Promise((resolve) => {
534
659
  let quiet = null;
535
- const settle = () => { clearTimeout(quiet); clearTimeout(cap); stop(); resolve(stats); };
660
+ const settle = () => { clearTimeout(quiet); clearTimeout(cap); resolve(stop()); };
536
661
  const cap = setTimeout(settle, drain);
537
662
  const bump = () => { clearTimeout(quiet); quiet = setTimeout(settle, QUIET_MS); };
538
663
  child.stdout?.on("data", bump);
@@ -542,6 +667,28 @@ export function watchDenials(onDeny, { pid = null, argv = null, platform = proce
542
667
  };
543
668
  }
544
669
 
670
+ /**
671
+ * Stop a `log stream` and wait for what it was still holding.
672
+ *
673
+ * SIGINT, not SIGTERM. Writing to a pipe, `log` buffers its own notices — a
674
+ * denial's line arrives at once, but the "messages dropped" markers were buffered
675
+ * 8 KB at a time and came out together, measured twelve seconds after the flood
676
+ * began. SIGINT makes it flush them before it exits; SIGTERM threw them away
677
+ * (18 markers against 0 in the same flood). Bounded, so a stream that will not
678
+ * exit cannot hold up the end of a run.
679
+ */
680
+ function ended(child, cap = END_MS) {
681
+ return new Promise((resolve) => {
682
+ if (child.exitCode !== null || child.signalCode !== null) return resolve();
683
+ const timer = setTimeout(() => { try { child.kill("SIGKILL"); } catch {} resolve(); }, cap);
684
+ child.once("close", () => { clearTimeout(timer); resolve(); });
685
+ try { child.kill("SIGINT"); } catch { clearTimeout(timer); resolve(); }
686
+ });
687
+ }
688
+
689
+ /** How long a stopped `log stream` gets to flush and exit. */
690
+ const END_MS = 1000;
691
+
545
692
  /**
546
693
  * How long the stream has to stay silent before a drain gives up on it.
547
694
  *
@@ -551,6 +698,18 @@ export function watchDenials(onDeny, { pid = null, argv = null, platform = proce
551
698
  */
552
699
  const QUIET_MS = 60;
553
700
 
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
+
554
713
  /**
555
714
  * How many undecided denials to keep while waiting to learn our own suffix.
556
715
  *
@@ -570,3 +729,68 @@ const HELD_MAX = 500;
570
729
  * `asked 3×` mean anything.
571
730
  */
572
731
  const REPEAT_MS = 1000;
732
+
733
+ /**
734
+ * Is the system log dropping messages right now, and who is writing the most?
735
+ *
736
+ * For `seisin check --system-log`. Two streams for `seconds`: one with the run's
737
+ * own predicate, counting the batches it loses — the number a run would have
738
+ * seen — and one unfiltered, counting messages by sender, because a flood
739
+ * comes from somebody and naming them is the only fix there is. A kernel
740
+ * message is named by its first word, which is the function that wrote it
741
+ * (`cfil_inp_log`): "kernel" alone names nothing.
742
+ *
743
+ * Reads the log and starts no sandbox. Not available off macOS, where denials
744
+ * are not read from a log at all.
745
+ */
746
+ export function measureSystemLog({ seconds = 3, platform = process.platform, spawnFn = spawn } = {}) {
747
+ if (platform !== "darwin") return Promise.resolve({ available: false, reason: `no system log stream on ${platform}` });
748
+ const streams = [];
749
+ const start = (args) => {
750
+ const child = spawnFn("/usr/bin/log", ["stream", ...args], { stdio: ["ignore", "pipe", "ignore"] });
751
+ child.on("error", () => {});
752
+ streams.push(child);
753
+ return child;
754
+ };
755
+ return new Promise((resolve) => {
756
+ let dropped = 0, lines = 0, tail = "", rest = "";
757
+ const by = new Map();
758
+ let ours, all;
759
+ try {
760
+ ours = start(["--predicate", PREDICATE, "--style", "compact"]);
761
+ all = start(["--style", "ndjson"]);
762
+ } catch (e) {
763
+ for (const c of streams) try { c.kill("SIGTERM"); } catch {}
764
+ return resolve({ available: false, reason: `could not start the log stream: ${e.message}` });
765
+ }
766
+ ours.stdout?.on("data", (d) => {
767
+ const text = tail + d.toString();
768
+ const cut = text.lastIndexOf("\n");
769
+ dropped += droppedIn(text.slice(0, cut + 1));
770
+ tail = text.slice(cut + 1);
771
+ });
772
+ all.stdout?.on("data", (d) => {
773
+ const parts = (rest + d.toString()).split("\n");
774
+ rest = parts.pop();
775
+ for (const line of parts) {
776
+ if (!line.startsWith("{")) continue;
777
+ let e;
778
+ try { e = JSON.parse(line); } catch { continue; }
779
+ lines++;
780
+ const proc = basename(String(e.processImagePath ?? "?"));
781
+ const who = e.subsystem
782
+ ? `${proc} · ${e.subsystem}`
783
+ : proc === "kernel" ? `kernel · ${String(e.eventMessage ?? "").split(/[\s:]/)[0] || "?"}` : proc;
784
+ by.set(who, (by.get(who) ?? 0) + 1);
785
+ }
786
+ if (rest.length > 1_000_000) rest = "";
787
+ });
788
+ setTimeout(async () => {
789
+ await Promise.all(streams.map((c) => ended(c)));
790
+ dropped += droppedIn(tail);
791
+ const top = [...by].sort((a, b) => b[1] - a[1]).slice(0, 3)
792
+ .map(([who, n]) => ({ who, perSecond: Math.round(n / seconds) }));
793
+ resolve({ available: true, seconds, dropped, perSecond: Math.round(lines / seconds), top });
794
+ }, seconds * 1000);
795
+ });
796
+ }
package/src/walls.js CHANGED
@@ -4,8 +4,8 @@
4
4
  * seisin answers *whose is this* at the moment of the refusal, once, in the
5
5
  * middle of a turn, and then the sentence is gone. Nothing carries it forward,
6
6
  * so the agent tries again — and the measurement that made this module exist
7
- * says how often: of 345 denials on a real team, **88 (25%) were a repeat of
8
- * something that same role had already been refused**. One role spent 37 calls
7
+ * says how often: of 345 denials in a multi-repo workspace, **88 (25%) were a repeat of
8
+ * something that same role had already been denied**. One role spent 37 calls
9
9
  * on two walls, hitting the same one nineteen times.
10
10
  *
11
11
  * A block that repeats nineteen times is not a boundary working. It is a
@@ -31,12 +31,12 @@
31
31
  * its wall disappear on the next turn instead of two days later.
32
32
  *
33
33
  * A window is still accepted, and still useful for a different reason: a
34
- * refusal from three weeks ago is noise even if it is still refused today.
34
+ * denial from three weeks ago is noise even if it is still denied today.
35
35
  */
36
- import { openSync, readSync, closeSync, statSync } from "node:fs";
37
- import { read } from "./log.js";
36
+ import { read, readEntries } from "./log.js";
38
37
  import { explain } from "./owners.js";
39
38
  import { STALE_RUNS, RUN_GAP_MS, runsAfter } from "./requests.js";
39
+ import { expectedOf } from "./kinds.js";
40
40
 
41
41
  /** A wall is something you hit more than once. Once is information; twice is a pattern. */
42
42
  export const MIN_HITS = 2;
@@ -49,12 +49,14 @@ export const MIN_HITS = 2;
49
49
  * different repositories are two walls, and merging them hides which one was
50
50
  * hit.
51
51
  */
52
- export function walls(config, role, { file, entries = null, min = MIN_HITS, since = null, limit = 6, fresh = false } = {}) {
52
+ export function walls(config, role, { file, entries = null, min = MIN_HITS, since = null, limit = 6, fresh = false, ask = explain } = {}) {
53
53
  // `entries`: the log already read, for a caller asking about every role at
54
54
  // once (the console asks for 32 on a real policy) — one read instead of 32.
55
- const denied = entries
55
+ // Less the caller's own probes (`seisin run --expect-denied`): denied as
56
+ // whoever started the run asked, and not a wall the role keeps hitting.
57
+ const denied = (entries
56
58
  ? entries.filter((e) => e.role === role && e.verdict === "denied" && (!since || e.at >= since))
57
- : read(file, { role, verdict: "denied", ...(since ? { since } : {}) });
59
+ : read(file, { role, verdict: "denied", ...(since ? { since } : {}) })).filter((e) => e.expected !== true);
58
60
 
59
61
  const byKey = new Map();
60
62
  for (const e of denied) {
@@ -70,9 +72,14 @@ export function walls(config, role, { file, entries = null, min = MIN_HITS, sinc
70
72
  if (w.times < min) continue;
71
73
  // The policy as it stands, not as it stood. A granted wall is not a wall,
72
74
  // and the whole point of saying this to an agent is that it is true now.
73
- const verdict = explain(config, role, w.action, w.target);
75
+ // `ask` is explain, or a remembered one (views.js) for a caller asking
76
+ // about every role at once.
77
+ const verdict = ask(config, role, w.action, w.target);
74
78
  if (verdict.allowed) continue;
75
- out.push({ ...w, reason: verdict.reason, owners: verdict.owners });
79
+ // Refused correctly and nothing to do about it (kinds.js expectedOf): kept,
80
+ // and marked, so a screen says it apart instead of as a role stuck.
81
+ const expected = expectedOf(w.target);
82
+ out.push({ ...w, reason: verdict.reason, owners: verdict.owners, ...(expected && { expected }) });
76
83
  }
77
84
  // A wall the role stopped hitting — it ran STALE_RUNS times since, refused
78
85
  // elsewhere but not here — is still true and no longer worth saying. Same
@@ -89,17 +96,18 @@ export function walls(config, role, { file, entries = null, min = MIN_HITS, sinc
89
96
  /**
90
97
  * How much of the log `timesHit` will look at.
91
98
  *
92
- * The log is append-only and never rotates — that is the storage design and it
93
- * is the right one — so "read the file" is O(everything you have ever done),
94
- * on a path that runs inside the hook. Measured on a 372 KB log: 3 ms per
95
- * call, which is nothing, and 300 ms at 37 MB, which is not, on every refusal
96
- * of every turn forever.
99
+ * The log is append-only and a segment only rotates at 8 MiB, so "read the
100
+ * file" is O(everything since the last rotation), on a path that runs inside
101
+ * the hook. Measured on a 372 KB log: 3 ms per
102
+ * call, which is nothing, and 300 ms at 37 MB, which is not, on every denial
103
+ * of every session forever.
97
104
  *
98
105
  * So it reads the tail. That makes the count **recent repetition** rather than
99
106
  * lifetime repetition, and recent is the one the sentence is about anyway: an
100
107
  * agent being told "you have hit this three times" means this session, not
101
108
  * last month. 4 MB is thousands of entries — far more than a turn produces,
102
- * and bounded.
109
+ * and bounded. Right after a rotation the tail is short, and so is the count:
110
+ * the current segment only, on purpose.
103
111
  */
104
112
  const TAIL_BYTES = 4 * 1024 * 1024;
105
113
 
@@ -113,39 +121,56 @@ const TAIL_BYTES = 4 * 1024 * 1024;
113
121
  * call.
114
122
  */
115
123
  export function timesHit(file, role, action, target) {
116
- let text;
117
- try {
118
- const { size } = statSync(file);
119
- const from = Math.max(0, size - TAIL_BYTES);
120
- const fd = openSync(file, "r");
121
- try {
122
- const buf = Buffer.alloc(size - from);
123
- readSync(fd, buf, 0, buf.length, from);
124
- text = buf.toString("utf8");
125
- } finally {
126
- closeSync(fd);
124
+ return hitsOf(file, role, action, target).total;
125
+ }
126
+
127
+ /**
128
+ * The same count, and how much of it is this run's.
129
+ *
130
+ * Denial lines carry no run id; each run's canary does, and comes first. So
131
+ * `inRun` counts from the canary of `run` (SEISIN_RUN_ID) onwards, and is null
132
+ * when that canary is not in the tail — outside `seisin run`, or a run older
133
+ * than the tail. Measured: a fresh run told "denied 5 times now" about its
134
+ * first try read as a counter gone wrong; the five were from earlier runs.
135
+ */
136
+ export function hitsOf(file, role, action, target, run = null) {
137
+ // The line the tail cut lands in is dropped (readLines checks the byte before
138
+ // the cut, so a cut on a boundary keeps it). Comparing the file's byte size
139
+ // with the text's UTF-16 length used to decide that, and with any multibyte
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;
142
+ let total = 0, inRun = null;
143
+ for (const e of readEntries(file, { tail: TAIL_BYTES })) {
144
+ if (id && e.event === "canary" && e.role === role && e.run === id) inRun = 0;
145
+ if (e.role === role && e.verdict === "denied" && e.action === action && e.target === target) {
146
+ total++;
147
+ if (inRun !== null) inRun++;
127
148
  }
128
- } catch {
129
- return 0; // no log yet is not an error; it is a first turn
130
- }
131
- const lines = text.split("\n");
132
- // The first line of a tail read is usually half an entry. Dropping it costs
133
- // one count at most and keeps a truncated line from being parsed as if it
134
- // were whole.
135
- if (from0(text, file)) lines.shift();
136
- let n = 0;
137
- for (const line of lines) {
138
- if (!line.trim()) continue;
139
- let e;
140
- try { e = JSON.parse(line); } catch { continue; }
141
- if (e.role === role && e.verdict === "denied" && e.action === action && e.target === target) n++;
142
149
  }
143
- return n;
150
+ return { total, inRun };
151
+ }
152
+
153
+ /**
154
+ * The sentence about having been denied this before, or "".
155
+ *
156
+ * `times` and `inRun` include the attempt being answered. Said about this run
157
+ * when the run is known, and earlier runs are named as earlier runs. Silent on
158
+ * a first time anywhere.
159
+ */
160
+ export function againWords({ times, inRun = null }) {
161
+ if (inRun === null)
162
+ return times > 1 ? ` You have been denied this ${times} times now; it is not going to work on the ${ordinal(times + 1)} try.` : "";
163
+ const earlier = times - inRun;
164
+ if (inRun > 1)
165
+ return ` You have been denied this ${inRun} times in this run` + (earlier > 0 ? ` (and ${earlier} in earlier runs)` : "") +
166
+ `; it is not going to work on the ${ordinal(inRun + 1)} try.`;
167
+ return earlier > 0 ? ` It was denied ${earlier} time(s) in earlier runs too; this run gets the same answer.` : "";
144
168
  }
145
169
 
146
- /** Did we start mid-file? Then the first line is a fragment. */
147
- function from0(text, file) {
148
- try { return statSync(file).size > text.length; } catch { return false; }
170
+ /** 4 -> "fourth". Past a handful the numeral reads better than the word. */
171
+ export function ordinal(n) {
172
+ const words = ["", "first", "second", "third", "fourth", "fifth", "sixth", "seventh", "eighth", "ninth", "tenth"];
173
+ return words[n] ?? `${n}th`;
149
174
  }
150
175
 
151
176
  /**
@@ -155,7 +180,9 @@ function from0(text, file) {
155
180
  * one after that is the cost of not having been able to remember.
156
181
  */
157
182
  export function wasted(list) {
158
- return list.reduce((n, w) => n + w.times - 1, 0);
183
+ // An expected refusal is the agent program's own housekeeping, not a call
184
+ // the agent spent retrying.
185
+ return list.reduce((n, w) => n + (w.expected ? 0 : w.times - 1), 0);
159
186
  }
160
187
 
161
188
  /**
@@ -166,6 +193,9 @@ export function wasted(list) {
166
193
  * whole section — and then the one turn it matters, it is skipped too.
167
194
  */
168
195
  export function render(list) {
196
+ // Not the agent's to stop retrying: Claude Code's plugin updater is not a
197
+ // tool call, so telling the agent about it is a line it can do nothing with.
198
+ list = list.filter((w) => !w.expected);
169
199
  if (!list.length) return "";
170
200
  const lines = [
171
201
  "WALLS — you have already been denied these, and the policy still denies them.",
package/src/worktree.js CHANGED
@@ -30,9 +30,10 @@
30
30
  * and no dependency — a tool people install to shrink their attack surface
31
31
  * should not shell out to find out where it is.
32
32
  */
33
- import { statSync, readFileSync, readdirSync, realpathSync } from "node:fs";
33
+ import { statSync, readFileSync, readdirSync } from "node:fs";
34
34
  import { dirname, join, resolve, relative, isAbsolute } from "node:path";
35
35
  import { findConfig } from "./config.js";
36
+ import { realOrSelf } from "./grants.js";
36
37
 
37
38
  const GITDIR = /^gitdir:\s*(.+?)\s*$/;
38
39
  const WORKTREE = /^(.+)\/\.git\/worktrees\/[^/]+$/;
@@ -63,18 +64,9 @@ function statOf(p) {
63
64
  }
64
65
  }
65
66
 
66
- /** The path with symlinks followed, or the path itself when it is not on disk. */
67
- function real(p) {
68
- try {
69
- return realpathSync(p);
70
- } catch {
71
- return p;
72
- }
73
- }
74
-
75
67
  /** Two spellings of one place — `/tmp` and `/private/tmp` — compare equal. */
76
68
  function same(a, b) {
77
- return real(a) === real(b);
69
+ return realOrSelf(a) === realOrSelf(b);
78
70
  }
79
71
 
80
72
  /**