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
@@ -0,0 +1,261 @@
1
+ /**
2
+ * `[runtime] read = "territory"`: reading denied where people keep their data,
3
+ * re-opened only along the paths a role needs.
4
+ *
5
+ * The default read model is a list of what not to read — key directories, the
6
+ * places credentials live, other agents' sign-in. It holds for agents you run
7
+ * yourself. Against one you would not trust it loses by one every time a new
8
+ * product leaves a folder of its own: measured on 2026-10-06 by an orchestrator that evaluates agents,
9
+ * a role confined that way could still list `~/.claude`, list the whole
10
+ * project tree of the person running it, and `cat` an evaluation check kept
11
+ * outside its worktree. None of the three was a credential, so none was on
12
+ * the list.
13
+ *
14
+ * So this mode turns the list around, but not all the way. Denying `/` and
15
+ * allowing back is the read allowlist decisions.md rejected, for a reason that
16
+ * still holds: every interpreter, library and cache a toolchain touches would
17
+ * have to be named. The data roots are what gets denied — homes, temp
18
+ * directories, mounted volumes — and the system stays readable, because there
19
+ * is nobody's data in `/usr`. What a role needs from inside a data root is
20
+ * finite and known before it starts: its repo, its territory, its run, its
21
+ * toolchain. That list is the short one, and it is the one written.
22
+ *
23
+ * **Denied whole, allowed back.** Each data root is one deny, and what the
24
+ * role keeps is an `allowRead` inside it. A deny covers what is created after
25
+ * it, so another run's directory made beside the repo an hour into this one
26
+ * is shut the moment it exists. That is the point of it.
27
+ *
28
+ * It was carved before — every sibling on the way to a kept path denied by
29
+ * name, which is a photograph: an entry created during the run beside a kept
30
+ * path was not covered, and read (measured, 2026-10-12). Two measured runtime
31
+ * behaviours kept the allow out then, and each is now handled where it bites:
32
+ *
33
+ * - macOS: an allow is emitted after the denies, and a deny that sits inside
34
+ * an allow is re-emitted after it whole (last match wins). For the key
35
+ * directory inside the repo that re-emitted deny also shuts the key files
36
+ * `[keys]` re-allows inside it. A deny spelt as a pattern is re-emitted
37
+ * MINUS the allows inside it, so those denies are spelt as one
38
+ * (outlastAllows, below).
39
+ * - Linux: the runtime restores the write mounts a deny wiped and then the
40
+ * read allows, read-only, on top of them — so a repo allowed back covers
41
+ * the territory inside it and every write fails with "Read-only file
42
+ * system" (srt 0.0.75/0.0.78). A kept path that holds a write path is
43
+ * allowed back piece by piece around it (territoryReads, below).
44
+ *
45
+ * **What that costs, said where it is decided.** On Linux, inside a kept
46
+ * directory that is on the way to a write path, an entry created during the
47
+ * run is not there for the role until the next run: it fails closed, it does
48
+ * not leak. On both, the names of what the role keeps are visible where the
49
+ * runtime has to show them (a Linux `ls ~` lists the mount points), and
50
+ * nothing else in a data root is.
51
+ */
52
+ import { readdirSync, lstatSync } from "node:fs";
53
+ import { homedir, tmpdir } from "node:os";
54
+ import { realOrSelf, writePathsOf, expand } from "./grants.js";
55
+ import { realAncestor } from "./paths.js";
56
+
57
+ export const READ_MODES = ["all", "territory"];
58
+
59
+ /** Files an agent reads at start that live outside its home directory. */
60
+ export const AGENT_FILES = { claude: ["~/.claude.json"], codex: [] };
61
+
62
+ /**
63
+ * Where data lives, per platform. Whole roots: what is under them belongs to
64
+ * somebody. `/opt/homebrew/var` and `/usr/local/var` hold the databases a
65
+ * package manager runs (Postgres, Redis); the rest of `/opt/homebrew` is the
66
+ * toolchain and stays readable.
67
+ */
68
+ export const DATA_ROOTS = {
69
+ darwin: [
70
+ "/Users", "/private/tmp", "/private/var/folders", "/private/var/root", "/Volumes",
71
+ "/opt/homebrew/var", "/usr/local/var", "/Library/Keychains",
72
+ ],
73
+ linux: [
74
+ "/home", "/root", "/tmp", "/var/tmp", "/mnt", "/media", "/srv", "/run/user",
75
+ "/run/secrets", "/var/lib/docker",
76
+ ],
77
+ };
78
+
79
+ /** The data roots of this machine: the platform's, plus HOME and TMPDIR wherever they are. */
80
+ export function dataRoots(platform = process.platform, home = homedir(), tmp = tmpdir(), real = realOrSelf) {
81
+ const base = DATA_ROOTS[platform] ?? DATA_ROOTS.linux;
82
+ return [...new Set([...base, home, tmp].map(real))];
83
+ }
84
+
85
+ /**
86
+ * The most entries one directory may have where a kept path is allowed back
87
+ * piece by piece (Linux, around a write path).
88
+ *
89
+ * Every piece is one allow, and one mount. The carving this replaced hit the
90
+ * same wall from the other side: a repo kept inside a Mac's TMPDIR, 9,105
91
+ * entries, made 9,163 denies, 815 KB of profile, and `spawn E2BIG` — no
92
+ * agent, and no reason anyone could read. Past this the answer is a refusal
93
+ * that names the directory, not a sandbox that cannot start.
94
+ */
95
+ export const MAX_CARVE = 1000;
96
+
97
+ /** What the runtime treats as glob syntax in a path (sandbox-utils containsGlobChars). */
98
+ const GLOB = /[[\]*?]/;
99
+
100
+ const under = (p, dir) => p === dir || p.startsWith(dir === "/" ? "/" : dir + "/");
101
+ const inside = (p, dir) => p !== dir && under(p, dir);
102
+
103
+ /**
104
+ * The read rules that leave exactly `keep` readable inside `roots`:
105
+ * `{ deny, allow }`, for denyRead and allowRead.
106
+ *
107
+ * Each root is denied whole, unless something kept covers it. Each kept path
108
+ * under a root is allowed back, in both spellings. On Linux a kept path that
109
+ * holds one of `writes` is not allowed back itself — its read mount would
110
+ * land on the write mount inside it — but walked toward the writes, and every
111
+ * child not on the way is allowed instead; the writes come back writable
112
+ * through the runtime's own restore. Nothing is denied by name, so nothing
113
+ * created later escapes the deny.
114
+ *
115
+ * Nothing at or under `denied` — the key directories, other roles' private
116
+ * folders, the credential homes — is allowed back: an allow there would win
117
+ * over the deny on macOS, and on Linux the deny's own mount restores it.
118
+ *
119
+ * `fs` is injectable for tests; it needs `readdir(dir) -> names`,
120
+ * `kind(path) -> "dir" | "link" | "other" | null` and `real(path) -> path`.
121
+ */
122
+ export function territoryReads(roots, keep, { writes = [], denied = [], platform = process.platform, fs = realFs } = {}) {
123
+ // realAncestor, not realOrSelf: a territory not created yet still has to
124
+ // be spelt the way the kernel will meet it (`/tmp/x` is `/private/tmp/x`).
125
+ // Both spellings of each: where it resolves, and where its NAME lives (the
126
+ // directories above resolved, the last component as written). A kept
127
+ // symlink — `~/.local/bin/claude` — has to be reachable by name for a PATH
128
+ // lookup to find it, and by target for the read to land.
129
+ const nameOf = (k) => { const d = k.slice(0, k.lastIndexOf("/")) || "/"; const r = fs.real(d); return `${r === "/" ? "" : r}/${k.slice(k.lastIndexOf("/") + 1)}`; };
130
+ const no = [...new Set(denied.map(fs.real))];
131
+ const open = (p) => !no.some((d) => under(p, d));
132
+ const kept = [...new Set(keep.flatMap((k) => [fs.real(k), nameOf(k)]))].filter(open);
133
+ // Nested roots (`/private/var/folders/…/T` inside `/private/var/folders`)
134
+ // are covered by the outer deny; one kept whole needs no deny at all.
135
+ const real = [...new Set(roots.map(fs.real))].filter((r) => fs.kind(r) === "dir");
136
+ const deny = real.filter((r) => !real.some((o) => inside(r, o)) && !kept.some((k) => under(r, k)));
137
+ const shut = kept.filter((k) => deny.some((r) => inside(k, r)));
138
+ if (platform !== "linux") return { deny, allow: shut };
139
+
140
+ const w = [...new Set(writes.map(fs.real))];
141
+ const allow = [];
142
+ const walk = (dir) => {
143
+ let names;
144
+ try { names = fs.readdir(dir); } catch { return; } // unreadable: nothing comes back
145
+ if (names.length > MAX_CARVE)
146
+ throw new Error(
147
+ `read = "territory" cannot keep ${dir} readable around what the role writes inside it: it holds ` +
148
+ `${names.length} entries, and on Linux each is one mount (the most it takes is ${MAX_CARVE} per directory).\n` +
149
+ ` Written inside it: ${w.filter((x) => inside(x, dir)).join(", ")}.\n` +
150
+ ` Move the repo to a directory with fewer neighbours, or narrow the reads entry that keeps this path.`);
151
+ for (const name of names.sort()) {
152
+ const child = dir === "/" ? `/${name}` : `${dir}/${name}`;
153
+ if (w.some((x) => under(child, x)) || !open(child)) continue; // a write, or denied
154
+ if (fs.kind(child) === "dir" && w.some((x) => inside(x, child))) walk(child);
155
+ else allow.push(child);
156
+ }
157
+ };
158
+ for (const k of shut) {
159
+ if (w.some((x) => under(k, x))) continue; // comes back as a write
160
+ if (w.some((x) => inside(x, k)) && fs.kind(k) === "dir") walk(k);
161
+ else allow.push(k);
162
+ }
163
+ return { deny, allow };
164
+ }
165
+
166
+ /**
167
+ * The denies, respelt so that each keeps its own allows open on macOS.
168
+ *
169
+ * A deny strictly inside an allow is re-emitted after the allows, because the
170
+ * last rule that matches wins and the allow would otherwise re-open it. A
171
+ * deny written as a path comes back whole, and that shuts every allow inside
172
+ * IT — the key files inside the key directory inside the repo. A deny written
173
+ * as a pattern comes back minus each allow its pattern covers (srt 0.0.78,
174
+ * macos-sandbox-utils lateReadDenyFilters). So those denies, and only those,
175
+ * are spelt as a pattern that matches exactly their own path: one character
176
+ * of the last name in brackets, `.secrets` as `.secret[s]`.
177
+ *
178
+ * A path that already holds glob syntax cannot be spelt that way without
179
+ * matching something else, and is refused.
180
+ */
181
+ export function outlastAllows(denies, allows, real = (p) => p) {
182
+ const open = allows.map(real);
183
+ return denies.map((written) => {
184
+ const d = real(written);
185
+ if (!open.some((a) => inside(a, d)) || !open.some((a) => inside(d, a))) return written;
186
+ const slash = d.lastIndexOf("/");
187
+ let at = d.length - 1;
188
+ while (at > slash && !/[A-Za-z0-9_-]/.test(d[at])) at--;
189
+ if (GLOB.test(d) || at <= slash)
190
+ throw new Error(
191
+ `read = "territory" cannot keep ${d} shut around what is allowed inside it: its name holds ` +
192
+ `characters the sandbox reads as a pattern, or none it can bracket.\n Rename it.`);
193
+ return `${d.slice(0, at)}[${d[at]}]${d.slice(at + 1)}`;
194
+ });
195
+ }
196
+
197
+ const realFs = {
198
+ real: realAncestor,
199
+ readdir: (d) => readdirSync(d),
200
+ kind: (p) => {
201
+ try {
202
+ const s = lstatSync(p);
203
+ return s.isSymbolicLink() ? "link" : s.isDirectory() ? "dir" : "other";
204
+ } catch { return null; }
205
+ },
206
+ };
207
+
208
+ /**
209
+ * What a role keeps readable: its repo, what it writes, its `reads` and its
210
+ * `toolchain`. One answer for the settings and for `check`, so the warning
211
+ * about an unreadable PATH entry and the kernel agree.
212
+ */
213
+ export function keptFor(config, role, { observe = false, agent, program } = {}) {
214
+ return [
215
+ config.root,
216
+ // The program the run starts, and where its link points: without it there
217
+ // is no run. Measured: `claude` installs as ~/.local/bin/claude ->
218
+ // ~/.local/share/claude/versions/<v>, and the run died on `env: claude: No
219
+ // such file or directory`. The file, not its directory — the rest of
220
+ // ~/.local/bin stays shut.
221
+ ...(program ? [program, realOrSelf(program)] : []),
222
+ // The agent's own files that are not under its home directory. Its home
223
+ // (~/.claude) is scratch and kept with the writes; ~/.claude.json, its
224
+ // settings and session index, sits beside it, and without it Claude Code
225
+ // starts and says "Not logged in" (measured, 2026-10-09). A run of no
226
+ // agent, or of another, does not get it.
227
+ ...(agent ? (AGENT_FILES[agent] ?? []).map(expand) : []),
228
+ ...writePathsOf(config, role, { observe, agent }),
229
+ ...(role.reads ?? []).map((p) => readPath(config.root, p)),
230
+ ...(role.toolchain ?? []).map((p) => readPath(config.root, p)),
231
+ ];
232
+ }
233
+
234
+ /** Whether `p` is shut by the mode: under a data root, and under nothing kept. */
235
+ export function shutByTerritory(p, keep, roots = dataRoots(), real = realAncestor) {
236
+ const r = real(p);
237
+ const kept = keep.map(real);
238
+ if (!roots.some((root) => under(r, root))) return false;
239
+ return !kept.some((k) => under(r, k));
240
+ }
241
+
242
+ /**
243
+ * The `bin` directory of each toolchain entry that has one, for the front of
244
+ * PATH. A venv, `~/.cargo`, a node install all keep their programs there;
245
+ * declaring the toolchain and then finding `python` resolves to another one
246
+ * would be a second way of not having it.
247
+ */
248
+ export function toolchainBins(config, role, isDir) {
249
+ return (role.toolchain ?? []).map((p) => readPath(config.root, p))
250
+ .flatMap((p) => [p.endsWith("/bin") ? p : `${p}/bin`].filter(isDir));
251
+ }
252
+
253
+ /** `~` and relative spellings of a read entry, absolute. Missing paths are kept as written. */
254
+ export function readPath(root, p) {
255
+ const trimmed = p.replace(/\/\*\*$/, "") || ".";
256
+ if (trimmed === "~") return homedir();
257
+ if (trimmed.startsWith("~/")) return `${homedir()}/${trimmed.slice(2)}`;
258
+ if (trimmed.startsWith("/")) return trimmed;
259
+ return `${root}/${trimmed}`.replace(/\/\.$/, "");
260
+ }
261
+
package/src/trace.js ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The launcher's trace, carried into the log.
3
+ *
4
+ * A launcher that runs many agents (a team, a CI job) gives each run a W3C
5
+ * `TRACEPARENT`. Without it in the log, nothing says which of these lines belong
6
+ * to which of the launcher's runs, and the log cannot be joined with anything
7
+ * else that observed the same run (a tracer, the launcher's own spans). With it,
8
+ * any OpenTelemetry tool joins them by trace id; seisin does not need to know
9
+ * who else is looking.
10
+ *
11
+ * Only a well-formed value is used (W3C Trace Context, version 00): a malformed
12
+ * one is ignored rather than copied into a log that other tools will trust.
13
+ */
14
+
15
+ const PART = { version: /^[0-9a-f]{2}$/, trace: /^[0-9a-f]{32}$/, span: /^[0-9a-f]{16}$/, flags: /^[0-9a-f]{2}$/ };
16
+
17
+ /** `{ trace_id, span_id }` from a `TRACEPARENT` value, or null. */
18
+ export function parseTraceparent(value) {
19
+ if (typeof value !== "string") return null;
20
+ const parts = value.trim().split("-");
21
+ if (parts.length < 4) return null;
22
+ const [version, trace, span, flags] = parts;
23
+ if (!PART.version.test(version) || version === "ff") return null;
24
+ if (version === "00" && parts.length !== 4) return null;
25
+ if (!PART.trace.test(trace) || !PART.span.test(span) || !PART.flags.test(flags)) return null;
26
+ if (/^0+$/.test(trace) || /^0+$/.test(span)) return null;
27
+ return { trace_id: trace, span_id: span };
28
+ }
29
+
30
+ /** The fields a log entry carries for the launcher's trace: `{}` when there is none. */
31
+ export function traceFields(env = process.env) {
32
+ return parseTraceparent(env.TRACEPARENT) ?? {};
33
+ }
package/src/uilink.js CHANGED
@@ -18,17 +18,35 @@
18
18
  * The pid is stored so a file left by a crashed run is recognisable: a link
19
19
  * whose writer is gone is stale, not a running console.
20
20
  */
21
- import { writeFileSync, readFileSync, unlinkSync } from "node:fs";
21
+ import { chmodSync, renameSync, writeFileSync, readFileSync, unlinkSync } from "node:fs";
22
+ import { randomBytes } from "node:crypto";
22
23
  import { join } from "node:path";
23
24
  import { runsRoot } from "./rundir.js";
25
+ import { alive as processAlive } from "./log.js";
24
26
 
25
27
  function linkFile(port, base) {
26
28
  return join(runsRoot(base), `ui-${port}.url`);
27
29
  }
28
30
 
29
- /** Records this run's live URL for the port it bound. */
30
- export function writeUiLink(port, url, base) {
31
- writeFileSync(linkFile(port, base), JSON.stringify({ url, pid: process.pid }) + "\n", { mode: 0o600 });
31
+ /**
32
+ * Records this run's live URL for the port it bound, and the policy it serves.
33
+ *
34
+ * The record is per port, not per repo, so `ui --link` in one repo found the
35
+ * console another repo had started — and opened a page about the wrong policy
36
+ * with nothing on it to say so. The policy's path travels with the link now.
37
+ */
38
+ export function writeUiLink(port, url, base, policy = null) {
39
+ const file = linkFile(port, base);
40
+ const temp = `${file}.tmp-${randomBytes(12).toString("hex")}`;
41
+ writeFileSync(temp, JSON.stringify({ url, pid: process.pid, policy }) + "\n", { flag: "wx", mode: 0o600 });
42
+ try {
43
+ // Replacing an existing record must neither inherit its mode nor follow
44
+ // its symlink. Readers see one complete record, including after a crash.
45
+ chmodSync(temp, 0o600);
46
+ renameSync(temp, file);
47
+ } finally {
48
+ try { unlinkSync(temp); } catch {}
49
+ }
32
50
  }
33
51
 
34
52
  /**
@@ -42,11 +60,8 @@ export function readUiLink(port, base) {
42
60
  try { e = JSON.parse(raw); } catch { return null; }
43
61
  if (!e || typeof e.url !== "string") return null;
44
62
  let alive = null;
45
- if (typeof e.pid === "number") {
46
- // Signal 0 tests for the process without touching it: it throws if gone.
47
- try { process.kill(e.pid, 0); alive = true; } catch { alive = false; }
48
- }
49
- return { url: e.url, pid: e.pid ?? null, alive };
63
+ if (Number.isInteger(e.pid) && e.pid > 0) alive = processAlive(e.pid);
64
+ return { url: e.url, pid: e.pid ?? null, alive, policy: typeof e.policy === "string" ? e.policy : null };
50
65
  }
51
66
 
52
67
  /** Removes the record. Called on exit, and when a stale one is found. */
package/src/views.js ADDED
@@ -0,0 +1,315 @@
1
+ /**
2
+ * What the console and the MCP server both say about the log, computed once.
3
+ *
4
+ * The two readers are different — a person opens the console, an agent calls
5
+ * the MCP — and they must not disagree about the same file. This held the
6
+ * shared arithmetic inside serve.js, so the MCP server imported node:http to
7
+ * group denials. Nothing here opens a socket or writes a file.
8
+ */
9
+ import { createHash } from "node:crypto";
10
+ import { readFileSync } from "node:fs";
11
+ import { explain, standingOf } from "./owners.js";
12
+ import { kindsOf, KINDS, expectedOf } from "./kinds.js";
13
+ import { walls } from "./walls.js";
14
+ import { queueOf } from "./requests.js";
15
+
16
+ /**
17
+ * An ISO date from what a person or an agent typed, or null for no filter.
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.
23
+ */
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();
28
+ const t = raw ? Date.parse(raw) : NaN;
29
+ return Number.isNaN(t) ? null : new Date(t).toISOString();
30
+ }
31
+
32
+ /**
33
+ * `explain`, remembered while the policy file stays the same.
34
+ *
35
+ * Causes and walls ask the policy about every refused path, and a real log has
36
+ * two thousand of them: ~0.75 ms each, most of the console's two-second poll.
37
+ * The answer depends on the policy text, so that is the key — and a little on
38
+ * the disk (on Linux a control file is protected only once it exists), so the
39
+ * memory is dropped after a minute too, as standingOf does. A config that was
40
+ * not read from a file gets a memory for this call only.
41
+ */
42
+ const VERDICT_TTL = 60_000;
43
+ let verdictMemo = { key: null, at: 0, map: new Map() };
44
+
45
+ export function verdicts(cfg, { now = Date.now() } = {}) {
46
+ let key = null;
47
+ try {
48
+ if (cfg.path) key = `${cfg.path}\u0000${createHash("sha256").update(readFileSync(cfg.path)).digest("hex")}`;
49
+ } catch {}
50
+ let map;
51
+ if (key === null) map = new Map();
52
+ else {
53
+ if (verdictMemo.key !== key || now - verdictMemo.at > VERDICT_TTL) verdictMemo = { key, at: now, map: new Map() };
54
+ map = verdictMemo.map;
55
+ }
56
+ return (c, role, action, target) => {
57
+ const k = `${role}\u0000${action}\u0000${target}`;
58
+ let v = map.get(k);
59
+ if (!v) map.set(k, (v = explain(c, role, action, target)));
60
+ return v;
61
+ };
62
+ }
63
+
64
+ /**
65
+ * Today's denials, grouped by what was denied rather than by who asked.
66
+ *
67
+ * Called `causes` and not `friction`: `seisin review` already has a `friction`
68
+ * that counts something narrower — repeated denials that are not about keys —
69
+ * and two screens of one product reporting different numbers under one label
70
+ * is the kind of thing nobody notices until they are compared.
71
+ *
72
+ * The console used to show them per role, which is the same list a hundred
73
+ * times over: measured on one deployment, 1,080 of 1,422 refusals were a
74
+ * single lock file across six repositories, and per-role that reads as six
75
+ * hundred identical rows instead of one sentence. Grouped, the shape of the
76
+ * day is visible at a glance and it is almost never a territory dispute.
77
+ *
78
+ * `stillDenied` is recomputed against the policy as it stands, not read out
79
+ * of the log — a cause that has been granted since is history, not friction,
80
+ * and leaving it on the page sends somebody to fix what is already fixed.
81
+ */
82
+ export function causesOf(cfg, entries, { ask = explain, limit = 12 } = {}) {
83
+ const by = new Map();
84
+ for (const e of entries) {
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;
89
+ const k = `${e.action}\u0000${e.target}`;
90
+ const g = by.get(k) ?? { action: e.action, target: e.target, kind: e.kind, times: 0, roles: new Set(), ownersThen: e.owners ?? [] };
91
+ g.times++;
92
+ g.roles.add(e.role);
93
+ by.set(k, g);
94
+ }
95
+ const total = [...by.values()].reduce((n, g) => n + g.times, 0);
96
+
97
+ /**
98
+ * The same grouping again, one level coarser: by the NAME at the end of the
99
+ * path rather than the path.
100
+ *
101
+ * Grouping by path alone gets the most important reading backwards. Measured
102
+ * here: six of the top seven causes were `.git/index.lock` in six different
103
+ * repositories, no single one above 17% — so "no cause dominates" is what the
104
+ * arithmetic says and the opposite of what is true. Three quarters of the
105
+ * window was one *kind* of thing, and that is a tooling problem with a
106
+ * mechanical fix, not a territory question anybody needs to rule on.
107
+ *
108
+ * By last segment, and nothing cleverer. A regex that recognised lock files,
109
+ * caches and build outputs would be a list of guesses about other people's
110
+ * toolchains that quietly goes stale; a repeated filename is a fact about
111
+ * this log.
112
+ */
113
+ const fams = new Map();
114
+ for (const g of by.values()) {
115
+ const name = g.target.split("/").filter(Boolean).pop() ?? g.target;
116
+ const f = fams.get(name) ?? { name, times: 0, paths: 0, where: [], roles: new Set() };
117
+ f.times += g.times;
118
+ f.paths++;
119
+ // The places, so the console can show what a name is made of instead of
120
+ // asserting a percentage the reader has to take on faith.
121
+ f.where.push({ target: g.target, times: g.times });
122
+ for (const r of g.roles) f.roles.add(r);
123
+ fams.set(name, f);
124
+ }
125
+ const families = [...fams.values()]
126
+ .sort((a, b) => b.times - a.times)
127
+ .map((f) => ({
128
+ name: f.name, times: f.times, paths: f.paths,
129
+ share: total ? f.times / total : 0,
130
+ roles: [...f.roles].sort(),
131
+ where: f.where.sort((a, b) => b.times - a.times).slice(0, 20),
132
+ }));
133
+
134
+ /**
135
+ * What a `grep` over the log cannot tell you: where each refused path stands
136
+ * against the policy as it is now.
137
+ *
138
+ * The headline used to be "74% of it is one name", and a field review
139
+ * applied this project's own test to it: *did the number tell you something
140
+ * you did not know?* For somebody who reads the raw log, no. So the console
141
+ * added how many causes are on paths no role owns — a decision rather than a
142
+ * number — and then computed it from the `owners` each log line carried,
143
+ * which is the one thing the log already had. It said the policy and read
144
+ * the past: every grant made since left its refusals counted as "nobody's",
145
+ * and protected surfaces, ports and paths outside the repository, which
146
+ * nobody can ever own, were counted as waiting for somebody to claim them.
147
+ *
148
+ * Now every cause is classified by `standing` (owners.js), and the four
149
+ * piles are reported in paths and in refusals. `unowned` keeps its name and
150
+ * now means what the page always said it meant. The owners the log recorded
151
+ * are kept on each cause as `ownersThen`: evidence of what was true, not
152
+ * authority over what is.
153
+ */
154
+ const standOf = standingOf(cfg);
155
+ const piles = Object.fromEntries(["unowned", "owned", "protected", "outside"].map((k) => [k, { paths: 0, denials: 0 }]));
156
+ for (const g of by.values()) {
157
+ const s = standOf({ kind: g.kind, target: g.target });
158
+ g.standing = s;
159
+ piles[s.kind].paths++;
160
+ piles[s.kind].denials += g.times;
161
+ }
162
+
163
+ /**
164
+ * And for the unowned pile, what kind of thing each path is (kinds.js).
165
+ *
166
+ * Suggesting an owner was measured first and was right about one time in
167
+ * twenty; the rest widened a role over git's lock files, a test's scratch
168
+ * directories and a credential. What the reader needs is the kind and the
169
+ * move that fits it, and `territory` — the part that really is a decision —
170
+ * is what is left. Over every unowned cause, not the twelve shown: one rule
171
+ * needs the whole set (the same name made unique per run in many places).
172
+ */
173
+ const unowned = [...by.values()].filter((g) => g.standing.kind === "unowned");
174
+ const natures = kindsOf(unowned.map((g) => g.target), { keyDirs: cfg.keyDirs ?? [] });
175
+ piles.unowned.kinds = Object.fromEntries(Object.keys(KINDS).map((k) => [k, { paths: 0, denials: 0 }]));
176
+ for (const g of unowned) {
177
+ g.nature = natures.get(g.target);
178
+ piles.unowned.kinds[g.nature.kind].paths++;
179
+ piles.unowned.kinds[g.nature.kind].denials += g.times;
180
+ }
181
+
182
+ /**
183
+ * What each refusal was about — a file, a key, a network destination or an
184
+ * MCP tool — counted over every cause, not the ones listed. The console
185
+ * shows keys, network and tools apart from files: "no role owns this" is a
186
+ * sentence about paths, and said of a port or a tool it is simply wrong.
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
+
201
+ const about = Object.fromEntries(ABOUT.map((k) => [k, { paths: 0, denials: 0 }]));
202
+ for (const g of by.values()) {
203
+ const a = aboutOf(g.kind);
204
+ about[a].paths++;
205
+ about[a].denials += g.times;
206
+ }
207
+
208
+ return {
209
+ total,
210
+ unowned: piles.unowned.paths,
211
+ about,
212
+ standing: piles,
213
+ expected,
214
+ // The real number of distinct causes, not the length of the list below.
215
+ // The page says "over N distinct paths" and the list is capped at twelve,
216
+ // so taking N from the list reported the cap as if it were the count —
217
+ // a wrong number stated confidently, which is worse than no number.
218
+ distinct: by.size,
219
+ families: families.slice(0, 8),
220
+ causes: [...by.values()]
221
+ .sort((a, b) => b.times - a.times)
222
+ .slice(0, limit)
223
+ .map((g) => ({
224
+ about: aboutOf(g.kind),
225
+ action: g.action,
226
+ target: g.target,
227
+ times: g.times,
228
+ share: total ? g.times / total : 0,
229
+ roles: [...g.roles].sort(),
230
+ standing: g.standing.kind,
231
+ ...(g.standing.why && { why: g.standing.why }),
232
+ // Only on an unowned cause: what kind of thing it is, and what to do.
233
+ ...(g.nature && { kind: g.nature.kind, hint: g.nature.hint }),
234
+ ...(g.expected && { expected: g.expected }),
235
+ owners: g.standing.owners,
236
+ ownersThen: g.ownersThen,
237
+ // How many of the roles that hit this would still hit it. `some` and
238
+ // not `every`: two roles out of three still blocked is still friction,
239
+ // and requiring all of them would quietly retire a live cause the day
240
+ // one role got a grant.
241
+ stillDenied: [...g.roles].filter((r) => cfg.roles[r] && !ask(cfg, r, g.action, g.target).allowed).length,
242
+ })),
243
+ };
244
+ }
245
+
246
+ /** What a log line was about. Lines from before the field existed are files. */
247
+ const ABOUT = ["file", "key", "network", "system", "tool"];
248
+ function aboutOf(kind) {
249
+ return ABOUT.includes(kind) ? kind : "file";
250
+ }
251
+
252
+ /**
253
+ * Walls as the console shows them: each with what it was about and, when no
254
+ * role owns the path, the same kind and hint the Denied screen gives it.
255
+ *
256
+ * Without this the two screens contradicted each other about one path: Walls
257
+ * said `.env.local` "has no owner — no role can write it until one claims it",
258
+ * an invitation to give it one, while Denied said it is a credential and never
259
+ * to grant a write to it. The hint is kinds.js's, so it is the same sentence.
260
+ *
261
+ * A copy: the MCP server hands out walls() as it is, and this adds to it.
262
+ */
263
+ export function describeWalls(cfg, byRole, entries) {
264
+ const kindOfLine = new Map();
265
+ for (const e of entries) if (e.verdict === "denied" && e.target) kindOfLine.set(`${e.role}\u0000${e.action}\u0000${e.target}`, e.kind);
266
+ const standOf = standingOf(cfg);
267
+ const out = {};
268
+ const unowned = [];
269
+ for (const [role, list] of Object.entries(byRole)) {
270
+ out[role] = list.map((w) => {
271
+ const about = aboutOf(kindOfLine.get(`${role}\u0000${w.action}\u0000${w.target}`));
272
+ // An expected wall (walls.js) is not the agent retrying anything.
273
+ const x = { ...w, about, retried: w.expected ? 0 : w.times - 1 };
274
+ // Every file wall, owned or not: a protected path can also lie inside
275
+ // someone's territory, and "owned" must not hide "never granted". The
276
+ // owners are today's, as Denied's are.
277
+ if (about === "file") {
278
+ const s = standOf({ kind: "file", target: w.target });
279
+ x.standing = s.kind;
280
+ x.owners = s.owners ?? [];
281
+ if (s.why) x.why = s.why;
282
+ if (s.kind === "unowned") unowned.push(x);
283
+ }
284
+ return x;
285
+ });
286
+ }
287
+ const natures = kindsOf(unowned.map((w) => w.target), { keyDirs: cfg.keyDirs ?? [] });
288
+ for (const w of unowned) Object.assign(w, natures.get(w.target));
289
+ return out;
290
+ }
291
+
292
+ /**
293
+ * Every role's walls, from one read of the log.
294
+ *
295
+ * `entries` are log lines of any verdict (walls keeps the denied ones); only
296
+ * roles with at least one wall appear. The MCP read the log once per role — 32
297
+ * reads on a real policy — to answer the same question.
298
+ */
299
+ export function wallsByRole(cfg, entries, { ask } = {}) {
300
+ const denied = entries.filter((e) => e.verdict === "denied");
301
+ return Object.fromEntries(
302
+ Object.keys(cfg.roles)
303
+ .map((r) => [r, walls(cfg, r, { entries: denied, ...(ask && { ask }) })])
304
+ .filter(([, w]) => w.length));
305
+ }
306
+
307
+ /**
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.
312
+ */
313
+ export function queue(cfg, history, { readOnly = false } = {}) {
314
+ return queueOf(cfg, history, { readOnly });
315
+ }