seisin 0.4.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/mcp.js CHANGED
@@ -31,7 +31,7 @@ import { loadConfig, findConfig } from "./config.js";
31
31
  import { inspect } from "./inspect.js";
32
32
  import { explain, ownersOf } from "./owners.js";
33
33
  import { settingsFor } from "./srt.js";
34
- import { pending, requestsPath, grantFor, refuseIfBarred, markStale } from "./requests.js";
34
+ import { pending, requestsPath, grantFor, refuseIfBarred, markStale, shellId } from "./requests.js";
35
35
  import { read, logPath } from "./log.js";
36
36
  import { causesOf } from "./serve.js";
37
37
  import { walls, wasted } from "./walls.js";
@@ -100,13 +100,22 @@ const TOOLS = [
100
100
  name: "seisin_causes",
101
101
  description:
102
102
  "Denials grouped by what was denied — by path, and one level coarser by the name at " +
103
- "the end of it — with how many are on paths no role owns. This is the question " +
104
- "`seisin_activity` cannot answer: the raw log has the events, this has them counted " +
105
- "against the policy as it stands, so a cause that has been granted since stops " +
106
- "counting. Reach for it before proposing a change: a day that is three quarters one " +
107
- "filename is a tooling problem, and the same volume spread across unrelated paths is " +
108
- "a question about who owns what. Read-only.",
109
- inputSchema: { type: "object", properties: { limit: { type: "number" } } },
103
+ "the end of it — over the whole log, or since an ISO date. The counts are history: " +
104
+ "what was refused. What is measured against the policy as it stands is where each " +
105
+ "path is now — `standing`: unowned (a decision nobody has made), owned, protected " +
106
+ "(closed to every role on purpose, nothing to grant) or outside (a port, a key, or " +
107
+ "outside the repository) — and, per cause, `stillRefused`, how many of the roles " +
108
+ "that hit it would hit it today. `seisin_activity` has the raw events; this has " +
109
+ "them read against the policy. Reach for it before proposing a change: a day that " +
110
+ "is three quarters one filename is a tooling problem, and the same volume spread " +
111
+ "across unrelated unowned paths is a question about who owns what. Read-only.",
112
+ inputSchema: {
113
+ type: "object",
114
+ properties: {
115
+ limit: { type: "number" },
116
+ since: { type: "string", description: "ISO date; only refusals at or after it" },
117
+ },
118
+ },
110
119
  },
111
120
  {
112
121
  name: "seisin_walls",
@@ -127,8 +136,10 @@ const TOOLS = [
127
136
  "does not grant. The approval is a human action in another channel.",
128
137
  inputSchema: {
129
138
  type: "object",
130
- properties: { number: { type: "number", description: "position in seisin_requests" } },
131
- required: ["number"],
139
+ properties: {
140
+ id: { type: "string", description: "the request's id from seisin_requests — preferred: it does not move when the queue does" },
141
+ number: { type: "number", description: "position in seisin_requests (legacy; the draft still names the id)" },
142
+ },
132
143
  },
133
144
  },
134
145
  ];
@@ -166,6 +177,8 @@ const HANDLERS = {
166
177
  // not enforced is documentation.
167
178
  if (action !== "read" && action !== "write" && action !== "mcp")
168
179
  throw new Error(`action must be "read", "write" or "mcp", got ${JSON.stringify(action)}`);
180
+ if (typeof role !== "string" || !role)
181
+ throw new Error("role must be a non-empty string");
169
182
  if (typeof target !== "string" || !target)
170
183
  throw new Error("target must be a non-empty path");
171
184
  const cfg = config();
@@ -182,14 +195,14 @@ const HANDLERS = {
182
195
  const queue = markStale(pending(requestsPath(cfg.root)), read(logPath(cfg.root)));
183
196
  return {
184
197
  pending: queue.map((r, i) => ({
185
- number: i + 1, role: r.role, action: r.action,
198
+ number: i + 1, id: r.key, role: r.role, action: r.action,
186
199
  wants: r.grant, ownedBy: r.owners, asked: r.times, lastAsked: r.last,
187
200
  ...(r.stale ? { stale: `not asked again in ${r.stale.runs} runs of ${r.role} since` } : {}),
188
201
  })),
189
202
  // Said in the payload and not only in the tool description, because a
190
203
  // model reading this is deciding what to do next.
191
204
  note: queue.length
192
- ? "Approving is not available through MCP. Tell the operator to run: seisin grant <number>"
205
+ ? "Approving is not available through MCP. Tell the operator to run: seisin grant '<id>'"
193
206
  : "nothing waiting",
194
207
  };
195
208
  },
@@ -203,13 +216,21 @@ const HANDLERS = {
203
216
  * and asks it to re-derive the grouping — without the policy, which is the
204
217
  * half that makes the grouping mean anything.
205
218
  */
206
- seisin_causes({ limit }) {
219
+ seisin_causes({ limit, since }) {
207
220
  const cfg = config();
208
- const c = causesOf(cfg, read(logPath(cfg.root), { limit: 4000 }));
221
+ // The whole log, as the console reads it. This read the last 4000 lines
222
+ // after the console had stopped doing so, and the two then disagreed about
223
+ // the same log — 3,999 refusals here against 6,630 there, measured — with
224
+ // nothing telling the agent its window was half of the person's.
225
+ const t = since ? Date.parse(since) : NaN;
226
+ const from = Number.isNaN(t) ? null : new Date(t).toISOString();
227
+ const c = causesOf(cfg, read(logPath(cfg.root), from ? { since: from } : {}));
209
228
  return {
210
229
  total: c.total,
211
230
  distinct: c.distinct,
231
+ ...(from && { since: from }),
212
232
  unowned: c.unowned,
233
+ standing: c.standing,
213
234
  families: c.families,
214
235
  causes: typeof limit === "number" ? c.causes.slice(0, limit) : c.causes,
215
236
  };
@@ -237,12 +258,23 @@ const HANDLERS = {
237
258
  return { entries: read(logPath(cfg.root), { role, verdict, limit: n }) };
238
259
  },
239
260
 
240
- seisin_draft_grant({ number }) {
241
- if (!Number.isInteger(Number(number)) || Number(number) < 1)
242
- throw new Error("number must be a positive integer from seisin_requests");
261
+ seisin_draft_grant({ id, number } = {}) {
243
262
  const cfg = config();
244
- const req = pending(requestsPath(cfg.root))[Number(number) - 1];
245
- if (!req) throw new Error(`no pending request #${number}`);
263
+ const queue = pending(requestsPath(cfg.root));
264
+ let req;
265
+ if (typeof id === "string" && id) {
266
+ req = queue.find((r) => r.key === id);
267
+ if (!req) throw new Error(`request ${id} is not pending`);
268
+ } else {
269
+ if (!Number.isInteger(Number(number)) || Number(number) < 1)
270
+ throw new Error("id (preferred) or number from seisin_requests is required");
271
+ req = queue[Number(number) - 1];
272
+ if (!req) throw new Error(`no pending request #${number}`);
273
+ }
274
+ // The command is written against the id, whatever was asked with: a
275
+ // number is a position in a queue agents keep writing to, and the person
276
+ // runs this later, against the queue as it is then.
277
+ const ref = shellId(req.key);
246
278
 
247
279
  // The same refusal `seisin grant` and the console apply, asked here first.
248
280
  // Without it this drafted, as approvable, a request that `never_writes`
@@ -255,7 +287,7 @@ const HANDLERS = {
255
287
  ownedBy: req.owners,
256
288
  applyWith: null,
257
289
  note: "Not approvable as it stands: never_writes wins over writes. Decline it with " +
258
- `\`seisin decline ${number}\`, or remove the never_writes entry if the subtraction is wrong.`,
290
+ `\`seisin decline ${ref}\`, or remove the never_writes entry if the subtraction is wrong.`,
259
291
  };
260
292
  }
261
293
  const field = req.action === "read" ? "keys" : "writes";
@@ -263,7 +295,8 @@ const HANDLERS = {
263
295
  wouldAdd: { role: req.role, field, value: req.grant },
264
296
  preview: `[roles.${req.role}]\n${field} = [ …, "${req.grant}" ] # asked ${req.times}×`,
265
297
  ownedBy: req.owners,
266
- applyWith: `seisin grant ${number} --reason "<why>"`,
298
+ id: req.key,
299
+ applyWith: `seisin grant ${ref} --reason "<why>"`,
267
300
  note: "Not applied. seisin's MCP server writes nothing — a person runs the command above.",
268
301
  };
269
302
  },
@@ -284,7 +317,10 @@ function handle(message, { write, version }) {
284
317
  const reply = (id, result) => send({ jsonrpc: "2.0", id, result });
285
318
  const fail = (id, code, msg) => send({ jsonrpc: "2.0", id, error: { code, message: msg } });
286
319
 
287
- const { id, method, params = {} } = message;
320
+ const { id, method } = message;
321
+ // `?? {}` so an explicit null params (not just missing) does not crash the
322
+ // dispatcher and leave the request unanswered.
323
+ const params = message.params ?? {};
288
324
 
289
325
  // A notification has no id and takes no answer. Replying to one is a protocol
290
326
  // error that some clients tolerate and others hang on.
@@ -310,8 +346,11 @@ function handle(message, { write, version }) {
310
346
  case "tools/list":
311
347
  return reply(id, { tools: TOOLS });
312
348
  case "tools/call": {
313
- const fn = HANDLERS[params.name];
314
- if (!fn) return fail(id, -32602, `unknown tool "${params.name}"`);
349
+ // hasOwn, not a bare lookup: `params.name` of "constructor" or
350
+ // "hasOwnProperty" would otherwise resolve to a function on Object's
351
+ // prototype and be invoked as a tool.
352
+ const fn = Object.hasOwn(HANDLERS, params.name) ? HANDLERS[params.name] : null;
353
+ if (typeof fn !== "function") return fail(id, -32602, `unknown tool ${JSON.stringify(params.name)}`);
315
354
  try {
316
355
  const result = fn(params.arguments ?? {});
317
356
  // Content, not a bare object: every client renders `content`, and only
package/src/notify.js CHANGED
@@ -24,7 +24,7 @@
24
24
  */
25
25
  import { readFileSync } from "node:fs";
26
26
  import { basename, join } from "node:path";
27
- import { keyOf, pending, requestsPath, grantFor } from "./requests.js";
27
+ import { keyOf, pending, requestsPath, grantFor, shellId } from "./requests.js";
28
28
 
29
29
  export const FORMATS = ["text", "json", "slack"];
30
30
  export const ENV_URL = "SEISIN_NOTIFY_URL";
@@ -46,13 +46,13 @@ export function message(config, req, number, format = "text") {
46
46
  const owner = req.owners?.length ? `It belongs to ${req.owners.join(", ")}.` : "Nobody owns it.";
47
47
  const text =
48
48
  `seisin (${basename(config.root)}): ${req.role} was refused ${req.action} on ${req.target}. ${owner} ` +
49
- `Approve: seisin grant ${number} · decline: seisin decline ${number}`;
49
+ `Approve: seisin grant ${shellId(keyOf(req))} · decline: seisin decline ${shellId(keyOf(req))}`;
50
50
  if (format === "slack") return { type: "application/json", body: JSON.stringify({ text }) };
51
51
  if (format === "json")
52
52
  return {
53
53
  type: "application/json",
54
54
  body: JSON.stringify({
55
- text, repo: basename(config.root), number, role: req.role, action: req.action,
55
+ text, repo: basename(config.root), number, id: keyOf(req), role: req.role, action: req.action,
56
56
  target: req.target, owners: req.owners ?? [], grant: grantFor(req),
57
57
  }),
58
58
  };
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
  }