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/render.js CHANGED
@@ -25,6 +25,17 @@ export const C =
25
25
  }
26
26
  : { dim: "", b: "", off: "", red: "", green: "", yellow: "", blue: "" };
27
27
 
28
+ /**
29
+ * A value chosen by an agent (a target, a grant, an id) made safe to print.
30
+ *
31
+ * `target` and the id built from it are strings the agent picked, and they were
32
+ * written to the terminal raw. Terminal escape sequences (ESC, CR, other C0
33
+ * controls) let such a string repaint the line a person reads before approving.
34
+ * On disk they are already JSON-escaped; this is for the screen. Stripped, not
35
+ * rendered, so what shows is the literal path with the control bytes removed.
36
+ */
37
+ export const safe = (s) => String(s ?? "").replace(/[\u0000-\u001f\u007f-\u009f]/g, "");
38
+
28
39
  export const out = (s) => process.stdout.write(s);
29
40
  export const err = (s) => process.stderr.write(s);
30
41
 
@@ -56,8 +67,16 @@ export function collapseSidecars(paths) {
56
67
  return out;
57
68
  }
58
69
 
59
- /** The `check` report, as text. Takes the object `inspect()` returns. */
60
- export function renderReport(report) {
70
+ /**
71
+ * The `check` report, as text. Takes the object `inspect()` returns.
72
+ *
73
+ * The map and one line per warning first; the paragraphs behind `verbose`.
74
+ * On a three-role repo the full report ran to forty lines of explanation under
75
+ * a six-line map, and the one warning a person could act on sat in the middle.
76
+ * Nothing is dropped: the protected paths, each warning's reason and the
77
+ * standing limits are one flag away, and the footer says how many.
78
+ */
79
+ export function renderReport(report, { verbose = true } = {}) {
61
80
  const lines = [`\n${C.b}${report.where}${C.off}\n\n`];
62
81
  const width = Math.max(...report.roles.map((r) => r.name.length), 4);
63
82
 
@@ -90,10 +109,10 @@ export function renderReport(report) {
90
109
  // What the kernel refuses inside a territory, and why. Shown because each one
91
110
  // takes something from a territory as written; capped because a monorepo has
92
111
  // a hooks directory per package and the reader needs the shape, not all 200.
93
- if (report.protected?.length) {
112
+ if (report.protected?.length && verbose) {
94
113
  const root = report.where ? report.where.replace(/\/[^/]*$/, "/") : "";
95
114
  const show = (p) => (root && p.startsWith(root) ? p.slice(root.length) : p.replace(/^\/Users\/[^/]+|^\/home\/[^/]+/, "~"));
96
- lines.push(` ${C.b}protected${C.off} ${C.dim}— inside a territory, refused anyway: something outside the box runs or reads it${C.off}\n`);
115
+ lines.push(` ${C.b}protected${C.off} ${C.dim}— inside a territory, denied anyway: something outside the sandbox runs or reads it${C.off}\n`);
97
116
  const LIMIT = 12;
98
117
  for (const e of report.protected.slice(0, LIMIT)) {
99
118
  const who = e.roles.length > 3 ? `${e.roles.slice(0, 3).join(", ")} +${e.roles.length - 3}` : e.roles.join(", ");
@@ -104,6 +123,18 @@ export function renderReport(report) {
104
123
  lines.push("\n");
105
124
  }
106
125
 
126
+ if (!verbose) {
127
+ for (const w of report.warnings) lines.push(` ${C.yellow}${w.headline}${C.off}\n`);
128
+ if (report.warnings.length) lines.push("\n");
129
+ const more = [
130
+ report.protected?.length ? `${report.protected.length} protected path(s)` : "",
131
+ report.warnings.some((w) => w.detail) ? "why each warning matters" : "",
132
+ report.limits?.length ? `${report.limits.length} standing limit(s)` : "",
133
+ ].filter(Boolean);
134
+ if (more.length) lines.push(` ${C.dim}seisin check --verbose: ${more.join(", ")}${C.off}\n\n`);
135
+ return lines.join("");
136
+ }
137
+
107
138
  for (const w of report.warnings) {
108
139
  lines.push(` ${C.yellow}${w.headline}${C.off}\n`);
109
140
  if (w.detail) lines.push(` ${C.dim}${w.detail}${C.off}\n`);
@@ -125,7 +156,7 @@ export function renderReport(report) {
125
156
  /** The `explain` answer, as text. Takes what `owners.explain()` returns. */
126
157
  export function renderVerdict(role, action, target, verdict) {
127
158
  const head = verdict.allowed ? `${C.green}allowed${C.off}` : `${C.yellow}denied${C.off}`;
128
- return `\n ${head} ${C.b}${role}${C.off} ${action} ${target}\n ${C.dim}${verdict.reason}${C.off}\n\n`;
159
+ return `\n ${head} ${C.b}${role}${C.off} ${action} ${safe(target)}\n ${C.dim}${safe(verdict.reason)}${C.off}\n\n`;
129
160
  }
130
161
 
131
162
  /* ── the log ──────────────────────────────────────────────────────────── */
@@ -149,7 +180,7 @@ export function renderEntry(e) {
149
180
  // about a run, a reader needs to know which line is which without going to
150
181
  // the file — so the one that carries more weight is the one that is marked.
151
182
  const from = e.source === "kernel" ? ` ${C.dim}[kernel]${C.off}` : "";
152
- return ` ${C.dim}${(e.at ?? "").slice(11, 19)}${C.off} ${mark} ${C.b}${e.role}${C.off} ${e.action} ${e.target}${owners}${from}\n`;
183
+ return ` ${C.dim}${(e.at ?? "").slice(11, 19)}${C.off} ${mark} ${C.b}${e.role}${C.off} ${e.action} ${safe(e.target)}${owners}${from}\n`;
153
184
  }
154
185
 
155
186
  /* ── the scan ─────────────────────────────────────────────────────────── */
@@ -166,16 +197,40 @@ export function renderScan(result, keyDirs) {
166
197
  const { certain, review, skipped, truncated } = result;
167
198
  const links = result.links ?? [];
168
199
  const where = keyDirs.length ? keyDirs.join(", ") : `${C.yellow}nowhere — [keys] dir is unset${C.off}`;
169
- const lines = [`\n ${C.dim}protected: ${where}${C.off}\n\n`];
200
+ const lines = [`\n ${C.dim}protected: ${where}${C.off}\n`];
201
+ // Scoped to the territories: say where it looked, and how to look everywhere.
202
+ if (result.scope) {
203
+ const shown = result.scope.slice(0, 10).map(safe).join(", ");
204
+ const more = result.scope.length > 10 ? ` and ${result.scope.length - 10} more` : "";
205
+ lines.push(` ${C.dim}scanned: where the territories are — ${shown}${more}. ${C.b}seisin scan --all${C.off}${C.dim} walks the whole folder${C.off}\n`);
206
+ }
207
+ lines.push("\n");
170
208
 
171
- if (certain.length === 0 && review.length === 0 && links.length === 0) {
172
- lines.push(` ${C.green}nothing credential-shaped outside the declared directories${C.off}\n\n`);
209
+ // A pruned checkout is part of what a clean result means, so it is said
210
+ // even when there is nothing else to say — in yellow, not dim: every role
211
+ // can still read it, and a dim line under a green verdict read as clean.
212
+ const paths = result.nestedPaths ?? [];
213
+ let nested = "";
214
+ if (skipped.nested) {
215
+ nested = ` ${C.yellow}not scanned: ${skipped.nested} nested checkout(s)${C.off} — another repository each; every role can still read them. Scan one with ${C.b}cd <dir> && seisin scan${C.off}\n`;
216
+ for (const p of paths.slice(0, 15)) nested += ` ${safe(p)}\n`;
217
+ if (paths.length > 15) nested += ` ${C.dim}… and ${paths.length - 15} more${C.off}\n`;
218
+ nested += "\n";
219
+ }
220
+ const named = result.named ?? [];
221
+ if (certain.length === 0 && review.length === 0 && links.length === 0 && named.length === 0) {
222
+ lines.push(skipped.nested
223
+ ? ` nothing credential-shaped in what was scanned ${C.dim}(outside the declared directories)${C.off}\n\n`
224
+ : ` ${C.green}nothing credential-shaped outside the declared directories${C.off}\n\n`, nested);
173
225
  return lines.join("");
174
226
  }
175
227
 
176
228
  if (certain.length) {
177
- lines.push(` ${C.red}${certain.length} credential(s)${C.off} — these shapes are issued, not written by accident\n\n`);
178
- for (const h of certain) lines.push(` ${C.b}${h.file}${C.off}${C.dim}:${h.line}${C.off} ${h.shape}\n`);
229
+ lines.push(` ${C.red}${certain.length} credential(s)${C.off} — issued shapes, or a password in a URL: a credential, not a word that mentions one\n\n`);
230
+ for (const h of certain) {
231
+ const whose = h.owners?.length ? ` ${C.dim}in ${h.owners.map(safe).join(", ")}'s territory${C.off}` : "";
232
+ lines.push(` ${C.b}${safe(h.file)}${C.off}${C.dim}:${h.line}${C.off} ${h.shape}${whose}\n`);
233
+ }
179
234
  lines.push("\n");
180
235
  }
181
236
 
@@ -189,6 +244,18 @@ export function renderScan(result, keyDirs) {
189
244
  lines.push("\n");
190
245
  }
191
246
 
247
+ // By name, not by content, and said that way: a .env of DEBUG=1 is on this
248
+ // list too. What it is for is the .env nobody declared.
249
+ if (named.length) {
250
+ lines.push(` ${C.yellow}${named.length} file(s) that usually hold secrets${C.off} — by their name, not certain; every role reads them\n\n`);
251
+ for (const h of named.slice(0, 15)) {
252
+ const whose = h.owners?.length ? ` ${C.dim}in ${h.owners.map(safe).join(", ")}'s territory${C.off}` : "";
253
+ lines.push(` ${C.b}${safe(h.file)}${C.off}${whose}\n`);
254
+ }
255
+ if (named.length > 15) lines.push(` ${C.dim}… and ${named.length - 15} more${C.off}\n`);
256
+ lines.push("\n");
257
+ }
258
+
192
259
  // Links are their own finding, not a line with a secret-shaped name in it.
193
260
  // What is reported is that the link leaves the repo — the destination is
194
261
  // named and never opened, because the point is that it exists.
@@ -206,7 +273,9 @@ export function renderScan(result, keyDirs) {
206
273
  if (skipped.ignored) quiet.push(`${skipped.ignored} ignored path(s)`);
207
274
  if (skipped.protectedDirs) quiet.push(`${skipped.protectedDirs} inside declared key dir(s)`);
208
275
  if (quiet.length) lines.push(` ${C.dim}not reported: ${quiet.join(" · ")}${C.off}\n`);
209
- if (truncated) lines.push(` ${C.yellow}stopped at the limit — there are more${C.off}\n`);
276
+ // Only the ones to review are capped; a certain hit is always listed above.
277
+ if (truncated) lines.push(` ${C.yellow}${result.omitted ?? "more"} more to review were not listed — the cap applies to review, never to certain hits${C.off}\n`);
278
+ if (nested) lines.push(nested);
210
279
  lines.push(` ${C.dim}seisin does not move these. Where a credential lives is your call.${C.off}\n\n`);
211
280
  return lines.join("");
212
281
  }
package/src/requests.js CHANGED
@@ -1,38 +1,41 @@
1
1
  /**
2
- * The queue of permissions an agent asked for and cannot have yet.
2
+ * The queue of requests: what an agent asked for and cannot have yet.
3
3
  *
4
4
  * ── Two words, kept apart on purpose ──
5
- * **Refused** is what the boundary does. **Declined** is what a person does to
5
+ * **Denied** is what the boundary does. **Declined** is what a person does to
6
6
  * a request. They used to be one word in one command: an entry read `first
7
7
  * refused on src/api/x.ts` and `seisin deny` answered `refused frontend ✕
8
8
  * src/api/**`. One means the kernel said no, the other means you did, and a
9
9
  * reader had nothing to tell them apart by.
10
10
  *
11
11
  * The entry says **asked** now, because refused was not always true. Asking for
12
- * a permission before reaching for it is reasonable, and nothing required a
12
+ * a path before reaching for it is reasonable, and nothing required a
13
13
  * denial to have happened first — so the queue could print a sentence about an
14
- * event that never occurred, in front of a person about to approve something.
15
- * `seisin run` drops any request the policy does not actually refuse; what is
14
+ * event that never occurred, in front of a person about to grant something.
15
+ * `seisin run` drops any request the policy does not actually deny; what is
16
16
  * left is "you cannot have this, and you asked", which holds whether the agent
17
17
  * tried or simply asked.
18
18
  *
19
19
  * A denial already contains everything a request needs — the hook knows the
20
- * role, the path and the owner — so the refusal leaves something behind that a
20
+ * role, the path and the owner — so the denial leaves something behind that a
21
21
  * person can act on in one command instead of in an editor. See
22
- * `docs/permission-requests.md` for why approving is deliberately not something
22
+ * `docs/permission-requests.md` for why granting is deliberately not something
23
23
  * an agent can do.
24
24
  *
25
25
  * Append-only, beside the log, for the same reason: a decision that can be
26
- * rewritten is not evidence. A request that is granted or refused is not
26
+ * rewritten is not evidence. A request that is granted or declined is not
27
27
  * deleted — it is followed by a line saying what happened to it.
28
28
  */
29
- import { neverWrites, isGitMetadata, covers } from "./owners.js";
30
- import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
29
+ import { neverWrites, isGitMetadata, covers, keyHolders, inKeyDir } from "./owners.js";
30
+ import { protectedBy } from "./surface.js";
31
+ import { appendFileSync, mkdirSync, readFileSync, statSync, writeFileSync, renameSync, unlinkSync } from "node:fs";
32
+ import { randomBytes } from "node:crypto";
31
33
  import { dirname, join } from "node:path";
32
34
  import { STATE_DIR, tomlString } from "./layout.js";
33
35
  import { send } from "./spool.js";
36
+ import { withLock, readEntries } from "./log.js";
34
37
 
35
- export const REQUESTS_NAME = "requests.jsonl";
38
+ const REQUESTS_NAME = "requests.jsonl";
36
39
 
37
40
  export function requestsPath(root) {
38
41
  return join(root, STATE_DIR, REQUESTS_NAME);
@@ -56,14 +59,34 @@ export function keyOf({ role, action, target }) {
56
59
  return `${role}:${action}:${dir}`;
57
60
  }
58
61
 
62
+ /**
63
+ * A request's id as one shell word, for the commands seisin suggests.
64
+ *
65
+ * The id carries a path an agent chose, so it is single-quoted whole and any
66
+ * quote inside it is closed and escaped: pasting the suggestion runs `seisin
67
+ * grant` on that request and nothing else.
68
+ */
69
+ export function shellId(key) {
70
+ return `'${String(key).replace(/'/g, `'\\''`)}'`;
71
+ }
72
+
59
73
  /** The glob a grant would add, derived from what was asked. */
60
- export function grantFor({ action, target }) {
74
+ export function grantFor({ action, target }, keyDir = null) {
61
75
  const t = typeof target === "string" ? target : "";
62
76
  // A key keeps its directory when it has one. Stripping it turned a request
63
77
  // for `shared/api.txt` into a grant of `api.txt`, which settingsFor then
64
- // resolves against the FIRST key directory — so the person approves one file
78
+ // resolves against the FIRST key directory — so the person grants one file
65
79
  // and another one of the same name is what gets read.
66
- if (action === "read") return t.includes("/") ? t : t.replace(/^.*\//, "");
80
+ //
81
+ // Except the first key directory itself, which is exactly the one a bare
82
+ // name resolves against: `.secrets/stripe.txt` is written `stripe.txt`, the
83
+ // way every hand-written key is. Granting the long form put two spellings of
84
+ // one key in the same file.
85
+ if (action === "read") {
86
+ const first = keyDir ? String(keyDir).replace(/^\.\/+/, "").replace(/\/+$/, "") : null;
87
+ if (first && t.startsWith(first + "/") && !t.slice(first.length + 1).includes("/")) return t.slice(first.length + 1);
88
+ return t.includes("/") ? t : t.replace(/^.*\//, "");
89
+ }
67
90
  const dir = t.split("/").slice(0, -1).join("/");
68
91
  return dir ? `${dir}/**` : t;
69
92
  }
@@ -72,7 +95,9 @@ function write(file, entry) {
72
95
  if (send("requests", entry)) return true;
73
96
  try {
74
97
  mkdirSync(dirname(file), { recursive: true });
75
- appendFileSync(file, JSON.stringify({ at: new Date().toISOString(), ...entry }) + "\n");
98
+ // The time is this process's, named after the spread so no entry sets it.
99
+ const now = new Date().toISOString();
100
+ appendFileSync(file, JSON.stringify({ at: now, ...entry, at: now }) + "\n");
76
101
  return true;
77
102
  } catch {
78
103
  // Same rule as the log: never take the agent down over bookkeeping.
@@ -80,8 +105,27 @@ function write(file, entry) {
80
105
  }
81
106
  }
82
107
 
108
+ /**
109
+ * Who a read request's key belongs to, from the policy as it is now.
110
+ *
111
+ * A key is declared, not owned: nobody writes `.secrets/`, so the owners a
112
+ * write-territory lookup finds for one are always none, and the queue said
113
+ * "(unowned)" about a key another role declares — right before a grant that
114
+ * would share it. Recomputed rather than read from the line, like every other
115
+ * field that decides what a person is shown.
116
+ */
117
+ export function withDeclarers(config, queue) {
118
+ for (const r of queue) {
119
+ if (r.action !== "read" || typeof r.target !== "string") continue;
120
+ if (!inKeyDir(config, r.target) && r.target.includes("/")) continue;
121
+ r.owners = keyHolders(config, r.target);
122
+ r.declared = true;
123
+ }
124
+ return queue;
125
+ }
126
+
83
127
  /** Records that a role asked for something the policy denies it. Safe to call
84
- * on every denial; `seisin run` drops anything the policy does not refuse. */
128
+ * on every denial; `seisin run` drops anything the policy does not deny. */
85
129
  export function record(file, { role, action, target, owners }) {
86
130
  return write(file, { kind: "asked", key: keyOf({ role, action, target }), role, action, target, owners: owners ?? [] });
87
131
  }
@@ -103,6 +147,9 @@ export function settle(file, key, decision, reason = "") {
103
147
  *
104
148
  * It exists so that a refusal to run cannot be invisible. Admission may
105
149
  * postpone work; it may not make work disappear from the queue a person reads.
150
+ *
151
+ * Nothing in src/ calls it today — handoff.js decides but nothing records the
152
+ * outcome; the tests do, and pending() keeps reading what it writes.
106
153
  */
107
154
  export function recordHandoff(file, key, decision = {}) {
108
155
  const { type, role, limit, max, reason, depth, chainId, revision } = decision;
@@ -123,20 +170,35 @@ export function recordHandoff(file, key, decision = {}) {
123
170
  * it was asked and what was decided.
124
171
  *
125
172
  * Reduced from the whole file rather than kept as state, so the file stays the
126
- * only thing that has to be correct.
173
+ * only thing that has to be correct. The reduction is remembered for as long as
174
+ * the file is unchanged (same inode, size and mtime): the console asks every
175
+ * two seconds, and re-parsing a 2 MB queue each time to find it had not moved
176
+ * was most of the cost of asking. Each call gets its own copies, so a caller
177
+ * marking entries (markStale) cannot mark the next caller's.
127
178
  */
128
- export function pending(file, { includeSettled = false } = {}) {
129
- if (!existsSync(file)) return [];
179
+ let reduced = null; // { file, ino, size, mtimeMs, all }
180
+
181
+ export function pending(file, { includeSettled = false, keyDirs = null } = {}) {
182
+ let st;
183
+ try { st = statSync(file); } catch { return []; }
184
+ let all;
185
+ if (reduced && reduced.file === file && reduced.ino === st.ino && reduced.size === st.size && reduced.mtimeMs === st.mtimeMs) {
186
+ all = reduced.all;
187
+ } else {
188
+ all = reduce(file);
189
+ reduced = { file, ino: st.ino, size: st.size, mtimeMs: st.mtimeMs, all };
190
+ }
191
+ // The grant is re-derived with the policy's first key directory when the
192
+ // caller has one, so a key is proposed in the form hand-written keys use.
193
+ const first = keyDirs?.[0] ?? null;
194
+ const copies = all.map((r) => (first && r.action === "read" ? { ...r, grant: grantFor(r, first) } : { ...r }));
195
+ return includeSettled ? copies : copies.filter((r) => r.state === "pending");
196
+ }
197
+
198
+ function reduce(file) {
130
199
  const byKey = new Map();
131
200
 
132
- for (const line of readFileSync(file, "utf8").split("\n")) {
133
- if (!line.trim()) continue;
134
- let e;
135
- try {
136
- e = JSON.parse(line);
137
- } catch {
138
- continue; // a half-written line from a killed process is not an error
139
- }
201
+ for (const e of readEntries(file)) {
140
202
  if (!e.key) continue;
141
203
 
142
204
  if (e.kind === "asked") {
@@ -157,7 +219,8 @@ export function pending(file, { includeSettled = false } = {}) {
157
219
  *
158
220
  * Without this branch the catch-all below would set state = "handoff" and a
159
221
  * throttled attempt would drop out of the pending queue — the exact silent
160
- * disappearance the attempt is recorded to prevent.
222
+ * disappearance the attempt is recorded to prevent. (Only the tests write
223
+ * handoff lines today — see recordHandoff.)
161
224
  */
162
225
  if (e.kind === "handoff") {
163
226
  seen.handoff = {
@@ -170,14 +233,13 @@ export function pending(file, { includeSettled = false } = {}) {
170
233
  seen.state = e.kind; seen.reason = e.reason ?? ""; seen.decided = e.at;
171
234
  }
172
235
 
173
- const all = [...byKey.values()];
174
- return includeSettled ? all : all.filter((r) => r.state === "pending");
236
+ return [...byKey.values()];
175
237
  }
176
238
 
177
239
  /**
178
240
  * How many runs of the role, after the last time it asked, make a request old.
179
241
  *
180
- * A request is recorded again on every refusal, so one the role still needs
242
+ * A request is recorded again on every denial, so one the role still needs
181
243
  * keeps a fresh `last`. One whose need went away — the work moved somewhere the
182
244
  * role may write, or the task ended — stops being asked while the role keeps
183
245
  * running. Three runs is enough to say "not since" and few enough that a queue
@@ -221,7 +283,7 @@ export function runsAfter(entries, since, gapMs = RUN_GAP_MS) {
221
283
  * reordering would shift the numbers `grant <n>` is typed against while
222
284
  * nobody decided anything. The mark says what is true — the role ran N times
223
285
  * since and did not ask again — and leaves the rest to whoever reads it.
224
- * `entries` are log lines (`read(logPath(root))`); `now` is for the tests.
286
+ * `entries` are log lines (`read(logPath(root))`).
225
287
  */
226
288
  export function markStale(queue, entries, { runs = STALE_RUNS, gapMs = RUN_GAP_MS } = {}) {
227
289
  const byRole = new Map(); // role -> its log lines
@@ -236,35 +298,13 @@ export function markStale(queue, entries, { runs = STALE_RUNS, gapMs = RUN_GAP_M
236
298
  return queue;
237
299
  }
238
300
 
239
- /**
240
- * Adds a granted glob to a role in the config text, with its provenance.
241
- *
242
- * Edits the text rather than re-emitting the file, because a config someone
243
- * wrote has comments and an order that mean something, and a tool that
244
- * reformats it on every grant is a tool people stop letting near it.
245
- */
246
- /**
247
- * Adds a granted glob to a role in the config text, with its provenance.
248
- *
249
- * Edits the text rather than re-emitting the file, because a config someone
250
- * wrote has comments and an order that mean something, and a tool that
251
- * reformats it on every grant is a tool people stop letting near it.
252
- *
253
- * The edit is bounded to the role's own section, and that is not tidiness. The
254
- * first version searched from the role header to the next `writes =` anywhere
255
- * in the file, so approving for a role that had no `writes` line wrote the
256
- * grant into the NEXT role — with a comment saying who it was for. A human
257
- * approved one thing and the file recorded another. In a permission tool that
258
- * is the worst possible bug, and it is not exotic: a role with only `keys`
259
- * declared is an ordinary config.
260
- */
261
301
  /**
262
302
  * Refuses a grant that `never_writes` would cancel, before anything is written.
263
303
  *
264
304
  * A request filed before the subtraction existed can still be in the queue.
265
- * Approving it would add the path to `writes` and change nothing — denyWrite
305
+ * Granting it would add the path to `writes` and change nothing — denyWrite
266
306
  * wins — so the person would believe they granted something the kernel still
267
- * refuses. The two keys contradict each other, and which one should win is the
307
+ * denies. The two settings contradict each other, and which one should win is the
268
308
  * decision; seisin will not make it by writing one of them silently.
269
309
  */
270
310
  export function refuseIfBarred(config, request) {
@@ -276,6 +316,18 @@ export function refuseIfBarred(config, request) {
276
316
  `${request.target} is under never_writes of ${request.role} ("${hit}"), which wins over ` +
277
317
  `writes. Granting it would change nothing. If the subtraction is wrong, remove that ` +
278
318
  `entry from [roles.${request.role}]; otherwise decline this request.`);
319
+ // A protected file stays denied whatever `writes` says: the kernel is given
320
+ // it as a deny, and explain answers the same. Granting `web/.vscode/**`
321
+ // used to add the line and change nothing — a grant that reads as done.
322
+ const guard = protectedBy(config, request.target, { role: request.role });
323
+ if (guard)
324
+ throw new Error(guard.family
325
+ ? `${request.target} is protected (${guard.why}). A grant cannot open it: ${request.role} ` +
326
+ `writes it only if the policy hands it the family, control_files = ["${guard.family}"] ` +
327
+ `under [roles.${request.role}], inside its own territory. Edit the policy if that is ` +
328
+ `what you mean; otherwise decline this request.`
329
+ : `${request.target} is protected (${guard.why}) and no role can ever be granted it. ` +
330
+ `Decline this request.`);
279
331
  // Only a repository the role does not write: its own `.git` is its own business.
280
332
  if (isGitMetadata(request.target) && !(role?.writes ?? []).some((g) => covers(g, request.target)))
281
333
  throw new Error(
@@ -283,6 +335,93 @@ export function refuseIfBarred(config, request) {
283
335
  `rewrite another owner's history. Decline it; a role that needs to commit gets a worktree of its own.`);
284
336
  }
285
337
 
338
+ /**
339
+ * Apply one policy edit under a lock, and write it atomically.
340
+ *
341
+ * `grant` and the console both did read → applyGrant → write with no lock, so
342
+ * two approvals racing (a person at the CLI and one on the console, or two
343
+ * terminals) lost one edit: the queue marked both granted while `seisin.toml`
344
+ * kept only the last writer's change (measured: 39 of 40 concurrent pairs). The
345
+ * lock serialises them — it already survives a writer that died holding it
346
+ * (see log.js) — and the write goes to a temp file then renames over the config,
347
+ * so a crash mid-write cannot leave a half-written, partly-privileged policy.
348
+ *
349
+ * `mutate(toml)` returns `{ toml, changed }`. Nothing is written when unchanged.
350
+ * `after(result)` runs once the file is written, still under the lock — for
351
+ * the queue line that records the decision, so a decision and its edit are
352
+ * one step to anyone else taking this lock.
353
+ */
354
+ export function editPolicy(config, mutate, { waitMs = 10000, after = null } = {}) {
355
+ return withLock(policyLockBase(config), () => {
356
+ const before = readFileSync(config.path, "utf8");
357
+ const result = mutate(before);
358
+ if (result.changed) writePolicy(config, result.toml);
359
+ after?.(result);
360
+ return result;
361
+ }, { waitMs });
362
+ }
363
+
364
+ /**
365
+ * Where the policy's lock lives: inside `.seisin/`, never beside the policy.
366
+ *
367
+ * It was `seisin.toml.lock`, next to the file. Any role that writes the repo
368
+ * root — every `--observe` run, any `writes = ["**"]` — could create that name
369
+ * holding a live pid and hold up every grant and every decline for as long as
370
+ * it liked. `.seisin/` is denied to every role (surface.js, parentInputs), so
371
+ * nothing confined can hold this one. withLock adds the `.lock`.
372
+ */
373
+ export function policyLockBase(config) {
374
+ const dir = join(config.root, STATE_DIR);
375
+ mkdirSync(dir, { recursive: true });
376
+ return join(dir, "policy");
377
+ }
378
+
379
+ /**
380
+ * Write the policy through a temp file that nobody could have prepared.
381
+ *
382
+ * The temp was `seisin.toml.tmp-<pid>` beside the policy: a predictable name,
383
+ * in a directory roles can write, opened by writeFileSync — which follows a
384
+ * symlink. A role could plant that name as a link into its own territory, and
385
+ * the next grant made the policy itself a symlink the role could rewrite.
386
+ * Now the temp sits in `.seisin/`, has a random name, and is opened with `wx`
387
+ * (O_CREAT|O_EXCL), which refuses anything already there, a link included.
388
+ *
389
+ * A `.seisin/` on another filesystem (a symlink a person made) cannot be
390
+ * renamed across; then the temp goes beside the policy, still random and still
391
+ * exclusive, which is what defeats a planted name.
392
+ */
393
+ function writePolicy(config, text) {
394
+ const name = `seisin.toml.tmp-${randomBytes(12).toString("hex")}`;
395
+ const stateTmp = join(config.root, STATE_DIR, name);
396
+ writeFileSync(stateTmp, text, { flag: "wx" });
397
+ try {
398
+ renameSync(stateTmp, config.path);
399
+ return;
400
+ } catch (e) {
401
+ try { unlinkSync(stateTmp); } catch {}
402
+ if (e.code !== "EXDEV") throw e;
403
+ }
404
+ const near = join(dirname(config.path), `.${name}`);
405
+ writeFileSync(near, text, { flag: "wx" });
406
+ try { renameSync(near, config.path); }
407
+ catch (e) { try { unlinkSync(near); } catch {} throw e; }
408
+ }
409
+
410
+ /**
411
+ * Adds a granted glob to a role in the config text, with its provenance.
412
+ *
413
+ * Edits the text rather than re-emitting the file, because a config someone
414
+ * wrote has comments and an order that mean something, and a tool that
415
+ * reformats it on every grant is a tool people stop letting near it.
416
+ *
417
+ * The edit is bounded to the role's own section, and that is not tidiness. The
418
+ * first version searched from the role header to the next `writes =` anywhere
419
+ * in the file, so granting for a role that had no `writes` line wrote the
420
+ * grant into the NEXT role — with a comment saying who it was for. A person
421
+ * granted one thing and the file recorded another. In a permission layer that
422
+ * is the worst possible bug, and it is not exotic: a role with only `keys`
423
+ * declared is an ordinary config.
424
+ */
286
425
  export function applyGrant(toml, request, note = "") {
287
426
  const field = request.action === "read" ? "keys" : "writes";
288
427
  const header = new RegExp(`^\\[roles\\.${escapeRe(request.role)}\\]\\s*$`, "m");
@@ -295,43 +434,82 @@ export function applyGrant(toml, request, note = "") {
295
434
  const to = next ? from + next.index : toml.length;
296
435
  const section = toml.slice(from, to);
297
436
 
298
- const line = new RegExp(`^(${field}\\s*=\\s*)\\[([^\\]]*)\\]`, "m");
299
- const m = line.exec(section);
300
- if (!m)
437
+ const open = new RegExp(`^${field}\\s*=\\s*\\[`, "m").exec(section);
438
+ if (!open)
301
439
  throw new Error(
302
440
  `[roles.${request.role}] has no ${field} list to grant into. ` +
303
- `Add \`${field} = []\` to that section first — seisin will not write it into another role's block.`
441
+ `Add \`${field} = []\` to that section first — seisin will not write it into another role's table.`
304
442
  );
305
443
 
306
444
  // The subset has no escapes, so a value carrying a quote or a newline cannot
307
445
  // be written down at all. The strict parser would reject the result rather
308
446
  // than widen anything — but leaving someone with a config that no longer
309
- // loads, after they approved something, is its own kind of broken.
447
+ // loads, after they granted something, is its own kind of broken.
310
448
  tomlString(request.grant); // refuses what the format cannot hold
311
449
 
312
- const items = (m[2].match(/"[^"]*"/g) ?? []).map((s) => s.slice(1, -1));
313
- if (items.includes(request.grant)) return { toml, changed: false };
450
+ const start = open.index + open[0].length;
451
+ const list = scanList(section, start);
452
+ if (list.items.includes(request.grant)) return { toml, changed: false };
314
453
 
454
+ /**
455
+ * Appended, never rebuilt.
456
+ *
457
+ * The list used to be re-written from every quoted string between its
458
+ * brackets — comments included. `"src/web/**", # was "src/**"` came back as
459
+ * two grants, so granting anything also granted what a comment mentioned,
460
+ * and the reason a person typed (quoted in the provenance comment) became
461
+ * a path on the next grant. Every earlier provenance comment was lost on
462
+ * the way. Now the items are the strings outside comments, what is there is
463
+ * left as it is, and the new entry goes last. The reason is set off with
464
+ * «», never a double quote, so no older reader can take it for a value.
465
+ */
466
+ let inner = section.slice(start, list.end).replace(/\s+$/, "");
467
+ if (list.lastItemEnd !== null && !list.commaAfterLast) {
468
+ const at = list.lastItemEnd - start;
469
+ inner = inner.slice(0, at) + "," + inner.slice(at);
470
+ }
315
471
  const stamp = `# granted ${new Date().toISOString().slice(0, 10)} · asked ${request.times}×` +
316
- `${note ? ` · "${cleanReason(note)}"` : ""}`;
317
- const body = [...items, request.grant]
318
- .map((v) => ` "${v}"${v === request.grant ? `, ${stamp}` : ","}`)
319
- .join("\n")
320
- .replace(/,(\s*#[^\n]*)?$/, "$1");
321
-
322
- const edited = section.replace(line, `$1[\n${body}\n]`);
472
+ `${note ? ` · «${cleanReason(note)}»` : ""}`;
473
+ const edited = section.slice(0, start) + `${inner}\n "${request.grant}" ${stamp}\n]` + section.slice(list.end + 1);
323
474
  return { toml: toml.slice(0, from) + edited + toml.slice(to), changed: true };
324
475
  }
325
476
 
326
477
  /**
327
- * The approver's own words, made safe to put in a config file.
478
+ * The strings of a TOML array that starts at `start` (just past its `[`),
479
+ * skipping comments, and where it closes. The subset has no escapes, so a
480
+ * string runs to the next double quote.
481
+ */
482
+ function scanList(text, start) {
483
+ const items = [];
484
+ let lastItemEnd = null;
485
+ let commaAfterLast = false;
486
+ for (let i = start; i < text.length; i++) {
487
+ const c = text[i];
488
+ if (c === "#") { const nl = text.indexOf("\n", i); if (nl === -1) break; i = nl; continue; }
489
+ if (c === '"') {
490
+ const close = text.indexOf('"', i + 1);
491
+ if (close === -1) break;
492
+ items.push(text.slice(i + 1, close));
493
+ lastItemEnd = close + 1;
494
+ commaAfterLast = false;
495
+ i = close;
496
+ continue;
497
+ }
498
+ if (c === ",") { commaAfterLast = true; continue; }
499
+ if (c === "]") return { items, end: i, lastItemEnd, commaAfterLast };
500
+ }
501
+ throw new Error(`the ${text.slice(0, 40).trim()}… list never closes; fix the config by hand before granting`);
502
+ }
503
+
504
+ /**
505
+ * The reason a person typed, made safe to put in a config file.
328
506
  *
329
507
  * A reason is free text typed by a person, and a person can be talked into
330
508
  * typing something — "paste this as the reason" is a plausible thing for an
331
509
  * agent to suggest. Newlines in a comment would end the comment, and what
332
510
  * follows is parsed as TOML. The strict parser rejects the result rather than
333
511
  * widening anything, so the worst case is a config that no longer loads, but a
334
- * permission file that can be broken by a sentence is still broken.
512
+ * policy that can be broken by a sentence is still broken.
335
513
  */
336
514
  export function cleanReason(s) {
337
515
  return String(s ?? "")