seisin 0.4.2 → 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 (82) hide show
  1. package/README.md +506 -162
  2. package/package.json +14 -6
  3. package/src/browsers.js +52 -0
  4. package/src/canary.js +149 -0
  5. package/src/cli.js +175 -52
  6. package/src/commands/check.js +157 -5
  7. package/src/commands/explain.js +31 -13
  8. package/src/commands/hook.js +20 -6
  9. package/src/commands/init.js +183 -88
  10. package/src/commands/log.js +48 -9
  11. package/src/commands/requests.js +122 -70
  12. package/src/commands/review.js +73 -21
  13. package/src/commands/run.js +422 -78
  14. package/src/commands/scan.js +18 -3
  15. package/src/commands/ui.js +18 -5
  16. package/src/commands/walls.js +62 -11
  17. package/src/commands/whose.js +9 -7
  18. package/src/commands/wire.js +156 -17
  19. package/src/config.js +505 -37
  20. package/src/controls.js +244 -0
  21. package/src/diagnose.js +102 -49
  22. package/src/env.js +14 -8
  23. package/src/grants.js +122 -9
  24. package/src/handoff.js +285 -10
  25. package/src/help.js +179 -0
  26. package/src/hook.js +101 -42
  27. package/src/index.d.ts +217 -0
  28. package/src/index.js +24 -16
  29. package/src/inspect/codex.js +64 -0
  30. package/src/inspect/credentials.js +48 -0
  31. package/src/inspect/enforce.js +24 -0
  32. package/src/inspect/files.js +71 -0
  33. package/src/inspect/home.js +43 -0
  34. package/src/inspect/inject.js +68 -0
  35. package/src/inspect/keys.js +74 -0
  36. package/src/inspect/layout.js +258 -0
  37. package/src/inspect/limits.js +98 -0
  38. package/src/inspect/machine.js +107 -0
  39. package/src/inspect/network.js +37 -0
  40. package/src/inspect/providers.js +71 -0
  41. package/src/inspect/read-territory.js +105 -0
  42. package/src/inspect/role-keys.js +136 -0
  43. package/src/inspect/shared.js +37 -0
  44. package/src/inspect/warnings.js +58 -0
  45. package/src/inspect.js +20 -756
  46. package/src/intake.js +150 -35
  47. package/src/keys.js +231 -73
  48. package/src/kinds.js +249 -0
  49. package/src/lines.js +26 -0
  50. package/src/log.js +242 -52
  51. package/src/mcp.js +57 -58
  52. package/src/nested.js +12 -3
  53. package/src/notify.js +66 -20
  54. package/src/owners.js +358 -39
  55. package/src/paths.js +54 -2
  56. package/src/provider-run.js +81 -0
  57. package/src/redact.js +46 -13
  58. package/src/render.js +101 -17
  59. package/src/requests.js +506 -87
  60. package/src/review.js +50 -25
  61. package/src/rundir.js +33 -14
  62. package/src/sandboxes/command.js +93 -0
  63. package/src/sandboxes/index.js +113 -0
  64. package/src/sandboxes/mach-profile.js +31 -0
  65. package/src/sandboxes/srt-mach.mjs +43 -0
  66. package/src/sandboxes/srt.js +163 -0
  67. package/src/sandboxes/tartaros.js +80 -0
  68. package/src/scan.js +181 -19
  69. package/src/serve.js +396 -230
  70. package/src/spool.js +16 -45
  71. package/src/srt.js +296 -49
  72. package/src/suggest.js +58 -0
  73. package/src/surface.js +314 -54
  74. package/src/territory.js +261 -0
  75. package/src/trace.js +33 -0
  76. package/src/uilink.js +24 -9
  77. package/src/views.js +315 -0
  78. package/src/violations.js +247 -23
  79. package/src/walls.js +76 -46
  80. package/src/worktree.js +3 -11
  81. package/ui/index.html +1429 -538
  82. package/ui/favicon.svg +0 -1
package/src/requests.js CHANGED
@@ -1,39 +1,44 @@
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
- * 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
- * a permission before reaching for it is reasonable, and nothing required a
13
+ * a path before reaching for it is reasonable, and nothing required a
13
14
  * 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
15
+ * event that never occurred, in front of a person about to grant something.
16
+ * `seisin run` drops any request the policy does not actually deny; what is
16
17
  * left is "you cannot have this, and you asked", which holds whether the agent
17
18
  * tried or simply asked.
18
19
  *
19
20
  * 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
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 approving 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
- * rewritten is not evidence. A request that is granted or refused is not
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 } from "./owners.js";
30
- import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from "node:fs";
30
+ import { neverWrites, isGitMetadata, covers, privateOwner } from "./owners.js";
31
+ import { decideHandoff, chainOf, isKeyRequest } from "./handoff.js";
32
+ import { protectedBy } from "./surface.js";
33
+ import { appendFileSync, chmodSync, closeSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, statSync, writeFileSync, renameSync, unlinkSync } from "node:fs";
34
+ import { randomBytes } from "node:crypto";
31
35
  import { dirname, join } from "node:path";
32
36
  import { STATE_DIR, tomlString } from "./layout.js";
33
37
  import { send } from "./spool.js";
34
- import { withLock } from "./log.js";
38
+ import { withLock, readEntries } from "./log.js";
39
+ import { loadConfig, policyId } from "./config.js";
35
40
 
36
- export const REQUESTS_NAME = "requests.jsonl";
41
+ const REQUESTS_NAME = "requests.jsonl";
37
42
 
38
43
  export function requestsPath(root) {
39
44
  return join(root, STATE_DIR, REQUESTS_NAME);
@@ -57,7 +62,6 @@ export function keyOf({ role, action, target }) {
57
62
  return `${role}:${action}:${dir}`;
58
63
  }
59
64
 
60
- /** The glob a grant would add, derived from what was asked. */
61
65
  /**
62
66
  * A request's id as one shell word, for the commands seisin suggests.
63
67
  *
@@ -69,13 +73,23 @@ export function shellId(key) {
69
73
  return `'${String(key).replace(/'/g, `'\\''`)}'`;
70
74
  }
71
75
 
72
- export function grantFor({ action, target }) {
76
+ /** The glob a grant would add, derived from what was asked. */
77
+ export function grantFor({ action, target }, keyDir = null) {
73
78
  const t = typeof target === "string" ? target : "";
74
79
  // A key keeps its directory when it has one. Stripping it turned a request
75
80
  // 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
81
+ // resolves against the FIRST key directory — so the person grants one file
77
82
  // and another one of the same name is what gets read.
78
- if (action === "read") return t.includes("/") ? t : t.replace(/^.*\//, "");
83
+ //
84
+ // Except the first key directory itself, which is exactly the one a bare
85
+ // name resolves against: `.secrets/stripe.txt` is written `stripe.txt`, the
86
+ // way every hand-written key is. Granting the long form put two spellings of
87
+ // one key in the same file.
88
+ if (action === "read") {
89
+ const first = keyDir ? String(keyDir).replace(/^\.\/+/, "").replace(/\/+$/, "") : null;
90
+ if (first && t.startsWith(first + "/") && !t.slice(first.length + 1).includes("/")) return t.slice(first.length + 1);
91
+ return t.includes("/") ? t : t.replace(/^.*\//, "");
92
+ }
79
93
  const dir = t.split("/").slice(0, -1).join("/");
80
94
  return dir ? `${dir}/**` : t;
81
95
  }
@@ -84,7 +98,11 @@ function write(file, entry) {
84
98
  if (send("requests", entry)) return true;
85
99
  try {
86
100
  mkdirSync(dirname(file), { recursive: true });
87
- appendFileSync(file, JSON.stringify({ at: new Date().toISOString(), ...entry }) + "\n");
101
+ // The time is this process's, named after the spread so no entry sets it.
102
+ const now = new Date().toISOString();
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"));
88
106
  return true;
89
107
  } catch {
90
108
  // Same rule as the log: never take the agent down over bookkeeping.
@@ -92,15 +110,150 @@ function write(file, entry) {
92
110
  }
93
111
  }
94
112
 
95
- /** 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. */
97
- export function record(file, { role, action, target, owners }) {
98
- return write(file, { kind: "asked", key: keyOf({ role, action, target }), role, action, target, owners: owners ?? [] });
113
+ /**
114
+ * Who each request goes to, decided now (handoff.js), and whose it is.
115
+ *
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.
129
+ */
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: {} };
134
+ for (const r of queue) {
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]);
150
+ }
151
+ for (const r of queue) if (stoppedIn.has(r.key)) r.chainStopped = stoppedIn.get(r.key);
152
+ return queue;
153
+ }
154
+
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
+ };
99
186
  }
100
187
 
101
- /** Records a decision. `by` is always a person; there is no other caller. */
102
- export function settle(file, key, decision, reason = "") {
103
- 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}.`;
104
257
  }
105
258
 
106
259
  /**
@@ -115,6 +268,10 @@ export function settle(file, key, decision, reason = "") {
115
268
  *
116
269
  * It exists so that a refusal to run cannot be invisible. Admission may
117
270
  * postpone work; it may not make work disappear from the queue a person reads.
271
+ *
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`.
118
275
  */
119
276
  export function recordHandoff(file, key, decision = {}) {
120
277
  const { type, role, limit, max, reason, depth, chainId, revision } = decision;
@@ -135,30 +292,79 @@ export function recordHandoff(file, key, decision = {}) {
135
292
  * it was asked and what was decided.
136
293
  *
137
294
  * Reduced from the whole file rather than kept as state, so the file stays the
138
- * only thing that has to be correct.
295
+ * only thing that has to be correct. The reduction is remembered for as long as
296
+ * the file is unchanged (same inode, size and mtime): the console asks every
297
+ * two seconds, and re-parsing a 2 MB queue each time to find it had not moved
298
+ * was most of the cost of asking. Each call gets its own copies, so a caller
299
+ * marking entries (markStale) cannot mark the next caller's.
139
300
  */
140
- export function pending(file, { includeSettled = false } = {}) {
141
- if (!existsSync(file)) return [];
142
- const byKey = new Map();
301
+ let reduced = null; // { file, ino, size, mtimeMs, all }
143
302
 
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
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 } = {}) {
308
+ let st;
309
+ try { st = statSync(file); } catch { return []; }
310
+ let all;
311
+ if (reduced && reduced.file === file && reduced.ino === st.ino && reduced.size === st.size && reduced.mtimeMs === st.mtimeMs) {
312
+ all = reduced.all;
313
+ } else {
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);
151
322
  }
323
+ if (entries.some(isOldDecline)) throw unmigrated(file, entries.filter(isOldDecline).length);
324
+ all = reduce(entries);
325
+ reduced = { file, ino: st.ino, size: st.size, mtimeMs: st.mtimeMs, all };
326
+ }
327
+ // The grant is re-derived with the policy's first key directory when the
328
+ // caller has one, so a key is proposed in the form hand-written keys use.
329
+ const first = keyDirs?.[0] ?? null;
330
+ const copies = all.map((r) => (first && r.action === "read" ? { ...r, grant: grantFor(r, first) } : { ...r }));
331
+ return includeSettled ? copies : copies.filter((r) => r.state === "pending");
332
+ }
333
+
334
+ function reduce(entries) {
335
+ const byKey = new Map();
336
+
337
+ for (const e of entries) {
152
338
  if (!e.key) continue;
153
339
 
154
340
  if (e.kind === "asked") {
155
341
  const seen = byKey.get(e.key);
156
- if (seen) { seen.times++; seen.last = e.at; continue; }
157
- 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 = {
158
362
  key: e.key, role: e.role, action: e.action, target: e.target,
159
363
  owners: e.owners ?? [], grant: grantFor(e),
160
364
  times: 1, first: e.at, last: e.at, state: "pending", reason: "",
161
- });
365
+ };
366
+ keepChain(fresh, e);
367
+ byKey.set(e.key, fresh);
162
368
  continue;
163
369
  }
164
370
  const seen = byKey.get(e.key);
@@ -169,10 +375,12 @@ export function pending(file, { includeSettled = false } = {}) {
169
375
  *
170
376
  * Without this branch the catch-all below would set state = "handoff" and a
171
377
  * throttled attempt would drop out of the pending queue — the exact silent
172
- * disappearance the attempt is recorded to prevent.
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.
173
381
  */
174
382
  if (e.kind === "handoff") {
175
- seen.handoff = {
383
+ seen.attempt = {
176
384
  outcome: e.outcome, role: e.role, limit: e.limit, max: e.max,
177
385
  reason: e.reason, depth: e.depth, chainId: e.chainId, at: e.at,
178
386
  };
@@ -180,16 +388,147 @@ export function pending(file, { includeSettled = false } = {}) {
180
388
  }
181
389
 
182
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
+ }
396
+ }
397
+
398
+ return [...byKey.values()];
399
+ }
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++;
183
435
  }
436
+ return { text: lines.join("\n"), n };
437
+ }
184
438
 
185
- const all = [...byKey.values()];
186
- return includeSettled ? all : all.filter((r) => r.state === "pending");
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;
187
526
  }
188
527
 
189
528
  /**
190
529
  * How many runs of the role, after the last time it asked, make a request old.
191
530
  *
192
- * A request is recorded again on every refusal, so one the role still needs
531
+ * A request is recorded again on every denial, so one the role still needs
193
532
  * keeps a fresh `last`. One whose need went away — the work moved somewhere the
194
533
  * role may write, or the task ended — stops being asked while the role keeps
195
534
  * running. Three runs is enough to say "not since" and few enough that a queue
@@ -233,7 +572,7 @@ export function runsAfter(entries, since, gapMs = RUN_GAP_MS) {
233
572
  * reordering would shift the numbers `grant <n>` is typed against while
234
573
  * nobody decided anything. The mark says what is true — the role ran N times
235
574
  * 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.
575
+ * `entries` are log lines (`read(logPath(root))`).
237
576
  */
238
577
  export function markStale(queue, entries, { runs = STALE_RUNS, gapMs = RUN_GAP_MS } = {}) {
239
578
  const byRole = new Map(); // role -> its log lines
@@ -248,35 +587,13 @@ export function markStale(queue, entries, { runs = STALE_RUNS, gapMs = RUN_GAP_M
248
587
  return queue;
249
588
  }
250
589
 
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
590
  /**
274
591
  * Refuses a grant that `never_writes` would cancel, before anything is written.
275
592
  *
276
593
  * 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
594
+ * Granting it would add the path to `writes` and change nothing — denyWrite
278
595
  * 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
596
+ * denies. The two settings contradict each other, and which one should win is the
280
597
  * decision; seisin will not make it by writing one of them silently.
281
598
  */
282
599
  export function refuseIfBarred(config, request) {
@@ -288,11 +605,45 @@ export function refuseIfBarred(config, request) {
288
605
  `${request.target} is under never_writes of ${request.role} ("${hit}"), which wins over ` +
289
606
  `writes. Granting it would change nothing. If the subtraction is wrong, remove that ` +
290
607
  `entry from [roles.${request.role}]; otherwise decline this request.`);
608
+ // A protected file stays denied whatever `writes` says: the kernel is given
609
+ // it as a deny, and explain answers the same. Granting `web/.vscode/**`
610
+ // used to add the line and change nothing — a grant that reads as done.
611
+ const guard = protectedBy(config, request.target, { role: request.role });
612
+ if (guard)
613
+ throw new Error(guard.family
614
+ ? `${request.target} is protected (${guard.why}). A grant cannot open it: ${request.role} ` +
615
+ `writes it only if the policy hands it the family, control_files = ["${guard.family}"] ` +
616
+ `under [roles.${request.role}], inside its own territory. Edit the policy if that is ` +
617
+ `what you mean; otherwise decline this request.`
618
+ : `${request.target} is protected (${guard.why}) and no role can ever be granted it. ` +
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.`);
291
628
  // Only a repository the role does not write: its own `.git` is its own business.
292
629
  if (isGitMetadata(request.target) && !(role?.writes ?? []).some((g) => covers(g, request.target)))
293
630
  throw new Error(
294
631
  `${request.target} is git's own bookkeeping, not a territory: granting it lets ${request.role} ` +
295
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.`);
296
647
  }
297
648
 
298
649
  /**
@@ -307,43 +658,110 @@ export function refuseIfBarred(config, request) {
307
658
  * so a crash mid-write cannot leave a half-written, partly-privileged policy.
308
659
  *
309
660
  * `mutate(toml)` returns `{ toml, changed }`. Nothing is written when unchanged.
661
+ * `after(result)` runs once the file is written, still under the lock — for
662
+ * the queue line that records the decision, so a decision and its edit are
663
+ * one step to anyone else taking this lock.
310
664
  */
311
- export function editPolicy(config, mutate, { waitMs = 10000 } = {}) {
312
- return withLock(config.path, () => {
665
+ export function editPolicy(config, mutate, { waitMs = 10000, after = null } = {}) {
666
+ return withLock(policyLockBase(config), () => {
313
667
  const before = readFileSync(config.path, "utf8");
314
668
  const result = mutate(before);
315
669
  if (result.changed) {
316
- const tmp = `${config.path}.tmp-${process.pid}`;
317
- writeFileSync(tmp, result.toml);
318
- renameSync(tmp, config.path);
670
+ // A decision cannot settle against text the next run cannot load.
671
+ loadConfig(config.path, result.toml);
672
+ writePolicy(config, result.toml);
319
673
  }
674
+ after?.(result);
320
675
  return result;
321
676
  }, { waitMs });
322
677
  }
323
678
 
679
+ /**
680
+ * Where the policy's lock lives: inside `.seisin/`, never beside the policy.
681
+ *
682
+ * It was `seisin.toml.lock`, next to the file. Any role that writes the repo
683
+ * root — every `--observe` run, any `writes = ["**"]` — could create that name
684
+ * holding a live pid and hold up every grant and every decline for as long as
685
+ * it liked. `.seisin/` is denied to every role (surface.js, parentInputs), so
686
+ * nothing confined can hold this one. withLock adds the `.lock`.
687
+ */
688
+ export function policyLockBase(config) {
689
+ const dir = join(config.root, STATE_DIR);
690
+ mkdirSync(dir, { recursive: true });
691
+ return join(dir, "policy");
692
+ }
693
+
694
+ /**
695
+ * Write the policy through a temp file that nobody could have prepared.
696
+ *
697
+ * The temp was `seisin.toml.tmp-<pid>` beside the policy: a predictable name,
698
+ * in a directory roles can write, opened by writeFileSync — which follows a
699
+ * symlink. A role could plant that name as a link into its own territory, and
700
+ * the next grant made the policy itself a symlink the role could rewrite.
701
+ * Now the temp sits in `.seisin/`, has a random name, and is opened with `wx`
702
+ * (O_CREAT|O_EXCL), which refuses anything already there, a link included.
703
+ *
704
+ * A `.seisin/` on another filesystem (a symlink a person made) cannot be
705
+ * renamed across; then the temp goes beside the policy, still random and still
706
+ * exclusive, which is what defeats a planted name.
707
+ */
708
+ function writePolicy(config, text) {
709
+ const mode = statSync(config.path).mode & 0o777;
710
+ const name = `seisin.toml.tmp-${randomBytes(12).toString("hex")}`;
711
+ const stateTmp = join(config.root, STATE_DIR, name);
712
+ writeFileSync(stateTmp, text, { flag: "wx", mode });
713
+ try {
714
+ chmodSync(stateTmp, mode);
715
+ renameSync(stateTmp, config.path);
716
+ return;
717
+ } catch (e) {
718
+ try { unlinkSync(stateTmp); } catch {}
719
+ if (e.code !== "EXDEV") throw e;
720
+ }
721
+ const near = join(dirname(config.path), `.${name}`);
722
+ writeFileSync(near, text, { flag: "wx", mode });
723
+ try { chmodSync(near, mode); renameSync(near, config.path); }
724
+ catch (e) { try { unlinkSync(near); } catch {} throw e; }
725
+ }
726
+
727
+ /**
728
+ * Adds a granted glob to a role in the config text, with its provenance.
729
+ *
730
+ * Edits the text rather than re-emitting the file, because a config someone
731
+ * wrote has comments and an order that mean something, and a tool that
732
+ * reformats it on every grant is a tool people stop letting near it.
733
+ *
734
+ * The edit is bounded to the role's own section, and that is not tidiness. The
735
+ * first version searched from the role header to the next `writes =` anywhere
736
+ * in the file, so granting for a role that had no `writes` line wrote the
737
+ * grant into the NEXT role — with a comment saying who it was for. A person
738
+ * granted one thing and the file recorded another. In a permission layer that
739
+ * is the worst possible bug, and it is not exotic: a role with only `keys`
740
+ * declared is an ordinary config.
741
+ */
324
742
  export function applyGrant(toml, request, note = "") {
325
743
  const field = request.action === "read" ? "keys" : "writes";
326
- const header = new RegExp(`^\\[roles\\.${escapeRe(request.role)}\\]\\s*$`, "m");
744
+ const header = new RegExp(`^[ \\t]*\\[roles\\.${escapeRe(request.role)}\\][ \\t\\r]*(?:#[^\\n]*)?$`, "m");
327
745
  const at = header.exec(toml);
328
746
  if (!at) throw new Error(`no [roles.${request.role}] section to grant into`);
329
747
 
330
748
  // The section runs from its header to the next table header, or to the end.
331
749
  const from = at.index + at[0].length;
332
- const next = /^\[[^\]]+\]\s*$/m.exec(toml.slice(from));
750
+ const next = /^[ \t]*\[[A-Za-z0-9_.\-]+\][ \t\r]*(?:#[^\n]*)?$/m.exec(toml.slice(from));
333
751
  const to = next ? from + next.index : toml.length;
334
752
  const section = toml.slice(from, to);
335
753
 
336
- const open = new RegExp(`^${field}\\s*=\\s*\\[`, "m").exec(section);
754
+ const open = new RegExp(`^[ \\t]*${field}[ \\t]*=[ \\t]*\\[`, "m").exec(section);
337
755
  if (!open)
338
756
  throw new Error(
339
757
  `[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.`
758
+ `Add \`${field} = []\` to that section first — seisin will not write it into another role's table.`
341
759
  );
342
760
 
343
761
  // The subset has no escapes, so a value carrying a quote or a newline cannot
344
762
  // be written down at all. The strict parser would reject the result rather
345
763
  // than widen anything — but leaving someone with a config that no longer
346
- // loads, after they approved something, is its own kind of broken.
764
+ // loads, after they granted something, is its own kind of broken.
347
765
  tomlString(request.grant); // refuses what the format cannot hold
348
766
 
349
767
  const start = open.index + open[0].length;
@@ -355,9 +773,9 @@ export function applyGrant(toml, request, note = "") {
355
773
  *
356
774
  * The list used to be re-written from every quoted string between its
357
775
  * 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
776
+ * two grants, so granting anything also granted what a comment mentioned,
777
+ * and the reason a person typed (quoted in the provenance comment) became
778
+ * a path on the next grant. Every earlier provenance comment was lost on
361
779
  * the way. Now the items are the strings outside comments, what is there is
362
780
  * left as it is, and the new entry goes last. The reason is set off with
363
781
  * «», never a double quote, so no older reader can take it for a value.
@@ -369,7 +787,8 @@ export function applyGrant(toml, request, note = "") {
369
787
  }
370
788
  const stamp = `# granted ${new Date().toISOString().slice(0, 10)} · asked ${request.times}×` +
371
789
  `${note ? ` · «${cleanReason(note)}»` : ""}`;
372
- 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);
373
792
  return { toml: toml.slice(0, from) + edited + toml.slice(to), changed: true };
374
793
  }
375
794
 
@@ -401,14 +820,14 @@ function scanList(text, start) {
401
820
  }
402
821
 
403
822
  /**
404
- * The approver's own words, made safe to put in a config file.
823
+ * The reason a person typed, made safe to put in a config file.
405
824
  *
406
825
  * A reason is free text typed by a person, and a person can be talked into
407
826
  * typing something — "paste this as the reason" is a plausible thing for an
408
827
  * agent to suggest. Newlines in a comment would end the comment, and what
409
828
  * follows is parsed as TOML. The strict parser rejects the result rather than
410
829
  * 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.
830
+ * policy that can be broken by a sentence is still broken.
412
831
  */
413
832
  export function cleanReason(s) {
414
833
  return String(s ?? "")