seisin 0.2.0 → 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/notify.js ADDED
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Tell a person when a request is filed.
3
+ *
4
+ * The queue is where a refusal becomes a handoff, and a queue only works if
5
+ * somebody looks at it. It was asynchronous with nothing to say it had moved:
6
+ * the handoff existed and the person did not know. This sends one message per
7
+ * new request — the role, the path, whose it is, and the command that answers
8
+ * it — to wherever the operator reads things (ntfy, Slack, a webhook).
9
+ *
10
+ * Approving is still a person at a terminal. This only notifies; it does not
11
+ * accept an answer, so it changes nothing about who decides. (Accepting one —
12
+ * nono's `approval-webhook-v1` — would, and is a separate decision.)
13
+ *
14
+ * Two rules, both from the review that found a role approving its own request
15
+ * through the console:
16
+ * - the POST leaves from the parent, never from the role: the role's process
17
+ * never holds the URL, and `SEISIN_NOTIFY_URL` is dropped from its
18
+ * environment even if the role names it;
19
+ * - the URL is not readable from any territory. A ntfy topic or a webhook is
20
+ * a credential — whoever can read it can send the person a fake "approve
21
+ * #3". So it lives in a key directory no role declares (`url_file`), or in
22
+ * the parent's environment, and never as a plain value in seisin.toml,
23
+ * which every role can read.
24
+ */
25
+ import { readFileSync } from "node:fs";
26
+ import { basename, join } from "node:path";
27
+ import { keyOf, pending, requestsPath, grantFor } from "./requests.js";
28
+
29
+ export const FORMATS = ["text", "json", "slack"];
30
+ export const ENV_URL = "SEISIN_NOTIFY_URL";
31
+
32
+ /** The URL to notify, or null. Read in the parent only. */
33
+ export function notifyUrl(config, env = process.env) {
34
+ if (env[ENV_URL]) return env[ENV_URL].trim();
35
+ if (!config.notify?.urlFile) return null;
36
+ try {
37
+ const url = readFileSync(join(config.root, config.notify.urlFile), "utf8").trim();
38
+ return url || null;
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ /** The body for one request, in the chosen format. */
45
+ export function message(config, req, number, format = "text") {
46
+ const owner = req.owners?.length ? `It belongs to ${req.owners.join(", ")}.` : "Nobody owns it.";
47
+ const text =
48
+ `seisin (${basename(config.root)}): ${req.role} was refused ${req.action} on ${req.target}. ${owner} ` +
49
+ `Approve: seisin grant ${number} · decline: seisin decline ${number}`;
50
+ if (format === "slack") return { type: "application/json", body: JSON.stringify({ text }) };
51
+ if (format === "json")
52
+ return {
53
+ type: "application/json",
54
+ body: JSON.stringify({
55
+ text, repo: basename(config.root), number, role: req.role, action: req.action,
56
+ target: req.target, owners: req.owners ?? [], grant: grantFor(req),
57
+ }),
58
+ };
59
+ return { type: "text/plain; charset=utf-8", body: text };
60
+ }
61
+
62
+ /**
63
+ * One per run. `maybe(req)` after a request is recorded; `settle()` before the
64
+ * run exits, so a message still in flight is not cut off.
65
+ *
66
+ * Only the first time a request appears — the queue already counts repeats,
67
+ * and a person pinged on every retry stops reading the pings.
68
+ */
69
+ export function notifier(config, { env = process.env, fetchImpl = globalThis.fetch, timeoutMs = 3000 } = {}) {
70
+ const url = notifyUrl(config, env);
71
+ const format = env.SEISIN_NOTIFY_FORMAT && FORMATS.includes(env.SEISIN_NOTIFY_FORMAT)
72
+ ? env.SEISIN_NOTIFY_FORMAT
73
+ : config.notify?.format ?? "text";
74
+ const file = requestsPath(config.root);
75
+ const known = url ? new Set(pending(file).map((p) => p.key)) : new Set();
76
+ const inflight = [];
77
+ const failures = [];
78
+
79
+ function maybe(req) {
80
+ if (!url) return false;
81
+ const key = keyOf(req);
82
+ if (known.has(key)) return false;
83
+ known.add(key);
84
+ const queue = pending(file);
85
+ const number = queue.findIndex((p) => p.key === key) + 1;
86
+ if (number < 1) return false;
87
+ const { type, body } = message(config, { ...req, owners: req.owners ?? queue[number - 1].owners }, number, format);
88
+ inflight.push(
89
+ fetchImpl(url, {
90
+ method: "POST",
91
+ headers: { "content-type": type },
92
+ body,
93
+ // A redirect would send the request to a place nobody configured.
94
+ redirect: "error",
95
+ signal: AbortSignal.timeout(timeoutMs),
96
+ })
97
+ .then((r) => { if (!r.ok) failures.push(`HTTP ${r.status}`); })
98
+ .catch((e) => failures.push(e.name === "TimeoutError" ? "timed out" : e.message)),
99
+ );
100
+ return true;
101
+ }
102
+
103
+ async function settle(capMs = timeoutMs + 500) {
104
+ if (!inflight.length) return failures;
105
+ await Promise.race([Promise.allSettled(inflight), new Promise((ok) => setTimeout(ok, capMs))]);
106
+ return failures;
107
+ }
108
+
109
+ return { enabled: !!url, maybe, settle };
110
+ }
111
+
112
+ /**
113
+ * `[notify]` from seisin.toml, refused rather than guessed when it would leak.
114
+ * Called by the config loader.
115
+ */
116
+ export function readNotify(table, { path, root, keyDirs, roles, own }) {
117
+ if (table === undefined) return null;
118
+ if (own(table, "url") !== undefined)
119
+ throw new Error(
120
+ `${path}: [notify] url is readable by every role — seisin.toml is not a secret. ` +
121
+ `Put the URL in a file inside a key directory no role declares and name it with url_file, ` +
122
+ `or set ${ENV_URL} in the environment that starts seisin.`);
123
+ const urlFile = own(table, "url_file");
124
+ const format = own(table, "format") ?? "text";
125
+ if (!FORMATS.includes(format))
126
+ throw new Error(`${path}: [notify] format = "${format}" is not one of ${FORMATS.join(", ")}`);
127
+ if (urlFile === undefined) return { urlFile: null, format };
128
+ if (typeof urlFile !== "string" || !urlFile.trim() || urlFile.startsWith("/") || urlFile.split("/").includes(".."))
129
+ throw new Error(`${path}: [notify] url_file must be a path inside the repo`);
130
+ const inKeyDir = keyDirs.some((d) => urlFile.startsWith(d.replace(/\/+$/, "") + "/"));
131
+ if (!inKeyDir)
132
+ throw new Error(
133
+ `${path}: [notify] url_file "${urlFile}" is not inside a key directory, so every role can read ` +
134
+ `it. Move it under ${keyDirs[0] ?? "a [keys] dir"}.`);
135
+ // A file key is written bare (`netlify.txt`, meaning the first key directory)
136
+ // or with its directory (`shared/api.txt`) — the same resolution settingsFor uses.
137
+ const resolve = (raw) => (raw.includes("/") ? raw : join(keyDirs[0] ?? "", raw));
138
+ const holders = Object.entries(roles)
139
+ .filter(([, r]) => r.keyEntries?.some((k) => k.kind === "file" && resolve(k.raw) === urlFile))
140
+ .map(([n]) => n);
141
+ if (holders.length)
142
+ throw new Error(
143
+ `${path}: [notify] url_file "${urlFile}" is declared as a key by ${holders.join(", ")} — ` +
144
+ `that role could read it and send the person a fake "approve". No role may hold it.`);
145
+ return { urlFile, format };
146
+ }
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
@@ -65,6 +65,18 @@ export function renderReport(report) {
65
65
  const writes = collapseSidecars(r.writes).join(" ") || `${C.dim}nothing${C.off}`;
66
66
  const keys = r.keys.length ? r.keys.join(" ") : `${C.dim}none${C.off}`;
67
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`);
68
80
  lines.push(` ${" ".repeat(width)} ${C.green}keys${C.off} ${keys}\n\n`);
69
81
  }
70
82
 
@@ -75,6 +87,23 @@ export function renderReport(report) {
75
87
  lines.push("\n");
76
88
  }
77
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
+
78
107
  for (const w of report.warnings) {
79
108
  lines.push(` ${C.yellow}${w.headline}${C.off}\n`);
80
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()]