seisin 0.4.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +142 -54
  2. package/package.json +3 -3
  3. package/src/cli.js +145 -46
  4. package/src/commands/check.js +3 -2
  5. package/src/commands/explain.js +34 -13
  6. package/src/commands/hook.js +14 -4
  7. package/src/commands/init.js +126 -38
  8. package/src/commands/log.js +48 -9
  9. package/src/commands/requests.js +109 -32
  10. package/src/commands/review.js +41 -16
  11. package/src/commands/run.js +159 -37
  12. package/src/commands/scan.js +18 -3
  13. package/src/commands/ui.js +18 -5
  14. package/src/commands/walls.js +25 -6
  15. package/src/commands/whose.js +7 -6
  16. package/src/commands/wire.js +156 -17
  17. package/src/config.js +187 -25
  18. package/src/controls.js +244 -0
  19. package/src/diagnose.js +35 -33
  20. package/src/env.js +11 -8
  21. package/src/grants.js +15 -7
  22. package/src/help.js +159 -0
  23. package/src/hook.js +49 -19
  24. package/src/index.js +4 -4
  25. package/src/inspect.js +137 -71
  26. package/src/intake.js +20 -15
  27. package/src/keys.js +13 -11
  28. package/src/kinds.js +207 -0
  29. package/src/log.js +236 -51
  30. package/src/mcp.js +42 -28
  31. package/src/notify.js +13 -8
  32. package/src/owners.js +198 -41
  33. package/src/paths.js +37 -1
  34. package/src/redact.js +19 -5
  35. package/src/render.js +68 -10
  36. package/src/requests.js +173 -72
  37. package/src/review.js +30 -17
  38. package/src/rundir.js +4 -6
  39. package/src/scan.js +177 -19
  40. package/src/serve.js +377 -229
  41. package/src/spool.js +9 -29
  42. package/src/srt.js +38 -21
  43. package/src/suggest.js +58 -0
  44. package/src/surface.js +191 -44
  45. package/src/uilink.js +10 -4
  46. package/src/views.js +289 -0
  47. package/src/violations.js +104 -16
  48. package/src/walls.js +19 -42
  49. package/src/worktree.js +3 -11
  50. package/ui/index.html +1356 -537
package/src/serve.js CHANGED
@@ -11,174 +11,123 @@
11
11
  * key each role can read, which is a map of where the credentials live.
12
12
  */
13
13
  import { createServer } from "node:http";
14
- import { randomBytes, timingSafeEqual } from "node:crypto";
15
- import { readFileSync, existsSync } from "node:fs";
14
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
15
+ import { readFileSync, existsSync, statSync } from "node:fs";
16
16
  import { join, dirname } from "node:path";
17
17
  import { fileURLToPath } from "node:url";
18
18
  import { loadConfig } from "./config.js";
19
- import { read, logPath } from "./log.js";
19
+ import { read, logPath, logSegments } from "./log.js";
20
20
  import { settingsFor } from "./srt.js";
21
- import { pending, requestsPath, settle, applyGrant, refuseIfBarred, markStale, editPolicy } from "./requests.js";
22
- import { walls } from "./walls.js";
23
- import { explain, standingOf } from "./owners.js";
21
+ import { pending, requestsPath, settle, applyGrant, refuseIfBarred, editPolicy, cleanReason } from "./requests.js";
22
+ import { causesOf, describeWalls, parseSince, queue, verdicts, wallsByRole } from "./views.js";
23
+ import { planEdit, hashOf } from "./controls.js";
24
+
25
+ // Moved to views.js, which the MCP server shares; kept importable from here.
26
+ export { causesOf } from "./views.js";
24
27
 
25
28
  const HERE = dirname(fileURLToPath(import.meta.url));
26
29
 
27
30
  /**
28
- * Everything the page needs, read fresh on each request.
29
- *
30
- * Read fresh rather than cached because the file on disk is the truth and the
31
- * page is a view of it — someone editing seisin.toml in an editor should see
32
- * the console change, not wonder why it disagrees.
31
+ * `?since=<ISO date>`, or null for no filter. Anything that does not parse is
32
+ * ignored rather than refused: a bad filter should show everything, not an error.
33
33
  */
34
+ export function sinceOf(url) {
35
+ return parseSince(new URLSearchParams((url ?? "").split("?")[1] ?? "").get("since"));
36
+ }
37
+
34
38
  /**
35
- * Today's denials, grouped by what was denied rather than by who asked.
36
- *
37
- * Called `causes` and not `friction`: `seisin review` already has a `friction`
38
- * that counts something narrower — repeated denials that are not about keys —
39
- * and two screens of one product reporting different numbers under one label
40
- * is the kind of thing nobody notices until they are compared.
39
+ * The files the state is made of, as they are on disk now: the policy's text,
40
+ * and the size and mtime of every log segment and of the queue.
41
41
  *
42
- * The console used to show them per role, which is the same list a hundred
43
- * times over: measured on one deployment, 1,080 of 1,422 refusals were a
44
- * single lock file across six repositories, and per-role that reads as six
45
- * hundred identical rows instead of one sentence. Grouped, the shape of the
46
- * day is visible at a glance and it is almost never a territory dispute.
42
+ * It was read fresh on every request, because the file on disk is the truth
43
+ * and the page is a view of it — someone editing seisin.toml in an editor
44
+ * should see the console change. That still holds: anything that changes one
45
+ * of these files changes the signature. What no longer happens is recomputing
46
+ * 1–2 s of causes and walls every two seconds to find nothing had moved
47
+ * (measured on a multi-repo workspace's log, 7,728 lines, 32 roles).
48
+ */
49
+ const memo = { path: null, hash: null, bucket: null, cfg: null, logSig: null, all: null, derived: new Map(), bodies: new Map() };
50
+
51
+ /**
52
+ * How long a loaded config and what was derived from it are trusted.
47
53
  *
48
- * `stillRefused` is recomputed against the policy as it stands, not read out
49
- * of the log — a cause that has been granted since is history, not friction,
50
- * and leaving it on the page sends somebody to fix what is already fixed.
54
+ * Not everything the page shows is a function of these files. Some of it is
55
+ * the disk around them — a key directory that became a symlink, a provider's
56
+ * executable that appeared, a control file that now exists (settingsFor, the
57
+ * walls' "would still be refused today") — and with the key made of file
58
+ * signatures alone, that stayed as it was first seen for the server's life,
59
+ * defeating the one-minute memory views.verdicts keeps for the same reason.
60
+ * A minute is that memory's own span.
51
61
  */
52
- export function causesOf(cfg, entries) {
53
- const by = new Map();
54
- for (const e of entries) {
55
- if (e.verdict !== "denied" || !e.target || !e.action) continue;
56
- const k = `${e.action}\u0000${e.target}`;
57
- const g = by.get(k) ?? { action: e.action, target: e.target, kind: e.kind, times: 0, roles: new Set(), ownersThen: e.owners ?? [] };
58
- g.times++;
59
- g.roles.add(e.role);
60
- by.set(k, g);
61
- }
62
- const total = [...by.values()].reduce((n, g) => n + g.times, 0);
62
+ const MEMO_SPAN = 60_000;
63
63
 
64
- /**
65
- * The same grouping again, one level coarser: by the NAME at the end of the
66
- * path rather than the path.
67
- *
68
- * Grouping by path alone gets the most important reading backwards. Measured
69
- * here: six of the top seven causes were `.git/index.lock` in six different
70
- * repositories, no single one above 17% — so "no cause dominates" is what the
71
- * arithmetic says and the opposite of what is true. Three quarters of the
72
- * window was one *kind* of thing, and that is a tooling problem with a
73
- * mechanical fix, not a territory question anybody needs to rule on.
74
- *
75
- * By last segment, and nothing cleverer. A regex that recognised lock files,
76
- * caches and build outputs would be a list of guesses about other people's
77
- * toolchains that quietly goes stale; a repeated filename is a fact about
78
- * this log.
79
- */
80
- const fams = new Map();
81
- for (const g of by.values()) {
82
- const name = g.target.split("/").filter(Boolean).pop() ?? g.target;
83
- const f = fams.get(name) ?? { name, times: 0, paths: 0, where: [], roles: new Set() };
84
- f.times += g.times;
85
- f.paths++;
86
- // The places, so the console can show what a name is made of instead of
87
- // asserting a percentage the reader has to take on faith.
88
- f.where.push({ target: g.target, times: g.times });
89
- for (const r of g.roles) f.roles.add(r);
90
- fams.set(name, f);
91
- }
92
- const families = [...fams.values()]
93
- .sort((a, b) => b.times - a.times)
94
- .map((f) => ({
95
- name: f.name, times: f.times, paths: f.paths,
96
- share: total ? f.times / total : 0,
97
- roles: [...f.roles].sort(),
98
- where: f.where.sort((a, b) => b.times - a.times).slice(0, 20),
99
- }));
64
+ /** How many causes the page is sent; it shows twelve and says how many more. */
65
+ const CAUSES_SHOWN = 200;
100
66
 
101
- /**
102
- * What a `grep` over the log cannot tell you: where each refused path stands
103
- * against the policy as it is now.
104
- *
105
- * The headline used to be "74% of it is one name", and a field review
106
- * applied this project's own test to it: *did the number tell you something
107
- * you did not know?* For somebody who reads the raw log, no. So the console
108
- * added how many causes are on paths no role owns — a decision rather than a
109
- * number — and then computed it from the `owners` each log line carried,
110
- * which is the one thing the log already had. It said the policy and read
111
- * the past: every grant made since left its refusals counted as "nobody's",
112
- * and protected surfaces, ports and paths outside the repository, which
113
- * nobody can ever own, were counted as waiting for somebody to claim them.
114
- *
115
- * Now every cause is classified by `standing` (owners.js), and the four
116
- * piles are reported in paths and in refusals. `unowned` keeps its name and
117
- * now means what the page always said it meant. The owners the log recorded
118
- * are kept on each cause as `ownersThen`: evidence of what was true, not
119
- * authority over what is.
120
- */
121
- const standOf = standingOf(cfg);
122
- const piles = Object.fromEntries(["unowned", "owned", "protected", "outside"].map((k) => [k, { paths: 0, denials: 0 }]));
123
- for (const g of by.values()) {
124
- const s = standOf({ kind: g.kind, target: g.target });
125
- g.standing = s;
126
- piles[s.kind].paths++;
127
- piles[s.kind].denials += g.times;
67
+ function statSig(p) {
68
+ try { const s = statSync(p); return `${p}:${s.ino}:${s.size}:${s.mtimeMs}`; } catch { return `${p}:-`; }
69
+ }
70
+
71
+ function snapshot(configPath) {
72
+ const text = readFileSync(configPath, "utf8");
73
+ const hash = createHash("sha256").update(text).digest("hex");
74
+ const bucket = Math.floor(Date.now() / MEMO_SPAN);
75
+ if (memo.path !== configPath || memo.hash !== hash || memo.bucket !== bucket) {
76
+ Object.assign(memo, { path: configPath, hash, bucket, cfg: loadConfig(configPath), logSig: null, all: null });
77
+ memo.derived.clear();
78
+ memo.bodies.clear();
128
79
  }
80
+ const cfg = memo.cfg;
81
+ const file = logPath(cfg.root);
82
+ const logSig = logSegments(file).map(statSig).join("|");
83
+ if (logSig !== memo.logSig) {
84
+ memo.all = read(file);
85
+ memo.logSig = logSig;
86
+ memo.derived.clear();
87
+ }
88
+ return { cfg, text, all: memo.all, key: `${hash}|${bucket}|${logSig}|${statSig(requestsPath(cfg.root))}` };
89
+ }
129
90
 
130
- return {
131
- total,
132
- unowned: piles.unowned.paths,
133
- standing: piles,
134
- // The real number of distinct causes, not the length of the list below.
135
- // The page says "over N distinct paths" and the list is capped at twelve,
136
- // so taking N from the list reported the cap as if it were the count —
137
- // a wrong number stated confidently, which is worse than no number.
138
- distinct: by.size,
139
- families: families.slice(0, 8),
140
- causes: [...by.values()]
141
- .sort((a, b) => b.times - a.times)
142
- .slice(0, 12)
143
- .map((g) => ({
144
- action: g.action,
145
- target: g.target,
146
- times: g.times,
147
- share: total ? g.times / total : 0,
148
- roles: [...g.roles].sort(),
149
- standing: g.standing.kind,
150
- ...(g.standing.why && { why: g.standing.why }),
151
- owners: g.standing.owners,
152
- ownersThen: g.ownersThen,
153
- // How many of the roles that hit this would still hit it. `some` and
154
- // not `every`: two roles out of three still blocked is still friction,
155
- // and requiring all of them would quietly retire a live cause the day
156
- // one role got a grant.
157
- stillRefused: [...g.roles].filter((r) => cfg.roles[r] && !explain(cfg, r, g.action, g.target).allowed).length,
158
- })),
159
- };
91
+ /** Keeps the last few entries of a memo map. */
92
+ function remember(map, key, value, max = 4) {
93
+ map.set(key, value);
94
+ while (map.size > max) map.delete(map.keys().next().value);
95
+ return value;
160
96
  }
161
97
 
162
98
  /**
163
- * `?since=<ISO date>`, or null for no filter. Anything that does not parse is
164
- * ignored rather than refused: a bad filter should show everything, not an error.
99
+ * Everything the page needs.
100
+ *
101
+ * Without each role's generated settings: they were 70 % of the 749 KB the
102
+ * page downloaded every two seconds and it drew none of them. One role's are
103
+ * served on demand by /api/settings.
165
104
  */
166
- export function sinceOf(url) {
167
- const raw = new URLSearchParams((url ?? "").split("?")[1] ?? "").get("since");
168
- const t = raw ? Date.parse(raw) : NaN;
169
- return Number.isNaN(t) ? null : new Date(t).toISOString();
170
- }
105
+ export function state(configPath, { since = null, snap = snapshot(configPath) } = {}) {
106
+ const { cfg, text, all } = snap;
171
107
 
172
- export function state(configPath, { since = null } = {}) {
173
- const cfg = loadConfig(configPath);
174
108
  // The date filter narrows everything read from the log: activity, causes,
175
109
  // walls and the per-role counts. Not the request queue, which is what is
176
- // waiting now whatever the window.
177
- const entries = read(logPath(cfg.root), { limit: 200, since });
178
- // The whole window, not a tail. "All" used to mean the last 4000 lines, so a
179
- // 30-day window could show more denials than "all" did (6k against 4k on a
180
- // real log). Every count on the page comes from this list.
181
- const forShape = read(logPath(cfg.root), since ? { since } : {});
110
+ // waiting now whatever the window. (An entry with no `at` is kept, as
111
+ // read({ since }) always did.)
112
+ const derived = memo.derived.get(since) ?? remember(memo.derived, since, (() => {
113
+ const win = since ? all.filter((e) => !(e.at < since)) : all;
114
+ const blocks = new Map();
115
+ for (const e of win) if (e.verdict === "denied") blocks.set(e.role, (blocks.get(e.role) ?? 0) + 1);
116
+ const ask = verdicts(cfg);
117
+ return {
118
+ blocks,
119
+ log: win.slice(-60).reverse(),
120
+ // The whole window, not a tail. "All" used to mean the last 4000 lines, so a
121
+ // 30-day window could show more denials than "all" did (6k against 4k on a
122
+ // real log). Every count on the page comes from this list.
123
+ // More than the MCP's twelve: the page lists twelve and offers the rest,
124
+ // rather than cutting the list without saying so.
125
+ causes: causesOf(cfg, win, { ask, limit: CAUSES_SHOWN }),
126
+ // What each role keeps being refused AND would still be refused today.
127
+ // Empty for a role that has hit nothing twice, which is most of them.
128
+ walls: describeWalls(cfg, wallsByRole(cfg, win, { ask }), win),
129
+ };
130
+ })());
182
131
 
183
132
  const roles = Object.values(cfg.roles).map((r) => ({
184
133
  name: r.name,
@@ -190,44 +139,67 @@ export function state(configPath, { since = null } = {}) {
190
139
  localPorts: r.localPorts ?? [],
191
140
  mcp: r.mcp ?? null,
192
141
  trustd: r.trustd === true,
142
+ controlFiles: r.controlFiles ?? [],
193
143
  // Counted over the same window as the causes, so the KPI and the screen it
194
144
  // links to agree.
195
- blocks: forShape.filter((e) => e.role === r.name && e.verdict === "denied").length,
196
- settings: settingsFor(cfg, r.name),
145
+ blocks: derived.blocks.get(r.name) ?? 0,
197
146
  }));
198
147
 
199
- // Read once for every role's walls, rather than once per role.
200
- const whole = read(logPath(cfg.root), { verdict: "denied", since });
201
- // Staleness is about the role's whole history, never the window: a request
202
- // is not old just because the filter hides the runs that made it old.
203
- const history = since ? read(logPath(cfg.root), { verdict: "denied" }) : whole;
204
148
  return {
205
149
  root: cfg.root,
206
150
  config: cfg.path,
207
- requests: markStale(pending(requestsPath(cfg.root)), history),
151
+ // Staleness is about the role's whole history, never the window: a request
152
+ // is not old just because the filter hides the runs that made it old. The
153
+ // whole log, every verdict, as `seisin requests` and the MCP read it — this
154
+ // counted runs over denials only, and marked differently from both.
155
+ requests: queue(cfg.root, all, cfg.keyDirs),
208
156
  since,
209
157
  // The file itself, not a regeneration of it. The page can render a policy
210
158
  // from its own model, and that model has no comments — so showing it under
211
159
  // the heading "seisin.toml" next to "copy this back" invites someone to
212
160
  // paste away the provenance a grant just wrote.
213
- toml: readFileSync(cfg.path, "utf8"),
161
+ toml: text,
214
162
  keyDirs: cfg.keyDirs,
163
+ protect: { instructions: cfg.protect?.instructions === true },
164
+ // What the control-files preview is computed against; a save sends it back
165
+ // so a policy edited in between is not overwritten by a stale decision.
166
+ base: hashOf(text),
215
167
  allowedDomains: cfg.allowedDomains,
216
168
  roles,
217
- causes: causesOf(cfg, forShape),
218
- // What each role keeps being refused AND would still be refused today.
219
- // Empty for a role that has hit nothing twice, which is most of them.
220
- walls: Object.fromEntries(
221
- Object.keys(cfg.roles)
222
- .map((r) => [r, walls(cfg, r, { entries: whole })])
223
- .filter(([, w]) => w.length)),
224
- log: entries.slice(-60).reverse(),
169
+ causes: derived.causes,
170
+ walls: derived.walls,
171
+ log: derived.log,
225
172
  live: true,
226
173
  };
227
174
  }
228
175
 
229
176
  /**
230
- * Approve or refuse one pending request, as a person.
177
+ * `state` as the response body, the same string for as long as nothing on disk
178
+ * moved — and a tag naming it, so a poll that already has it gets a 304 and
179
+ * no body at all.
180
+ */
181
+ function stateBody(configPath, since) {
182
+ // One snapshot for the tag and the body. Each used to take its own, so a
183
+ // file that moved between the two calls got a body filed under the tag of
184
+ // what was there before — and a 304 for it until something else moved.
185
+ const snap = snapshot(configPath);
186
+ const k = `${snap.key}|${since}`;
187
+ return memo.bodies.get(k) ?? remember(memo.bodies, k, {
188
+ body: JSON.stringify(state(configPath, { since, snap })),
189
+ etag: `"${createHash("sha256").update(k).digest("hex").slice(0, 24)}"`,
190
+ });
191
+ }
192
+
193
+ /** One role's generated sandbox settings — what `seisin run` would hand the runtime. */
194
+ function settingsOf(configPath, role) {
195
+ const { cfg } = snapshot(configPath);
196
+ if (typeof role !== "string" || !Object.hasOwn(cfg.roles, role))
197
+ throw Object.assign(new Error(`unknown role ${JSON.stringify(role)}`), { status: 404 });
198
+ return settingsFor(cfg, role);
199
+ }
200
+
201
+ /**
202
+ * Grant or decline one pending request, as a person.
231
203
  *
232
204
  * This is the only thing in seisin that writes policy, and it lives here rather
233
205
  * than in the MCP server for one reason: an agent cannot reach this. Measured,
@@ -237,87 +209,238 @@ export function state(configPath, { since = null } = {}) {
237
209
  * MCP server deliberately does not.
238
210
  */
239
211
  function decide(configPath, { key, decision, reason }) {
240
- const cfg = loadConfig(configPath);
241
- const file = requestsPath(cfg.root);
242
212
  /**
243
213
  * By the request's id, never by its place in the queue.
244
214
  *
245
215
  * It took a row number, counted against the queue as it stood when the
246
216
  * request arrived — not when the person read the page. Agents keep writing
247
217
  * to that queue, and another channel can settle an entry in between, so
248
- * "approve #2" could approve whatever had moved into second place, with the
249
- * reason typed for something else. Re-sending the POST approved the next one.
218
+ * "grant #2" could grant whatever had moved into second place, with the
219
+ * reason typed for something else. Re-sending the POST granted the next one.
250
220
  * The CLI stopped doing this long ago; the console had not.
251
221
  */
252
- if (typeof key !== "string" || !key) throw new Error("key is required — the id of the request on screen");
253
- const req = pending(file).find((r) => r.key === key);
254
- if (!req) throw new Error(`request ${key} is no longer pending — reload to see the queue as it is now`);
255
- if (decision !== "granted" && decision !== "denied")
256
- throw new Error(`decision must be granted or denied`);
222
+ if (typeof key !== "string" || !key) throw bad("key is required — the id of the request on screen");
223
+ /**
224
+ * A person declines; the boundary denies. The console sends "declined".
225
+ * "denied" is still accepted from anything that scripted against the old
226
+ * value, and is what the queue stores either way — renaming a stored value
227
+ * is a breaking change, listed in docs/glossary.md, not made here.
228
+ */
229
+ if (decision === "denied") decision = "declined";
230
+ if (decision !== "granted" && decision !== "declined")
231
+ throw bad(`decision must be granted or declined`);
232
+ /**
233
+ * A reason, always. A decision from the console used to be one click with an
234
+ * optional box beside it, so most grants reached seisin.toml with no word of
235
+ * why — and the provenance comment is the only record a later reader has.
236
+ * The CLI can still be terse; a click is too easy to be.
237
+ */
238
+ if (typeof reason !== "string" || !cleanReason(reason))
239
+ throw bad("a reason is required — it is written next to the decision");
240
+ // It grants the folder the request is keyed by (docs/api.md asked, docs/**
241
+ // granted), and the page says so before the click. Granting the one file
242
+ // instead would need the queue to key by file: a later ask for a sibling
243
+ // folds into this settled request and would never reach the queue.
244
+ const cfg = loadConfig(configPath);
245
+ const file = requestsPath(cfg.root);
257
246
 
258
- if (decision === "granted") {
259
- // The config is edited as text, so comments and order survive. Written
260
- // before the queue is settled: if this throws, the request is still open
261
- // rather than marked done against a file that never changed.
262
- refuseIfBarred(cfg, req);
263
- // Locked and atomic, so a grant from another channel is serialised with
264
- // this one instead of one silently overwriting the other. See editPolicy.
265
- try {
266
- editPolicy(cfg, (before) => applyGrant(before, req, reason));
267
- } catch (e) {
268
- if (e.code === "ELOCKED") throw new Error("another grant is in progress — try again in a moment");
269
- throw e;
270
- }
247
+ /**
248
+ * Looked up, applied and settled under one lock — the policy's, the one
249
+ * every grant takes (editPolicy). The lookup used to happen before it and
250
+ * the settle after it, so the console and `seisin grant` could both find the
251
+ * same request pending and both act on it. A decline takes the lock as well,
252
+ * though it writes no policy, for the same reason.
253
+ *
254
+ * The config is edited as text, so comments and order survive, and it is
255
+ * written before the queue is settled: if the write throws, the request is
256
+ * still open rather than marked done against a file that never changed.
257
+ */
258
+ let req = null;
259
+ let result = null;
260
+ try {
261
+ result = editPolicy(cfg, (before) => {
262
+ req = pending(file, { keyDirs: cfg.keyDirs }).find((r) => r.key === key);
263
+ if (!req) throw bad(`request ${key} is no longer pending — reload to see the queue as it is now`);
264
+ if (decision !== "granted") return { toml: before, changed: false };
265
+ refuseIfBarred(cfg, req);
266
+ return applyGrant(before, req, reason);
267
+ }, { after: () => settle(file, req.key, decision === "granted" ? "granted" : "denied", reason) });
268
+ } catch (e) {
269
+ if (e.code === "ELOCKED") throw Object.assign(new Error("another grant is in progress — try again in a moment"), { status: 503 });
270
+ throw e;
271
+ }
272
+ return {
273
+ ok: true, role: req.role, grant: req.grant, decision,
274
+ // The line the grant wrote, and where, so the page can say exactly what
275
+ // changed instead of "done". Null when nothing was written (a decline, or
276
+ // a grant the policy already held).
277
+ line: decision === "granted" && result?.changed ? grantLine(result.toml, req.grant) : null,
278
+ };
279
+ }
280
+
281
+ /** Where a grant landed in the policy: the text of its line and its number. */
282
+ function grantLine(toml, grant) {
283
+ const lines = toml.split("\n");
284
+ for (let i = lines.length - 1; i >= 0; i--)
285
+ if (lines[i].includes(`"${grant}"`) && lines[i].includes("# granted")) return { text: lines[i].trim(), number: i + 1 };
286
+ return null;
287
+ }
288
+
289
+ /**
290
+ * The lines an edit changes, and where: what the preview shows before a save
291
+ * and the confirmation names after it. The file is edited in one place, so a
292
+ * common head and tail leave exactly the lines that moved.
293
+ */
294
+ export function lineDiff(before, after) {
295
+ const a = before.split("\n"), b = after.split("\n");
296
+ let head = 0;
297
+ while (head < a.length && head < b.length && a[head] === b[head]) head++;
298
+ let tail = 0;
299
+ while (tail < a.length - head && tail < b.length - head && a[a.length - 1 - tail] === b[b.length - 1 - tail]) tail++;
300
+ return { at: head + 1, removed: a.slice(head, a.length - tail), added: b.slice(head, b.length - tail) };
301
+ }
302
+
303
+ /**
304
+ * Hand a role a family of control files, or protect instruction files — the
305
+ * second thing a person can change from the console, and the same kind of
306
+ * write as a grant: through editPolicy, under the policy's lock, atomically,
307
+ * with a stamp saying it came from here and when.
308
+ *
309
+ * `dryRun` answers what the edit would do — the denyWrite entries each role's
310
+ * profile gains or loses, and who could then write each family — and writes
311
+ * nothing. The page shows that and asks before it sends the real one. `base`
312
+ * is the hash of the text the preview was computed from: a policy that moved
313
+ * in between (a grant, an editor) is a 409, not a save over a decision the
314
+ * person never saw.
315
+ *
316
+ * One edit per call: `{ role, families }` for a role, or `{ protect }` for
317
+ * `[protect] instructions`. The families are config.js's own list; asking
318
+ * for `.claude` or git hooks is a 400, as it is in the file.
319
+ */
320
+ function controlFiles(configPath, args, { lockWaitMs = 10000 } = {}) {
321
+ const { role, families, protect, reason, dryRun, base } = args;
322
+ const forRole = role !== undefined || families !== undefined;
323
+ if (forRole === (protect !== undefined))
324
+ throw bad("send either { role, families } or { protect } — one edit at a time");
325
+ if (reason !== undefined && typeof reason !== "string") throw bad("reason must be text");
326
+ // Strictly a boolean: "true" as a string must not be read as a save.
327
+ if (dryRun !== undefined && typeof dryRun !== "boolean") throw bad("dryRun must be true or false");
328
+ if (base !== undefined && typeof base !== "string") throw bad("base must be the hash the preview returned");
329
+ const edit = forRole ? { kind: "role", role, families, reason } : { kind: "protect", on: protect, reason };
330
+ const cfg = loadConfig(configPath);
331
+ // The roles an edit reaches, and of those the ones whose profile it leaves
332
+ // as it is — named, so a preview that lists two roles does not read as
333
+ // "the other five were forgotten".
334
+ const reached = () => edit.kind === "role" ? [edit.role] : Object.keys(cfg.roles);
335
+ const unaffected = (p) => p.changed ? reached(p).filter((r) => !p.diff.some((d) => d.role === r)) : [];
336
+ if (dryRun === true) {
337
+ const before = readFileSync(configPath, "utf8");
338
+ const { toml, ...plan } = planEdit(cfg, before, edit);
339
+ return { ok: true, dryRun: true, ...plan, unaffected: unaffected(plan), lines: plan.changed ? lineDiff(before, toml) : null };
340
+ }
341
+ let plan = null;
342
+ let lines = null;
343
+ try {
344
+ editPolicy(cfg, (before) => {
345
+ if (base !== undefined && hashOf(before) !== base)
346
+ throw Object.assign(new Error("the policy changed since this preview — look at it again before saving"), { status: 409 });
347
+ // Planned against the text under the lock, and against a config loaded
348
+ // from it: a grant that landed a moment ago is kept, not overwritten.
349
+ plan = planEdit(loadConfig(configPath, before), before, edit);
350
+ if (plan.changed) lines = lineDiff(before, plan.toml);
351
+ return { toml: plan.toml, changed: plan.changed };
352
+ }, { waitMs: lockWaitMs });
353
+ } catch (e) {
354
+ if (e.code === "ELOCKED") throw Object.assign(new Error("another edit of the policy is in progress — try again in a moment"), { status: 503 });
355
+ throw e;
271
356
  }
272
- settle(file, req.key, decision, reason ?? "");
273
- return { ok: true, role: req.role, grant: req.grant, decision };
357
+ const { toml, ...rest } = plan;
358
+ return { ok: true, dryRun: false, ...rest, unaffected: unaffected(plan), lines };
359
+ }
360
+
361
+ /** An error that is the request's fault. */
362
+ function bad(message) {
363
+ return Object.assign(new Error(message), { status: 400 });
364
+ }
365
+
366
+ /**
367
+ * The status for an error thrown while answering: its own when it has one; a
368
+ * failed read or write of a file (it carries a syscall) is the server's; what
369
+ * is left — a bad field, a grant the policy refuses — is the request's.
370
+ */
371
+ function statusOf(e) {
372
+ if (Number.isInteger(e?.status)) return e.status;
373
+ return e?.syscall ? 500 : 400;
274
374
  }
275
375
 
276
- /** The body of a POST, capped: this endpoint takes three short fields. */
277
376
  /**
278
377
  * A request body, capped. The cap is per route: one decision is a few hundred
279
378
  * bytes, but "decline all" carries every key on screen — 109 of them were
280
379
  * 9.3 KB, over the old flat 4 KB, and the connection was cut with nothing but
281
- * "Failed to fetch" in the browser. Found by Carlos on the first real use.
380
+ * "Failed to fetch" in the browser. Found on first real use.
381
+ *
382
+ * Over the cap it is refused with 413, not cut: the caller answers first and
383
+ * the connection is closed after the answer has gone (see `answer`).
282
384
  */
283
385
  function readBody(req, max = 4096) {
284
386
  return new Promise((ok, fail) => {
285
387
  let s = "";
388
+ let over = false;
389
+ req.setEncoding("utf8");
286
390
  req.on("data", (c) => {
391
+ if (over) return;
287
392
  s += c;
288
- if (s.length > max) { fail(new Error("body too large")); req.destroy(); }
393
+ if (Buffer.byteLength(s) > max) {
394
+ over = true;
395
+ s = "";
396
+ fail(Object.assign(new Error(`body too large — this route takes at most ${max} bytes`), { status: 413 }));
397
+ }
289
398
  });
290
- req.on("end", () => ok(s));
399
+ req.on("end", () => { if (!over) ok(s); });
400
+ req.on("error", (e) => { if (!over) fail(Object.assign(e, { status: 400 })); });
291
401
  });
292
402
  }
293
403
 
294
404
  /**
295
- * Refuse every request the page was showing, in one go.
405
+ * Decline every request the page was showing, in one go.
296
406
  *
297
- * Only refusing. There is no "approve all" and there will not be: a grant is a
407
+ * Only declining. There is no "grant all" and there will not be: a grant is a
298
408
  * decision about one path and one owner, and doing forty of them without
299
409
  * reading them is the permissive default this tool exists to stand against.
300
- * Refusing in bulk is safe — it grants nothing and changes no policy — and it
410
+ * Declining in bulk is safe — it grants nothing and changes no policy — and it
301
411
  * is what a queue full of noise from a bug already fixed needs.
302
412
  *
303
413
  * By key, not "whatever is pending now": a request that arrived after the page
304
- * drew is one the person has not seen, and it stays.
414
+ * drew is one the person has not seen, and it stays. Under the policy lock,
415
+ * like a single decision, so a grant from another channel cannot land between
416
+ * the lookup and the settle.
305
417
  */
306
418
  function declineAll(configPath, { keys, reason }) {
307
- if (!Array.isArray(keys) || !keys.length) throw new Error("keys must list the requests on screen");
419
+ if (!Array.isArray(keys) || !keys.length) throw bad("keys must list the requests on screen");
420
+ if (typeof reason !== "string" || !cleanReason(reason))
421
+ throw bad("a reason is required — it is written next to every request declined");
308
422
  const cfg = loadConfig(configPath);
309
423
  const file = requestsPath(cfg.root);
310
424
  const wanted = new Set(keys.map(String));
311
425
  let declined = 0;
312
- for (const req of pending(file)) {
313
- if (!wanted.has(req.key)) continue;
314
- settle(file, req.key, "denied", reason ?? "");
315
- declined++;
426
+ try {
427
+ editPolicy(cfg, (before) => ({ toml: before, changed: false }), {
428
+ after: () => {
429
+ for (const req of pending(file)) {
430
+ if (!wanted.has(req.key)) continue;
431
+ settle(file, req.key, "denied", reason);
432
+ declined++;
433
+ }
434
+ },
435
+ });
436
+ } catch (e) {
437
+ if (e.code === "ELOCKED") throw Object.assign(new Error("another grant is in progress — try again in a moment"), { status: 503 });
438
+ throw e;
316
439
  }
317
440
  return { ok: true, declined };
318
441
  }
319
442
 
320
- export function serve(configPath, port = 4178) {
443
+ export function serve(configPath, port = 4178, { lockWaitMs = 10000 } = {}) {
321
444
  const page = join(HERE, "..", "ui", "index.html");
322
445
  if (!existsSync(page)) throw new Error("the console is missing from this install");
323
446
 
@@ -327,7 +450,7 @@ export function serve(configPath, port = 4178) {
327
450
  * Loopback is not a boundary against the browser: any site the operator has
328
451
  * open can POST to 127.0.0.1. It cannot read this token, and asking for it in
329
452
  * a custom header also forces a preflight that this server never answers. So
330
- * the page can approve and a tab from somewhere else cannot.
453
+ * the page can grant and a tab from somewhere else cannot.
331
454
  */
332
455
  const token = randomBytes(24).toString("hex");
333
456
 
@@ -391,42 +514,67 @@ export function serve(configPath, port = 4178) {
391
514
  res.writeHead(403, { "content-type": "application/json" });
392
515
  return res.end(JSON.stringify({ error: "bad or missing token — open the URL `seisin ui` printed" }));
393
516
  }
394
- if (req.method === "POST" && pathOf(req.url) === "/api/decline-all") {
395
- try {
396
- // ~85 bytes per key: 1 MB is ten thousand requests on one screen.
397
- const out = declineAll(configPath, JSON.parse((await readBody(req, 1024 * 1024)) || "{}"));
398
- res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
399
- return res.end(JSON.stringify(out));
400
- } catch (e) {
401
- res.writeHead(400, { "content-type": "application/json" });
402
- return res.end(JSON.stringify({ error: e.message }));
403
- }
404
- }
405
- if (req.method === "POST" && pathOf(req.url) === "/api/decide") {
517
+ /**
518
+ * A JSON answer. On an error the connection is closed once the answer has
519
+ * gone: a body refused half-read would otherwise be parsed as the next
520
+ * request on a keep-alive socket.
521
+ */
522
+ const answer = (status, obj, close = false) => {
523
+ res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store", ...(close && { connection: "close" }) });
524
+ res.end(JSON.stringify(obj), () => { if (close) req.socket?.destroy(); });
525
+ };
526
+ const post = async (max, fn) => {
527
+ let body;
528
+ try { body = await readBody(req, max); }
529
+ catch (e) { return answer(e.status ?? 400, { error: e.message }, true); }
406
530
  try {
407
- const out = decide(configPath, JSON.parse((await readBody(req)) || "{}"));
408
- res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
409
- return res.end(JSON.stringify(out));
531
+ let args;
532
+ try { args = JSON.parse(body || "{}"); } catch { throw bad("the body is not JSON"); }
533
+ return answer(200, fn(args ?? {}));
410
534
  } catch (e) {
411
- res.writeHead(400, { "content-type": "application/json" });
412
- return res.end(JSON.stringify({ error: e.message }));
535
+ return answer(statusOf(e), { error: e.message });
413
536
  }
414
- }
537
+ };
538
+ // ~85 bytes per key: 1 MB is ten thousand requests on one screen.
539
+ if (req.method === "POST" && pathOf(req.url) === "/api/decline-all")
540
+ return post(1024 * 1024, (args) => declineAll(configPath, args));
541
+ if (req.method === "POST" && pathOf(req.url) === "/api/decide")
542
+ return post(4096, (args) => decide(configPath, args));
543
+ if (req.method === "POST" && pathOf(req.url) === "/api/control-files")
544
+ return post(4096, (args) => controlFiles(configPath, args, { lockWaitMs }));
415
545
  if (req.method !== "GET" && req.method !== "HEAD") {
416
546
  res.writeHead(405).end("method not allowed");
417
547
  return;
418
548
  }
419
- if (pathOf(req.url) === "/api/state") {
420
- let body;
421
- try {
422
- body = JSON.stringify(state(configPath, { since: sinceOf(req.url) }));
423
- } catch (e) {
424
- res.writeHead(500, { "content-type": "application/json" });
425
- return res.end(JSON.stringify({ error: e.message }));
549
+ if (pathOf(req.url).startsWith("/api/")) {
550
+ // HEAD computed the whole state and threw the body away — a request that
551
+ // cost what a GET does and answered nothing. The API is GET only.
552
+ if (req.method !== "GET") {
553
+ res.writeHead(405, { allow: "GET" });
554
+ return res.end();
555
+ }
556
+ if (pathOf(req.url) === "/api/state") {
557
+ let got;
558
+ try {
559
+ got = stateBody(configPath, sinceOf(req.url));
560
+ } catch (e) {
561
+ return answer(500, { error: e.message });
562
+ }
563
+ // The page polls, so tell every cache in between to stay out of it;
564
+ // the page itself sends the tag back and is told when nothing moved.
565
+ const head = { "content-type": "application/json", "cache-control": "no-store", etag: got.etag };
566
+ if (req.headers["if-none-match"] === got.etag) { res.writeHead(304, head); return res.end(); }
567
+ res.writeHead(200, head);
568
+ return res.end(got.body);
569
+ }
570
+ if (pathOf(req.url) === "/api/settings") {
571
+ try {
572
+ const role = new URLSearchParams(req.url.split("?")[1] ?? "").get("role");
573
+ return answer(200, { role, settings: settingsOf(configPath, role) });
574
+ } catch (e) {
575
+ return answer(e.status ?? 500, { error: e.message });
576
+ }
426
577
  }
427
- // The page polls, so tell every cache in between to stay out of it.
428
- res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
429
- return res.end(body);
430
578
  }
431
579
  if (pathOf(req.url) === "/" || pathOf(req.url) === "/index.html") {
432
580
  // No token in here. It used to be inlined, which handed it to anything