seisin 0.3.0 → 0.4.2

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.
package/src/owners.js CHANGED
@@ -10,7 +10,8 @@
10
10
  * agents have to read each other's code to do anything useful. Keys are the
11
11
  * exception, and they are handled separately below.
12
12
  */
13
- import { existsSync } from "node:fs";
13
+ import { createHash } from "node:crypto";
14
+ import { existsSync, readFileSync } from "node:fs";
14
15
  import { join } from "node:path";
15
16
  import { protectedBy } from "./surface.js";
16
17
 
@@ -45,7 +46,7 @@ export function covers(glob, path) {
45
46
  // The path itself, or anything beneath it. The `/` is load-bearing: without
46
47
  // it `src/api` would also cover `src/apifoo.ts`, which the kernel does not.
47
48
  if (m.prefix !== null) return p === m.prefix || p.startsWith(m.prefix + "/");
48
- return m.re.test(p);
49
+ return matchGlob(m.glob, p);
49
50
  }
50
51
 
51
52
  /**
@@ -68,7 +69,7 @@ function matcherOf(glob) {
68
69
  if (m) return m;
69
70
  let g = normalize(glob);
70
71
  if (glob.endsWith("/")) g += "/**";
71
- m = WILD.test(g) ? { prefix: null, re: toRegExp(g) } : { prefix: g, re: null };
72
+ m = WILD.test(g) ? { prefix: null, glob: g } : { prefix: g, glob: null };
72
73
  if (GLOBS.size >= MAX_CACHED) GLOBS.clear();
73
74
  GLOBS.set(glob, m);
74
75
  return m;
@@ -126,18 +127,66 @@ export function normalize(s) {
126
127
  * must not: covering one file too many costs an argument, covering one too few
127
128
  * is the hole. When the two readings disagree, this one takes the wider set.
128
129
  */
129
- function toRegExp(glob) {
130
- const out = glob
131
- .replace(/[.+^${}()|[\]\\]/g, "\\$&")
132
- .replace(/\/\*\*$/, "\u0001")
133
- .replace(/\*\*\//g, "\u0002")
134
- .replace(/\*\*/g, "\u0003")
135
- .replace(/\*/g, "[^/]*")
136
- .replace(/\?/g, "[^/]")
137
- .replace(/\u0001/g, "(?:/.*)?")
138
- .replace(/\u0002/g, "(?:.*/)?")
139
- .replace(/\u0003/g, ".*");
140
- return new RegExp("^" + out + "$");
130
+ /**
131
+ * Does `glob` cover `path`? A linear matcher, not a regular expression.
132
+ *
133
+ * The regex form (`**` → `.*`, `**\/` → `(?:.*\/)?`) put several unbounded `.*`
134
+ * next to literals, and on a long path that does not match, the engine
135
+ * backtracks catastrophically: measured on a crafted glob, 9 `**` took ~26 s
136
+ * and 11 hung for over a minute. Since a request's target — which an agent
137
+ * chooses — becomes a glob here (`grantFor`), that is a denial-of-service on the
138
+ * hook and on `explain`. This matcher is memoised on (glob index, path index),
139
+ * so its cost is bounded by their product and no input backtracks.
140
+ *
141
+ * Same wildcards as before: `?` one non-`/`, `*` a run of non-`/`, `**` any run
142
+ * including `/`, `**\/` zero or more whole directories, a trailing `/**` the
143
+ * subtree. Characters are compared literally; nothing is treated as regex.
144
+ */
145
+ function matchGlob(glob, path) {
146
+ const G = glob.length, P = path.length;
147
+ // memo[gi * (P+1) + pi]: 0 unknown, 1 true, 2 false. One flat array, reused.
148
+ const memo = new Uint8Array((G + 1) * (P + 1));
149
+ const at = (gi, pi) => {
150
+ for (;;) {
151
+ const key = gi * (P + 1) + pi;
152
+ const seen = memo[key];
153
+ if (seen) return seen === 1;
154
+ let res;
155
+ if (gi === G) { res = pi === P; }
156
+ else if (glob[gi] === "*" && glob[gi + 1] === "*") {
157
+ if (glob[gi + 2] === "/") {
158
+ // `**/` = zero or more whole directories: empty, or any chars ending
159
+ // at a `/` then the rest.
160
+ if (at(gi + 3, pi)) res = true;
161
+ else if (pi < P) {
162
+ if (path[pi] === "/" && at(gi + 3, pi + 1)) res = true;
163
+ else { memo[key] = 2; pi++; continue; } // consume one char, stay on `**/`
164
+ } else res = false;
165
+ } else {
166
+ // `**` = any run including `/` (also covers `**` at the very end,
167
+ // where `at(gi+2, pi)` requires pi to reach the end).
168
+ if (at(gi + 2, pi)) res = true;
169
+ else if (pi < P) { memo[key] = 2; pi++; continue; } // consume one char, stay on `**`
170
+ else res = false;
171
+ }
172
+ } else if (glob[gi] === "/" && glob[gi + 1] === "*" && glob[gi + 2] === "*" && gi + 3 === G) {
173
+ // trailing `/**`: the directory itself, or anything beneath it.
174
+ res = pi === P || path[pi] === "/";
175
+ } else if (glob[gi] === "*") {
176
+ // `*` = a run of non-`/`.
177
+ if (at(gi + 1, pi)) res = true;
178
+ else if (pi < P && path[pi] !== "/") { memo[key] = 2; pi++; continue; }
179
+ else res = false;
180
+ } else if (glob[gi] === "?") {
181
+ res = pi < P && path[pi] !== "/" && at(gi + 1, pi + 1);
182
+ } else {
183
+ res = pi < P && glob[gi] === path[pi] && at(gi + 1, pi + 1);
184
+ }
185
+ memo[key] = res ? 1 : 2;
186
+ return res;
187
+ }
188
+ };
189
+ return at(0, 0);
141
190
  }
142
191
 
143
192
  /** Is this path inside a `.git` directory (git's metadata, not a working file)? */
@@ -166,6 +215,82 @@ export function ownersOf(config, path) {
166
215
  .map((r) => r.name);
167
216
  }
168
217
 
218
+ /**
219
+ * Where a refused target stands against the policy as it is now.
220
+ *
221
+ * One of four, and only the last one is a decision somebody has to make:
222
+ *
223
+ * outside not a place in the map at all — a port, a key directory, or a
224
+ * path outside the repository, which no `writes` reaches
225
+ * protected closed to every role on purpose (surface.js); the boundary
226
+ * working, with nothing to grant
227
+ * owned some role's territory today
228
+ * unowned in the repository, ownable, and nobody claims it
229
+ *
230
+ * Computed from the policy, never from the `owners` a log line carries. That
231
+ * value is what was true the day of the refusal; every grant made since makes
232
+ * it more wrong, so a screen reading it gets worse the more seisin is used the
233
+ * way it is meant to be. Measured on one deployment: the console said 1,178 of
234
+ * 1,486 refused paths had no owner; against the policy of that day it was 316.
235
+ * The rest had been claimed since, were protected, or were not paths at all.
236
+ */
237
+ export function standing(config, entry) {
238
+ const target = entry.target;
239
+ if (entry.kind === "network")
240
+ return { kind: "outside", owners: [], why: "a port or a socket, which no role owns" };
241
+ if (entry.kind === "key")
242
+ return { kind: "outside", owners: [], why: "a key directory, closed on purpose" };
243
+ const guard = protectedBy(config, target);
244
+ if (guard) return { kind: "protected", owners: [], why: guard.why };
245
+ if (target.startsWith("/")) {
246
+ const root = config.root?.endsWith("/") ? config.root : `${config.root}/`;
247
+ if (!config.root || !target.startsWith(root))
248
+ return { kind: "outside", owners: [], why: "outside the repository, where no role's territory reaches" };
249
+ }
250
+ const owners = ownersOf(config, target);
251
+ return { kind: owners.length ? "owned" : "unowned", owners };
252
+ }
253
+
254
+ /**
255
+ * `standing`, remembered while the policy stays the same.
256
+ *
257
+ * The console asks for its whole state every two seconds, and a real log has
258
+ * well over a thousand distinct refused paths: classifying all of them costs
259
+ * most of a second each time (measured: ~400 ms in protectedBy, ~335 ms in
260
+ * ownersOf), on a call that already took about that long. What a path's
261
+ * standing depends on is the policy file, so that is the key. It also depends,
262
+ * a little, on the disk — on Linux a control file is protected only once it
263
+ * exists — so the memory is dropped after a minute as well, which bounds how
264
+ * stale an answer can be without paying for it on every poll.
265
+ *
266
+ * A config that was not read from a file (tests, embedders) gets a memory for
267
+ * this call only.
268
+ */
269
+ const STANDING_TTL = 60_000;
270
+ let standingMemo = { key: null, at: 0, map: new Map() };
271
+
272
+ export function standingOf(config, { now = Date.now() } = {}) {
273
+ let map;
274
+ let key = null;
275
+ try {
276
+ if (config.path) key = `${config.path}\u0000${createHash("sha256").update(readFileSync(config.path)).digest("hex")}`;
277
+ } catch {
278
+ key = null;
279
+ }
280
+ if (key === null) map = new Map();
281
+ else {
282
+ if (standingMemo.key !== key || now - standingMemo.at > STANDING_TTL)
283
+ standingMemo = { key, at: now, map: new Map() };
284
+ map = standingMemo.map;
285
+ }
286
+ return (entry) => {
287
+ const k = `${entry.kind ?? ""}\u0000${entry.target}`;
288
+ let s = map.get(k);
289
+ if (!s) map.set(k, (s = standing(config, entry)));
290
+ return s;
291
+ };
292
+ }
293
+
169
294
  /**
170
295
  * The `never_writes` entries the kernel will actually be given for this role.
171
296
  *
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
 
@@ -125,7 +136,7 @@ export function renderReport(report) {
125
136
  /** The `explain` answer, as text. Takes what `owners.explain()` returns. */
126
137
  export function renderVerdict(role, action, target, verdict) {
127
138
  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`;
139
+ return `\n ${head} ${C.b}${role}${C.off} ${action} ${safe(target)}\n ${C.dim}${safe(verdict.reason)}${C.off}\n\n`;
129
140
  }
130
141
 
131
142
  /* ── the log ──────────────────────────────────────────────────────────── */
@@ -149,7 +160,7 @@ export function renderEntry(e) {
149
160
  // about a run, a reader needs to know which line is which without going to
150
161
  // the file — so the one that carries more weight is the one that is marked.
151
162
  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`;
163
+ 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
164
  }
154
165
 
155
166
  /* ── the scan ─────────────────────────────────────────────────────────── */
package/src/requests.js CHANGED
@@ -27,10 +27,11 @@
27
27
  * deleted — it is followed by a line saying what happened to it.
28
28
  */
29
29
  import { neverWrites, isGitMetadata, covers } from "./owners.js";
30
- import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
30
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from "node:fs";
31
31
  import { dirname, join } from "node:path";
32
32
  import { STATE_DIR, tomlString } from "./layout.js";
33
33
  import { send } from "./spool.js";
34
+ import { withLock } from "./log.js";
34
35
 
35
36
  export const REQUESTS_NAME = "requests.jsonl";
36
37
 
@@ -57,6 +58,17 @@ export function keyOf({ role, action, target }) {
57
58
  }
58
59
 
59
60
  /** The glob a grant would add, derived from what was asked. */
61
+ /**
62
+ * A request's id as one shell word, for the commands seisin suggests.
63
+ *
64
+ * The id carries a path an agent chose, so it is single-quoted whole and any
65
+ * quote inside it is closed and escaped: pasting the suggestion runs `seisin
66
+ * grant` on that request and nothing else.
67
+ */
68
+ export function shellId(key) {
69
+ return `'${String(key).replace(/'/g, `'\\''`)}'`;
70
+ }
71
+
60
72
  export function grantFor({ action, target }) {
61
73
  const t = typeof target === "string" ? target : "";
62
74
  // A key keeps its directory when it has one. Stripping it turned a request
@@ -283,6 +295,32 @@ export function refuseIfBarred(config, request) {
283
295
  `rewrite another owner's history. Decline it; a role that needs to commit gets a worktree of its own.`);
284
296
  }
285
297
 
298
+ /**
299
+ * Apply one policy edit under a lock, and write it atomically.
300
+ *
301
+ * `grant` and the console both did read → applyGrant → write with no lock, so
302
+ * two approvals racing (a person at the CLI and one on the console, or two
303
+ * terminals) lost one edit: the queue marked both granted while `seisin.toml`
304
+ * kept only the last writer's change (measured: 39 of 40 concurrent pairs). The
305
+ * lock serialises them — it already survives a writer that died holding it
306
+ * (see log.js) — and the write goes to a temp file then renames over the config,
307
+ * so a crash mid-write cannot leave a half-written, partly-privileged policy.
308
+ *
309
+ * `mutate(toml)` returns `{ toml, changed }`. Nothing is written when unchanged.
310
+ */
311
+ export function editPolicy(config, mutate, { waitMs = 10000 } = {}) {
312
+ return withLock(config.path, () => {
313
+ const before = readFileSync(config.path, "utf8");
314
+ const result = mutate(before);
315
+ if (result.changed) {
316
+ const tmp = `${config.path}.tmp-${process.pid}`;
317
+ writeFileSync(tmp, result.toml);
318
+ renameSync(tmp, config.path);
319
+ }
320
+ return result;
321
+ }, { waitMs });
322
+ }
323
+
286
324
  export function applyGrant(toml, request, note = "") {
287
325
  const field = request.action === "read" ? "keys" : "writes";
288
326
  const header = new RegExp(`^\\[roles\\.${escapeRe(request.role)}\\]\\s*$`, "m");
@@ -295,9 +333,8 @@ export function applyGrant(toml, request, note = "") {
295
333
  const to = next ? from + next.index : toml.length;
296
334
  const section = toml.slice(from, to);
297
335
 
298
- const line = new RegExp(`^(${field}\\s*=\\s*)\\[([^\\]]*)\\]`, "m");
299
- const m = line.exec(section);
300
- if (!m)
336
+ const open = new RegExp(`^${field}\\s*=\\s*\\[`, "m").exec(section);
337
+ if (!open)
301
338
  throw new Error(
302
339
  `[roles.${request.role}] has no ${field} list to grant into. ` +
303
340
  `Add \`${field} = []\` to that section first — seisin will not write it into another role's block.`
@@ -309,20 +346,60 @@ export function applyGrant(toml, request, note = "") {
309
346
  // loads, after they approved something, is its own kind of broken.
310
347
  tomlString(request.grant); // refuses what the format cannot hold
311
348
 
312
- const items = (m[2].match(/"[^"]*"/g) ?? []).map((s) => s.slice(1, -1));
313
- if (items.includes(request.grant)) return { toml, changed: false };
349
+ const start = open.index + open[0].length;
350
+ const list = scanList(section, start);
351
+ if (list.items.includes(request.grant)) return { toml, changed: false };
314
352
 
353
+ /**
354
+ * Appended, never rebuilt.
355
+ *
356
+ * The list used to be re-written from every quoted string between its
357
+ * 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
361
+ * the way. Now the items are the strings outside comments, what is there is
362
+ * left as it is, and the new entry goes last. The reason is set off with
363
+ * «», never a double quote, so no older reader can take it for a value.
364
+ */
365
+ let inner = section.slice(start, list.end).replace(/\s+$/, "");
366
+ if (list.lastItemEnd !== null && !list.commaAfterLast) {
367
+ const at = list.lastItemEnd - start;
368
+ inner = inner.slice(0, at) + "," + inner.slice(at);
369
+ }
315
370
  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]`);
371
+ `${note ? ` · «${cleanReason(note)}»` : ""}`;
372
+ const edited = section.slice(0, start) + `${inner}\n "${request.grant}" ${stamp}\n]` + section.slice(list.end + 1);
323
373
  return { toml: toml.slice(0, from) + edited + toml.slice(to), changed: true };
324
374
  }
325
375
 
376
+ /**
377
+ * The strings of a TOML array that starts at `start` (just past its `[`),
378
+ * skipping comments, and where it closes. The subset has no escapes, so a
379
+ * string runs to the next double quote.
380
+ */
381
+ function scanList(text, start) {
382
+ const items = [];
383
+ let lastItemEnd = null;
384
+ let commaAfterLast = false;
385
+ for (let i = start; i < text.length; i++) {
386
+ const c = text[i];
387
+ if (c === "#") { const nl = text.indexOf("\n", i); if (nl === -1) break; i = nl; continue; }
388
+ if (c === '"') {
389
+ const close = text.indexOf('"', i + 1);
390
+ if (close === -1) break;
391
+ items.push(text.slice(i + 1, close));
392
+ lastItemEnd = close + 1;
393
+ commaAfterLast = false;
394
+ i = close;
395
+ continue;
396
+ }
397
+ if (c === ",") { commaAfterLast = true; continue; }
398
+ if (c === "]") return { items, end: i, lastItemEnd, commaAfterLast };
399
+ }
400
+ throw new Error(`the ${text.slice(0, 40).trim()}… list never closes; fix the config by hand before granting`);
401
+ }
402
+
326
403
  /**
327
404
  * The approver's own words, made safe to put in a config file.
328
405
  *
package/src/review.js CHANGED
@@ -28,7 +28,7 @@
28
28
  * means nothing without "…in how long".
29
29
  */
30
30
  import { read, logPath } from "./log.js";
31
- import { covers, ownersOf } from "./owners.js";
31
+ import { covers, standingOf } from "./owners.js";
32
32
 
33
33
  /**
34
34
  * One reading of the log against the policy.
@@ -122,7 +122,10 @@ function findings(config, entries, minDenials) {
122
122
  * than full — a consumer that has never heard of `unusedKnowable` then reports
123
123
  * nothing instead of everything, which is the direction this has to fail in.
124
124
  */
125
- const knowable = entries.some((e) => e.verdict === "allowed");
125
+ // A disputed line is the parent saying the verdict did not match the policy
126
+ // — often a forged line. It is not evidence of legitimate use, so it neither
127
+ // makes the window knowable nor marks a territory used.
128
+ const knowable = entries.some((e) => e.verdict === "allowed" && !e.disputed);
126
129
 
127
130
  // Only writes. A key that is declared and not read is ordinary — most roles
128
131
  // hold a credential for the one turn a month that needs it — but a folder a
@@ -130,7 +133,7 @@ function findings(config, entries, minDenials) {
130
133
  // miss, and the only evidence anyone will ever have for removing it.
131
134
  const used = new Set();
132
135
  for (const e of entries) {
133
- if (e.verdict !== "allowed" || e.action !== "write" || !e.target) continue;
136
+ if (e.verdict !== "allowed" || e.disputed || e.action !== "write" || !e.target) continue;
134
137
  for (const role of Object.values(config.roles))
135
138
  for (const glob of role.writes)
136
139
  if (role.name === e.role && covers(glob, e.target)) used.add(`${role.name} ${glob}`);
@@ -144,12 +147,17 @@ function findings(config, entries, minDenials) {
144
147
 
145
148
  /* ── 3. what nobody owns ────────────────────────────────────────────── */
146
149
 
150
+ // The same classification the console and `seisin_causes` use, so the word
151
+ // means one thing everywhere. A key directory is closed on purpose, a port is
152
+ // not a path, a protected file is closed to every role and a path outside
153
+ // the repository is outside every territory: none of them is a hole in the
154
+ // map. This used to skip keys and ports and count the rest, protected ones
155
+ // included, while the console counted all four — one word, two numbers.
147
156
  const unowned = new Map();
157
+ const standOf = standingOf(config);
148
158
  for (const e of entries) {
149
- // A key directory is closed on purpose; a port or a socket is not a path
150
- // anybody could own. Neither is a hole in the map.
151
- if (!e.target || e.kind === "key" || e.kind === "network") continue;
152
- if (ownersOf(config, e.target).length) continue;
159
+ if (!e.target) continue;
160
+ if (standOf(e).kind !== "unowned") continue;
153
161
  const dir = dirOf(e.target);
154
162
  unowned.set(dir, (unowned.get(dir) ?? 0) + 1);
155
163
  }
package/src/serve.js CHANGED
@@ -11,16 +11,16 @@
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 } from "node:crypto";
15
- import { readFileSync, writeFileSync, existsSync } from "node:fs";
14
+ import { randomBytes, timingSafeEqual } from "node:crypto";
15
+ import { readFileSync, existsSync } 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
19
  import { read, logPath } from "./log.js";
20
20
  import { settingsFor } from "./srt.js";
21
- import { pending, requestsPath, settle, applyGrant, refuseIfBarred, markStale } from "./requests.js";
21
+ import { pending, requestsPath, settle, applyGrant, refuseIfBarred, markStale, editPolicy } from "./requests.js";
22
22
  import { walls } from "./walls.js";
23
- import { explain } from "./owners.js";
23
+ import { explain, standingOf } from "./owners.js";
24
24
 
25
25
  const HERE = dirname(fileURLToPath(import.meta.url));
26
26
 
@@ -54,7 +54,7 @@ export function causesOf(cfg, entries) {
54
54
  for (const e of entries) {
55
55
  if (e.verdict !== "denied" || !e.target || !e.action) continue;
56
56
  const k = `${e.action}\u0000${e.target}`;
57
- const g = by.get(k) ?? { action: e.action, target: e.target, times: 0, roles: new Set(), owners: e.owners ?? [] };
57
+ const g = by.get(k) ?? { action: e.action, target: e.target, kind: e.kind, times: 0, roles: new Set(), ownersThen: e.owners ?? [] };
58
58
  g.times++;
59
59
  g.roles.add(e.role);
60
60
  by.set(k, g);
@@ -99,25 +99,38 @@ export function causesOf(cfg, entries) {
99
99
  }));
100
100
 
101
101
  /**
102
- * What a `grep` over the log cannot tell you.
102
+ * What a `grep` over the log cannot tell you: where each refused path stands
103
+ * against the policy as it is now.
103
104
  *
104
105
  * The headline used to be "74% of it is one name", and a field review
105
106
  * applied this project's own test to it: *did the number tell you something
106
- * you did not know?* For somebody who reads the raw log, no — they had
107
- * already counted that. The console was repeating the log back.
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.
108
114
  *
109
- * These two are different, because they need the **policy** and the log does
110
- * not contain it. `unowned` is the count of causes on paths no role claims:
111
- * no grant resolves those until somebody decides who owns them, which is a
112
- * decision rather than a number. `settled` is friction that has since been
113
- * granted — real yesterday, noise today.
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.
114
120
  */
115
- const live = [...by.values()];
116
- const unowned = live.filter((g) => !(g.owners ?? []).length).length;
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;
128
+ }
117
129
 
118
130
  return {
119
131
  total,
120
- unowned,
132
+ unowned: piles.unowned.paths,
133
+ standing: piles,
121
134
  // The real number of distinct causes, not the length of the list below.
122
135
  // The page says "over N distinct paths" and the list is capped at twelve,
123
136
  // so taking N from the list reported the cap as if it were the count —
@@ -133,7 +146,10 @@ export function causesOf(cfg, entries) {
133
146
  times: g.times,
134
147
  share: total ? g.times / total : 0,
135
148
  roles: [...g.roles].sort(),
136
- owners: g.owners,
149
+ standing: g.standing.kind,
150
+ ...(g.standing.why && { why: g.standing.why }),
151
+ owners: g.standing.owners,
152
+ ownersThen: g.ownersThen,
137
153
  // How many of the roles that hit this would still hit it. `some` and
138
154
  // not `every`: two roles out of three still blocked is still friction,
139
155
  // and requiring all of them would quietly retire a live cause the day
@@ -220,11 +236,22 @@ export function state(configPath, { since = null } = {}) {
220
236
  * an exception for loopback. That is what lets the console hold the half the
221
237
  * MCP server deliberately does not.
222
238
  */
223
- function decide(configPath, { number, decision, reason }) {
239
+ function decide(configPath, { key, decision, reason }) {
224
240
  const cfg = loadConfig(configPath);
225
241
  const file = requestsPath(cfg.root);
226
- const req = pending(file)[Number(number) - 1];
227
- if (!req) throw new Error(`no pending request #${number}`);
242
+ /**
243
+ * By the request's id, never by its place in the queue.
244
+ *
245
+ * It took a row number, counted against the queue as it stood when the
246
+ * request arrived — not when the person read the page. Agents keep writing
247
+ * 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.
250
+ * The CLI stopped doing this long ago; the console had not.
251
+ */
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`);
228
255
  if (decision !== "granted" && decision !== "denied")
229
256
  throw new Error(`decision must be granted or denied`);
230
257
 
@@ -233,8 +260,14 @@ function decide(configPath, { number, decision, reason }) {
233
260
  // before the queue is settled: if this throws, the request is still open
234
261
  // rather than marked done against a file that never changed.
235
262
  refuseIfBarred(cfg, req);
236
- const { toml, changed } = applyGrant(readFileSync(cfg.path, "utf8"), req, reason);
237
- if (changed) writeFileSync(cfg.path, toml);
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
+ }
238
271
  }
239
272
  settle(file, req.key, decision, reason ?? "");
240
273
  return { ok: true, role: req.role, grant: req.grant, decision };
@@ -315,7 +348,16 @@ export function serve(configPath, port = 4178) {
315
348
  * from there, keeps it for the tab, and removes it from the address bar and
316
349
  * the history entry. Anything that fetches `/` gets a page with no token in it.
317
350
  */
318
- const authorized = (req) => req.headers["x-seisin-token"] === token;
351
+ // Constant-time compare: `===` on a secret leaks its length and a prefix
352
+ // through timing. Loopback with a Host check makes it near-theoretical, but a
353
+ // role with local_binding can reach this port, so it is not free.
354
+ const tokenBuf = Buffer.from(token);
355
+ const authorized = (req) => {
356
+ const got = req.headers["x-seisin-token"];
357
+ if (typeof got !== "string") return false;
358
+ const gotBuf = Buffer.from(got);
359
+ return gotBuf.length === tokenBuf.length && timingSafeEqual(gotBuf, tokenBuf);
360
+ };
319
361
 
320
362
  /**
321
363
  * The Host header has to name this server.
@@ -406,7 +448,9 @@ export function serve(configPath, port = 4178) {
406
448
  });
407
449
  server.on("error", (e) =>
408
450
  fail(e.code === "EADDRINUSE"
409
- ? new Error(`port ${port} is busy — pass another with: seisin ui --port <n>`)
451
+ // Keep the code on the rejection: `seisin ui` reads it to tell "a
452
+ // console is already here, reopen its link" from any other failure.
453
+ ? Object.assign(new Error(`port ${port} is busy — pass another with: seisin ui --port <n>`), { code: "EADDRINUSE" })
410
454
  : e));
411
455
  });
412
456
  }
package/src/surface.js CHANGED
@@ -235,8 +235,13 @@ function dedupe(entries) {
235
235
  */
236
236
  export function parentInputs(config, { env = process.env } = {}) {
237
237
  const abs = (p) => (isAbsolute(p) ? p : join(config.root, p));
238
+ const policy = abs(config.path ?? CONFIG_NAME);
238
239
  const out = [
239
- { path: abs(config.path ?? CONFIG_NAME), why: "the policy" },
240
+ { path: policy, why: "the policy" },
241
+ // `seisin.toml.observed` is the proposal `init --from-observations` writes
242
+ // for a person to diff and move onto the policy; a role that could write it
243
+ // could plant a policy the operator then promotes in good faith.
244
+ { path: policy + ".observed", why: "the policy" },
240
245
  { path: abs(STATE_DIR), why: "seisin's log and request queue" },
241
246
  ...(config.keyDirs ?? []).map((d) => ({ path: abs(d), why: "a key directory" })),
242
247
  ];
@@ -447,6 +452,15 @@ function computeDenies(config, role, { env, platform, observe }) {
447
452
  ? repoDirs.flatMap((d) => [
448
453
  `${d}/**/.claude`, `${d}/**/.claude/**`,
449
454
  `${d}/**/.git/hooks/**`, `${d}/**/.git/config`,
455
+ // What git runs that lives beside, not in, `.git/config`: a submodule's
456
+ // own config and hooks, and the files that redirect a worktree to
457
+ // another gitdir or config. Ordinary commits never write these, so
458
+ // denying them costs a working role nothing; a role that could write
459
+ // one would leave a command for the next `git` outside the box. The
460
+ // subtree of `.git` stays writable on purpose, so the agent can commit.
461
+ `${d}/**/.git/config.worktree`,
462
+ `${d}/**/.git/modules/**/config`, `${d}/**/.git/modules/**/hooks/**`,
463
+ `${d}/**/.git/worktrees/**/config.worktree`, `${d}/**/.git/worktrees/**/commondir`,
450
464
  `${d}/**/.mcp.json`, `${d}/**/.envrc`, `${d}/**/.codex/config.toml`,
451
465
  ]).map((path) => ({ path, why: "any project's control files in this territory, by pattern" }))
452
466
  : [];