seisin 0.4.0 → 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 +151 -67
  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 -30
  8. package/src/commands/log.js +55 -10
  9. package/src/commands/requests.js +124 -39
  10. package/src/commands/review.js +41 -16
  11. package/src/commands/run.js +174 -39
  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 +261 -27
  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 +24 -16
  27. package/src/keys.js +13 -11
  28. package/src/kinds.js +207 -0
  29. package/src/log.js +312 -55
  30. package/src/mcp.js +96 -43
  31. package/src/notify.js +15 -10
  32. package/src/owners.js +323 -41
  33. package/src/paths.js +37 -1
  34. package/src/redact.js +19 -5
  35. package/src/render.js +81 -12
  36. package/src/requests.js +250 -72
  37. package/src/review.js +42 -21
  38. package/src/rundir.js +4 -6
  39. package/src/scan.js +177 -19
  40. package/src/serve.js +395 -205
  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 +203 -42
  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 +1366 -529
package/src/views.js ADDED
@@ -0,0 +1,289 @@
1
+ /**
2
+ * What the console and the MCP server both say about the log, computed once.
3
+ *
4
+ * The two readers are different — a person opens the console, an agent calls
5
+ * the MCP — and they must not disagree about the same file. This held the
6
+ * shared arithmetic inside serve.js, so the MCP server imported node:http to
7
+ * group denials. Nothing here opens a socket or writes a file.
8
+ */
9
+ import { createHash } from "node:crypto";
10
+ import { readFileSync } from "node:fs";
11
+ import { explain, standingOf } from "./owners.js";
12
+ import { kindsOf, KINDS } from "./kinds.js";
13
+ import { walls } from "./walls.js";
14
+ import { pending, requestsPath, markStale } from "./requests.js";
15
+
16
+ /**
17
+ * An ISO date from what a person or an agent typed, or null for no filter.
18
+ * Anything that does not parse is ignored rather than refused: a bad filter
19
+ * should show everything, not an error.
20
+ */
21
+ export function parseSince(raw) {
22
+ const t = raw ? Date.parse(raw) : NaN;
23
+ return Number.isNaN(t) ? null : new Date(t).toISOString();
24
+ }
25
+
26
+ /**
27
+ * `explain`, remembered while the policy file stays the same.
28
+ *
29
+ * Causes and walls ask the policy about every refused path, and a real log has
30
+ * two thousand of them: ~0.75 ms each, most of the console's two-second poll.
31
+ * The answer depends on the policy text, so that is the key — and a little on
32
+ * the disk (on Linux a control file is protected only once it exists), so the
33
+ * memory is dropped after a minute too, as standingOf does. A config that was
34
+ * not read from a file gets a memory for this call only.
35
+ */
36
+ const VERDICT_TTL = 60_000;
37
+ let verdictMemo = { key: null, at: 0, map: new Map() };
38
+
39
+ export function verdicts(cfg, { now = Date.now() } = {}) {
40
+ let key = null;
41
+ try {
42
+ if (cfg.path) key = `${cfg.path}\u0000${createHash("sha256").update(readFileSync(cfg.path)).digest("hex")}`;
43
+ } catch {}
44
+ let map;
45
+ if (key === null) map = new Map();
46
+ else {
47
+ if (verdictMemo.key !== key || now - verdictMemo.at > VERDICT_TTL) verdictMemo = { key, at: now, map: new Map() };
48
+ map = verdictMemo.map;
49
+ }
50
+ return (c, role, action, target) => {
51
+ const k = `${role}\u0000${action}\u0000${target}`;
52
+ let v = map.get(k);
53
+ if (!v) map.set(k, (v = explain(c, role, action, target)));
54
+ return v;
55
+ };
56
+ }
57
+
58
+ /**
59
+ * Today's denials, grouped by what was denied rather than by who asked.
60
+ *
61
+ * Called `causes` and not `friction`: `seisin review` already has a `friction`
62
+ * that counts something narrower — repeated denials that are not about keys —
63
+ * and two screens of one product reporting different numbers under one label
64
+ * is the kind of thing nobody notices until they are compared.
65
+ *
66
+ * The console used to show them per role, which is the same list a hundred
67
+ * times over: measured on one deployment, 1,080 of 1,422 refusals were a
68
+ * single lock file across six repositories, and per-role that reads as six
69
+ * hundred identical rows instead of one sentence. Grouped, the shape of the
70
+ * day is visible at a glance and it is almost never a territory dispute.
71
+ *
72
+ * `stillRefused` is recomputed against the policy as it stands, not read out
73
+ * of the log — a cause that has been granted since is history, not friction,
74
+ * and leaving it on the page sends somebody to fix what is already fixed.
75
+ */
76
+ export function causesOf(cfg, entries, { ask = explain, limit = 12 } = {}) {
77
+ const by = new Map();
78
+ for (const e of entries) {
79
+ if (e.verdict !== "denied" || !e.target || !e.action) continue;
80
+ const k = `${e.action}\u0000${e.target}`;
81
+ const g = by.get(k) ?? { action: e.action, target: e.target, kind: e.kind, times: 0, roles: new Set(), ownersThen: e.owners ?? [] };
82
+ g.times++;
83
+ g.roles.add(e.role);
84
+ by.set(k, g);
85
+ }
86
+ const total = [...by.values()].reduce((n, g) => n + g.times, 0);
87
+
88
+ /**
89
+ * The same grouping again, one level coarser: by the NAME at the end of the
90
+ * path rather than the path.
91
+ *
92
+ * Grouping by path alone gets the most important reading backwards. Measured
93
+ * here: six of the top seven causes were `.git/index.lock` in six different
94
+ * repositories, no single one above 17% — so "no cause dominates" is what the
95
+ * arithmetic says and the opposite of what is true. Three quarters of the
96
+ * window was one *kind* of thing, and that is a tooling problem with a
97
+ * mechanical fix, not a territory question anybody needs to rule on.
98
+ *
99
+ * By last segment, and nothing cleverer. A regex that recognised lock files,
100
+ * caches and build outputs would be a list of guesses about other people's
101
+ * toolchains that quietly goes stale; a repeated filename is a fact about
102
+ * this log.
103
+ */
104
+ const fams = new Map();
105
+ for (const g of by.values()) {
106
+ const name = g.target.split("/").filter(Boolean).pop() ?? g.target;
107
+ const f = fams.get(name) ?? { name, times: 0, paths: 0, where: [], roles: new Set() };
108
+ f.times += g.times;
109
+ f.paths++;
110
+ // The places, so the console can show what a name is made of instead of
111
+ // asserting a percentage the reader has to take on faith.
112
+ f.where.push({ target: g.target, times: g.times });
113
+ for (const r of g.roles) f.roles.add(r);
114
+ fams.set(name, f);
115
+ }
116
+ const families = [...fams.values()]
117
+ .sort((a, b) => b.times - a.times)
118
+ .map((f) => ({
119
+ name: f.name, times: f.times, paths: f.paths,
120
+ share: total ? f.times / total : 0,
121
+ roles: [...f.roles].sort(),
122
+ where: f.where.sort((a, b) => b.times - a.times).slice(0, 20),
123
+ }));
124
+
125
+ /**
126
+ * What a `grep` over the log cannot tell you: where each refused path stands
127
+ * against the policy as it is now.
128
+ *
129
+ * The headline used to be "74% of it is one name", and a field review
130
+ * applied this project's own test to it: *did the number tell you something
131
+ * you did not know?* For somebody who reads the raw log, no. So the console
132
+ * added how many causes are on paths no role owns — a decision rather than a
133
+ * number — and then computed it from the `owners` each log line carried,
134
+ * which is the one thing the log already had. It said the policy and read
135
+ * the past: every grant made since left its refusals counted as "nobody's",
136
+ * and protected surfaces, ports and paths outside the repository, which
137
+ * nobody can ever own, were counted as waiting for somebody to claim them.
138
+ *
139
+ * Now every cause is classified by `standing` (owners.js), and the four
140
+ * piles are reported in paths and in refusals. `unowned` keeps its name and
141
+ * now means what the page always said it meant. The owners the log recorded
142
+ * are kept on each cause as `ownersThen`: evidence of what was true, not
143
+ * authority over what is.
144
+ */
145
+ const standOf = standingOf(cfg);
146
+ const piles = Object.fromEntries(["unowned", "owned", "protected", "outside"].map((k) => [k, { paths: 0, denials: 0 }]));
147
+ for (const g of by.values()) {
148
+ const s = standOf({ kind: g.kind, target: g.target });
149
+ g.standing = s;
150
+ piles[s.kind].paths++;
151
+ piles[s.kind].denials += g.times;
152
+ }
153
+
154
+ /**
155
+ * And for the unowned pile, what kind of thing each path is (kinds.js).
156
+ *
157
+ * Suggesting an owner was measured first and was right about one time in
158
+ * twenty; the rest widened a role over git's lock files, a test's scratch
159
+ * directories and a credential. What the reader needs is the kind and the
160
+ * move that fits it, and `territory` — the part that really is a decision —
161
+ * is what is left. Over every unowned cause, not the twelve shown: one rule
162
+ * needs the whole set (the same name made unique per run in many places).
163
+ */
164
+ const unowned = [...by.values()].filter((g) => g.standing.kind === "unowned");
165
+ const natures = kindsOf(unowned.map((g) => g.target), { keyDirs: cfg.keyDirs ?? [] });
166
+ piles.unowned.kinds = Object.fromEntries(Object.keys(KINDS).map((k) => [k, { paths: 0, denials: 0 }]));
167
+ for (const g of unowned) {
168
+ g.nature = natures.get(g.target);
169
+ piles.unowned.kinds[g.nature.kind].paths++;
170
+ piles.unowned.kinds[g.nature.kind].denials += g.times;
171
+ }
172
+
173
+ /**
174
+ * What each refusal was about — a file, a key, a network destination or an
175
+ * MCP tool — counted over every cause, not the ones listed. The console
176
+ * shows keys, network and tools apart from files: "no role owns this" is a
177
+ * sentence about paths, and said of a port or a tool it is simply wrong.
178
+ */
179
+ const about = Object.fromEntries(ABOUT.map((k) => [k, { paths: 0, denials: 0 }]));
180
+ for (const g of by.values()) {
181
+ const a = aboutOf(g.kind);
182
+ about[a].paths++;
183
+ about[a].denials += g.times;
184
+ }
185
+
186
+ return {
187
+ total,
188
+ unowned: piles.unowned.paths,
189
+ about,
190
+ standing: piles,
191
+ // The real number of distinct causes, not the length of the list below.
192
+ // The page says "over N distinct paths" and the list is capped at twelve,
193
+ // so taking N from the list reported the cap as if it were the count —
194
+ // a wrong number stated confidently, which is worse than no number.
195
+ distinct: by.size,
196
+ families: families.slice(0, 8),
197
+ causes: [...by.values()]
198
+ .sort((a, b) => b.times - a.times)
199
+ .slice(0, limit)
200
+ .map((g) => ({
201
+ about: aboutOf(g.kind),
202
+ action: g.action,
203
+ target: g.target,
204
+ times: g.times,
205
+ share: total ? g.times / total : 0,
206
+ roles: [...g.roles].sort(),
207
+ standing: g.standing.kind,
208
+ ...(g.standing.why && { why: g.standing.why }),
209
+ // Only on an unowned cause: what kind of thing it is, and what to do.
210
+ ...(g.nature && { kind: g.nature.kind, hint: g.nature.hint }),
211
+ owners: g.standing.owners,
212
+ ownersThen: g.ownersThen,
213
+ // How many of the roles that hit this would still hit it. `some` and
214
+ // not `every`: two roles out of three still blocked is still friction,
215
+ // and requiring all of them would quietly retire a live cause the day
216
+ // one role got a grant.
217
+ stillRefused: [...g.roles].filter((r) => cfg.roles[r] && !ask(cfg, r, g.action, g.target).allowed).length,
218
+ })),
219
+ };
220
+ }
221
+
222
+ /** What a log line was about. Lines from before the field existed are files. */
223
+ const ABOUT = ["file", "key", "network", "tool"];
224
+ function aboutOf(kind) {
225
+ return ABOUT.includes(kind) ? kind : "file";
226
+ }
227
+
228
+ /**
229
+ * Walls as the console shows them: each with what it was about and, when no
230
+ * role owns the path, the same kind and hint the Denied screen gives it.
231
+ *
232
+ * Without this the two screens contradicted each other about one path: Walls
233
+ * said `.env.local` "has no owner — no role can write it until one claims it",
234
+ * an invitation to give it one, while Denied said it is a credential and never
235
+ * to grant a write to it. The hint is kinds.js's, so it is the same sentence.
236
+ *
237
+ * A copy: the MCP server hands out walls() as it is, and this adds to it.
238
+ */
239
+ export function describeWalls(cfg, byRole, entries) {
240
+ const kindOfLine = new Map();
241
+ for (const e of entries) if (e.verdict === "denied" && e.target) kindOfLine.set(`${e.role}\u0000${e.action}\u0000${e.target}`, e.kind);
242
+ const standOf = standingOf(cfg);
243
+ const out = {};
244
+ const unowned = [];
245
+ for (const [role, list] of Object.entries(byRole)) {
246
+ out[role] = list.map((w) => {
247
+ const about = aboutOf(kindOfLine.get(`${role}\u0000${w.action}\u0000${w.target}`));
248
+ const x = { ...w, about, retried: w.times - 1 };
249
+ // Every file wall, owned or not: a protected path can also lie inside
250
+ // someone's territory, and "owned" must not hide "never granted". The
251
+ // owners are today's, as Denied's are.
252
+ if (about === "file") {
253
+ const s = standOf({ kind: "file", target: w.target });
254
+ x.standing = s.kind;
255
+ x.owners = s.owners ?? [];
256
+ if (s.why) x.why = s.why;
257
+ if (s.kind === "unowned") unowned.push(x);
258
+ }
259
+ return x;
260
+ });
261
+ }
262
+ const natures = kindsOf(unowned.map((w) => w.target), { keyDirs: cfg.keyDirs ?? [] });
263
+ for (const w of unowned) Object.assign(w, natures.get(w.target));
264
+ return out;
265
+ }
266
+
267
+ /**
268
+ * Every role's walls, from one read of the log.
269
+ *
270
+ * `entries` are log lines of any verdict (walls keeps the denied ones); only
271
+ * roles with at least one wall appear. The MCP read the log once per role — 32
272
+ * reads on a real policy — to answer the same question.
273
+ */
274
+ export function wallsByRole(cfg, entries, { ask } = {}) {
275
+ const denied = entries.filter((e) => e.verdict === "denied");
276
+ return Object.fromEntries(
277
+ Object.keys(cfg.roles)
278
+ .map((r) => [r, walls(cfg, r, { entries: denied, ...(ask && { ask }) })])
279
+ .filter(([, w]) => w.length));
280
+ }
281
+
282
+ /**
283
+ * The request queue as a person should read it: pending, with the requests
284
+ * the role has stopped asking for marked. `history` is the whole log, every
285
+ * verdict — a run that was only allowed things is still a run.
286
+ */
287
+ export function queue(root, history, keyDirs = null) {
288
+ return markStale(pending(requestsPath(root), { keyDirs }), history);
289
+ }
package/src/violations.js CHANGED
@@ -98,7 +98,7 @@ export function actionOf(operation) {
98
98
  * - `remote:*:<port>` — a TCP dial the profile refused. The kernel does not
99
99
  * name the host, so the target is the port and nothing more.
100
100
  * - a unix socket path — except under /var/run, where the system daemons
101
- * live: every DNS lookup inside the box is a refused connect to
101
+ * live: every DNS lookup inside the sandbox is a refused connect to
102
102
  * mDNSResponder, and recording those would bury everything else.
103
103
  * - anything else (an empty detail, which the kernel also writes) — dropped.
104
104
  */
@@ -219,8 +219,8 @@ export function isOurs(tagged, argv) {
219
219
  * reimplementing the runtime's shell quoting and inheriting its bugs; a process
220
220
  * tree is a fact the OS already holds.
221
221
  *
222
- * One `ps` per unseen pid, and only until the session suffix is learned — after
223
- * that the suffix alone is exact, because it is generated once per `srt`
222
+ * At most one `ps` per TREE_MS, and only until the session suffix is learned —
223
+ * after that the suffix alone is exact, because it is generated once per `srt`
224
224
  * process. In practice that is one `ps` per run.
225
225
  */
226
226
  export function descends(pid, root, snapshot) {
@@ -235,14 +235,25 @@ export function descends(pid, root, snapshot) {
235
235
  return false;
236
236
  }
237
237
 
238
- /** pid → ppid for every process this user can see. */
238
+ /**
239
+ * pid → ppid for every process this user can see, and in `tree.roots` the
240
+ * pids that are a sandbox run's root: an `srt`, or a `seisin run`.
241
+ *
242
+ * The roots are what makes "not ours" provable. A process outside our tree is
243
+ * someone else's only if its ancestry reaches another run; one whose ancestry
244
+ * ends at init was orphaned — `cmd &`, a build daemon, git's fsmonitor — and
245
+ * may well be ours, reparented when the shell that started it exited.
246
+ */
239
247
  export function processTree(run = execFileSync) {
240
248
  const tree = new Map();
249
+ tree.roots = new Set();
241
250
  try {
242
- const out = run("/bin/ps", ["-Ao", "pid=,ppid="], { encoding: "utf8" });
251
+ const out = run("/bin/ps", ["-Ao", "pid=,ppid=,args="], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
243
252
  for (const line of out.split("\n")) {
244
- const m = line.trim().match(/^(\d+)\s+(\d+)$/);
245
- if (m) tree.set(Number(m[1]), Number(m[2]));
253
+ const m = line.trim().match(/^(\d+)\s+(\d+)(?:\s+(.*))?$/);
254
+ if (!m) continue;
255
+ tree.set(Number(m[1]), Number(m[2]));
256
+ if (m[3] && RUN_ROOT.test(m[3])) tree.roots.add(Number(m[1]));
246
257
  }
247
258
  } catch {
248
259
  // No tree means nothing can be attributed, which the caller handles by
@@ -251,6 +262,25 @@ export function processTree(run = execFileSync) {
251
262
  return tree;
252
263
  }
253
264
 
265
+ /** The command line of an `srt` (however it was installed) or of `seisin run`. */
266
+ const RUN_ROOT = /(?:^|[\s/])srt(?:\s|$)|sandbox-runtime\/dist\/cli\.js(?:\s|$)|(?:^|[\s/])(?:seisin|cli\.js)\s+run\s/;
267
+
268
+ /**
269
+ * Does `pid` descend from a run root other than `root`?
270
+ *
271
+ * A table without command lines (`roots` absent) cannot tell an orphan from a
272
+ * stranger, and answers as before: alive and not ours is someone else's.
273
+ */
274
+ export function underAnotherRun(pid, root, snapshot) {
275
+ if (!snapshot.roots) return true;
276
+ const seen = new Set();
277
+ for (let at = pid; at && at !== 1 && !seen.has(at); at = snapshot.get(at)) {
278
+ if (at !== root && snapshot.roots.has(at)) return true;
279
+ seen.add(at);
280
+ }
281
+ return false;
282
+ }
283
+
254
284
  /**
255
285
  * Did this refusal reach for content, or just walk past the door?
256
286
  *
@@ -336,7 +366,7 @@ export function scopeOf(settings, root) {
336
366
  * without `log` looks like — and the caller says so once rather than silently
337
367
  * logging less than it used to.
338
368
  */
339
- export function watchDenials(onDeny, { pid = null, argv = null, platform = process.platform, spawnFn = spawn, treeFn = processTree } = {}) {
369
+ export function watchDenials(onDeny, { pid = null, argv = null, platform = process.platform, spawnFn = spawn, treeFn = processTree, now = () => performance.now() } = {}) {
340
370
  const stats = { attributed: 0, foreign: 0, unattributed: 0 };
341
371
 
342
372
  /**
@@ -370,9 +400,29 @@ export function watchDenials(onDeny, { pid = null, argv = null, platform = proce
370
400
 
371
401
  let suffix = null; // learned once, exact from then on
372
402
  let tree = null; // refreshed only while still learning
403
+ let treeAt = -Infinity; // when `tree` was taken
373
404
  let root = pid; // the srt pid; not known until after spawn
374
405
  const mine = new Set(); // pids already proven to be ours
375
406
  const held = []; // denials seen before they could be attributed
407
+ const foreignSuffixes = new Set(); // other sandboxes, proven while they were alive
408
+ let recheck = null; // a deferred look, when a snapshot was refused
409
+
410
+ /**
411
+ * One `ps` per TREE_MS at most, and a later look instead of a skipped one.
412
+ *
413
+ * `ps` is synchronous and it was taken for every denial from another sandbox
414
+ * while this run had not learned its own suffix — which, for a run that is
415
+ * never refused anything, is the whole run. Several cells at once is exactly
416
+ * that: 83 ms of blocked event loop per foreign line, measured. A line whose
417
+ * process may be younger than the last snapshot is not decided on it; it is
418
+ * held, and looked at again once the window has passed.
419
+ */
420
+ const snapshot = () => { tree = treeFn(); treeAt = now(); };
421
+ const later = () => {
422
+ if (recheck) return;
423
+ recheck = setTimeout(() => { recheck = null; if (!suffix && held.length) revisit(); }, TREE_MS);
424
+ recheck.unref?.();
425
+ };
376
426
 
377
427
  /**
378
428
  * Two anchors, and either one is enough.
@@ -396,12 +446,27 @@ export function watchDenials(onDeny, { pid = null, argv = null, platform = proce
396
446
  }
397
447
  if (root === null) return false; // nothing to descend from yet
398
448
  if (mine.has(d.pid)) return true;
399
- if (tree === null) tree = treeFn();
449
+ // A suffix already proven foreign needs no `ps`: the suffix is per `srt`.
450
+ if (d.suffix && foreignSuffixes.has(d.suffix)) return false;
451
+ if (tree === null) snapshot();
400
452
  if (!descends(d.pid, root, tree)) {
401
- // A pid we have never seen may simply be younger than the snapshot.
402
- // Refresh once, then believe the answer.
403
- tree = treeFn();
404
- if (!descends(d.pid, root, tree)) return false;
453
+ // A pid the snapshot does not have may simply be younger than it.
454
+ // Refresh once, then believe the answer — unless the last refresh was
455
+ // moments ago, and then look again later rather than refresh now. A pid
456
+ // it does have is answered: its parents do not change into ours.
457
+ if (treeAt < d.seen && !tree.has(d.pid)) {
458
+ if (now() - treeAt < TREE_MS) { later(); return LATER; }
459
+ snapshot();
460
+ }
461
+ if (!descends(d.pid, root, tree)) {
462
+ // Alive, not ours by either anchor, and under another run — the tag
463
+ // did not match above. Every later line from that `srt` is someone
464
+ // else's without asking. Alive but under no run at all is an orphan
465
+ // (`cmd &`, a daemon): possibly ours, so its suffix stays undecided
466
+ // rather than turning this run's own later lines into strangers.
467
+ if (d.suffix && tree.has(d.pid) && underAnotherRun(d.pid, root, tree)) foreignSuffixes.add(d.suffix);
468
+ return false;
469
+ }
405
470
  }
406
471
  mine.add(d.pid);
407
472
  // The first proven denial hands us the suffix for every later one.
@@ -432,7 +497,8 @@ export function watchDenials(onDeny, { pid = null, argv = null, platform = proce
432
497
  };
433
498
 
434
499
  const consider = (d) => {
435
- if (attribute(d)) {
500
+ const verdict = attribute(d);
501
+ if (verdict === true) {
436
502
  stats.attributed++;
437
503
  onDeny(d);
438
504
  // Learning the suffix makes every held line decidable. Without this,
@@ -445,7 +511,9 @@ export function watchDenials(onDeny, { pid = null, argv = null, platform = proce
445
511
  // child whose pid anchors attribution, or the process died before `ps`
446
512
  // could see it. Either way a foreign sandbox and one of ours look the same
447
513
  // from here, so hold the line rather than credit or discard it.
448
- if (!suffix && d.suffix) {
514
+ // A line put off to the next snapshot is held with or without a suffix:
515
+ // the look it is waiting for is the only thing that can decide it.
516
+ if (verdict === LATER || (!suffix && d.suffix && !foreignSuffixes.has(d.suffix))) {
449
517
  stats.unattributed++;
450
518
  held.push(d);
451
519
  // Bounded, because the common case on a busy machine is a run that is
@@ -473,7 +541,13 @@ export function watchDenials(onDeny, { pid = null, argv = null, platform = proce
473
541
  child = spawnFn("/usr/bin/log", ["stream", "--predicate", PREDICATE, "--style", "compact"],
474
542
  { stdio: ["ignore", "pipe", "ignore"] });
475
543
  } catch (e) {
476
- return { available: false, reason: `could not start the log stream: ${e.message}`, stats, close() {} };
544
+ // The same shape as the unavailable watcher above, for the same reason:
545
+ // `run` calls attributeTo() and awaits close() unconditionally.
546
+ return {
547
+ available: false, reason: `could not start the log stream: ${e.message}`, stats,
548
+ attributeTo() {},
549
+ close: () => Promise.resolve(stats),
550
+ };
477
551
  }
478
552
 
479
553
  let buf = "";
@@ -486,6 +560,7 @@ export function watchDenials(onDeny, { pid = null, argv = null, platform = proce
486
560
  const chunk = buf.slice(0, cut + 4);
487
561
  buf = buf.slice(cut + 4);
488
562
  const d = parseChunk(chunk);
563
+ if (d) d.seen = now();
489
564
  // Deduplicated here and not in consider(), because a held denial goes
490
565
  // through consider() a second time when it is finally attributed. Doing
491
566
  // this there would drop every line that arrived before the child had a
@@ -528,6 +603,7 @@ export function watchDenials(onDeny, { pid = null, argv = null, platform = proce
528
603
  * nothing pending — costs one tick rather than the full window.
529
604
  */
530
605
  close({ drain = 0 } = {}) {
606
+ clearTimeout(recheck);
531
607
  const stop = () => { try { child.kill("SIGTERM"); } catch { /* already gone */ } };
532
608
  if (drain <= 0) { stop(); return Promise.resolve(stats); }
533
609
  return new Promise((resolve) => {
@@ -551,6 +627,18 @@ export function watchDenials(onDeny, { pid = null, argv = null, platform = proce
551
627
  */
552
628
  const QUIET_MS = 60;
553
629
 
630
+ /**
631
+ * The shortest time between two `ps` snapshots while attribution is learning.
632
+ *
633
+ * Long enough that a burst of foreign denials costs one snapshot, not one
634
+ * each; short enough that a line held for a younger process is decided before
635
+ * anyone reads the log.
636
+ */
637
+ const TREE_MS = 250;
638
+
639
+ /** What `attribute` answers for a line it has put off to the next snapshot. */
640
+ const LATER = "later";
641
+
554
642
  /**
555
643
  * How many undecided denials to keep while waiting to learn our own suffix.
556
644
  *
package/src/walls.js CHANGED
@@ -4,8 +4,8 @@
4
4
  * seisin answers *whose is this* at the moment of the refusal, once, in the
5
5
  * middle of a turn, and then the sentence is gone. Nothing carries it forward,
6
6
  * so the agent tries again — and the measurement that made this module exist
7
- * says how often: of 345 denials on a real team, **88 (25%) were a repeat of
8
- * something that same role had already been refused**. One role spent 37 calls
7
+ * says how often: of 345 denials in a multi-repo workspace, **88 (25%) were a repeat of
8
+ * something that same role had already been denied**. One role spent 37 calls
9
9
  * on two walls, hitting the same one nineteen times.
10
10
  *
11
11
  * A block that repeats nineteen times is not a boundary working. It is a
@@ -33,8 +33,7 @@
33
33
  * A window is still accepted, and still useful for a different reason: a
34
34
  * refusal from three weeks ago is noise even if it is still refused today.
35
35
  */
36
- import { openSync, readSync, closeSync, statSync } from "node:fs";
37
- import { read } from "./log.js";
36
+ import { read, readEntries } from "./log.js";
38
37
  import { explain } from "./owners.js";
39
38
  import { STALE_RUNS, RUN_GAP_MS, runsAfter } from "./requests.js";
40
39
 
@@ -49,7 +48,7 @@ export const MIN_HITS = 2;
49
48
  * different repositories are two walls, and merging them hides which one was
50
49
  * hit.
51
50
  */
52
- export function walls(config, role, { file, entries = null, min = MIN_HITS, since = null, limit = 6, fresh = false } = {}) {
51
+ export function walls(config, role, { file, entries = null, min = MIN_HITS, since = null, limit = 6, fresh = false, ask = explain } = {}) {
53
52
  // `entries`: the log already read, for a caller asking about every role at
54
53
  // once (the console asks for 32 on a real policy) — one read instead of 32.
55
54
  const denied = entries
@@ -70,7 +69,9 @@ export function walls(config, role, { file, entries = null, min = MIN_HITS, sinc
70
69
  if (w.times < min) continue;
71
70
  // The policy as it stands, not as it stood. A granted wall is not a wall,
72
71
  // and the whole point of saying this to an agent is that it is true now.
73
- const verdict = explain(config, role, w.action, w.target);
72
+ // `ask` is explain, or a remembered one (views.js) for a caller asking
73
+ // about every role at once.
74
+ const verdict = ask(config, role, w.action, w.target);
74
75
  if (verdict.allowed) continue;
75
76
  out.push({ ...w, reason: verdict.reason, owners: verdict.owners });
76
77
  }
@@ -89,17 +90,18 @@ export function walls(config, role, { file, entries = null, min = MIN_HITS, sinc
89
90
  /**
90
91
  * How much of the log `timesHit` will look at.
91
92
  *
92
- * The log is append-only and never rotates — that is the storage design and it
93
- * is the right one — so "read the file" is O(everything you have ever done),
94
- * on a path that runs inside the hook. Measured on a 372 KB log: 3 ms per
95
- * call, which is nothing, and 300 ms at 37 MB, which is not, on every refusal
96
- * of every turn forever.
93
+ * The log is append-only and a segment only rotates at 8 MiB, so "read the
94
+ * file" is O(everything since the last rotation), on a path that runs inside
95
+ * the hook. Measured on a 372 KB log: 3 ms per
96
+ * call, which is nothing, and 300 ms at 37 MB, which is not, on every denial
97
+ * of every session forever.
97
98
  *
98
99
  * So it reads the tail. That makes the count **recent repetition** rather than
99
100
  * lifetime repetition, and recent is the one the sentence is about anyway: an
100
101
  * agent being told "you have hit this three times" means this session, not
101
102
  * last month. 4 MB is thousands of entries — far more than a turn produces,
102
- * and bounded.
103
+ * and bounded. Right after a rotation the tail is short, and so is the count:
104
+ * the current segment only, on purpose.
103
105
  */
104
106
  const TAIL_BYTES = 4 * 1024 * 1024;
105
107
 
@@ -113,41 +115,16 @@ const TAIL_BYTES = 4 * 1024 * 1024;
113
115
  * call.
114
116
  */
115
117
  export function timesHit(file, role, action, target) {
116
- let text;
117
- try {
118
- const { size } = statSync(file);
119
- const from = Math.max(0, size - TAIL_BYTES);
120
- const fd = openSync(file, "r");
121
- try {
122
- const buf = Buffer.alloc(size - from);
123
- readSync(fd, buf, 0, buf.length, from);
124
- text = buf.toString("utf8");
125
- } finally {
126
- closeSync(fd);
127
- }
128
- } catch {
129
- return 0; // no log yet is not an error; it is a first turn
130
- }
131
- const lines = text.split("\n");
132
- // The first line of a tail read is usually half an entry. Dropping it costs
133
- // one count at most and keeps a truncated line from being parsed as if it
134
- // were whole.
135
- if (from0(text, file)) lines.shift();
118
+ // The line the tail cut lands in is dropped (readLines checks the byte before
119
+ // the cut, so a cut on a boundary keeps it). Comparing the file's byte size
120
+ // with the text's UTF-16 length used to decide that, and with any multibyte
121
+ // character in the tail it kept the fragment of a line or dropped a whole one.
136
122
  let n = 0;
137
- for (const line of lines) {
138
- if (!line.trim()) continue;
139
- let e;
140
- try { e = JSON.parse(line); } catch { continue; }
123
+ for (const e of readEntries(file, { tail: TAIL_BYTES }))
141
124
  if (e.role === role && e.verdict === "denied" && e.action === action && e.target === target) n++;
142
- }
143
125
  return n;
144
126
  }
145
127
 
146
- /** Did we start mid-file? Then the first line is a fragment. */
147
- function from0(text, file) {
148
- try { return statSync(file).size > text.length; } catch { return false; }
149
- }
150
-
151
128
  /**
152
129
  * The calls that went into hitting the same wall twice or more.
153
130
  *
package/src/worktree.js CHANGED
@@ -30,9 +30,10 @@
30
30
  * and no dependency — a tool people install to shrink their attack surface
31
31
  * should not shell out to find out where it is.
32
32
  */
33
- import { statSync, readFileSync, readdirSync, realpathSync } from "node:fs";
33
+ import { statSync, readFileSync, readdirSync } from "node:fs";
34
34
  import { dirname, join, resolve, relative, isAbsolute } from "node:path";
35
35
  import { findConfig } from "./config.js";
36
+ import { realOrSelf } from "./grants.js";
36
37
 
37
38
  const GITDIR = /^gitdir:\s*(.+?)\s*$/;
38
39
  const WORKTREE = /^(.+)\/\.git\/worktrees\/[^/]+$/;
@@ -63,18 +64,9 @@ function statOf(p) {
63
64
  }
64
65
  }
65
66
 
66
- /** The path with symlinks followed, or the path itself when it is not on disk. */
67
- function real(p) {
68
- try {
69
- return realpathSync(p);
70
- } catch {
71
- return p;
72
- }
73
- }
74
-
75
67
  /** Two spellings of one place — `/tmp` and `/private/tmp` — compare equal. */
76
68
  function same(a, b) {
77
- return real(a) === real(b);
69
+ return realOrSelf(a) === realOrSelf(b);
78
70
  }
79
71
 
80
72
  /**