seisin 0.5.0 → 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 (76) hide show
  1. package/README.md +385 -129
  2. package/package.json +13 -5
  3. package/src/browsers.js +52 -0
  4. package/src/canary.js +149 -0
  5. package/src/cli.js +47 -23
  6. package/src/commands/check.js +154 -3
  7. package/src/commands/explain.js +2 -5
  8. package/src/commands/hook.js +7 -3
  9. package/src/commands/init.js +57 -50
  10. package/src/commands/requests.js +31 -56
  11. package/src/commands/review.js +33 -6
  12. package/src/commands/run.js +332 -110
  13. package/src/commands/walls.js +39 -7
  14. package/src/commands/whose.js +2 -1
  15. package/src/config.js +321 -15
  16. package/src/controls.js +5 -5
  17. package/src/diagnose.js +67 -16
  18. package/src/env.js +3 -0
  19. package/src/grants.js +107 -2
  20. package/src/handoff.js +285 -10
  21. package/src/help.js +39 -19
  22. package/src/hook.js +59 -30
  23. package/src/index.d.ts +217 -0
  24. package/src/index.js +20 -12
  25. package/src/inspect/codex.js +64 -0
  26. package/src/inspect/credentials.js +48 -0
  27. package/src/inspect/enforce.js +24 -0
  28. package/src/inspect/files.js +71 -0
  29. package/src/inspect/home.js +43 -0
  30. package/src/inspect/inject.js +68 -0
  31. package/src/inspect/keys.js +74 -0
  32. package/src/inspect/layout.js +258 -0
  33. package/src/inspect/limits.js +98 -0
  34. package/src/inspect/machine.js +107 -0
  35. package/src/inspect/network.js +37 -0
  36. package/src/inspect/providers.js +71 -0
  37. package/src/inspect/read-territory.js +105 -0
  38. package/src/inspect/role-keys.js +136 -0
  39. package/src/inspect/shared.js +37 -0
  40. package/src/inspect/warnings.js +58 -0
  41. package/src/inspect.js +13 -815
  42. package/src/intake.js +131 -21
  43. package/src/keys.js +223 -67
  44. package/src/kinds.js +42 -0
  45. package/src/lines.js +26 -0
  46. package/src/log.js +7 -2
  47. package/src/mcp.js +28 -43
  48. package/src/nested.js +12 -3
  49. package/src/notify.js +53 -12
  50. package/src/owners.js +165 -3
  51. package/src/paths.js +17 -1
  52. package/src/provider-run.js +81 -0
  53. package/src/redact.js +34 -15
  54. package/src/render.js +35 -9
  55. package/src/requests.js +363 -45
  56. package/src/review.js +21 -9
  57. package/src/rundir.js +29 -8
  58. package/src/sandboxes/command.js +93 -0
  59. package/src/sandboxes/index.js +113 -0
  60. package/src/sandboxes/mach-profile.js +31 -0
  61. package/src/sandboxes/srt-mach.mjs +43 -0
  62. package/src/sandboxes/srt.js +163 -0
  63. package/src/sandboxes/tartaros.js +80 -0
  64. package/src/scan.js +7 -3
  65. package/src/serve.js +35 -17
  66. package/src/spool.js +8 -17
  67. package/src/srt.js +263 -33
  68. package/src/surface.js +129 -16
  69. package/src/territory.js +261 -0
  70. package/src/trace.js +33 -0
  71. package/src/uilink.js +15 -6
  72. package/src/views.js +40 -14
  73. package/src/violations.js +143 -7
  74. package/src/walls.js +62 -9
  75. package/ui/index.html +85 -13
  76. package/ui/favicon.svg +0 -1
package/src/views.js CHANGED
@@ -9,16 +9,22 @@
9
9
  import { createHash } from "node:crypto";
10
10
  import { readFileSync } from "node:fs";
11
11
  import { explain, standingOf } from "./owners.js";
12
- import { kindsOf, KINDS } from "./kinds.js";
12
+ import { kindsOf, KINDS, expectedOf } from "./kinds.js";
13
13
  import { walls } from "./walls.js";
14
- import { pending, requestsPath, markStale } from "./requests.js";
14
+ import { queueOf } from "./requests.js";
15
15
 
16
16
  /**
17
17
  * An ISO date from what a person or an agent typed, or null for no filter.
18
- * Anything that does not parse is ignored rather than refused: a bad filter
19
- * should show everything, not an error.
18
+ *
19
+ * A date (`2026-10-01`, any ISO time) or a duration back from `now`: `7d`,
20
+ * `12h`, `2w`, `30m` (minutes — a month is not a fixed length). Anything that
21
+ * does not parse is null; the MCP ignores it rather than refusing (a bad filter
22
+ * should show everything, not an error), and the CLI says so instead.
20
23
  */
21
- export function parseSince(raw) {
24
+ const UNIT_MS = { m: 60_000, h: 3_600_000, d: 86_400_000, w: 7 * 86_400_000 };
25
+ export function parseSince(raw, now = Date.now()) {
26
+ const d = /^\s*(\d+)\s*([mhdw])\s*$/i.exec(String(raw ?? ""));
27
+ if (d) return new Date(now - Number(d[1]) * UNIT_MS[d[2].toLowerCase()]).toISOString();
22
28
  const t = raw ? Date.parse(raw) : NaN;
23
29
  return Number.isNaN(t) ? null : new Date(t).toISOString();
24
30
  }
@@ -69,7 +75,7 @@ export function verdicts(cfg, { now = Date.now() } = {}) {
69
75
  * hundred identical rows instead of one sentence. Grouped, the shape of the
70
76
  * day is visible at a glance and it is almost never a territory dispute.
71
77
  *
72
- * `stillRefused` is recomputed against the policy as it stands, not read out
78
+ * `stillDenied` is recomputed against the policy as it stands, not read out
73
79
  * of the log — a cause that has been granted since is history, not friction,
74
80
  * and leaving it on the page sends somebody to fix what is already fixed.
75
81
  */
@@ -77,6 +83,9 @@ export function causesOf(cfg, entries, { ask = explain, limit = 12 } = {}) {
77
83
  const by = new Map();
78
84
  for (const e of entries) {
79
85
  if (e.verdict !== "denied" || !e.target || !e.action) continue;
86
+ // A probe the caller of the run asked to be denied (`--expect-denied`):
87
+ // in the log, and not a cause of anything.
88
+ if (e.expected === true) continue;
80
89
  const k = `${e.action}\u0000${e.target}`;
81
90
  const g = by.get(k) ?? { action: e.action, target: e.target, kind: e.kind, times: 0, roles: new Set(), ownersThen: e.owners ?? [] };
82
91
  g.times++;
@@ -176,6 +185,19 @@ export function causesOf(cfg, entries, { ask = explain, limit = 12 } = {}) {
176
185
  * shows keys, network and tools apart from files: "no role owns this" is a
177
186
  * sentence about paths, and said of a port or a tool it is simply wrong.
178
187
  */
188
+ /**
189
+ * Refusals that are expected (kinds.js expectedOf) — Claude Code's plugin
190
+ * updater — counted apart so the page can say them as the boundary working
191
+ * rather than leave them at the top of the list as something to grant.
192
+ */
193
+ const expected = { paths: 0, denials: 0 };
194
+ for (const g of by.values()) {
195
+ g.expected = expectedOf(g.target);
196
+ if (!g.expected) continue;
197
+ expected.paths++;
198
+ expected.denials += g.times;
199
+ }
200
+
179
201
  const about = Object.fromEntries(ABOUT.map((k) => [k, { paths: 0, denials: 0 }]));
180
202
  for (const g of by.values()) {
181
203
  const a = aboutOf(g.kind);
@@ -188,6 +210,7 @@ export function causesOf(cfg, entries, { ask = explain, limit = 12 } = {}) {
188
210
  unowned: piles.unowned.paths,
189
211
  about,
190
212
  standing: piles,
213
+ expected,
191
214
  // The real number of distinct causes, not the length of the list below.
192
215
  // The page says "over N distinct paths" and the list is capped at twelve,
193
216
  // so taking N from the list reported the cap as if it were the count —
@@ -208,19 +231,20 @@ export function causesOf(cfg, entries, { ask = explain, limit = 12 } = {}) {
208
231
  ...(g.standing.why && { why: g.standing.why }),
209
232
  // Only on an unowned cause: what kind of thing it is, and what to do.
210
233
  ...(g.nature && { kind: g.nature.kind, hint: g.nature.hint }),
234
+ ...(g.expected && { expected: g.expected }),
211
235
  owners: g.standing.owners,
212
236
  ownersThen: g.ownersThen,
213
237
  // How many of the roles that hit this would still hit it. `some` and
214
238
  // not `every`: two roles out of three still blocked is still friction,
215
239
  // and requiring all of them would quietly retire a live cause the day
216
240
  // one role got a grant.
217
- stillRefused: [...g.roles].filter((r) => cfg.roles[r] && !ask(cfg, r, g.action, g.target).allowed).length,
241
+ stillDenied: [...g.roles].filter((r) => cfg.roles[r] && !ask(cfg, r, g.action, g.target).allowed).length,
218
242
  })),
219
243
  };
220
244
  }
221
245
 
222
246
  /** What a log line was about. Lines from before the field existed are files. */
223
- const ABOUT = ["file", "key", "network", "tool"];
247
+ const ABOUT = ["file", "key", "network", "system", "tool"];
224
248
  function aboutOf(kind) {
225
249
  return ABOUT.includes(kind) ? kind : "file";
226
250
  }
@@ -245,7 +269,8 @@ export function describeWalls(cfg, byRole, entries) {
245
269
  for (const [role, list] of Object.entries(byRole)) {
246
270
  out[role] = list.map((w) => {
247
271
  const about = aboutOf(kindOfLine.get(`${role}\u0000${w.action}\u0000${w.target}`));
248
- const x = { ...w, about, retried: w.times - 1 };
272
+ // An expected wall (walls.js) is not the agent retrying anything.
273
+ const x = { ...w, about, retried: w.expected ? 0 : w.times - 1 };
249
274
  // Every file wall, owned or not: a protected path can also lie inside
250
275
  // someone's territory, and "owned" must not hide "never granted". The
251
276
  // owners are today's, as Denied's are.
@@ -280,10 +305,11 @@ export function wallsByRole(cfg, entries, { ask } = {}) {
280
305
  }
281
306
 
282
307
  /**
283
- * The request queue as a person should read it: pending, with the requests
284
- * the role has stopped asking for marked. `history` is the whole log, every
285
- * verdict — a run that was only allowed things is still a run.
308
+ * The request queue as a person should read it: pending, each with its handoff
309
+ * decided, and the requests the role has stopped asking for marked. `history`
310
+ * is the whole log, every verdict — a run that was only allowed things is
311
+ * still a run.
286
312
  */
287
- export function queue(root, history, keyDirs = null) {
288
- return markStale(pending(requestsPath(root), { keyDirs }), history);
313
+ export function queue(cfg, history, { readOnly = false } = {}) {
314
+ return queueOf(cfg, history, { readOnly });
289
315
  }
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
  *
@@ -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;
@@ -367,7 +411,7 @@ export function scopeOf(settings, root) {
367
411
  * logging less than it used to.
368
412
  */
369
413
  export function watchDenials(onDeny, { pid = null, argv = null, platform = process.platform, spawnFn = spawn, treeFn = processTree, now = () => performance.now() } = {}) {
370
- const stats = { attributed: 0, foreign: 0, unattributed: 0 };
414
+ const stats = { attributed: 0, foreign: 0, unattributed: 0, dropped: 0 };
371
415
 
372
416
  /**
373
417
  * Unavailable is a watcher that does nothing, NOT a different shape.
@@ -391,7 +435,7 @@ export function watchDenials(onDeny, { pid = null, argv = null, platform = proce
391
435
  return {
392
436
  available: false,
393
437
  reason: platform === "linux"
394
- ? "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"
395
439
  : `no kernel denial stream on ${platform}`,
396
440
  stats,
397
441
  attributeTo() {},
@@ -559,6 +603,9 @@ export function watchDenials(onDeny, { pid = null, argv = null, platform = proce
559
603
  while ((cut = buf.indexOf("_SBX")) !== -1) {
560
604
  const chunk = buf.slice(0, cut + 4);
561
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);
562
609
  const d = parseChunk(chunk);
563
610
  if (d) d.seen = now();
564
611
  // Deduplicated here and not in consider(), because a held denial goes
@@ -567,7 +614,9 @@ export function watchDenials(onDeny, { pid = null, argv = null, platform = proce
567
614
  // pid — which, on a short command, is all of them.
568
615
  if (d && !isRepeat(d)) consider(d);
569
616
  }
570
- 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 = ""; }
571
620
  });
572
621
  // A monitor that cannot run must never take the run down with it. Same rule
573
622
  // as append(): the instrument is allowed to fail, the agent is not.
@@ -604,11 +653,11 @@ export function watchDenials(onDeny, { pid = null, argv = null, platform = proce
604
653
  */
605
654
  close({ drain = 0 } = {}) {
606
655
  clearTimeout(recheck);
607
- const stop = () => { try { child.kill("SIGTERM"); } catch { /* already gone */ } };
608
- if (drain <= 0) { stop(); return Promise.resolve(stats); }
656
+ const stop = () => ended(child).then(() => { stats.dropped += droppedIn(buf); buf = ""; return stats; });
657
+ if (drain <= 0) return stop();
609
658
  return new Promise((resolve) => {
610
659
  let quiet = null;
611
- const settle = () => { clearTimeout(quiet); clearTimeout(cap); stop(); resolve(stats); };
660
+ const settle = () => { clearTimeout(quiet); clearTimeout(cap); resolve(stop()); };
612
661
  const cap = setTimeout(settle, drain);
613
662
  const bump = () => { clearTimeout(quiet); quiet = setTimeout(settle, QUIET_MS); };
614
663
  child.stdout?.on("data", bump);
@@ -618,6 +667,28 @@ export function watchDenials(onDeny, { pid = null, argv = null, platform = proce
618
667
  };
619
668
  }
620
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
+
621
692
  /**
622
693
  * How long the stream has to stay silent before a drain gives up on it.
623
694
  *
@@ -658,3 +729,68 @@ const HELD_MAX = 500;
658
729
  * `asked 3×` mean anything.
659
730
  */
660
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
@@ -31,11 +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
36
  import { read, readEntries } from "./log.js";
37
37
  import { explain } from "./owners.js";
38
38
  import { STALE_RUNS, RUN_GAP_MS, runsAfter } from "./requests.js";
39
+ import { expectedOf } from "./kinds.js";
39
40
 
40
41
  /** A wall is something you hit more than once. Once is information; twice is a pattern. */
41
42
  export const MIN_HITS = 2;
@@ -51,9 +52,11 @@ export const MIN_HITS = 2;
51
52
  export function walls(config, role, { file, entries = null, min = MIN_HITS, since = null, limit = 6, fresh = false, ask = explain } = {}) {
52
53
  // `entries`: the log already read, for a caller asking about every role at
53
54
  // once (the console asks for 32 on a real policy) — one read instead of 32.
54
- 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
55
58
  ? entries.filter((e) => e.role === role && e.verdict === "denied" && (!since || e.at >= since))
56
- : read(file, { role, verdict: "denied", ...(since ? { since } : {}) });
59
+ : read(file, { role, verdict: "denied", ...(since ? { since } : {}) })).filter((e) => e.expected !== true);
57
60
 
58
61
  const byKey = new Map();
59
62
  for (const e of denied) {
@@ -73,7 +76,10 @@ export function walls(config, role, { file, entries = null, min = MIN_HITS, sinc
73
76
  // about every role at once.
74
77
  const verdict = ask(config, role, w.action, w.target);
75
78
  if (verdict.allowed) continue;
76
- 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 }) });
77
83
  }
78
84
  // A wall the role stopped hitting — it ran STALE_RUNS times since, refused
79
85
  // elsewhere but not here — is still true and no longer worth saying. Same
@@ -115,14 +121,56 @@ const TAIL_BYTES = 4 * 1024 * 1024;
115
121
  * call.
116
122
  */
117
123
  export function timesHit(file, role, action, target) {
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) {
118
137
  // The line the tail cut lands in is dropped (readLines checks the byte before
119
138
  // the cut, so a cut on a boundary keeps it). Comparing the file's byte size
120
139
  // with the text's UTF-16 length used to decide that, and with any multibyte
121
140
  // character in the tail it kept the fragment of a line or dropped a whole one.
122
- let n = 0;
123
- for (const e of readEntries(file, { tail: TAIL_BYTES }))
124
- if (e.role === role && e.verdict === "denied" && e.action === action && e.target === target) n++;
125
- return n;
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++;
148
+ }
149
+ }
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.` : "";
168
+ }
169
+
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`;
126
174
  }
127
175
 
128
176
  /**
@@ -132,7 +180,9 @@ export function timesHit(file, role, action, target) {
132
180
  * one after that is the cost of not having been able to remember.
133
181
  */
134
182
  export function wasted(list) {
135
- 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);
136
186
  }
137
187
 
138
188
  /**
@@ -143,6 +193,9 @@ export function wasted(list) {
143
193
  * whole section — and then the one turn it matters, it is skipped too.
144
194
  */
145
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);
146
199
  if (!list.length) return "";
147
200
  const lines = [
148
201
  "WALLS — you have already been denied these, and the policy still denies them.",