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/log.js CHANGED
@@ -11,9 +11,9 @@
11
11
  * what an agent tried last night reads the same today — and since the change in
12
12
  * spool.js, nothing inside the sandbox can rewrite one either. append() below
13
13
  * tries the parent's socket first for exactly that reason; writing the file
14
- * directly is what happens when there is no parent, outside the box.
14
+ * directly is what happens when there is no parent, outside the sandbox.
15
15
  */
16
- import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, statSync, unlinkSync, writeSync } from "node:fs";
16
+ import { appendFileSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
17
17
  import { createHash } from "node:crypto";
18
18
  import { basename, dirname, join } from "node:path";
19
19
  import { STATE_DIR, LOG_NAME } from "./layout.js";
@@ -44,9 +44,14 @@ export function append(file, entry) {
44
44
  // segment. Done here so the size check and the append never race.
45
45
  maybeRotate(file);
46
46
  // `prev` goes last and is computed from the previous line exactly as it
47
- // sits on disk, so verifying needs nothing but the file.
48
- const line = JSON.stringify({ at: new Date().toISOString(), ...entry, prev: hashOf(lastLine(file)) });
47
+ // sits on disk, so verifying needs nothing but the file. `at` is this
48
+ // process's clock, whatever the entry says: named again after the spread
49
+ // so a caller's `at` cannot replace it (the key keeps its first place).
50
+ const now = new Date().toISOString();
51
+ const line = JSON.stringify({ at: now, ...entry, at: now, prev: hashOf(lastLine(file)) });
49
52
  appendFileSync(file, line + "\n");
53
+ // Once per log: pin where the chain begins. See recordGenesis.
54
+ if (!existsSync(genesisPath(file))) { try { recordGenesis(file); } catch {} }
50
55
  }, { waitMs: envInt("SEISIN_LOG_LOCK_WAIT_MS", 6000) });
51
56
  return true;
52
57
  } catch (e) {
@@ -65,7 +70,52 @@ export function droppedPath(file) {
65
70
  }
66
71
 
67
72
  function countLines(file) {
68
- try { return readFileSync(file, "utf8").split("\n").filter((l) => l.trim()).length; } catch { return 0; }
73
+ return readLines(file).length;
74
+ }
75
+
76
+ /**
77
+ * The complete lines of a JSON-lines file, raw and non-blank, oldest first.
78
+ *
79
+ * The one reader the JSONL files here share (the log, its segments, the
80
+ * request queue, the tail the hook counts from). With `tail`, only the last
81
+ * that many bytes are read, and the line the cut lands in is dropped — unless
82
+ * the cut falls exactly on a line boundary, which is checked by reading one
83
+ * byte earlier rather than guessed from string lengths (a byte count and a
84
+ * UTF-16 length disagree on every "ñ"). A missing file is no lines.
85
+ */
86
+ export function readLines(file, { tail = 0 } = {}) {
87
+ let fd;
88
+ try { fd = openSync(file, "r"); } catch { return []; }
89
+ try {
90
+ const { size } = fstatSync(fd);
91
+ const from = tail > 0 ? Math.max(0, size - tail) : 0;
92
+ const start = from > 0 ? from - 1 : 0;
93
+ const buf = Buffer.alloc(size - start);
94
+ let got = 0;
95
+ while (got < buf.length) {
96
+ const n = readSync(fd, buf, got, buf.length - got, start + got);
97
+ if (n === 0) break;
98
+ got += n;
99
+ }
100
+ const lines = buf.subarray(0, got).toString("utf8").split("\n");
101
+ // Started one byte early: the first piece is either empty (the cut was on
102
+ // a newline) or the fragment the cut landed in. Dropped either way.
103
+ if (from > 0) lines.shift();
104
+ return lines.filter((l) => l.trim());
105
+ } finally {
106
+ closeSync(fd);
107
+ }
108
+ }
109
+
110
+ /** `readLines`, parsed. A malformed line — a half-written one from a killed process — is skipped. */
111
+ export function readEntries(file, opts) {
112
+ const out = [];
113
+ for (const line of readLines(file, opts)) {
114
+ let e;
115
+ try { e = JSON.parse(line); } catch { continue; }
116
+ if (e && typeof e === "object") out.push(e);
117
+ }
118
+ return out;
69
119
  }
70
120
 
71
121
  /**
@@ -140,9 +190,9 @@ function maybeRotate(file) {
140
190
  * from nono's audit trail; the Merkle root and the signature it adds on top
141
191
  * are left for when someone asks for them.
142
192
  *
143
- * The first chained line of a file points at GENESIS. A file started by
144
- * rotation should point at the last line of the one before it; there is no
145
- * rotation yet, and when there is, that is what it has to do.
193
+ * The first chained line of a log points at GENESIS (or, after a legacy
194
+ * prefix, at the last legacy line). A segment started by rotation points at
195
+ * the last line of the one before it — see maybeRotate and verifyLog.
146
196
  */
147
197
  export const GENESIS = "0".repeat(32);
148
198
 
@@ -150,18 +200,34 @@ export function hashOf(line) {
150
200
  return line == null ? GENESIS : createHash("sha256").update(line).digest("hex").slice(0, 32);
151
201
  }
152
202
 
153
- /** The last complete line of the file, or null when there is none. */
203
+ /**
204
+ * The last complete line of the file, or null when there is none.
205
+ *
206
+ * Read backwards a chunk at a time until a newline is found before it. It
207
+ * read a fixed 64 KB, so a single line longer than that came back as its own
208
+ * tail, the next `prev` hashed a fragment, and the chain broke with nobody
209
+ * having touched the file.
210
+ */
154
211
  function lastLine(file) {
155
- if (!existsSync(file)) return null;
156
- const { size } = statSync(file);
157
- if (size === 0) return null;
158
- const from = Math.max(0, size - 64 * 1024);
159
- const fd = openSync(file, "r");
212
+ let fd;
213
+ try { fd = openSync(file, "r"); } catch { return null; }
160
214
  try {
161
- const buf = Buffer.alloc(size - from);
162
- readSync(fd, buf, 0, buf.length, from);
163
- const lines = buf.toString("utf8").split("\n").filter((l) => l.trim());
164
- return lines.length ? lines[lines.length - 1] : null;
215
+ const CHUNK = 64 * 1024;
216
+ let pos = fstatSync(fd).size;
217
+ let acc = Buffer.alloc(0);
218
+ while (pos > 0) {
219
+ const from = Math.max(0, pos - CHUNK);
220
+ const buf = Buffer.alloc(pos - from);
221
+ readSync(fd, buf, 0, buf.length, from);
222
+ acc = Buffer.concat([buf, acc]);
223
+ pos = from;
224
+ const lines = acc.toString("utf8").split("\n");
225
+ let i = lines.length - 1;
226
+ while (i >= 0 && !lines[i].trim()) i--;
227
+ // Whole once something precedes it in the buffer, or the file starts here.
228
+ if (i > 0 || (i === 0 && pos === 0)) return lines[i];
229
+ }
230
+ return null;
165
231
  } finally {
166
232
  closeSync(fd);
167
233
  }
@@ -213,10 +279,23 @@ export function withLock(file, fn, { waitMs = 6000, staleMs = 5000 } = {}) {
213
279
  }
214
280
  }
215
281
 
216
- /** True if the process is still there. EPERM means it is, and not ours to signal. */
217
- function alive(pid) {
282
+ /**
283
+ * Is this process still there? False only when the kernel says there is no
284
+ * such process (ESRCH).
285
+ *
286
+ * The one answer both callers need, and it leans the same way for both: an
287
+ * answer that is not "gone" — EPERM (there, and not ours to signal), anything
288
+ * unexpected, a pid that is not a positive integer — counts as alive. For a
289
+ * lock that means waiting on a holder that might be done; for a run directory
290
+ * it means leaving one that might be stale. Both are cheaper than the other
291
+ * mistake: breaking a lock someone holds, or deleting a live run's socket and
292
+ * keys from under it. rundir.js used to call every error but EPERM "dead" and
293
+ * swept on it.
294
+ */
295
+ export function alive(pid) {
296
+ if (!Number.isInteger(pid) || pid <= 0) return true; // not a pid we can ask about; kill(-n) is a group
218
297
  try { process.kill(pid, 0); return true; }
219
- catch (e) { return e.code !== "ESRCH"; }
298
+ catch (e) { return e?.code !== "ESRCH"; }
220
299
  }
221
300
 
222
301
  /**
@@ -243,21 +322,30 @@ function reapable(lock, staleMs) {
243
322
  * Walk the file and say where the chain breaks.
244
323
  *
245
324
  * Lines written before the chain existed have no `prev`; they are counted as an
246
- * unchained prefix, not reported as tampering. After the first chained line,
247
- * a line without `prev` is itself a break.
325
+ * unchained prefix, not reported as tampering — but only where such a prefix
326
+ * can be: at the head of the oldest segment (`legacy`). After the first chained
327
+ * line, and anywhere in a segment that rotation started, a line without `prev`
328
+ * is itself a break. So is a rotation marker without one: seisin never wrote
329
+ * such a marker.
330
+ *
331
+ * `digest` hashes the unchained prefix and `first` is the hash of the first
332
+ * chained line: what the genesis record pins (see recordGenesis).
248
333
  */
249
- function walk(file, { seed = GENESIS, startLine = 0 } = {}) {
250
- const out = { lines: 0, unchained: 0, chained: 0, breaks: [], tail: null };
251
- if (!existsSync(file)) return out;
334
+ function walk(file, { seed = GENESIS, startLine = 0, legacy = true } = {}) {
335
+ const out = { lines: 0, unchained: 0, chained: 0, breaks: [], tail: null, digest: null, first: null, rotated: false };
336
+ const prefix = createHash("sha256");
252
337
  let previous = null;
253
338
  let started = false;
254
- for (const line of readFileSync(file, "utf8").split("\n")) {
255
- if (!line.trim()) continue;
339
+ for (const line of readLines(file)) {
256
340
  out.lines++;
257
341
  let e = null;
258
342
  try { e = JSON.parse(line); } catch {}
259
343
  const prev = e?.prev;
260
- if (!started && prev === undefined) { out.unchained++; previous = line; continue; }
344
+ if (out.lines === 1 && e?.event === "rotated") out.rotated = true;
345
+ if (!started && prev === undefined && legacy && !out.rotated) {
346
+ out.unchained++; prefix.update(line + "\n"); previous = line; continue;
347
+ }
348
+ if (!started) out.first = hashOf(line);
261
349
  started = true;
262
350
  const first = out.chained === 0 && out.unchained === 0;
263
351
  // A rotation marker leading a segment we have nothing to check it against —
@@ -266,14 +354,15 @@ function walk(file, { seed = GENESIS, startLine = 0 } = {}) {
266
354
  // called a break. For any later segment the seed is the real previous tail,
267
355
  // so the marker's `prev` IS checked, and a whole segment deleted or
268
356
  // reordered in the middle shows.
269
- const rotationStart = first && e?.event === "rotated" && seed === GENESIS;
357
+ const rotationStart = first && e?.event === "rotated" && prev !== undefined && seed === GENESIS;
270
358
  const expected = first ? seed : hashOf(previous);
271
- const ok = rotationStart || prev === expected || (first && prev === hashOf(previous));
359
+ const ok = prev !== undefined && (rotationStart || prev === expected || (first && prev === hashOf(previous)));
272
360
  if (!ok) out.breaks.push({ line: startLine + out.lines, expected, found: prev ?? null });
273
361
  out.chained++;
274
362
  previous = line;
275
363
  }
276
364
  out.tail = previous;
365
+ out.digest = out.unchained ? prefix.digest("hex").slice(0, 32) : null;
277
366
  return out;
278
367
  }
279
368
 
@@ -282,6 +371,54 @@ export function verifyChain(file, seed = GENESIS) {
282
371
  return { lines: r.lines, unchained: r.unchained, chained: r.chained, breaks: r.breaks };
283
372
  }
284
373
 
374
+ /**
375
+ * Where the chain begins, written once beside the log.
376
+ *
377
+ * The unchained prefix was taken on trust, and that was the hole: strip `prev`
378
+ * from every line and the whole log becomes "from before the chain", so an
379
+ * edit or a deletion anywhere in it verified as intact. The record pins the
380
+ * boundary the first time a chained line is written by a seisin that knows to
381
+ * — how many legacy lines there were, a digest of them, and the hash of the
382
+ * first chained line — so a log that no longer matches it is broken, not old.
383
+ *
384
+ * Written under the log lock, by `append`, and never rewritten. A log whose
385
+ * record is missing (written by an older seisin and not appended to since, or
386
+ * the record deleted) verifies as before and says the genesis is unrecorded.
387
+ *
388
+ * ── Recorded late ──
389
+ * The record is only as good as the moment it was taken. Written with the
390
+ * first chained line, it pins a start nobody could have edited yet. Written
391
+ * later — a log chained by an older seisin, or a record deleted and re-made by
392
+ * the next append — it pins whatever the log says by then, tampered or not.
393
+ * That is still worth pinning (nothing after it can be rewritten unseen), but
394
+ * it must not read as the same guarantee: such a record says `late`, how many
395
+ * chained lines already stood before it, and `verify` repeats both.
396
+ */
397
+ export function genesisPath(file) {
398
+ return file + ".genesis";
399
+ }
400
+
401
+ function recordGenesis(file) {
402
+ const segs = logSegments(file);
403
+ if (!segs.length) return;
404
+ const r = walk(segs[0]);
405
+ if (!r.first) return; // nothing chained yet: nothing to pin
406
+ // Chained lines that stood before the one `append` just wrote. Any at all
407
+ // means the start is being pinned after the fact.
408
+ let before = r.chained - 1;
409
+ segs.slice(1).forEach((seg) => { before += walk(seg, { legacy: false }).chained; });
410
+ const record = { at: new Date().toISOString(), legacy: r.unchained, digest: r.digest, first: r.first };
411
+ if (before > 0) Object.assign(record, { late: true, after: before });
412
+ writeFileSync(genesisPath(file), JSON.stringify(record) + "\n", { flag: "wx" });
413
+ }
414
+
415
+ function readGenesis(file) {
416
+ try {
417
+ const g = JSON.parse(readFileSync(genesisPath(file), "utf8"));
418
+ return g && Number.isInteger(g.legacy) && typeof g.first === "string" ? g : null;
419
+ } catch { return null; }
420
+ }
421
+
285
422
  /**
286
423
  * Verify the whole log, across every rotated segment, as one chain.
287
424
  *
@@ -291,40 +428,88 @@ export function verifyChain(file, seed = GENESIS) {
291
428
  * single segment and this is exactly `verifyChain`. What it cannot see is a
292
429
  * segment dropped off the *oldest* end by the keep limit: that is retention, not
293
430
  * tampering, and the count of segments is reported so the drop is not silent.
431
+ *
432
+ * `genesis` says whether the chain's starting point was checked against its
433
+ * record: `recorded`, `unrecorded` (no record yet — see recordGenesis), or
434
+ * `pruned` (the segment it described was dropped for retention). `late` is
435
+ * `{ at, after }` when the record was made after chained lines already stood.
436
+ *
437
+ * A record whose first chained hash differs, over a chain that otherwise
438
+ * holds, is not an edit inside this log: it describes a different start.
439
+ * That happens when the operator archives the log by hand and leaves the
440
+ * record behind — or when the log was rewritten from its first line. Both are
441
+ * reported as a break (the second is exactly what the record exists to catch),
442
+ * but marked `foreign`, so `verify` can say which file to move or remove.
294
443
  */
295
444
  export function verifyLog(file) {
296
445
  const segs = logSegments(file);
297
- const agg = { lines: 0, unchained: 0, chained: 0, breaks: [], segments: segs.length, dropped: countLines(droppedPath(file)) };
446
+ const agg = { lines: 0, unchained: 0, chained: 0, breaks: [], segments: segs.length, dropped: countLines(droppedPath(file)), genesis: "unrecorded", late: null };
447
+ const pinned = readGenesis(file);
298
448
  let seed = GENESIS;
299
- for (const seg of segs) {
300
- const r = walk(seg, { seed, startLine: agg.lines });
449
+ segs.forEach((seg, i) => {
450
+ const r = walk(seg, { seed, startLine: agg.lines, legacy: i === 0 });
451
+ if (i === 0 && pinned) {
452
+ if (r.rotated) agg.genesis = "pruned";
453
+ else {
454
+ agg.genesis = "recorded";
455
+ if (pinned.late) agg.late = { at: pinned.at ?? null, after: Number.isInteger(pinned.after) ? pinned.after : null };
456
+ // Where the first chained line should be, and what it should hash to.
457
+ const at = pinned.legacy + 1;
458
+ // A different first line in a chain that otherwise holds is a
459
+ // different start, not an edit — an edit to the first line would also
460
+ // break the line after it. A log with no chain left at all was not
461
+ // replaced by another seisin log (those chain from their first line):
462
+ // it was stripped.
463
+ if (r.first && r.first !== pinned.first && r.breaks.length === 0)
464
+ r.breaks.unshift({ line: at, expected: pinned.first, found: r.first, genesis: true, foreign: true });
465
+ else if (r.unchained !== pinned.legacy || r.first !== pinned.first)
466
+ r.breaks.unshift({ line: at, expected: pinned.first, found: r.unchained !== pinned.legacy ? null : r.first, genesis: true });
467
+ else if ((r.digest ?? null) !== (pinned.digest ?? null))
468
+ r.breaks.unshift({ line: 1, expected: pinned.digest, found: r.digest, genesis: true });
469
+ }
470
+ }
301
471
  agg.lines += r.lines;
302
472
  agg.unchained += r.unchained;
303
473
  agg.chained += r.chained;
304
474
  agg.breaks.push(...r.breaks);
305
475
  seed = hashOf(r.tail);
306
- }
476
+ });
307
477
  return agg;
308
478
  }
309
479
 
310
- /** Reads entries, newest last. A malformed line is skipped, never fatal. */
480
+ /**
481
+ * Reads entries, newest last, across every segment. A malformed line is
482
+ * skipped, never fatal.
483
+ *
484
+ * It read the current segment only, so the first rotation (8 MiB) took the
485
+ * history out from under everything that reads a window — the console, causes,
486
+ * walls, the staleness of a request — without a word. Segments are read newest
487
+ * first and the walk stops at the first one that ends before `since`, or once
488
+ * `limit` entries are in hand.
489
+ */
311
490
  export function read(file, { role, verdict, since, limit = 0 } = {}) {
312
- if (!existsSync(file)) return [];
313
- let out = [];
314
- for (const line of readFileSync(file, "utf8").split("\n")) {
315
- if (!line.trim()) continue;
316
- let e;
317
- try {
318
- e = JSON.parse(line);
319
- } catch {
320
- continue; // a half-written line from a killed process is not an error
491
+ const parts = [];
492
+ let have = 0;
493
+ for (const seg of logSegments(file).reverse()) {
494
+ if (since && seg !== file) {
495
+ // A retired segment that ended before the window has nothing in it.
496
+ let last = null;
497
+ try { last = JSON.parse(lastLine(seg)); } catch {}
498
+ if (typeof last?.at === "string" && last.at < since) break;
499
+ }
500
+ const got = [];
501
+ for (const e of readEntries(seg)) {
502
+ if (e.event === "rotated") continue; // a structural marker, not a decision
503
+ if (role && e.role !== role) continue;
504
+ if (verdict && e.verdict !== verdict) continue;
505
+ if (since && e.at < since) continue;
506
+ got.push(e);
321
507
  }
322
- if (e.event === "rotated") continue; // a structural marker, not a decision
323
- if (role && e.role !== role) continue;
324
- if (verdict && e.verdict !== verdict) continue;
325
- if (since && e.at < since) continue;
326
- out.push(e);
508
+ parts.unshift(got);
509
+ have += got.length;
510
+ if (limit > 0 && have >= limit) break;
327
511
  }
512
+ let out = parts.flat();
328
513
  if (limit > 0 && out.length > limit) out = out.slice(-limit);
329
514
  return out;
330
515
  }
@@ -354,7 +539,7 @@ export function observed(entries) {
354
539
  // it, or the line was not written by the hook at all. Either way it is not
355
540
  // evidence of what the role legitimately reached for, so it must not become
356
541
  // policy: `init --from-observations` builds `writes`/`keys` from here, and a
357
- // process inside the box could otherwise send `verdict:"observed"` lines for
542
+ // process inside the sandbox could otherwise send `verdict:"observed"` lines for
358
543
  // paths outside its territory and have them proposed as its own.
359
544
  if (e.disputed) continue;
360
545
  const r = roles.get(e.role) ?? { writes: new Set(), keys: new Set() };
package/src/mcp.js CHANGED
@@ -29,11 +29,12 @@
29
29
  import { toRepoRelative } from "./paths.js";
30
30
  import { loadConfig, findConfig } from "./config.js";
31
31
  import { inspect } from "./inspect.js";
32
- import { explain, ownersOf } from "./owners.js";
32
+ import { explain, explainFileRead, ownersOf, readTarget } from "./owners.js";
33
+ import { CREDENTIAL_HOMES, expand } from "./grants.js";
33
34
  import { settingsFor } from "./srt.js";
34
- import { pending, requestsPath, grantFor, refuseIfBarred, markStale, shellId } from "./requests.js";
35
+ import { pending, requestsPath, refuseIfBarred, shellId } from "./requests.js";
35
36
  import { read, logPath } from "./log.js";
36
- import { causesOf } from "./serve.js";
37
+ import { causesOf, parseSince, queue as requestQueue, verdicts, wallsByRole } from "./views.js";
37
38
  import { walls, wasted } from "./walls.js";
38
39
 
39
40
  /**
@@ -53,7 +54,7 @@ const TOOLS = [
53
54
  {
54
55
  name: "seisin_state",
55
56
  description:
56
- "The permission map: every role, the folders it may write, the keys it may read, " +
57
+ "The map: every role, its territory (the folders it may write), the keys it may read, " +
57
58
  "and every way the policy silently does not hold. Read-only.",
58
59
  inputSchema: {
59
60
  type: "object",
@@ -78,15 +79,15 @@ const TOOLS = [
78
79
  {
79
80
  name: "seisin_requests",
80
81
  description:
81
- "Permissions agents asked for and cannot have yet, waiting on a human, with how many " +
82
- "times each was asked. A refusal files one of these automatically, so this is where " +
83
- "to look after being denied rather than retrying. Read-only — approving is not " +
82
+ "Requests: access agents asked for and cannot have yet, waiting on a person, with how many " +
83
+ "times each was asked. A denial files one of these automatically, so this is where " +
84
+ "to look after being denied rather than retrying. Read-only — granting is not " +
84
85
  "available here, by design.",
85
86
  inputSchema: { type: "object", properties: {} },
86
87
  },
87
88
  {
88
89
  name: "seisin_activity",
89
- description: "Recent allow/deny decisions, newest last. Read-only.",
90
+ description: "Recent log entries — allowed, denied or observed — newest last. Read-only.",
90
91
  inputSchema: {
91
92
  type: "object",
92
93
  properties: {
@@ -101,11 +102,16 @@ const TOOLS = [
101
102
  description:
102
103
  "Denials grouped by what was denied — by path, and one level coarser by the name at " +
103
104
  "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
+ "what was denied. What is measured against the policy as it stands is where each " +
105
106
  "path is now — `standing`: unowned (a decision nobody has made), owned, protected " +
106
107
  "(closed to every role on purpose, nothing to grant) or outside (a port, a key, or " +
107
108
  "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
+ "that hit it would hit it today. An unowned cause also carries `kind` and `hint`: " +
110
+ "git (lock or metadata), credential, temporary, build or territory, and the move " +
111
+ "that fits it (`standing.unowned.kinds` counts them). Only `territory` is a question " +
112
+ "of who owns what; do not propose an owner for the others — a lock, a test's scratch " +
113
+ "or a credential given to whoever asked widens a role. " +
114
+ "`seisin_activity` has the raw events; this has " +
109
115
  "them read against the policy. Reach for it before proposing a change: a day that " +
110
116
  "is three quarters one filename is a tooling problem, and the same volume spread " +
111
117
  "across unrelated unowned paths is a question about who owns what. Read-only.",
@@ -113,7 +119,7 @@ const TOOLS = [
113
119
  type: "object",
114
120
  properties: {
115
121
  limit: { type: "number" },
116
- since: { type: "string", description: "ISO date; only refusals at or after it" },
122
+ since: { type: "string", description: "ISO date; only denials at or after it" },
117
123
  },
118
124
  },
119
125
  },
@@ -130,10 +136,10 @@ const TOOLS = [
130
136
  name: "seisin_draft_grant",
131
137
  description:
132
138
  "Draft the change a pending request would make, as text, plus the command a person " +
133
- "runs to apply it. Reach for this after a refusal: the denial is already in " +
134
- "seisin_requests, and this turns it into a concrete proposal someone can approve. " +
139
+ "runs to apply it. Reach for this after a denial: it is already in " +
140
+ "seisin_requests, and this turns it into a concrete proposal a person can grant. " +
135
141
  "Read-only, and it is the tool where that matters most: the name says grant and it " +
136
- "does not grant. The approval is a human action in another channel.",
142
+ "does not grant. Granting is a person's action, in another channel.",
137
143
  inputSchema: {
138
144
  type: "object",
139
145
  properties: {
@@ -185,6 +191,14 @@ const HANDLERS = {
185
191
  if (!cfg.roles[role])
186
192
  return { error: `unknown role "${role}"`, known: Object.keys(cfg.roles) };
187
193
  if (action === "mcp") return explain(cfg, role, "mcp", target);
194
+ // A read of a file outside every key dir is open, as it is for the CLI.
195
+ // The server has no working directory of its own, so the root stands in.
196
+ if (action === "read") {
197
+ const asked = readTarget(cfg, target, cfg.root);
198
+ return asked.key
199
+ ? explain(cfg, role, "read", asked.target)
200
+ : explainFileRead(cfg, role, asked.target, CREDENTIAL_HOMES.map(expand));
201
+ }
188
202
  const rel = toRepoRelative(cfg, target);
189
203
  const verdict = explain(cfg, role, action, rel);
190
204
  return { ...verdict, alsoOwnedBy: action === "write" ? ownersOf(cfg, rel) : undefined };
@@ -192,7 +206,7 @@ const HANDLERS = {
192
206
 
193
207
  seisin_requests() {
194
208
  const cfg = config();
195
- const queue = markStale(pending(requestsPath(cfg.root)), read(logPath(cfg.root)));
209
+ const queue = requestQueue(cfg.root, read(logPath(cfg.root)), cfg.keyDirs);
196
210
  return {
197
211
  pending: queue.map((r, i) => ({
198
212
  number: i + 1, id: r.key, role: r.role, action: r.action,
@@ -202,7 +216,7 @@ const HANDLERS = {
202
216
  // Said in the payload and not only in the tool description, because a
203
217
  // model reading this is deciding what to do next.
204
218
  note: queue.length
205
- ? "Approving is not available through MCP. Tell the operator to run: seisin grant '<id>'"
219
+ ? "Granting is not available through MCP. Ask a person to run: seisin grant '<id>'"
206
220
  : "nothing waiting",
207
221
  };
208
222
  },
@@ -222,9 +236,8 @@ const HANDLERS = {
222
236
  // after the console had stopped doing so, and the two then disagreed about
223
237
  // the same log — 3,999 refusals here against 6,630 there, measured — with
224
238
  // 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 } : {}));
239
+ const from = parseSince(since);
240
+ const c = causesOf(cfg, read(logPath(cfg.root), from ? { since: from } : {}), { ask: verdicts(cfg) });
228
241
  return {
229
242
  total: c.total,
230
243
  distinct: c.distinct,
@@ -238,14 +251,15 @@ const HANDLERS = {
238
251
 
239
252
  seisin_walls({ role }) {
240
253
  const cfg = config();
241
- const file = logPath(cfg.root);
242
- const names = role ? [role] : Object.keys(cfg.roles);
243
254
  if (role && !cfg.roles[role]) throw new Error(`unknown role "${role}"`);
255
+ // One read of the log for every role, not one per role (32 on a real policy).
256
+ const denied = read(logPath(cfg.root), { verdict: "denied" });
257
+ const ask = verdicts(cfg);
258
+ const byRole = role
259
+ ? Object.fromEntries([[role, walls(cfg, role, { entries: denied, ask })]].filter(([, w]) => w.length))
260
+ : wallsByRole(cfg, denied, { ask });
244
261
  const out = {};
245
- for (const r of names) {
246
- const w = walls(cfg, r, { file });
247
- if (w.length) out[r] = { walls: w, spentRetrying: wasted(w) };
248
- }
262
+ for (const [r, w] of Object.entries(byRole)) out[r] = { walls: w, spentRetrying: wasted(w) };
249
263
  return { roles: out, spentRetrying: Object.values(out).reduce((n, x) => n + x.spentRetrying, 0) };
250
264
  },
251
265
 
@@ -260,7 +274,7 @@ const HANDLERS = {
260
274
 
261
275
  seisin_draft_grant({ id, number } = {}) {
262
276
  const cfg = config();
263
- const queue = pending(requestsPath(cfg.root));
277
+ const queue = pending(requestsPath(cfg.root), { keyDirs: cfg.keyDirs });
264
278
  let req;
265
279
  if (typeof id === "string" && id) {
266
280
  req = queue.find((r) => r.key === id);
@@ -334,9 +348,9 @@ function handle(message, { write, version }) {
334
348
  capabilities: { tools: {} },
335
349
  serverInfo: { name: "seisin", title: "seisin — folders and keys per agent", version },
336
350
  instructions:
337
- "Read-only view of this repo's agent permissions. Ask seisin_explain before " +
351
+ "Read-only view of this repo's roles, territories and keys. Ask seisin_explain before " +
338
352
  "suggesting an agent edit a file it may not own. Granting is not available " +
339
- "here: surface seisin_requests and let the operator run `seisin grant`.",
353
+ "here: surface seisin_requests and let a person run `seisin grant`.",
340
354
  });
341
355
  }
342
356
  case "ping":
package/src/notify.js CHANGED
@@ -17,7 +17,7 @@
17
17
  * never holds the URL, and `SEISIN_NOTIFY_URL` is dropped from its
18
18
  * environment even if the role names it;
19
19
  * - the URL is not readable from any territory. A ntfy topic or a webhook is
20
- * a credential — whoever can read it can send the person a fake "approve
20
+ * a credential — whoever can read it can send the person a fake "grant
21
21
  * #3". So it lives in a key directory no role declares (`url_file`), or in
22
22
  * the parent's environment, and never as a plain value in seisin.toml,
23
23
  * which every role can read.
@@ -26,11 +26,11 @@ import { readFileSync } from "node:fs";
26
26
  import { basename, join } from "node:path";
27
27
  import { keyOf, pending, requestsPath, grantFor, shellId } from "./requests.js";
28
28
 
29
- export const FORMATS = ["text", "json", "slack"];
30
- export const ENV_URL = "SEISIN_NOTIFY_URL";
29
+ const FORMATS = ["text", "json", "slack"];
30
+ const ENV_URL = "SEISIN_NOTIFY_URL";
31
31
 
32
32
  /** The URL to notify, or null. Read in the parent only. */
33
- export function notifyUrl(config, env = process.env) {
33
+ function notifyUrl(config, env = process.env) {
34
34
  if (env[ENV_URL]) return env[ENV_URL].trim();
35
35
  if (!config.notify?.urlFile) return null;
36
36
  try {
@@ -45,9 +45,14 @@ export function notifyUrl(config, env = process.env) {
45
45
  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
- `seisin (${basename(config.root)}): ${req.role} was refused ${req.action} on ${req.target}. ${owner} ` +
49
- `Approve: seisin grant ${shellId(keyOf(req))} · decline: seisin decline ${shellId(keyOf(req))}`;
50
- if (format === "slack") return { type: "application/json", body: JSON.stringify({ text }) };
48
+ `seisin (${basename(config.root)}): ${req.role} was denied ${req.action} on ${req.target}. ${owner} ` +
49
+ `Grant: seisin grant ${shellId(keyOf(req))} · decline: seisin decline ${shellId(keyOf(req))}`;
50
+ // Slack reads `<url|label>` as a link and `<!channel>` as a mention, and the
51
+ // path is chosen by the agent: `<https://evil|approve here>` in a target
52
+ // would arrive as a clickable link in the channel a person approves from.
53
+ // Its own escaping, and only these three characters, is what it asks for.
54
+ if (format === "slack")
55
+ return { type: "application/json", body: JSON.stringify({ text: text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;") }) };
51
56
  if (format === "json")
52
57
  return {
53
58
  type: "application/json",
@@ -141,6 +146,6 @@ export function readNotify(table, { path, root, keyDirs, roles, own }) {
141
146
  if (holders.length)
142
147
  throw new Error(
143
148
  `${path}: [notify] url_file "${urlFile}" is declared as a key by ${holders.join(", ")} — ` +
144
- `that role could read it and send the person a fake "approve". No role may hold it.`);
149
+ `that role could read it and send the person a fake "grant this". No role may hold it.`);
145
150
  return { urlFile, format };
146
151
  }