seisin 0.4.2 → 0.5.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 (50) hide show
  1. package/README.md +142 -54
  2. package/package.json +3 -3
  3. package/src/cli.js +145 -46
  4. package/src/commands/check.js +3 -2
  5. package/src/commands/explain.js +34 -13
  6. package/src/commands/hook.js +14 -4
  7. package/src/commands/init.js +126 -38
  8. package/src/commands/log.js +48 -9
  9. package/src/commands/requests.js +109 -32
  10. package/src/commands/review.js +41 -16
  11. package/src/commands/run.js +159 -37
  12. package/src/commands/scan.js +18 -3
  13. package/src/commands/ui.js +18 -5
  14. package/src/commands/walls.js +25 -6
  15. package/src/commands/whose.js +7 -6
  16. package/src/commands/wire.js +156 -17
  17. package/src/config.js +187 -25
  18. package/src/controls.js +244 -0
  19. package/src/diagnose.js +35 -33
  20. package/src/env.js +11 -8
  21. package/src/grants.js +15 -7
  22. package/src/help.js +159 -0
  23. package/src/hook.js +49 -19
  24. package/src/index.js +4 -4
  25. package/src/inspect.js +137 -71
  26. package/src/intake.js +20 -15
  27. package/src/keys.js +13 -11
  28. package/src/kinds.js +207 -0
  29. package/src/log.js +236 -51
  30. package/src/mcp.js +42 -28
  31. package/src/notify.js +13 -8
  32. package/src/owners.js +198 -41
  33. package/src/paths.js +37 -1
  34. package/src/redact.js +19 -5
  35. package/src/render.js +68 -10
  36. package/src/requests.js +173 -72
  37. package/src/review.js +30 -17
  38. package/src/rundir.js +4 -6
  39. package/src/scan.js +177 -19
  40. package/src/serve.js +377 -229
  41. package/src/spool.js +9 -29
  42. package/src/srt.js +38 -21
  43. package/src/suggest.js +58 -0
  44. package/src/surface.js +191 -44
  45. package/src/uilink.js +10 -4
  46. package/src/views.js +289 -0
  47. package/src/violations.js +104 -16
  48. package/src/walls.js +19 -42
  49. package/src/worktree.js +3 -11
  50. package/ui/index.html +1356 -537
package/src/requests.js CHANGED
@@ -1,39 +1,41 @@
1
1
  /**
2
- * The queue of permissions an agent asked for and cannot have yet.
2
+ * The queue of requests: what an agent asked for and cannot have yet.
3
3
  *
4
4
  * ── Two words, kept apart on purpose ──
5
- * **Refused** is what the boundary does. **Declined** is what a person does to
5
+ * **Denied** is what the boundary does. **Declined** is what a person does to
6
6
  * a request. They used to be one word in one command: an entry read `first
7
7
  * refused on src/api/x.ts` and `seisin deny` answered `refused frontend ✕
8
8
  * src/api/**`. One means the kernel said no, the other means you did, and a
9
9
  * reader had nothing to tell them apart by.
10
10
  *
11
11
  * The entry says **asked** now, because refused was not always true. Asking for
12
- * a permission before reaching for it is reasonable, and nothing required a
12
+ * a path before reaching for it is reasonable, and nothing required a
13
13
  * denial to have happened first — so the queue could print a sentence about an
14
- * event that never occurred, in front of a person about to approve something.
15
- * `seisin run` drops any request the policy does not actually refuse; what is
14
+ * event that never occurred, in front of a person about to grant something.
15
+ * `seisin run` drops any request the policy does not actually deny; what is
16
16
  * left is "you cannot have this, and you asked", which holds whether the agent
17
17
  * tried or simply asked.
18
18
  *
19
19
  * A denial already contains everything a request needs — the hook knows the
20
- * role, the path and the owner — so the refusal leaves something behind that a
20
+ * role, the path and the owner — so the denial leaves something behind that a
21
21
  * person can act on in one command instead of in an editor. See
22
- * `docs/permission-requests.md` for why approving is deliberately not something
22
+ * `docs/permission-requests.md` for why granting is deliberately not something
23
23
  * an agent can do.
24
24
  *
25
25
  * Append-only, beside the log, for the same reason: a decision that can be
26
- * rewritten is not evidence. A request that is granted or refused is not
26
+ * rewritten is not evidence. A request that is granted or declined 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";
30
- import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from "node:fs";
29
+ import { neverWrites, isGitMetadata, covers, keyHolders, inKeyDir } from "./owners.js";
30
+ import { protectedBy } from "./surface.js";
31
+ import { appendFileSync, mkdirSync, readFileSync, statSync, writeFileSync, renameSync, unlinkSync } from "node:fs";
32
+ import { randomBytes } from "node:crypto";
31
33
  import { dirname, join } from "node:path";
32
34
  import { STATE_DIR, tomlString } from "./layout.js";
33
35
  import { send } from "./spool.js";
34
- import { withLock } from "./log.js";
36
+ import { withLock, readEntries } from "./log.js";
35
37
 
36
- export const REQUESTS_NAME = "requests.jsonl";
38
+ const REQUESTS_NAME = "requests.jsonl";
37
39
 
38
40
  export function requestsPath(root) {
39
41
  return join(root, STATE_DIR, REQUESTS_NAME);
@@ -57,7 +59,6 @@ export function keyOf({ role, action, target }) {
57
59
  return `${role}:${action}:${dir}`;
58
60
  }
59
61
 
60
- /** The glob a grant would add, derived from what was asked. */
61
62
  /**
62
63
  * A request's id as one shell word, for the commands seisin suggests.
63
64
  *
@@ -69,13 +70,23 @@ export function shellId(key) {
69
70
  return `'${String(key).replace(/'/g, `'\\''`)}'`;
70
71
  }
71
72
 
72
- export function grantFor({ action, target }) {
73
+ /** The glob a grant would add, derived from what was asked. */
74
+ export function grantFor({ action, target }, keyDir = null) {
73
75
  const t = typeof target === "string" ? target : "";
74
76
  // A key keeps its directory when it has one. Stripping it turned a request
75
77
  // for `shared/api.txt` into a grant of `api.txt`, which settingsFor then
76
- // resolves against the FIRST key directory — so the person approves one file
78
+ // resolves against the FIRST key directory — so the person grants one file
77
79
  // and another one of the same name is what gets read.
78
- if (action === "read") return t.includes("/") ? t : t.replace(/^.*\//, "");
80
+ //
81
+ // Except the first key directory itself, which is exactly the one a bare
82
+ // name resolves against: `.secrets/stripe.txt` is written `stripe.txt`, the
83
+ // way every hand-written key is. Granting the long form put two spellings of
84
+ // one key in the same file.
85
+ if (action === "read") {
86
+ const first = keyDir ? String(keyDir).replace(/^\.\/+/, "").replace(/\/+$/, "") : null;
87
+ if (first && t.startsWith(first + "/") && !t.slice(first.length + 1).includes("/")) return t.slice(first.length + 1);
88
+ return t.includes("/") ? t : t.replace(/^.*\//, "");
89
+ }
79
90
  const dir = t.split("/").slice(0, -1).join("/");
80
91
  return dir ? `${dir}/**` : t;
81
92
  }
@@ -84,7 +95,9 @@ function write(file, entry) {
84
95
  if (send("requests", entry)) return true;
85
96
  try {
86
97
  mkdirSync(dirname(file), { recursive: true });
87
- appendFileSync(file, JSON.stringify({ at: new Date().toISOString(), ...entry }) + "\n");
98
+ // The time is this process's, named after the spread so no entry sets it.
99
+ const now = new Date().toISOString();
100
+ appendFileSync(file, JSON.stringify({ at: now, ...entry, at: now }) + "\n");
88
101
  return true;
89
102
  } catch {
90
103
  // Same rule as the log: never take the agent down over bookkeeping.
@@ -92,8 +105,27 @@ function write(file, entry) {
92
105
  }
93
106
  }
94
107
 
108
+ /**
109
+ * Who a read request's key belongs to, from the policy as it is now.
110
+ *
111
+ * A key is declared, not owned: nobody writes `.secrets/`, so the owners a
112
+ * write-territory lookup finds for one are always none, and the queue said
113
+ * "(unowned)" about a key another role declares — right before a grant that
114
+ * would share it. Recomputed rather than read from the line, like every other
115
+ * field that decides what a person is shown.
116
+ */
117
+ export function withDeclarers(config, queue) {
118
+ for (const r of queue) {
119
+ if (r.action !== "read" || typeof r.target !== "string") continue;
120
+ if (!inKeyDir(config, r.target) && r.target.includes("/")) continue;
121
+ r.owners = keyHolders(config, r.target);
122
+ r.declared = true;
123
+ }
124
+ return queue;
125
+ }
126
+
95
127
  /** Records that a role asked for something the policy denies it. Safe to call
96
- * on every denial; `seisin run` drops anything the policy does not refuse. */
128
+ * on every denial; `seisin run` drops anything the policy does not deny. */
97
129
  export function record(file, { role, action, target, owners }) {
98
130
  return write(file, { kind: "asked", key: keyOf({ role, action, target }), role, action, target, owners: owners ?? [] });
99
131
  }
@@ -115,6 +147,9 @@ export function settle(file, key, decision, reason = "") {
115
147
  *
116
148
  * It exists so that a refusal to run cannot be invisible. Admission may
117
149
  * postpone work; it may not make work disappear from the queue a person reads.
150
+ *
151
+ * Nothing in src/ calls it today — handoff.js decides but nothing records the
152
+ * outcome; the tests do, and pending() keeps reading what it writes.
118
153
  */
119
154
  export function recordHandoff(file, key, decision = {}) {
120
155
  const { type, role, limit, max, reason, depth, chainId, revision } = decision;
@@ -135,20 +170,35 @@ export function recordHandoff(file, key, decision = {}) {
135
170
  * it was asked and what was decided.
136
171
  *
137
172
  * Reduced from the whole file rather than kept as state, so the file stays the
138
- * only thing that has to be correct.
173
+ * only thing that has to be correct. The reduction is remembered for as long as
174
+ * the file is unchanged (same inode, size and mtime): the console asks every
175
+ * two seconds, and re-parsing a 2 MB queue each time to find it had not moved
176
+ * was most of the cost of asking. Each call gets its own copies, so a caller
177
+ * marking entries (markStale) cannot mark the next caller's.
139
178
  */
140
- export function pending(file, { includeSettled = false } = {}) {
141
- if (!existsSync(file)) return [];
179
+ let reduced = null; // { file, ino, size, mtimeMs, all }
180
+
181
+ export function pending(file, { includeSettled = false, keyDirs = null } = {}) {
182
+ let st;
183
+ try { st = statSync(file); } catch { return []; }
184
+ let all;
185
+ if (reduced && reduced.file === file && reduced.ino === st.ino && reduced.size === st.size && reduced.mtimeMs === st.mtimeMs) {
186
+ all = reduced.all;
187
+ } else {
188
+ all = reduce(file);
189
+ reduced = { file, ino: st.ino, size: st.size, mtimeMs: st.mtimeMs, all };
190
+ }
191
+ // The grant is re-derived with the policy's first key directory when the
192
+ // caller has one, so a key is proposed in the form hand-written keys use.
193
+ const first = keyDirs?.[0] ?? null;
194
+ const copies = all.map((r) => (first && r.action === "read" ? { ...r, grant: grantFor(r, first) } : { ...r }));
195
+ return includeSettled ? copies : copies.filter((r) => r.state === "pending");
196
+ }
197
+
198
+ function reduce(file) {
142
199
  const byKey = new Map();
143
200
 
144
- for (const line of readFileSync(file, "utf8").split("\n")) {
145
- if (!line.trim()) continue;
146
- let e;
147
- try {
148
- e = JSON.parse(line);
149
- } catch {
150
- continue; // a half-written line from a killed process is not an error
151
- }
201
+ for (const e of readEntries(file)) {
152
202
  if (!e.key) continue;
153
203
 
154
204
  if (e.kind === "asked") {
@@ -169,7 +219,8 @@ export function pending(file, { includeSettled = false } = {}) {
169
219
  *
170
220
  * Without this branch the catch-all below would set state = "handoff" and a
171
221
  * throttled attempt would drop out of the pending queue — the exact silent
172
- * disappearance the attempt is recorded to prevent.
222
+ * disappearance the attempt is recorded to prevent. (Only the tests write
223
+ * handoff lines today — see recordHandoff.)
173
224
  */
174
225
  if (e.kind === "handoff") {
175
226
  seen.handoff = {
@@ -182,14 +233,13 @@ export function pending(file, { includeSettled = false } = {}) {
182
233
  seen.state = e.kind; seen.reason = e.reason ?? ""; seen.decided = e.at;
183
234
  }
184
235
 
185
- const all = [...byKey.values()];
186
- return includeSettled ? all : all.filter((r) => r.state === "pending");
236
+ return [...byKey.values()];
187
237
  }
188
238
 
189
239
  /**
190
240
  * How many runs of the role, after the last time it asked, make a request old.
191
241
  *
192
- * A request is recorded again on every refusal, so one the role still needs
242
+ * A request is recorded again on every denial, so one the role still needs
193
243
  * keeps a fresh `last`. One whose need went away — the work moved somewhere the
194
244
  * role may write, or the task ended — stops being asked while the role keeps
195
245
  * running. Three runs is enough to say "not since" and few enough that a queue
@@ -233,7 +283,7 @@ export function runsAfter(entries, since, gapMs = RUN_GAP_MS) {
233
283
  * reordering would shift the numbers `grant <n>` is typed against while
234
284
  * nobody decided anything. The mark says what is true — the role ran N times
235
285
  * since and did not ask again — and leaves the rest to whoever reads it.
236
- * `entries` are log lines (`read(logPath(root))`); `now` is for the tests.
286
+ * `entries` are log lines (`read(logPath(root))`).
237
287
  */
238
288
  export function markStale(queue, entries, { runs = STALE_RUNS, gapMs = RUN_GAP_MS } = {}) {
239
289
  const byRole = new Map(); // role -> its log lines
@@ -248,35 +298,13 @@ export function markStale(queue, entries, { runs = STALE_RUNS, gapMs = RUN_GAP_M
248
298
  return queue;
249
299
  }
250
300
 
251
- /**
252
- * Adds a granted glob to a role in the config text, with its provenance.
253
- *
254
- * Edits the text rather than re-emitting the file, because a config someone
255
- * wrote has comments and an order that mean something, and a tool that
256
- * reformats it on every grant is a tool people stop letting near it.
257
- */
258
- /**
259
- * Adds a granted glob to a role in the config text, with its provenance.
260
- *
261
- * Edits the text rather than re-emitting the file, because a config someone
262
- * wrote has comments and an order that mean something, and a tool that
263
- * reformats it on every grant is a tool people stop letting near it.
264
- *
265
- * The edit is bounded to the role's own section, and that is not tidiness. The
266
- * first version searched from the role header to the next `writes =` anywhere
267
- * in the file, so approving for a role that had no `writes` line wrote the
268
- * grant into the NEXT role — with a comment saying who it was for. A human
269
- * approved one thing and the file recorded another. In a permission tool that
270
- * is the worst possible bug, and it is not exotic: a role with only `keys`
271
- * declared is an ordinary config.
272
- */
273
301
  /**
274
302
  * Refuses a grant that `never_writes` would cancel, before anything is written.
275
303
  *
276
304
  * A request filed before the subtraction existed can still be in the queue.
277
- * Approving it would add the path to `writes` and change nothing — denyWrite
305
+ * Granting it would add the path to `writes` and change nothing — denyWrite
278
306
  * wins — so the person would believe they granted something the kernel still
279
- * refuses. The two keys contradict each other, and which one should win is the
307
+ * denies. The two settings contradict each other, and which one should win is the
280
308
  * decision; seisin will not make it by writing one of them silently.
281
309
  */
282
310
  export function refuseIfBarred(config, request) {
@@ -288,6 +316,18 @@ export function refuseIfBarred(config, request) {
288
316
  `${request.target} is under never_writes of ${request.role} ("${hit}"), which wins over ` +
289
317
  `writes. Granting it would change nothing. If the subtraction is wrong, remove that ` +
290
318
  `entry from [roles.${request.role}]; otherwise decline this request.`);
319
+ // A protected file stays denied whatever `writes` says: the kernel is given
320
+ // it as a deny, and explain answers the same. Granting `web/.vscode/**`
321
+ // used to add the line and change nothing — a grant that reads as done.
322
+ const guard = protectedBy(config, request.target, { role: request.role });
323
+ if (guard)
324
+ throw new Error(guard.family
325
+ ? `${request.target} is protected (${guard.why}). A grant cannot open it: ${request.role} ` +
326
+ `writes it only if the policy hands it the family, control_files = ["${guard.family}"] ` +
327
+ `under [roles.${request.role}], inside its own territory. Edit the policy if that is ` +
328
+ `what you mean; otherwise decline this request.`
329
+ : `${request.target} is protected (${guard.why}) and no role can ever be granted it. ` +
330
+ `Decline this request.`);
291
331
  // Only a repository the role does not write: its own `.git` is its own business.
292
332
  if (isGitMetadata(request.target) && !(role?.writes ?? []).some((g) => covers(g, request.target)))
293
333
  throw new Error(
@@ -307,20 +347,81 @@ export function refuseIfBarred(config, request) {
307
347
  * so a crash mid-write cannot leave a half-written, partly-privileged policy.
308
348
  *
309
349
  * `mutate(toml)` returns `{ toml, changed }`. Nothing is written when unchanged.
350
+ * `after(result)` runs once the file is written, still under the lock — for
351
+ * the queue line that records the decision, so a decision and its edit are
352
+ * one step to anyone else taking this lock.
310
353
  */
311
- export function editPolicy(config, mutate, { waitMs = 10000 } = {}) {
312
- return withLock(config.path, () => {
354
+ export function editPolicy(config, mutate, { waitMs = 10000, after = null } = {}) {
355
+ return withLock(policyLockBase(config), () => {
313
356
  const before = readFileSync(config.path, "utf8");
314
357
  const result = mutate(before);
315
- if (result.changed) {
316
- const tmp = `${config.path}.tmp-${process.pid}`;
317
- writeFileSync(tmp, result.toml);
318
- renameSync(tmp, config.path);
319
- }
358
+ if (result.changed) writePolicy(config, result.toml);
359
+ after?.(result);
320
360
  return result;
321
361
  }, { waitMs });
322
362
  }
323
363
 
364
+ /**
365
+ * Where the policy's lock lives: inside `.seisin/`, never beside the policy.
366
+ *
367
+ * It was `seisin.toml.lock`, next to the file. Any role that writes the repo
368
+ * root — every `--observe` run, any `writes = ["**"]` — could create that name
369
+ * holding a live pid and hold up every grant and every decline for as long as
370
+ * it liked. `.seisin/` is denied to every role (surface.js, parentInputs), so
371
+ * nothing confined can hold this one. withLock adds the `.lock`.
372
+ */
373
+ export function policyLockBase(config) {
374
+ const dir = join(config.root, STATE_DIR);
375
+ mkdirSync(dir, { recursive: true });
376
+ return join(dir, "policy");
377
+ }
378
+
379
+ /**
380
+ * Write the policy through a temp file that nobody could have prepared.
381
+ *
382
+ * The temp was `seisin.toml.tmp-<pid>` beside the policy: a predictable name,
383
+ * in a directory roles can write, opened by writeFileSync — which follows a
384
+ * symlink. A role could plant that name as a link into its own territory, and
385
+ * the next grant made the policy itself a symlink the role could rewrite.
386
+ * Now the temp sits in `.seisin/`, has a random name, and is opened with `wx`
387
+ * (O_CREAT|O_EXCL), which refuses anything already there, a link included.
388
+ *
389
+ * A `.seisin/` on another filesystem (a symlink a person made) cannot be
390
+ * renamed across; then the temp goes beside the policy, still random and still
391
+ * exclusive, which is what defeats a planted name.
392
+ */
393
+ function writePolicy(config, text) {
394
+ const name = `seisin.toml.tmp-${randomBytes(12).toString("hex")}`;
395
+ const stateTmp = join(config.root, STATE_DIR, name);
396
+ writeFileSync(stateTmp, text, { flag: "wx" });
397
+ try {
398
+ renameSync(stateTmp, config.path);
399
+ return;
400
+ } catch (e) {
401
+ try { unlinkSync(stateTmp); } catch {}
402
+ if (e.code !== "EXDEV") throw e;
403
+ }
404
+ const near = join(dirname(config.path), `.${name}`);
405
+ writeFileSync(near, text, { flag: "wx" });
406
+ try { renameSync(near, config.path); }
407
+ catch (e) { try { unlinkSync(near); } catch {} throw e; }
408
+ }
409
+
410
+ /**
411
+ * Adds a granted glob to a role in the config text, with its provenance.
412
+ *
413
+ * Edits the text rather than re-emitting the file, because a config someone
414
+ * wrote has comments and an order that mean something, and a tool that
415
+ * reformats it on every grant is a tool people stop letting near it.
416
+ *
417
+ * The edit is bounded to the role's own section, and that is not tidiness. The
418
+ * first version searched from the role header to the next `writes =` anywhere
419
+ * in the file, so granting for a role that had no `writes` line wrote the
420
+ * grant into the NEXT role — with a comment saying who it was for. A person
421
+ * granted one thing and the file recorded another. In a permission layer that
422
+ * is the worst possible bug, and it is not exotic: a role with only `keys`
423
+ * declared is an ordinary config.
424
+ */
324
425
  export function applyGrant(toml, request, note = "") {
325
426
  const field = request.action === "read" ? "keys" : "writes";
326
427
  const header = new RegExp(`^\\[roles\\.${escapeRe(request.role)}\\]\\s*$`, "m");
@@ -337,13 +438,13 @@ export function applyGrant(toml, request, note = "") {
337
438
  if (!open)
338
439
  throw new Error(
339
440
  `[roles.${request.role}] has no ${field} list to grant into. ` +
340
- `Add \`${field} = []\` to that section first — seisin will not write it into another role's block.`
441
+ `Add \`${field} = []\` to that section first — seisin will not write it into another role's table.`
341
442
  );
342
443
 
343
444
  // The subset has no escapes, so a value carrying a quote or a newline cannot
344
445
  // be written down at all. The strict parser would reject the result rather
345
446
  // than widen anything — but leaving someone with a config that no longer
346
- // loads, after they approved something, is its own kind of broken.
447
+ // loads, after they granted something, is its own kind of broken.
347
448
  tomlString(request.grant); // refuses what the format cannot hold
348
449
 
349
450
  const start = open.index + open[0].length;
@@ -355,9 +456,9 @@ export function applyGrant(toml, request, note = "") {
355
456
  *
356
457
  * The list used to be re-written from every quoted string between its
357
458
  * brackets — comments included. `"src/web/**", # was "src/**"` came back as
358
- * two grants, so approving anything also granted what a comment mentioned,
359
- * and the reason an approver typed (quoted in the provenance comment) became
360
- * a path on the next approval. Every earlier provenance comment was lost on
459
+ * two grants, so granting anything also granted what a comment mentioned,
460
+ * and the reason a person typed (quoted in the provenance comment) became
461
+ * a path on the next grant. Every earlier provenance comment was lost on
361
462
  * the way. Now the items are the strings outside comments, what is there is
362
463
  * left as it is, and the new entry goes last. The reason is set off with
363
464
  * «», never a double quote, so no older reader can take it for a value.
@@ -401,14 +502,14 @@ function scanList(text, start) {
401
502
  }
402
503
 
403
504
  /**
404
- * The approver's own words, made safe to put in a config file.
505
+ * The reason a person typed, made safe to put in a config file.
405
506
  *
406
507
  * A reason is free text typed by a person, and a person can be talked into
407
508
  * typing something — "paste this as the reason" is a plausible thing for an
408
509
  * agent to suggest. Newlines in a comment would end the comment, and what
409
510
  * follows is parsed as TOML. The strict parser rejects the result rather than
410
511
  * widening anything, so the worst case is a config that no longer loads, but a
411
- * permission file that can be broken by a sentence is still broken.
512
+ * policy that can be broken by a sentence is still broken.
412
513
  */
413
514
  export function cleanReason(s) {
414
515
  return String(s ?? "")
package/src/review.js CHANGED
@@ -9,13 +9,13 @@
9
9
  *
10
10
  * Three questions, and the second is the one nothing else can answer.
11
11
  *
12
- * 1. What is a role being stopped by, over and over?
12
+ * 1. What is a role being denied, over and over?
13
13
  * Forty denials on one directory is not an agent misbehaving. It is a
14
14
  * policy that is wrong, and today it reads as forty tidy amber lines
15
15
  * nobody adds up.
16
16
  *
17
17
  * 2. What was granted and never used?
18
- * Every permission file only ever grows, everywhere, and the reason is
18
+ * Every allowlist only ever grows, everywhere, and the reason is
19
19
  * always the same: nobody can prove a line is dead, so the safe move is to
20
20
  * leave it. Here the log can prove it. This is the only direction that
21
21
  * makes a policy smaller.
@@ -29,6 +29,7 @@
29
29
  */
30
30
  import { read, logPath } from "./log.js";
31
31
  import { covers, standingOf } from "./owners.js";
32
+ import { kindsOf } from "./kinds.js";
32
33
 
33
34
  /**
34
35
  * One reading of the log against the policy.
@@ -103,11 +104,11 @@ function findings(config, entries, minDenials) {
103
104
  * that refusal is the whole of the second finding.
104
105
  *
105
106
  * "Never used" is read off `allowed` lines, and **only the hook writes those**.
106
- * The kernel reports what it refused; it has nothing to say about what went
107
+ * The kernel reports what it denied; it has nothing to say about what went
107
108
  * through. So a log with no hook behind it holds denials and nothing else,
108
109
  * every grant falls through as unused, and this section reports that the whole
109
110
  * policy is dead — under a heading calling itself the only evidence anyone
110
- * will ever have for making a permission file smaller.
111
+ * will ever have for making a policy smaller.
111
112
  *
112
113
  * Measured on a real repository with the hook not installed: every write
113
114
  * permission of every role listed as never used, while those roles were
@@ -128,15 +129,21 @@ function findings(config, entries, minDenials) {
128
129
  const knowable = entries.some((e) => e.verdict === "allowed" && !e.disputed);
129
130
 
130
131
  // Only writes. A key that is declared and not read is ordinary — most roles
131
- // hold a credential for the one turn a month that needs it — but a folder a
132
+ // hold a credential for the one run a month that needs it — but a folder a
132
133
  // role owns and has never written to in the whole log is a line nobody would
133
134
  // miss, and the only evidence anyone will ever have for removing it.
135
+ // Straight to the line's own role, and each (role, target) once: walking
136
+ // every role's globs for every line to keep only one role's was the cost.
134
137
  const used = new Set();
138
+ const seen = new Set();
135
139
  for (const e of entries) {
136
140
  if (e.verdict !== "allowed" || e.disputed || e.action !== "write" || !e.target) continue;
137
- for (const role of Object.values(config.roles))
138
- for (const glob of role.writes)
139
- if (role.name === e.role && covers(glob, e.target)) used.add(`${role.name} ${glob}`);
141
+ if (!Object.prototype.hasOwnProperty.call(config.roles, e.role)) continue;
142
+ const k = `${e.role}\u0000${e.target}`;
143
+ if (seen.has(k)) continue;
144
+ seen.add(k);
145
+ for (const glob of config.roles[e.role].writes)
146
+ if (covers(glob, e.target)) used.add(`${e.role} ${glob}`);
140
147
  }
141
148
 
142
149
  const unused = [];
@@ -153,13 +160,21 @@ function findings(config, entries, minDenials) {
153
160
  // the repository is outside every territory: none of them is a hole in the
154
161
  // map. This used to skip keys and ports and count the rest, protected ones
155
162
  // included, while the console counted all four — one word, two numbers.
156
- const unowned = new Map();
163
+ //
164
+ // Each row also says what kind of thing it is (kinds.js), by directory and
165
+ // kind together: `.git` holding a lock and a folder holding a source file
166
+ // are different answers, and only `territory` is a hole somebody has to fill.
157
167
  const standOf = standingOf(config);
158
- for (const e of entries) {
159
- if (!e.target) continue;
160
- if (standOf(e).kind !== "unowned") continue;
161
- const dir = dirOf(e.target);
162
- unowned.set(dir, (unowned.get(dir) ?? 0) + 1);
168
+ const loose = entries.filter((e) => e.target && standOf(e).kind === "unowned");
169
+ const natures = kindsOf(loose.map((e) => e.target), { keyDirs: config.keyDirs ?? [] });
170
+ const unowned = new Map();
171
+ for (const e of loose) {
172
+ const { kind, hint } = natures.get(e.target);
173
+ const where = dirOf(e.target);
174
+ const k = `${kind}\u0000${where}`;
175
+ const row = unowned.get(k) ?? { where, times: 0, kind, hint };
176
+ row.times++;
177
+ unowned.set(k, row);
163
178
  }
164
179
 
165
180
  return {
@@ -168,9 +183,7 @@ function findings(config, entries, minDenials) {
168
183
  connects,
169
184
  unused,
170
185
  unusedKnowable: knowable,
171
- unowned: [...unowned.entries()]
172
- .map(([where, times]) => ({ where, times }))
173
- .sort((a, b) => b.times - a.times),
186
+ unowned: [...unowned.values()].sort((a, b) => b.times - a.times),
174
187
  };
175
188
  }
176
189
 
package/src/rundir.js CHANGED
@@ -25,6 +25,7 @@ import { tmpdir } from "node:os";
25
25
  import { randomUUID } from "node:crypto";
26
26
  import { realOrSelf } from "./grants.js";
27
27
  import { channelName } from "./spool.js";
28
+ import { alive } from "./log.js";
28
29
 
29
30
  export const RUNS_NAME = "snr";
30
31
 
@@ -48,11 +49,6 @@ export function runsRoot(base = tmpdir()) {
48
49
  return root;
49
50
  }
50
51
 
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
52
  /**
57
53
  * Removes the directories of runs whose process is gone.
58
54
  *
@@ -66,6 +62,8 @@ function sweep(root) {
66
62
  const dir = join(root, name);
67
63
  let pid;
68
64
  try { pid = Number(readFileSync(join(dir, "pid"), "utf8")); } catch { continue; }
65
+ // Only a process the kernel says is gone: a directory kept one sweep too
66
+ // long costs disk, one removed early takes a live run's socket and keys.
69
67
  if (pid && !alive(pid)) rmSync(dir, { recursive: true, force: true });
70
68
  }
71
69
  }
@@ -87,7 +85,7 @@ export function openRun({ base = tmpdir(), id = randomUUID() } = {}) {
87
85
  dir,
88
86
  root,
89
87
  // A socket on macOS, a FIFO on Linux, where the runtime blocks every unix
90
- // socket inside the box (spool.js).
88
+ // socket inside the sandbox (spool.js).
91
89
  sock: join(dir, channelName()),
92
90
  keys: join(dir, "keys"),
93
91
  /**