seisin 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +385 -129
  2. package/package.json +13 -5
  3. package/src/browsers.js +52 -0
  4. package/src/canary.js +149 -0
  5. package/src/cli.js +47 -23
  6. package/src/commands/check.js +154 -3
  7. package/src/commands/explain.js +2 -5
  8. package/src/commands/hook.js +7 -3
  9. package/src/commands/init.js +57 -50
  10. package/src/commands/requests.js +31 -56
  11. package/src/commands/review.js +33 -6
  12. package/src/commands/run.js +332 -110
  13. package/src/commands/walls.js +39 -7
  14. package/src/commands/whose.js +2 -1
  15. package/src/config.js +321 -15
  16. package/src/controls.js +5 -5
  17. package/src/diagnose.js +67 -16
  18. package/src/env.js +3 -0
  19. package/src/grants.js +107 -2
  20. package/src/handoff.js +285 -10
  21. package/src/help.js +39 -19
  22. package/src/hook.js +59 -30
  23. package/src/index.d.ts +217 -0
  24. package/src/index.js +20 -12
  25. package/src/inspect/codex.js +64 -0
  26. package/src/inspect/credentials.js +48 -0
  27. package/src/inspect/enforce.js +24 -0
  28. package/src/inspect/files.js +71 -0
  29. package/src/inspect/home.js +43 -0
  30. package/src/inspect/inject.js +68 -0
  31. package/src/inspect/keys.js +74 -0
  32. package/src/inspect/layout.js +258 -0
  33. package/src/inspect/limits.js +98 -0
  34. package/src/inspect/machine.js +107 -0
  35. package/src/inspect/network.js +37 -0
  36. package/src/inspect/providers.js +71 -0
  37. package/src/inspect/read-territory.js +105 -0
  38. package/src/inspect/role-keys.js +136 -0
  39. package/src/inspect/shared.js +37 -0
  40. package/src/inspect/warnings.js +58 -0
  41. package/src/inspect.js +13 -815
  42. package/src/intake.js +131 -21
  43. package/src/keys.js +223 -67
  44. package/src/kinds.js +42 -0
  45. package/src/lines.js +26 -0
  46. package/src/log.js +7 -2
  47. package/src/mcp.js +28 -43
  48. package/src/nested.js +12 -3
  49. package/src/notify.js +53 -12
  50. package/src/owners.js +165 -3
  51. package/src/paths.js +17 -1
  52. package/src/provider-run.js +81 -0
  53. package/src/redact.js +34 -15
  54. package/src/render.js +35 -9
  55. package/src/requests.js +363 -45
  56. package/src/review.js +21 -9
  57. package/src/rundir.js +29 -8
  58. package/src/sandboxes/command.js +93 -0
  59. package/src/sandboxes/index.js +113 -0
  60. package/src/sandboxes/mach-profile.js +31 -0
  61. package/src/sandboxes/srt-mach.mjs +43 -0
  62. package/src/sandboxes/srt.js +163 -0
  63. package/src/sandboxes/tartaros.js +80 -0
  64. package/src/scan.js +7 -3
  65. package/src/serve.js +35 -17
  66. package/src/spool.js +8 -17
  67. package/src/srt.js +263 -33
  68. package/src/surface.js +129 -16
  69. package/src/territory.js +261 -0
  70. package/src/trace.js +33 -0
  71. package/src/uilink.js +15 -6
  72. package/src/views.js +40 -14
  73. package/src/violations.js +143 -7
  74. package/src/walls.js +62 -9
  75. package/ui/index.html +85 -13
  76. package/ui/favicon.svg +0 -1
package/src/requests.js CHANGED
@@ -4,9 +4,10 @@
4
4
  * ── Two words, kept apart on purpose ──
5
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
- * refused on src/api/x.ts` and `seisin deny` answered `refused frontend ✕
8
- * src/api/**`. One means the kernel said no, the other means you did, and a
9
- * reader had nothing to tell them apart by.
7
+ * refused on src/api/x.ts` and the command that declined it answered `refused
8
+ * frontend ✕ src/api/**`. One means the kernel said no, the other means you
9
+ * did, and a reader had nothing to tell them apart by. The file kept the
10
+ * boundary's word for a decline until 0.6.0; see migrateQueue.
10
11
  *
11
12
  * The entry says **asked** now, because refused was not always true. Asking for
12
13
  * a path before reaching for it is reasonable, and nothing required a
@@ -19,21 +20,23 @@
19
20
  * A denial already contains everything a request needs — the hook knows the
20
21
  * role, the path and the owner — so the denial leaves something behind that a
21
22
  * person can act on in one command instead of in an editor. See
22
- * `docs/permission-requests.md` for why granting is deliberately not something
23
+ * `docs/requests.md` for why granting is deliberately not something
23
24
  * an agent can do.
24
25
  *
25
26
  * Append-only, beside the log, for the same reason: a decision that can be
26
27
  * rewritten is not evidence. A request that is granted or declined is not
27
28
  * deleted — it is followed by a line saying what happened to it.
28
29
  */
29
- import { neverWrites, isGitMetadata, covers, keyHolders, inKeyDir } from "./owners.js";
30
+ import { neverWrites, isGitMetadata, covers, privateOwner } from "./owners.js";
31
+ import { decideHandoff, chainOf, isKeyRequest } from "./handoff.js";
30
32
  import { protectedBy } from "./surface.js";
31
- import { appendFileSync, mkdirSync, readFileSync, statSync, writeFileSync, renameSync, unlinkSync } from "node:fs";
33
+ import { appendFileSync, chmodSync, closeSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, statSync, writeFileSync, renameSync, unlinkSync } from "node:fs";
32
34
  import { randomBytes } from "node:crypto";
33
35
  import { dirname, join } from "node:path";
34
36
  import { STATE_DIR, tomlString } from "./layout.js";
35
37
  import { send } from "./spool.js";
36
38
  import { withLock, readEntries } from "./log.js";
39
+ import { loadConfig, policyId } from "./config.js";
37
40
 
38
41
  const REQUESTS_NAME = "requests.jsonl";
39
42
 
@@ -97,7 +100,9 @@ function write(file, entry) {
97
100
  mkdirSync(dirname(file), { recursive: true });
98
101
  // The time is this process's, named after the spread so no entry sets it.
99
102
  const now = new Date().toISOString();
100
- appendFileSync(file, JSON.stringify({ at: now, ...entry, at: now }) + "\n");
103
+ // Under the queue's lock, as the log's lines are under the log's: the one
104
+ // rewrite the queue ever gets (migrateQueue) cannot then miss this line.
105
+ withLock(file, () => appendFileSync(file, JSON.stringify({ at: now, ...entry, at: now }) + "\n"));
101
106
  return true;
102
107
  } catch {
103
108
  // Same rule as the log: never take the agent down over bookkeeping.
@@ -106,33 +111,149 @@ function write(file, entry) {
106
111
  }
107
112
 
108
113
  /**
109
- * Who a read request's key belongs to, from the policy as it is now.
114
+ * Who each request goes to, decided now (handoff.js), and whose it is.
110
115
  *
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
+ * Every reader of the queue goes through here — `seisin requests`, the end of
117
+ * `seisin run`, the notification, the console, the MCP — so they cannot
118
+ * disagree about where a request goes, and none of them works it out on its
119
+ * own. `owners` is replaced by the decision's: the owners stored on the line
120
+ * are who owned the path when it was denied, which is evidence; who owns it
121
+ * now is the policy's answer, and that is the one shown. For a key it is who
122
+ * declares it — nobody writes `.secrets/`, and the queue used to say
123
+ * "(unowned)" about a key another role declares, right before a grant that
124
+ * would share it.
125
+ *
126
+ * `handoff` is the decision as decideHandoff returns it: `type` is route,
127
+ * human, resolved, cycle or depth, and a route names `role`. It is the field
128
+ * something outside seisin reads to act on; seisin itself starts nothing.
116
129
  */
117
- export function withDeclarers(config, queue) {
130
+ export function withHandoffs(config, queue, revision = policyId(config)) {
131
+ // Reading the queue must not be the thing that fails: a caller holding only
132
+ // a root gets every request decided as nobody's, which is what it can know.
133
+ const policy = config?.roles ? config : { ...config, roles: {} };
118
134
  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;
135
+ if (typeof r.role !== "string" || typeof r.target !== "string") continue;
136
+ r.ownersThen = r.owners;
137
+ r.handoff = decideHandoff({ request: r, config: policy, chain: chainOf(r), revision });
138
+ r.owners = r.handoff.owners ?? [];
139
+ if (isKeyRequest(config, r)) r.declared = true;
140
+ }
141
+ // Each request's chain is named by the request it started from. Where a
142
+ // later request in that chain stopped — a cycle, too deep, a person's call —
143
+ // the one it started from says so: seisin cannot know whether the role that
144
+ // took it finished, but it knows the chain did not end in a handoff.
145
+ const stoppedIn = new Map();
146
+ for (const q of queue) {
147
+ const id = q.chain?.id;
148
+ if (!id || id === q.key || !["cycle", "depth", "human"].includes(q.handoff?.type)) continue;
149
+ stoppedIn.set(id, [...(stoppedIn.get(id) ?? []), q.key]);
123
150
  }
151
+ for (const r of queue) if (stoppedIn.has(r.key)) r.chainStopped = stoppedIn.get(r.key);
124
152
  return queue;
125
153
  }
126
154
 
127
- /** Records that a role asked for something the policy denies it. Safe to call
128
- * on every denial; `seisin run` drops anything the policy does not deny. */
129
- export function record(file, { role, action, target, owners }) {
130
- return write(file, { kind: "asked", key: keyOf({ role, action, target }), role, action, target, owners: owners ?? [] });
155
+ /**
156
+ * The pending queue as a person and an agent read it: keys in the policy's own
157
+ * form, each request's handoff decided against the policy as it stands, and —
158
+ * given the log — the ones the role stopped asking for marked. `readOnly` is
159
+ * pending()'s: the MCP server reads the queue and never migrates it.
160
+ */
161
+ export function queueOf(config, history = null, { readOnly = false } = {}) {
162
+ const queue = withHandoffs(config, pending(requestsPath(config.root), { keyDirs: config.keyDirs, readOnly }));
163
+ return history ? markStale(queue, history) : queue;
164
+ }
165
+
166
+ /**
167
+ * The queue as data: what `seisin requests --json` prints and `seisin_requests`
168
+ * returns, one shape for both.
169
+ *
170
+ * `handoff` is the field to act on. It is decided by seisin from the policy as
171
+ * it stands (handoff.js) and carried whole, so whatever reads it — an agent, a
172
+ * script that starts roles — is reading a decision and not making one: a route
173
+ * names the role, and `seisin run <role> --handoff <id>` refuses any other.
174
+ */
175
+ export function queueData(queue) {
176
+ return {
177
+ pending: queue.map((r, i) => ({
178
+ number: i + 1, id: r.key, role: r.role, action: r.action, target: r.target,
179
+ wants: r.grant, ownedBy: r.owners, asked: r.times, lastAsked: r.last,
180
+ handoff: r.handoff ?? null,
181
+ ...(r.attempt ? { attempt: r.attempt } : {}),
182
+ ...(r.chainStopped ? { chainStopped: r.chainStopped } : {}),
183
+ ...(r.stale ? { stale: `not asked again in ${r.stale.runs} runs of ${r.role} since` } : {}),
184
+ })),
185
+ };
131
186
  }
132
187
 
133
- /** Records a decision. `by` is always a person; there is no other caller. */
134
- export function settle(file, key, decision, reason = "") {
135
- return write(file, { kind: decision, key, reason });
188
+ /**
189
+ * Records that a role asked for something the policy denies it. Safe to call
190
+ * on every denial; `seisin run` drops anything the policy does not deny.
191
+ *
192
+ * `chain` is set only by a run started for a handoff (`seisin run --handoff`),
193
+ * from the decision the parent checked before starting it — never from
194
+ * anything the sandbox sent. A line without one starts a chain of its own.
195
+ */
196
+ export function record(file, { role, action, target, owners, chain = null }) {
197
+ return write(file, {
198
+ kind: "asked", key: keyOf({ role, action, target }), role, action, target, owners: owners ?? [],
199
+ ...(chain && { chain: { id: chain.id, depth: chain.depth, visited: chain.visited } }),
200
+ });
201
+ }
202
+
203
+ /**
204
+ * Records a decision. `by` is always a person; there is no other caller.
205
+ *
206
+ * `only` is the one file a grant covered when the person chose it over the
207
+ * folder the request is keyed by. The key stays the folder's, so the queue
208
+ * needs to know: a later ask for a sibling is a new question, not this one
209
+ * asked again (see reduce).
210
+ */
211
+ export function settle(file, key, decision, reason = "", { only = null } = {}) {
212
+ return write(file, { kind: decision, key, reason, ...(only ? { only } : {}) });
213
+ }
214
+
215
+ /**
216
+ * The same request, granting the one file it was asked over instead of the
217
+ * folder it is keyed by.
218
+ *
219
+ * Writes only: a key is already one file. The grant is the target exactly as
220
+ * the request carries it, so it reaches the policy as a literal path and goes
221
+ * through the same refuseIfBarred and applyGrant as the folder does.
222
+ */
223
+ export function asFileGrant(request) {
224
+ if (request.action === "read")
225
+ throw new Error(`${request.key} is a key, and a key is already one file — grant it without --file`);
226
+ if (request.action !== "write")
227
+ throw new Error(`only a write can be granted as one file, and ${request.key} is a ${request.action}`);
228
+ const t = typeof request.target === "string" ? request.target : "";
229
+ if (!t || t.endsWith("/") || t.includes("*"))
230
+ throw new Error(`${JSON.stringify(t)} is not one file — grant the folder instead`);
231
+ return { ...request, grant: t, only: t };
232
+ }
233
+
234
+ /**
235
+ * The agent a role ran last, as its last canary line recorded it, or null.
236
+ *
237
+ * Asked for one reason: a literal file in `writes` is useless to Claude Code,
238
+ * whose Write and Edit save to a temporary beside the file and rename over it
239
+ * (docs/first-day.md). Null when the role never ran or ran before the canary
240
+ * line carried the agent — the caller then says it conditionally.
241
+ */
242
+ export function lastAgentOf(entries, role) {
243
+ for (let i = entries.length - 1; i >= 0; i--) {
244
+ const e = entries[i];
245
+ if (e?.event === "canary" && e.role === role) return typeof e.agent === "string" ? e.agent : null;
246
+ }
247
+ return null;
248
+ }
249
+
250
+ /** One line on what a file-only grant does to a role that runs Claude Code, or "". */
251
+ export function fileOnlyNote(role, agent) {
252
+ const why = "Claude Code's Write and Edit save to a temporary beside the file and rename it over, " +
253
+ "and the temporary is outside a one-file grant (EPERM)";
254
+ if (agent === "claude") return `${role} runs Claude Code: ${why} — grant the folder if it edits this file.`;
255
+ if (agent) return "";
256
+ return `If ${role} runs Claude Code, a one-file grant will not let it edit: ${why}.`;
136
257
  }
137
258
 
138
259
  /**
@@ -148,8 +269,9 @@ export function settle(file, key, decision, reason = "") {
148
269
  * It exists so that a refusal to run cannot be invisible. Admission may
149
270
  * postpone work; it may not make work disappear from the queue a person reads.
150
271
  *
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.
272
+ * `seisin run --handoff` writes one when it starts the receiver; whatever
273
+ * else starts roles writes one when it holds one back. It reaches a request
274
+ * as `attempt`, beside the decision, and never as `state`.
153
275
  */
154
276
  export function recordHandoff(file, key, decision = {}) {
155
277
  const { type, role, limit, max, reason, depth, chainId, revision } = decision;
@@ -178,14 +300,28 @@ export function recordHandoff(file, key, decision = {}) {
178
300
  */
179
301
  let reduced = null; // { file, ino, size, mtimeMs, all }
180
302
 
181
- export function pending(file, { includeSettled = false, keyDirs = null } = {}) {
303
+ /**
304
+ * `readOnly` is for the MCP server, which opens nothing for writing: a queue
305
+ * that still needs migrating is an error there, never a rewrite (migrateQueue).
306
+ */
307
+ export function pending(file, { includeSettled = false, keyDirs = null, readOnly = false } = {}) {
182
308
  let st;
183
309
  try { st = statSync(file); } catch { return []; }
184
310
  let all;
185
311
  if (reduced && reduced.file === file && reduced.ino === st.ino && reduced.size === st.size && reduced.mtimeMs === st.mtimeMs) {
186
312
  all = reduced.all;
187
313
  } else {
188
- all = reduce(file);
314
+ let entries = readEntries(file);
315
+ // Twice at most: a line an older seisin appended while the first pass ran
316
+ // is caught by the second; one still there after that is an error, not a guess.
317
+ for (let pass = 0; pass < 2 && entries.some(isOldDecline); pass++) {
318
+ if (readOnly) throw unmigrated(file, entries.filter(isOldDecline).length);
319
+ migrateQueue(file);
320
+ st = statSync(file);
321
+ entries = readEntries(file);
322
+ }
323
+ if (entries.some(isOldDecline)) throw unmigrated(file, entries.filter(isOldDecline).length);
324
+ all = reduce(entries);
189
325
  reduced = { file, ino: st.ino, size: st.size, mtimeMs: st.mtimeMs, all };
190
326
  }
191
327
  // The grant is re-derived with the policy's first key directory when the
@@ -195,20 +331,40 @@ export function pending(file, { includeSettled = false, keyDirs = null } = {}) {
195
331
  return includeSettled ? copies : copies.filter((r) => r.state === "pending");
196
332
  }
197
333
 
198
- function reduce(file) {
334
+ function reduce(entries) {
199
335
  const byKey = new Map();
200
336
 
201
- for (const e of readEntries(file)) {
337
+ for (const e of entries) {
202
338
  if (!e.key) continue;
203
339
 
204
340
  if (e.kind === "asked") {
205
341
  const seen = byKey.get(e.key);
206
- if (seen) { seen.times++; seen.last = e.at; continue; }
207
- byKey.set(e.key, {
342
+ /**
343
+ * A grant of one file answered that file, not the folder the key names.
344
+ * A sibling asked after it is a new question: reopened with that target,
345
+ * so it reaches the queue instead of folding into a settled request that
346
+ * nobody reads again. The same file asked again folds as before.
347
+ */
348
+ if (seen && seen.state === "granted" && seen.only?.length && typeof e.target === "string" &&
349
+ !seen.only.includes(e.target)) {
350
+ Object.assign(seen, {
351
+ target: e.target, owners: e.owners ?? [], grant: grantFor(e),
352
+ times: 1, first: e.at, last: e.at, state: "pending", reason: "",
353
+ });
354
+ delete seen.decided;
355
+ delete seen.chain;
356
+ delete seen.attempt;
357
+ keepChain(seen, e);
358
+ continue;
359
+ }
360
+ if (seen) { seen.times++; seen.last = e.at; keepChain(seen, e); continue; }
361
+ const fresh = {
208
362
  key: e.key, role: e.role, action: e.action, target: e.target,
209
363
  owners: e.owners ?? [], grant: grantFor(e),
210
364
  times: 1, first: e.at, last: e.at, state: "pending", reason: "",
211
- });
365
+ };
366
+ keepChain(fresh, e);
367
+ byKey.set(e.key, fresh);
212
368
  continue;
213
369
  }
214
370
  const seen = byKey.get(e.key);
@@ -219,11 +375,12 @@ function reduce(file) {
219
375
  *
220
376
  * Without this branch the catch-all below would set state = "handoff" and a
221
377
  * throttled attempt would drop out of the pending queue — the exact silent
222
- * disappearance the attempt is recorded to prevent. (Only the tests write
223
- * handoff lines today — see recordHandoff.)
378
+ * disappearance the attempt is recorded to prevent. It is `attempt`, not
379
+ * `handoff`: the decision is recomputed on every read (withHandoffs), and
380
+ * what was tried is a different fact from where it goes.
224
381
  */
225
382
  if (e.kind === "handoff") {
226
- seen.handoff = {
383
+ seen.attempt = {
227
384
  outcome: e.outcome, role: e.role, limit: e.limit, max: e.max,
228
385
  reason: e.reason, depth: e.depth, chainId: e.chainId, at: e.at,
229
386
  };
@@ -231,11 +388,143 @@ function reduce(file) {
231
388
  }
232
389
 
233
390
  seen.state = e.kind; seen.reason = e.reason ?? ""; seen.decided = e.at;
391
+ // Kept across reopenings: every file granted one at a time stays answered.
392
+ if (e.kind === "granted") {
393
+ if (typeof e.only === "string") seen.only = [...(seen.only ?? []), e.only];
394
+ else delete seen.only;
395
+ }
234
396
  }
235
397
 
236
398
  return [...byKey.values()];
237
399
  }
238
400
 
401
+ /**
402
+ * A decline as seisin wrote it before 0.6.0: `kind: "denied"`.
403
+ *
404
+ * A person declines; the boundary denies (docs/glossary.md). The queue stored
405
+ * the boundary's word for the person's answer, and 0.6.0 stores "declined".
406
+ * Nothing reads the old value as a decline: a queue that holds one is
407
+ * rewritten once by migrateQueue, or refused where it cannot be.
408
+ */
409
+ const OLD_DECLINE = "denied";
410
+
411
+ function isOldDecline(e) {
412
+ return e.kind === OLD_DECLINE && typeof e.key === "string" && e.verdict === undefined;
413
+ }
414
+
415
+ function unmigrated(file, n) {
416
+ return Object.assign(new Error(
417
+ `${file} holds ${n} decline${n === 1 ? "" : "s"} written by seisin before 0.6.0 ` +
418
+ `as "kind":"denied", which this version does not read.\n` +
419
+ ` Run \`seisin requests\` once from a terminal: it rewrites them as "declined" and ` +
420
+ `keeps the original beside the queue as requests.jsonl.pre-0.6.0.`), { code: "EQUEUEFORMAT" });
421
+ }
422
+
423
+ /** One chunk of the queue with every old decline renamed; anything else, byte for byte. */
424
+ function renameDeclines(text) {
425
+ let n = 0;
426
+ const lines = text.split("\n");
427
+ // The last piece has no newline after it: a line still being written, or
428
+ // nothing. It is copied as it is, and a later open migrates it if it must.
429
+ for (let i = 0; i < lines.length - 1; i++) {
430
+ let e;
431
+ try { e = JSON.parse(lines[i]); } catch { continue; }
432
+ if (!e || typeof e !== "object" || !isOldDecline(e)) continue;
433
+ lines[i] = JSON.stringify({ ...e, kind: "declined" });
434
+ n++;
435
+ }
436
+ return { text: lines.join("\n"), n };
437
+ }
438
+
439
+ /** Bytes of `fd` from `from` to its end, as it is now. */
440
+ function readFrom(fd, from) {
441
+ const size = fstatSync(fd).size;
442
+ const buf = Buffer.alloc(Math.max(0, size - from));
443
+ let got = 0;
444
+ while (got < buf.length) {
445
+ const n = readSync(fd, buf, got, buf.length - got, from + got);
446
+ if (n === 0) break;
447
+ got += n;
448
+ }
449
+ return buf.subarray(0, got);
450
+ }
451
+
452
+ /**
453
+ * Rewrite the old declines of a queue as "declined", once. Returns how many.
454
+ *
455
+ * The one place the queue is not appended to, and why that is allowed is in
456
+ * docs/decisions.md: it renames one stored value, keeps every line, its order
457
+ * and its time, and copies the file as it was to `requests.jsonl.pre-0.6.0`
458
+ * before touching it, so the evidence is still there, word for word.
459
+ *
460
+ * Every line seisin 0.6.0 appends takes the queue's lock (write), and so does
461
+ * this, so none of those can land between the read and the rename. A run
462
+ * started by an older seisin and still going appends without it; for that one
463
+ * the rewrite follows the old file instead of trusting the lock: it works from
464
+ * one open descriptor, copies what was appended while it worked, renames, and
465
+ * keeps copying what still lands in the old file until it has been quiet for
466
+ * FOLLOW_QUIET_MS. Such an append opened the file before the rename and writes
467
+ * microseconds later; the wait is a hundred times that.
468
+ */
469
+ const FOLLOW_QUIET_MS = 50;
470
+
471
+ export function migrateQueue(file) {
472
+ return withLock(file, () => {
473
+ let fd;
474
+ try { fd = openSync(file, "r"); } catch (e) { if (e.code === "ENOENT") return 0; throw e; }
475
+ try {
476
+ const before = readFrom(fd, 0);
477
+ let read = before.length;
478
+ const first = renameDeclines(before.toString("utf8"));
479
+ if (!first.n) return 0;
480
+ let n = first.n;
481
+ const mode = fstatSync(fd).mode & 0o777;
482
+ let backup = `${file}.pre-0.6.0`;
483
+ try { writeFileSync(backup, before, { flag: "wx", mode }); }
484
+ catch (e) {
485
+ if (e.code !== "EEXIST") throw e;
486
+ backup = `${file}.pre-0.6.0.${Date.now()}`;
487
+ writeFileSync(backup, before, { flag: "wx", mode });
488
+ }
489
+ const tmp = join(dirname(file), `.requests.migrate-${randomBytes(8).toString("hex")}`);
490
+ writeFileSync(tmp, first.text, { flag: "wx", mode });
491
+ try {
492
+ const more = readFrom(fd, read);
493
+ read += more.length;
494
+ const tail = renameDeclines(more.toString("utf8"));
495
+ n += tail.n;
496
+ appendFileSync(tmp, tail.text);
497
+ renameSync(tmp, file);
498
+ } catch (e) { try { unlinkSync(tmp); } catch {} throw e; }
499
+ const until = Date.now() + 2000;
500
+ for (let quiet = Date.now(); Date.now() - quiet < FOLLOW_QUIET_MS && Date.now() < until;) {
501
+ const late = readFrom(fd, read);
502
+ if (!late.length) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5); continue; }
503
+ read += late.length;
504
+ quiet = Date.now();
505
+ const tail = renameDeclines(late.toString("utf8"));
506
+ n += tail.n;
507
+ appendFileSync(file, tail.text);
508
+ }
509
+ return n;
510
+ } finally { closeSync(fd); }
511
+ });
512
+ }
513
+
514
+ /**
515
+ * The chain a request was asked in, kept as the deepest one seen.
516
+ *
517
+ * One request (same role, action, folder) can be asked by a run that was
518
+ * handed work and by one that was not. The deeper chain is the one that knows
519
+ * more of where the work has been, so it is the one kept: dropping it on a
520
+ * later plain ask would let the work be handed back to a role it came from.
521
+ */
522
+ function keepChain(seen, e) {
523
+ const c = e.chain;
524
+ if (!c || typeof c !== "object" || !Number.isInteger(c.depth)) return;
525
+ if (!seen.chain || c.depth > seen.chain.depth) seen.chain = c;
526
+ }
527
+
239
528
  /**
240
529
  * How many runs of the role, after the last time it asked, make a request old.
241
530
  *
@@ -328,11 +617,33 @@ export function refuseIfBarred(config, request) {
328
617
  `what you mean; otherwise decline this request.`
329
618
  : `${request.target} is protected (${guard.why}) and no role can ever be granted it. ` +
330
619
  `Decline this request.`);
620
+ // Another role's private folder: the kernel denies it to every role but its
621
+ // owner, however wide `writes` gets, so a grant would read as done and open nothing.
622
+ const mine = privateOwner(config, request.target);
623
+ if (mine && mine.role !== request.role)
624
+ throw new Error(
625
+ `${request.target} is in ${mine.role}'s private folder (private = ["${mine.entry}"]): every other role ` +
626
+ `is denied it, so a grant to ${request.role} would change nothing. Decline this request, or move the ` +
627
+ `folder out of ${mine.role}'s private list if it should not be private.`);
331
628
  // Only a repository the role does not write: its own `.git` is its own business.
332
629
  if (isGitMetadata(request.target) && !(role?.writes ?? []).some((g) => covers(g, request.target)))
333
630
  throw new Error(
334
631
  `${request.target} is git's own bookkeeping, not a territory: granting it lets ${request.role} ` +
335
632
  `rewrite another owner's history. Decline it; a role that needs to commit gets a worktree of its own.`);
633
+ /**
634
+ * The whole repository is never granted from a request. A request is one
635
+ * denied path; the grant it derives is that path's folder, and at the root
636
+ * that folder is everything — every other role's territory included. A role
637
+ * that should write the whole repo gets `writes = ["**"]` from a person
638
+ * editing the policy, not from approving one denial.
639
+ */
640
+ const grant = request.grant ?? grantFor(request);
641
+ const g = String(grant).replace(/^\.\/+/, "").replace(/\/+$/, "");
642
+ if (["", ".", "*", "**", "./**"].includes(g) || /^(\*\*\/)*\*\*$/.test(g))
643
+ throw new Error(
644
+ `this request would grant ${request.role} the whole repository ("${grant}"), every other ` +
645
+ `role's territory included. A request cannot do that: decline it, and if ${request.role} really ` +
646
+ `should write everything, edit [roles.${request.role}] writes by hand.`);
336
647
  }
337
648
 
338
649
  /**
@@ -355,7 +666,11 @@ export function editPolicy(config, mutate, { waitMs = 10000, after = null } = {}
355
666
  return withLock(policyLockBase(config), () => {
356
667
  const before = readFileSync(config.path, "utf8");
357
668
  const result = mutate(before);
358
- if (result.changed) writePolicy(config, result.toml);
669
+ if (result.changed) {
670
+ // A decision cannot settle against text the next run cannot load.
671
+ loadConfig(config.path, result.toml);
672
+ writePolicy(config, result.toml);
673
+ }
359
674
  after?.(result);
360
675
  return result;
361
676
  }, { waitMs });
@@ -391,10 +706,12 @@ export function policyLockBase(config) {
391
706
  * exclusive, which is what defeats a planted name.
392
707
  */
393
708
  function writePolicy(config, text) {
709
+ const mode = statSync(config.path).mode & 0o777;
394
710
  const name = `seisin.toml.tmp-${randomBytes(12).toString("hex")}`;
395
711
  const stateTmp = join(config.root, STATE_DIR, name);
396
- writeFileSync(stateTmp, text, { flag: "wx" });
712
+ writeFileSync(stateTmp, text, { flag: "wx", mode });
397
713
  try {
714
+ chmodSync(stateTmp, mode);
398
715
  renameSync(stateTmp, config.path);
399
716
  return;
400
717
  } catch (e) {
@@ -402,8 +719,8 @@ function writePolicy(config, text) {
402
719
  if (e.code !== "EXDEV") throw e;
403
720
  }
404
721
  const near = join(dirname(config.path), `.${name}`);
405
- writeFileSync(near, text, { flag: "wx" });
406
- try { renameSync(near, config.path); }
722
+ writeFileSync(near, text, { flag: "wx", mode });
723
+ try { chmodSync(near, mode); renameSync(near, config.path); }
407
724
  catch (e) { try { unlinkSync(near); } catch {} throw e; }
408
725
  }
409
726
 
@@ -424,17 +741,17 @@ function writePolicy(config, text) {
424
741
  */
425
742
  export function applyGrant(toml, request, note = "") {
426
743
  const field = request.action === "read" ? "keys" : "writes";
427
- const header = new RegExp(`^\\[roles\\.${escapeRe(request.role)}\\]\\s*$`, "m");
744
+ const header = new RegExp(`^[ \\t]*\\[roles\\.${escapeRe(request.role)}\\][ \\t\\r]*(?:#[^\\n]*)?$`, "m");
428
745
  const at = header.exec(toml);
429
746
  if (!at) throw new Error(`no [roles.${request.role}] section to grant into`);
430
747
 
431
748
  // The section runs from its header to the next table header, or to the end.
432
749
  const from = at.index + at[0].length;
433
- const next = /^\[[^\]]+\]\s*$/m.exec(toml.slice(from));
750
+ const next = /^[ \t]*\[[A-Za-z0-9_.\-]+\][ \t\r]*(?:#[^\n]*)?$/m.exec(toml.slice(from));
434
751
  const to = next ? from + next.index : toml.length;
435
752
  const section = toml.slice(from, to);
436
753
 
437
- const open = new RegExp(`^${field}\\s*=\\s*\\[`, "m").exec(section);
754
+ const open = new RegExp(`^[ \\t]*${field}[ \\t]*=[ \\t]*\\[`, "m").exec(section);
438
755
  if (!open)
439
756
  throw new Error(
440
757
  `[roles.${request.role}] has no ${field} list to grant into. ` +
@@ -470,7 +787,8 @@ export function applyGrant(toml, request, note = "") {
470
787
  }
471
788
  const stamp = `# granted ${new Date().toISOString().slice(0, 10)} · asked ${request.times}×` +
472
789
  `${note ? ` · «${cleanReason(note)}»` : ""}`;
473
- const edited = section.slice(0, start) + `${inner}\n "${request.grant}" ${stamp}\n]` + section.slice(list.end + 1);
790
+ const eol = toml.includes("\r\n") ? "\r\n" : "\n";
791
+ const edited = section.slice(0, start) + `${inner}${eol} "${request.grant}" ${stamp}${eol}]` + section.slice(list.end + 1);
474
792
  return { toml: toml.slice(0, from) + edited + toml.slice(to), changed: true };
475
793
  }
476
794
 
package/src/review.js CHANGED
@@ -29,7 +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
+ import { kindsOf, expectedOf } from "./kinds.js";
33
33
 
34
34
  /**
35
35
  * One reading of the log against the policy.
@@ -38,16 +38,18 @@ import { kindsOf } from "./kinds.js";
38
38
  * is low on purpose: the cost of mentioning it is a line, and the cost of
39
39
  * missing it is a role that quietly cannot do its job.
40
40
  */
41
- export function review(config, { minDenials = 3 } = {}) {
42
- const entries = read(logPath(config.root), { limit: 0 });
41
+ export function review(config, { minDenials = 3, since = null } = {}) {
42
+ // `since`: an ISO time (views.js parseSince). Without it the whole log, and
43
+ // on a log months long "denied repeatedly" is led by paths fixed long ago.
44
+ const entries = read(logPath(config.root), { limit: 0, ...(since && { since }) });
43
45
 
44
46
  if (entries.length === 0)
45
- return { window: null, entries: 0, friction: [], guarded: [], unused: [], unusedKnowable: false, unowned: [] };
47
+ return { window: null, ...(since && { since }), entries: 0, friction: [], expected: [], guarded: [], unused: [], unusedKnowable: false, unowned: [] };
46
48
 
47
49
  const at = entries.map((e) => e.at).filter(Boolean).sort();
48
50
  const window = { from: at[0], to: at[at.length - 1], entries: entries.length };
49
51
 
50
- return { window, entries: entries.length, ...findings(config, entries, minDenials) };
52
+ return { window, ...(since && { since }), entries: entries.length, ...findings(config, entries, minDenials) };
51
53
  }
52
54
 
53
55
  function findings(config, entries, minDenials) {
@@ -74,15 +76,19 @@ function findings(config, entries, minDenials) {
74
76
  const stopped = new Map(); // role + directory -> how often
75
77
  for (const e of entries) {
76
78
  if (e.verdict !== "denied" || !e.target) continue;
77
- const dir = e.action === "read" || e.action === "connect" ? e.target : dirOf(e.target);
78
- const key = `${e.role} ${e.action} ${dir}`;
79
+ // The caller's own probe (`seisin run --expect-denied`): denied on purpose.
80
+ if (e.expected === true) continue;
81
+ const dir = e.action === "read" || e.action === "connect" || e.kind === "system" ? e.target : dirOf(e.target);
82
+ const expected = expectedOf(e.target);
83
+ const key = `${e.role} ${e.action} ${dir}${expected ? " expected" : ""}`;
79
84
  const seen = stopped.get(key) ?? {
80
85
  role: e.role, action: e.action, where: dir, times: 0,
81
86
  owners: e.owners ?? [],
87
+ ...(expected && { expected }),
82
88
  // Carried by the log from both writers — the hook sets it from the config
83
89
  // and so does the parent for a kernel denial. Recomputing it from the path
84
90
  // here would be a second opinion on a settled question.
85
- kind: e.kind === "key" || e.kind === "network" ? e.kind : "file",
91
+ kind: e.kind === "key" || e.kind === "network" || e.kind === "system" ? e.kind : "file",
86
92
  };
87
93
  seen.times++;
88
94
  stopped.set(key, seen);
@@ -92,7 +98,12 @@ function findings(config, entries, minDenials) {
92
98
  .filter((f) => f.times >= minDenials)
93
99
  .sort((a, b) => b.times - a.times);
94
100
 
95
- const friction = repeated.filter((f) => f.kind !== "key" && f.kind !== "network");
101
+ // Expected (kinds.js expectedOf): Claude Code's plugin updater, refused
102
+ // correctly. Repeated on every start, and not a policy that is wrong — so it
103
+ // is said apart and never counts as friction, which is what exits 1.
104
+ const expected = repeated.filter((f) => f.expected);
105
+ // A system service is once per run (intake.js) and never grantable: not friction.
106
+ const friction = repeated.filter((f) => !f.expected && f.kind !== "key" && f.kind !== "network" && f.kind !== "system");
96
107
  const guarded = repeated.filter((f) => f.kind === "key");
97
108
  // Ports and sockets: repeated, and not a thing `grant` can answer.
98
109
  const connects = repeated.filter((f) => f.kind === "network");
@@ -179,6 +190,7 @@ function findings(config, entries, minDenials) {
179
190
 
180
191
  return {
181
192
  friction,
193
+ expected,
182
194
  guarded,
183
195
  connects,
184
196
  unused,