seisin 0.1.1 → 0.3.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.
package/src/owners.js CHANGED
@@ -10,6 +10,9 @@
10
10
  * agents have to read each other's code to do anything useful. Keys are the
11
11
  * exception, and they are handled separately below.
12
12
  */
13
+ import { existsSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { protectedBy } from "./surface.js";
13
16
 
14
17
  /**
15
18
  * Does this glob cover this path? Supports `**`, `*`, `?` and a trailing `/`.
@@ -37,13 +40,47 @@
37
40
  * kernel is already enforcing it. What was missing was seisin saying so.
38
41
  */
39
42
  export function covers(glob, path) {
40
- const p = normalize(path);
41
- let g = normalize(glob);
42
- if (glob.endsWith("/")) g += "/**";
43
+ const p = normalized(path);
44
+ const m = matcherOf(glob);
43
45
  // The path itself, or anything beneath it. The `/` is load-bearing: without
44
46
  // it `src/api` would also cover `src/apifoo.ts`, which the kernel does not.
45
- if (!WILD.test(g)) return p === g || p.startsWith(g + "/");
46
- return toRegExp(g).test(p);
47
+ if (m.prefix !== null) return p === m.prefix || p.startsWith(m.prefix + "/");
48
+ return m.re.test(p);
49
+ }
50
+
51
+ /**
52
+ * The same glob and the same path, prepared once.
53
+ *
54
+ * `covers` is the inner loop of everything that asks "whose is this": owners of
55
+ * a path is every role's every glob against it, and the console asks that for
56
+ * every path of every role. On a real 32-role policy that was ~1.7M calls per
57
+ * refresh, each normalizing the glob again and compiling its regex again — 8 s
58
+ * of a console that polls, measured with a CPU profile (normalize alone 5 s).
59
+ * The answer never changes for the same string, so it is kept. Bounded: a
60
+ * server that runs for days sees paths from the log, and those keep coming.
61
+ */
62
+ const MAX_CACHED = 50_000;
63
+ const GLOBS = new Map();
64
+ const PATHS = new Map();
65
+
66
+ function matcherOf(glob) {
67
+ let m = GLOBS.get(glob);
68
+ if (m) return m;
69
+ let g = normalize(glob);
70
+ if (glob.endsWith("/")) g += "/**";
71
+ m = WILD.test(g) ? { prefix: null, re: toRegExp(g) } : { prefix: g, re: null };
72
+ if (GLOBS.size >= MAX_CACHED) GLOBS.clear();
73
+ GLOBS.set(glob, m);
74
+ return m;
75
+ }
76
+
77
+ function normalized(path) {
78
+ let p = PATHS.get(path);
79
+ if (p !== undefined) return p;
80
+ p = normalize(path);
81
+ if (PATHS.size >= MAX_CACHED) PATHS.clear();
82
+ PATHS.set(path, p);
83
+ return p;
47
84
  }
48
85
 
49
86
  /** The four characters toRegExp() treats as wildcards. */
@@ -103,13 +140,64 @@ function toRegExp(glob) {
103
140
  return new RegExp("^" + out + "$");
104
141
  }
105
142
 
143
+ /** Is this path inside a `.git` directory (git's metadata, not a working file)? */
144
+ export function isGitMetadata(path) {
145
+ return /(^|\/)\.git\//.test(normalize(path));
146
+ }
147
+
106
148
  /** Every role whose territory covers `path`. Usually one; zero is a finding. */
107
149
  export function ownersOf(config, path) {
150
+ /**
151
+ * An absolute path is outside the repo, or it is made relative first.
152
+ *
153
+ * The kernel reports paths as it meets them, and toRepoRelative leaves the
154
+ * ones outside the repo absolute on purpose. They then met `writes = ["**"]`,
155
+ * which matches any string, so a refused write to ~/.claude/plugins was
156
+ * logged as "owned by dev, wide" — two roles that own nothing outside the
157
+ * repo. Measured on the first run of Claude Code under the new denies.
158
+ */
159
+ if (path.startsWith("/")) {
160
+ const root = config.root?.endsWith("/") ? config.root : `${config.root}/`;
161
+ if (!config.root || !path.startsWith(root)) return [];
162
+ path = path.slice(root.length);
163
+ }
108
164
  return Object.values(config.roles)
109
- .filter((r) => r.writes.some((g) => covers(g, path)))
165
+ .filter((r) => r.writes.some((g) => covers(g, path)) && !neverWrites(r, path, config))
110
166
  .map((r) => r.name);
111
167
  }
112
168
 
169
+ /**
170
+ * The `never_writes` entries the kernel will actually be given for this role.
171
+ *
172
+ * On macOS, all of them. On Linux, only the ones that exist on disk right now,
173
+ * and that is the runtime's shape rather than a choice: bubblewrap denies a
174
+ * path by mounting over it, and to mount over a path that does not exist it
175
+ * creates it on the host — an empty, read-only file that stays there while the
176
+ * role runs and forever if the process is killed. For `.git/index.lock`, the
177
+ * case the key exists for, that locks every other user of the repo out.
178
+ * Measured in Docker (Debian 12, bwrap 0.8.0) on 2026-09-23: present during the
179
+ * run, gone after a clean exit, still there after SIGKILL.
180
+ *
181
+ * `explain` asks this same function, so the sentence and the kernel agree: a
182
+ * document stricter than the boundary is the direction this project refuses.
183
+ * `check` names the entries that are not enforced.
184
+ */
185
+ export function enforcedNeverWrites(config, role) {
186
+ const all = role?.neverWrites ?? [];
187
+ if (process.platform !== "linux" || !config?.root) return all;
188
+ return all.filter((g) => existsSync(join(config.root, g.replace(/\/\*\*$/, ""))));
189
+ }
190
+
191
+ /**
192
+ * The `never_writes` entry of `role` that covers `path`, or null.
193
+ *
194
+ * `?? []` because a caller embedding the library builds role objects itself,
195
+ * and one built before the key existed must keep meaning what it meant.
196
+ */
197
+ export function neverWrites(role, path, config = null) {
198
+ return enforcedNeverWrites(config, role).find((g) => covers(g, path)) ?? null;
199
+ }
200
+
113
201
  /** Every role allowed to read `key`, matched with or without its extension. */
114
202
  export function keyHolders(config, key) {
115
203
  const dirs = config.keyDirs ?? [];
@@ -149,7 +237,72 @@ export function keyHolders(config, key) {
149
237
  * not a permission problem, it is a hole in the map. Saying so is more useful
150
238
  * than denying quietly, and it is the only way the hole ever gets fixed.
151
239
  */
240
+ /**
241
+ * A connection the kernel refused: `tcp:<port>` or a unix socket path.
242
+ *
243
+ * The kernel does not say which host — its line is `remote:*:<port>` — so the
244
+ * sentence says the port and what the policy gives for it, and nothing it
245
+ * cannot know. Three answers, because they send a person to three places:
246
+ *
247
+ * - the port is not in `local_ports`: a policy question, add it or do not;
248
+ * - it is, and was refused anyway: the client dialled directly instead of
249
+ * through the proxy — a MySQL driver, Chromium, a raw socket — and adding
250
+ * the port again will not change that;
251
+ * - a unix socket: seisin grants none, on purpose. The one that comes up is
252
+ * Docker's, and a role that can reach Docker can mount the whole disk.
253
+ *
254
+ * Never an owner and never a request: a port is not anybody's territory.
255
+ */
256
+ function explainConnect(config, role, target) {
257
+ const r = config.roles[role];
258
+ const port = /^tcp:(\d+)$/.exec(target)?.[1];
259
+ if (port) {
260
+ const n = Number(port);
261
+ if (r?.localPorts?.includes(n))
262
+ return {
263
+ allowed: false, owners: [], network: true, listed: true,
264
+ reason: `port ${n} is in ${role}'s local_ports, but this client skipped the proxy. ` +
265
+ `curl, fetch and urllib use HTTP_PROXY on their own; a database driver or a browser ` +
266
+ `has to be pointed at it`,
267
+ };
268
+ // The kernel names no host, and every direct dial is refused — to an
269
+ // outside host as much as to localhost. So both readings, not a guess:
270
+ // advising `local_ports = [443]` for a client that skipped the proxy on
271
+ // its way to the internet opens a port and fixes nothing.
272
+ return {
273
+ allowed: false, owners: [], network: true,
274
+ reason: `port ${n}, host unknown. Local service: a person adds it to local_ports` +
275
+ `${r?.localPorts?.length ? ` (has ${r.localPorts.join(", ")})` : ""}. ` +
276
+ `Outside host: the client skipped HTTP_PROXY`,
277
+ };
278
+ }
279
+ return {
280
+ allowed: false, owners: [], network: true,
281
+ reason: `${target} is a unix socket, and roles get none` +
282
+ (/docker\.sock$/.test(target)
283
+ ? `. Docker's socket can mount any directory on this machine: start containers outside ` +
284
+ `the role and reach them by port`
285
+ : ""),
286
+ };
287
+ }
288
+
289
+ /** May this role load the MCP server `name`? A declaration, not something the kernel sees. */
290
+ function explainMcp(config, role, name) {
291
+ const list = config.roles[role]?.mcp;
292
+ if (list == null)
293
+ return { allowed: true, owners: [], mcp: true, declared: false,
294
+ reason: `${role} has no mcp list, so the policy does not limit its MCP servers` };
295
+ if (list.includes(name))
296
+ return { allowed: true, owners: [], mcp: true, declared: true, reason: `${role} declares mcp = [${list.map((n) => `"${n}"`).join(", ")}]` };
297
+ return { allowed: false, owners: [], mcp: true, declared: true,
298
+ reason: list.length
299
+ ? `${role} may load only ${list.join(", ")}. Adding ${name} is a change to its mcp list`
300
+ : `${role} declares mcp = [], no MCP servers` };
301
+ }
302
+
152
303
  export function explain(config, role, action, target) {
304
+ if (action === "connect") return explainConnect(config, role, target);
305
+ if (action === "mcp") return explainMcp(config, role, target);
153
306
  if (action === "read") {
154
307
  const holders = keyHolders(config, target);
155
308
  if (holders.includes(role)) return { allowed: true, owners: holders, reason: `${role} declares ${target}` };
@@ -159,7 +312,56 @@ export function explain(config, role, action, target) {
159
312
  }
160
313
 
161
314
  const owners = ownersOf(config, target);
315
+ /**
316
+ * Protected before anything else, because no territory reaches it.
317
+ *
318
+ * The policy, seisin's state, the key directories, what the parent executes
319
+ * or reads for a key, and the files that make git or Claude Code run
320
+ * something outside the box. The kernel denies all of them to every role —
321
+ * surface.js has the list and the reasons — so saying "belongs to dev" about
322
+ * `seisin.toml` sent a request to a person that no grant could satisfy.
323
+ */
324
+ const guard = protectedBy(config, target);
325
+ if (guard)
326
+ return {
327
+ allowed: false, owners, protected: guard.why,
328
+ reason: `${target} is protected (${guard.why}) — no role writes it, so there is nothing to grant`,
329
+ };
330
+ /**
331
+ * Named for what it is, and checked before "no owner".
332
+ *
333
+ * A path the role gave up through `never_writes` is not a permission it is
334
+ * missing. Said as "belongs to nobody", the agent files a request, a person
335
+ * approves it, and the subtraction is undone from the other side without
336
+ * anyone deciding to undo it.
337
+ */
338
+ const r = config.roles[role];
339
+ const barred = r && r.writes.some((g) => covers(g, target)) ? neverWrites(r, target, config) : null;
340
+ if (barred)
341
+ return {
342
+ allowed: false, owners, neverWrites: barred,
343
+ reason: `${target} is denied by never_writes of ${role} ("${barred}") — ` +
344
+ `a subtraction written into the policy, not a missing permission`,
345
+ };
162
346
  if (owners.includes(role)) return { allowed: true, owners, reason: `${target} is inside ${role}'s territory` };
347
+ /**
348
+ * Git's own bookkeeping in a repository this role does not write.
349
+ *
350
+ * Lock files, FETCH_HEAD, objects, refs, packed-refs: never a territory to
351
+ * hand over, because granting them is letting the role rewrite somebody
352
+ * else's history. Codex keeps `.git` read-only under a writable root for the
353
+ * same reason; nono and Gemini grant the whole of it. Here it stays refused
354
+ * and is said for what it is — not "belongs to X, ask them", which filed a
355
+ * request per lock file (327 of them for one index.lock on one portfolio)
356
+ * that nobody could sensibly approve. The requests side skips it too.
357
+ */
358
+ if (isGitMetadata(target))
359
+ return {
360
+ allowed: false, owners, gitMetadata: true,
361
+ reason: `${target} is git's own bookkeeping in a repository ${role} does not write — ` +
362
+ `not a permission to ask for. Read what you need (git log, git show, git ls-remote) ` +
363
+ `or work in a worktree of your own`,
364
+ };
163
365
  if (owners.length === 0)
164
366
  return { allowed: false, owners: [], reason: `${target} has no owner — no role can write it until one claims it` };
165
367
  return { allowed: false, owners, reason: `${target} belongs to ${owners.join(", ")}` };
package/src/paths.js ADDED
@@ -0,0 +1,50 @@
1
+ /**
2
+ * One way to turn whatever path a caller holds into the repo-relative path the
3
+ * policy is written in.
4
+ *
5
+ * It was copied by hand into four places (explain, whose, the hook, run) and a
6
+ * fifth, the MCP server, never had it: `seisin_explain` answered "has no owner"
7
+ * for an absolute path the CLI called allowed — the same defect decisions.md
8
+ * records as closed, closed in one surface only. And none of the copies
9
+ * resolved symlinks, so on macOS a path under `/tmp` (a link to `/private/tmp`)
10
+ * was refused by the sentence while the kernel, which sees the real path,
11
+ * allowed it: the document tighter than the boundary, the direction this
12
+ * project refuses. Found by the read-only review of 2026-09-22.
13
+ */
14
+ import { realpathSync } from "node:fs";
15
+ import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
16
+
17
+ /**
18
+ * `p` with its deepest existing ancestor resolved through symlinks and the rest
19
+ * appended as written. realpath alone fails on a path that does not exist yet,
20
+ * which is the shape of every file about to be created — a lock file included.
21
+ */
22
+ export function realAncestor(p) {
23
+ let head = p, tail = "";
24
+ while (head !== dirname(head)) {
25
+ try {
26
+ const real = realpathSync(head);
27
+ return tail ? join(real, tail) : real;
28
+ } catch {
29
+ tail = tail ? join(basename(head), tail) : basename(head);
30
+ head = dirname(head);
31
+ }
32
+ }
33
+ return p;
34
+ }
35
+
36
+ /**
37
+ * Repo-relative when `target` is inside the repo, spelled either way; exactly as
38
+ * written when it is relative already or lies outside. A path outside keeps its
39
+ * honest "no owner" — it is a question about somewhere else.
40
+ */
41
+ export function toRepoRelative(config, target) {
42
+ if (typeof target !== "string" || !isAbsolute(target)) return target;
43
+ const root = config.root;
44
+ const inside = (r, t) => t === r || t.startsWith(r + sep);
45
+ if (inside(root, target)) return relative(root, target) || ".";
46
+ const realRoot = realAncestor(root);
47
+ const realTarget = realAncestor(target);
48
+ if (inside(realRoot, realTarget)) return relative(realRoot, realTarget) || ".";
49
+ return target;
50
+ }
package/src/render.js CHANGED
@@ -30,18 +30,80 @@ export const err = (s) => process.stderr.write(s);
30
30
 
31
31
  /* ── the map ──────────────────────────────────────────────────────────── */
32
32
 
33
+ /**
34
+ * A database's sidecars, shown as the database.
35
+ *
36
+ * `writes` carries the expansion because the grant and the sentence have to
37
+ * agree — see config.js. Printing all four is then honest and unreadable: on a
38
+ * real policy it turned eight databases into thirty-two rows of the same name
39
+ * with three suffixes, which is what the expansion existed to remove.
40
+ *
41
+ * So the list is collapsed back for DISPLAY only, and only where the base is
42
+ * present, and it says the sidecars are there rather than hiding them. A
43
+ * sidecar declared without its database still prints on its own line — that is
44
+ * unusual enough to be worth seeing.
45
+ */
46
+ const SIDECAR_RE = /-(wal|shm|journal)$/;
47
+
48
+ export function collapseSidecars(paths) {
49
+ const have = new Set(paths);
50
+ const out = [];
51
+ for (const p of paths) {
52
+ const base = p.replace(SIDECAR_RE, "");
53
+ if (SIDECAR_RE.test(p) && have.has(base)) continue; // shown on its base
54
+ out.push(SIDECAR_RE.test(p) || !have.has(p + "-wal") ? p : `${p}${C.dim}+wal+shm+journal${C.off}`);
55
+ }
56
+ return out;
57
+ }
58
+
33
59
  /** The `check` report, as text. Takes the object `inspect()` returns. */
34
60
  export function renderReport(report) {
35
61
  const lines = [`\n${C.b}${report.where}${C.off}\n\n`];
36
62
  const width = Math.max(...report.roles.map((r) => r.name.length), 4);
37
63
 
38
64
  for (const r of report.roles) {
39
- const writes = r.writes.join(" ") || `${C.dim}nothing${C.off}`;
65
+ const writes = collapseSidecars(r.writes).join(" ") || `${C.dim}nothing${C.off}`;
40
66
  const keys = r.keys.length ? r.keys.join(" ") : `${C.dim}none${C.off}`;
41
67
  lines.push(` ${C.b}${r.name.padEnd(width)}${C.off} ${C.blue}writes${C.off} ${writes}\n`);
68
+ // Only when present: absent is the default, and a "never_writes nothing" line
69
+ // on every role is noise that teaches the reader to skip the one that matters.
70
+ if (r.neverWrites?.length)
71
+ lines.push(` ${" ".repeat(width)} ${C.yellow}never${C.off} ${collapseSidecars(r.neverWrites).join(" ")}\n`);
72
+ if (r.trustd)
73
+ lines.push(` ${" ".repeat(width)} ${C.yellow}trustd${C.off} TLS through the system verifier (trustd)\n`);
74
+ if (r.localBinding)
75
+ lines.push(` ${" ".repeat(width)} ${C.yellow}listen${C.off} local ports (local_binding)\n`);
76
+ if (r.mcp)
77
+ lines.push(` ${" ".repeat(width)} ${C.yellow}mcp${C.off} ${r.mcp.length ? r.mcp.join(" ") : "none"} ${C.dim}(declared; the launcher enforces it)${C.off}\n`);
78
+ if (r.localPorts?.length)
79
+ lines.push(` ${" ".repeat(width)} ${C.yellow}reach${C.off} localhost ${r.localPorts.join(" ")} ${C.dim}(local_ports, through the proxy)${C.off}\n`);
42
80
  lines.push(` ${" ".repeat(width)} ${C.green}keys${C.off} ${keys}\n\n`);
43
81
  }
44
82
 
83
+ if (report.providers?.length) {
84
+ lines.push(` ${C.b}key providers${C.off} ${C.dim}— seisin runs these itself, outside the sandbox, as you${C.off}\n`);
85
+ for (const p of report.providers)
86
+ lines.push(` ${C.green}${p.name}${C.off} ${p.command.join(" ")}${p.mode ? ` ${C.dim}(${p.mode})${C.off}` : ""}\n`);
87
+ lines.push("\n");
88
+ }
89
+
90
+ // What the kernel refuses inside a territory, and why. Shown because each one
91
+ // takes something from a territory as written; capped because a monorepo has
92
+ // a hooks directory per package and the reader needs the shape, not all 200.
93
+ if (report.protected?.length) {
94
+ const root = report.where ? report.where.replace(/\/[^/]*$/, "/") : "";
95
+ const show = (p) => (root && p.startsWith(root) ? p.slice(root.length) : p.replace(/^\/Users\/[^/]+|^\/home\/[^/]+/, "~"));
96
+ lines.push(` ${C.b}protected${C.off} ${C.dim}— inside a territory, refused anyway: something outside the box runs or reads it${C.off}\n`);
97
+ const LIMIT = 12;
98
+ for (const e of report.protected.slice(0, LIMIT)) {
99
+ const who = e.roles.length > 3 ? `${e.roles.slice(0, 3).join(", ")} +${e.roles.length - 3}` : e.roles.join(", ");
100
+ lines.push(` ${show(e.path)} ${C.dim}${e.why} · ${who}${C.off}\n`);
101
+ }
102
+ if (report.protected.length > LIMIT)
103
+ lines.push(` ${C.dim}… and ${report.protected.length - LIMIT} more${C.off}\n`);
104
+ lines.push("\n");
105
+ }
106
+
45
107
  for (const w of report.warnings) {
46
108
  lines.push(` ${C.yellow}${w.headline}${C.off}\n`);
47
109
  if (w.detail) lines.push(` ${C.dim}${w.detail}${C.off}\n`);
package/src/requests.js CHANGED
@@ -26,6 +26,7 @@
26
26
  * rewritten is not evidence. A request that is granted or refused is not
27
27
  * deleted — it is followed by a line saying what happened to it.
28
28
  */
29
+ import { neverWrites, isGitMetadata, covers } from "./owners.js";
29
30
  import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
30
31
  import { dirname, join } from "node:path";
31
32
  import { STATE_DIR, tomlString } from "./layout.js";
@@ -173,6 +174,68 @@ export function pending(file, { includeSettled = false } = {}) {
173
174
  return includeSettled ? all : all.filter((r) => r.state === "pending");
174
175
  }
175
176
 
177
+ /**
178
+ * How many runs of the role, after the last time it asked, make a request old.
179
+ *
180
+ * A request is recorded again on every refusal, so one the role still needs
181
+ * keeps a fresh `last`. One whose need went away — the work moved somewhere the
182
+ * role may write, or the task ended — stops being asked while the role keeps
183
+ * running. Three runs is enough to say "not since" and few enough that a queue
184
+ * does not carry a dead request for a week.
185
+ */
186
+ export const STALE_RUNS = 3;
187
+
188
+ /**
189
+ * Two log lines of a role this far apart belong to different runs — for lines
190
+ * written before the log had a run marker. Lines with `run` are counted by it.
191
+ */
192
+ export const RUN_GAP_MS = 10 * 60_000;
193
+
194
+ /**
195
+ * How many runs produced these lines, strictly after `since` (ms).
196
+ *
197
+ * By the `run` id when a line has one (intake.js writes it on every line since
198
+ * 2026-09-23), by the gap otherwise. The gap guessed: three runs started a
199
+ * minute apart by an orchestrator counted as one, and one run idle for eleven
200
+ * minutes counted as two.
201
+ */
202
+ export function runsAfter(entries, since, gapMs = RUN_GAP_MS) {
203
+ const ids = new Set();
204
+ const untagged = [];
205
+ for (const e of entries) {
206
+ const t = Date.parse(e.at);
207
+ if (Number.isNaN(t) || t <= since) continue;
208
+ if (e.run) ids.add(e.run);
209
+ else untagged.push(t);
210
+ }
211
+ untagged.sort((a, b) => a - b);
212
+ let n = 0, prev = -Infinity;
213
+ for (const t of untagged) { if (t - prev > gapMs) n++; prev = t; }
214
+ return ids.size + n;
215
+ }
216
+
217
+ /**
218
+ * Marks the requests the role has stopped asking for, read off the log.
219
+ *
220
+ * Marked, never settled and never moved. Declining is a person's decision, and
221
+ * reordering would shift the numbers `grant <n>` is typed against while
222
+ * nobody decided anything. The mark says what is true — the role ran N times
223
+ * since and did not ask again — and leaves the rest to whoever reads it.
224
+ * `entries` are log lines (`read(logPath(root))`); `now` is for the tests.
225
+ */
226
+ export function markStale(queue, entries, { runs = STALE_RUNS, gapMs = RUN_GAP_MS } = {}) {
227
+ const byRole = new Map(); // role -> its log lines
228
+ for (const e of entries) {
229
+ if (!e.role) continue;
230
+ (byRole.get(e.role) ?? byRole.set(e.role, []).get(e.role)).push(e);
231
+ }
232
+ for (const r of queue) {
233
+ const n = runsAfter(byRole.get(r.role) ?? [], Date.parse(r.last), gapMs);
234
+ if (n >= runs) r.stale = { runs: n, since: r.last };
235
+ }
236
+ return queue;
237
+ }
238
+
176
239
  /**
177
240
  * Adds a granted glob to a role in the config text, with its provenance.
178
241
  *
@@ -195,6 +258,31 @@ export function pending(file, { includeSettled = false } = {}) {
195
258
  * is the worst possible bug, and it is not exotic: a role with only `keys`
196
259
  * declared is an ordinary config.
197
260
  */
261
+ /**
262
+ * Refuses a grant that `never_writes` would cancel, before anything is written.
263
+ *
264
+ * A request filed before the subtraction existed can still be in the queue.
265
+ * Approving it would add the path to `writes` and change nothing — denyWrite
266
+ * wins — so the person would believe they granted something the kernel still
267
+ * refuses. The two keys contradict each other, and which one should win is the
268
+ * decision; seisin will not make it by writing one of them silently.
269
+ */
270
+ export function refuseIfBarred(config, request) {
271
+ if (request.action === "read") return;
272
+ const role = config.roles?.[request.role];
273
+ const hit = role && neverWrites(role, request.target, config);
274
+ if (hit)
275
+ throw new Error(
276
+ `${request.target} is under never_writes of ${request.role} ("${hit}"), which wins over ` +
277
+ `writes. Granting it would change nothing. If the subtraction is wrong, remove that ` +
278
+ `entry from [roles.${request.role}]; otherwise decline this request.`);
279
+ // Only a repository the role does not write: its own `.git` is its own business.
280
+ if (isGitMetadata(request.target) && !(role?.writes ?? []).some((g) => covers(g, request.target)))
281
+ throw new Error(
282
+ `${request.target} is git's own bookkeeping, not a territory: granting it lets ${request.role} ` +
283
+ `rewrite another owner's history. Decline it; a role that needs to commit gets a worktree of its own.`);
284
+ }
285
+
198
286
  export function applyGrant(toml, request, note = "") {
199
287
  const field = request.action === "read" ? "keys" : "writes";
200
288
  const header = new RegExp(`^\\[roles\\.${escapeRe(request.role)}\\]\\s*$`, "m");
package/src/review.js CHANGED
@@ -73,7 +73,7 @@ function findings(config, entries, minDenials) {
73
73
  const stopped = new Map(); // role + directory -> how often
74
74
  for (const e of entries) {
75
75
  if (e.verdict !== "denied" || !e.target) continue;
76
- const dir = e.action === "read" ? e.target : dirOf(e.target);
76
+ const dir = e.action === "read" || e.action === "connect" ? e.target : dirOf(e.target);
77
77
  const key = `${e.role} ${e.action} ${dir}`;
78
78
  const seen = stopped.get(key) ?? {
79
79
  role: e.role, action: e.action, where: dir, times: 0,
@@ -81,7 +81,7 @@ function findings(config, entries, minDenials) {
81
81
  // Carried by the log from both writers — the hook sets it from the config
82
82
  // and so does the parent for a kernel denial. Recomputing it from the path
83
83
  // here would be a second opinion on a settled question.
84
- kind: e.kind === "key" ? "key" : "file",
84
+ kind: e.kind === "key" || e.kind === "network" ? e.kind : "file",
85
85
  };
86
86
  seen.times++;
87
87
  stopped.set(key, seen);
@@ -91,8 +91,10 @@ function findings(config, entries, minDenials) {
91
91
  .filter((f) => f.times >= minDenials)
92
92
  .sort((a, b) => b.times - a.times);
93
93
 
94
- const friction = repeated.filter((f) => f.kind !== "key");
94
+ const friction = repeated.filter((f) => f.kind !== "key" && f.kind !== "network");
95
95
  const guarded = repeated.filter((f) => f.kind === "key");
96
+ // Ports and sockets: repeated, and not a thing `grant` can answer.
97
+ const connects = repeated.filter((f) => f.kind === "network");
96
98
 
97
99
  /* ── 2. what was granted and never used ─────────────────────────────── */
98
100
 
@@ -144,7 +146,9 @@ function findings(config, entries, minDenials) {
144
146
 
145
147
  const unowned = new Map();
146
148
  for (const e of entries) {
147
- if (!e.target || e.kind === "key") continue;
149
+ // A key directory is closed on purpose; a port or a socket is not a path
150
+ // anybody could own. Neither is a hole in the map.
151
+ if (!e.target || e.kind === "key" || e.kind === "network") continue;
148
152
  if (ownersOf(config, e.target).length) continue;
149
153
  const dir = dirOf(e.target);
150
154
  unowned.set(dir, (unowned.get(dir) ?? 0) + 1);
@@ -153,6 +157,7 @@ function findings(config, entries, minDenials) {
153
157
  return {
154
158
  friction,
155
159
  guarded,
160
+ connects,
156
161
  unused,
157
162
  unusedKnowable: knowable,
158
163
  unowned: [...unowned.entries()]
package/src/rundir.js ADDED
@@ -0,0 +1,113 @@
1
+ /**
2
+ * One private directory per run, for everything that belongs to that run only:
3
+ * the audit socket, the sandbox settings, and `scratch` keys.
4
+ *
5
+ * ── Why ──
6
+ * These lived in two places and both leaked. The settings were written to
7
+ * `.seisin/<role>.json`, one file per ROLE, so two runs of the same role — an
8
+ * orchestrator does that all day — wrote the same file with different socket
9
+ * paths, and one could start with the other's. The socket and the scratch keys
10
+ * lived in `$TMPDIR/seisin-XXXX/`, private by mode, but every role runs as the
11
+ * same user and every role can read the temp dir: measured on 2026-09-23, one
12
+ * role read another's `scratch` key while it ran (`cat …/keys/S` → the value).
13
+ *
14
+ * ── How ──
15
+ * All runs live under one root, `$TMPDIR/snr/`, and each role's profile denies
16
+ * reading that root and allows reading its own run's directory — the shape the
17
+ * key directories and the isolated homes already use. The root is denied for
18
+ * writing too, so no role can swap it for a symlink between runs.
19
+ *
20
+ * Short names on purpose: the socket path is capped near 104 bytes on macOS.
21
+ */
22
+ import { mkdirSync, mkdtempSync, lstatSync, chmodSync, writeFileSync, readdirSync, readFileSync, rmSync } from "node:fs";
23
+ import { join, basename, dirname } from "node:path";
24
+ import { tmpdir } from "node:os";
25
+ import { randomUUID } from "node:crypto";
26
+ import { realOrSelf } from "./grants.js";
27
+ import { channelName } from "./spool.js";
28
+
29
+ export const RUNS_NAME = "snr";
30
+
31
+ /**
32
+ * The root every run lives under, created private, and checked.
33
+ *
34
+ * Real path, because the kernel enforces on real paths and on macOS the temp
35
+ * dir sits behind /var → /private/var. Refused rather than used when it is not
36
+ * a directory of this user's with nobody else's bits on it: it holds keys.
37
+ */
38
+ export function runsRoot(base = tmpdir()) {
39
+ const root = join(realOrSelf(base), RUNS_NAME);
40
+ try { mkdirSync(root, { mode: 0o700 }); } catch (e) { if (e.code !== "EEXIST") throw e; }
41
+ const st = lstatSync(root);
42
+ const mine = typeof process.getuid !== "function" || st.uid === process.getuid();
43
+ if (st.isSymbolicLink() || !st.isDirectory() || !mine)
44
+ throw new Error(
45
+ `${root} is not a directory of yours (${st.isSymbolicLink() ? "it is a symlink" : "wrong owner or type"}).\n` +
46
+ ` It holds each run's socket and keys, so seisin will not use it. Remove it and run again.`);
47
+ if (st.mode & 0o077) chmodSync(root, 0o700);
48
+ return root;
49
+ }
50
+
51
+ /** Is the process that owns a run directory still alive? */
52
+ function alive(pid) {
53
+ try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; }
54
+ }
55
+
56
+ /**
57
+ * Removes the directories of runs whose process is gone.
58
+ *
59
+ * A run cleans up after itself on exit. A run that is killed does not, and
60
+ * its directory keeps its `scratch` keys on disk indefinitely — measured by
61
+ * SIGKILL on a keyed run. Swept at the start of the next one; a directory
62
+ * without a pid file is younger than the write that creates it, and is left.
63
+ */
64
+ function sweep(root) {
65
+ for (const name of readdirSync(root)) {
66
+ const dir = join(root, name);
67
+ let pid;
68
+ try { pid = Number(readFileSync(join(dir, "pid"), "utf8")); } catch { continue; }
69
+ if (pid && !alive(pid)) rmSync(dir, { recursive: true, force: true });
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Opens a run: its id, its directory, and the paths inside it.
75
+ *
76
+ * The id is the one the rest of the run uses — the nonce in the kernel's
77
+ * command tag, the `run` field on every log line — so one value ties the
78
+ * socket, the settings, the log and the kernel's reports together.
79
+ */
80
+ export function openRun({ base = tmpdir(), id = randomUUID() } = {}) {
81
+ const root = runsRoot(base);
82
+ try { sweep(root); } catch { /* a sweep that fails is not a reason not to run */ }
83
+ const dir = mkdtempSync(join(root, `${id.slice(0, 6)}-`));
84
+ writeFileSync(join(dir, "pid"), String(process.pid), { mode: 0o600 });
85
+ return {
86
+ id,
87
+ dir,
88
+ root,
89
+ // A socket on macOS, a FIFO on Linux, where the runtime blocks every unix
90
+ // socket inside the box (spool.js).
91
+ sock: join(dir, channelName()),
92
+ keys: join(dir, "keys"),
93
+ /**
94
+ * `wx`: created by this call or not at all. A settings file that already
95
+ * exists is not this run's, and the runtime would read whatever it says.
96
+ */
97
+ writeSettings(settings) {
98
+ const file = join(dir, "settings.json");
99
+ writeFileSync(file, JSON.stringify(settings, null, 2) + "\n", { flag: "wx", mode: 0o600 });
100
+ return file;
101
+ },
102
+ close() {
103
+ try { rmSync(dir, { recursive: true, force: true }); } catch {}
104
+ },
105
+ };
106
+ }
107
+
108
+ /** The runs root a socket path belongs to, or null when it is not one of ours. */
109
+ export function runsRootOf(sock) {
110
+ if (!sock) return null;
111
+ const root = dirname(dirname(sock));
112
+ return basename(root) === RUNS_NAME ? root : null;
113
+ }