seisin 0.4.2 → 0.6.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 (82) hide show
  1. package/README.md +506 -162
  2. package/package.json +14 -6
  3. package/src/browsers.js +52 -0
  4. package/src/canary.js +149 -0
  5. package/src/cli.js +175 -52
  6. package/src/commands/check.js +157 -5
  7. package/src/commands/explain.js +31 -13
  8. package/src/commands/hook.js +20 -6
  9. package/src/commands/init.js +183 -88
  10. package/src/commands/log.js +48 -9
  11. package/src/commands/requests.js +122 -70
  12. package/src/commands/review.js +73 -21
  13. package/src/commands/run.js +422 -78
  14. package/src/commands/scan.js +18 -3
  15. package/src/commands/ui.js +18 -5
  16. package/src/commands/walls.js +62 -11
  17. package/src/commands/whose.js +9 -7
  18. package/src/commands/wire.js +156 -17
  19. package/src/config.js +505 -37
  20. package/src/controls.js +244 -0
  21. package/src/diagnose.js +102 -49
  22. package/src/env.js +14 -8
  23. package/src/grants.js +122 -9
  24. package/src/handoff.js +285 -10
  25. package/src/help.js +179 -0
  26. package/src/hook.js +101 -42
  27. package/src/index.d.ts +217 -0
  28. package/src/index.js +24 -16
  29. package/src/inspect/codex.js +64 -0
  30. package/src/inspect/credentials.js +48 -0
  31. package/src/inspect/enforce.js +24 -0
  32. package/src/inspect/files.js +71 -0
  33. package/src/inspect/home.js +43 -0
  34. package/src/inspect/inject.js +68 -0
  35. package/src/inspect/keys.js +74 -0
  36. package/src/inspect/layout.js +258 -0
  37. package/src/inspect/limits.js +98 -0
  38. package/src/inspect/machine.js +107 -0
  39. package/src/inspect/network.js +37 -0
  40. package/src/inspect/providers.js +71 -0
  41. package/src/inspect/read-territory.js +105 -0
  42. package/src/inspect/role-keys.js +136 -0
  43. package/src/inspect/shared.js +37 -0
  44. package/src/inspect/warnings.js +58 -0
  45. package/src/inspect.js +20 -756
  46. package/src/intake.js +150 -35
  47. package/src/keys.js +231 -73
  48. package/src/kinds.js +249 -0
  49. package/src/lines.js +26 -0
  50. package/src/log.js +242 -52
  51. package/src/mcp.js +57 -58
  52. package/src/nested.js +12 -3
  53. package/src/notify.js +66 -20
  54. package/src/owners.js +358 -39
  55. package/src/paths.js +54 -2
  56. package/src/provider-run.js +81 -0
  57. package/src/redact.js +46 -13
  58. package/src/render.js +101 -17
  59. package/src/requests.js +506 -87
  60. package/src/review.js +50 -25
  61. package/src/rundir.js +33 -14
  62. package/src/sandboxes/command.js +93 -0
  63. package/src/sandboxes/index.js +113 -0
  64. package/src/sandboxes/mach-profile.js +31 -0
  65. package/src/sandboxes/srt-mach.mjs +43 -0
  66. package/src/sandboxes/srt.js +163 -0
  67. package/src/sandboxes/tartaros.js +80 -0
  68. package/src/scan.js +181 -19
  69. package/src/serve.js +396 -230
  70. package/src/spool.js +16 -45
  71. package/src/srt.js +296 -49
  72. package/src/suggest.js +58 -0
  73. package/src/surface.js +314 -54
  74. package/src/territory.js +261 -0
  75. package/src/trace.js +33 -0
  76. package/src/uilink.js +24 -9
  77. package/src/views.js +315 -0
  78. package/src/violations.js +247 -23
  79. package/src/walls.js +76 -46
  80. package/src/worktree.js +3 -11
  81. package/ui/index.html +1429 -538
  82. package/ui/favicon.svg +0 -1
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
  /**
@@ -227,7 +306,12 @@ function alive(pid) {
227
306
  */
228
307
  function reapable(lock, staleMs) {
229
308
  let seen, mtime;
230
- try { seen = readFileSync(lock, "utf8"); mtime = statSync(lock).mtimeMs; } catch { return true; }
309
+ try { seen = readFileSync(lock, "utf8"); mtime = statSync(lock).mtimeMs; }
310
+ catch (e) {
311
+ // Only a disappeared lock is an immediate retry. An unreadable lock or
312
+ // a directory at this name otherwise spins forever, bypassing deadline.
313
+ return e.code === "ENOENT";
314
+ }
231
315
  const pid = Number(seen.split(" ")[0]);
232
316
  const named = Number.isInteger(pid) && pid > 0;
233
317
  // A named holder that is alive is waited on, however long it takes: its age
@@ -243,21 +327,30 @@ function reapable(lock, staleMs) {
243
327
  * Walk the file and say where the chain breaks.
244
328
  *
245
329
  * 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.
330
+ * unchained prefix, not reported as tampering — but only where such a prefix
331
+ * can be: at the head of the oldest segment (`legacy`). After the first chained
332
+ * line, and anywhere in a segment that rotation started, a line without `prev`
333
+ * is itself a break. So is a rotation marker without one: seisin never wrote
334
+ * such a marker.
335
+ *
336
+ * `digest` hashes the unchained prefix and `first` is the hash of the first
337
+ * chained line: what the genesis record pins (see recordGenesis).
248
338
  */
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;
339
+ function walk(file, { seed = GENESIS, startLine = 0, legacy = true } = {}) {
340
+ const out = { lines: 0, unchained: 0, chained: 0, breaks: [], tail: null, digest: null, first: null, rotated: false };
341
+ const prefix = createHash("sha256");
252
342
  let previous = null;
253
343
  let started = false;
254
- for (const line of readFileSync(file, "utf8").split("\n")) {
255
- if (!line.trim()) continue;
344
+ for (const line of readLines(file)) {
256
345
  out.lines++;
257
346
  let e = null;
258
347
  try { e = JSON.parse(line); } catch {}
259
348
  const prev = e?.prev;
260
- if (!started && prev === undefined) { out.unchained++; previous = line; continue; }
349
+ if (out.lines === 1 && e?.event === "rotated") out.rotated = true;
350
+ if (!started && prev === undefined && legacy && !out.rotated) {
351
+ out.unchained++; prefix.update(line + "\n"); previous = line; continue;
352
+ }
353
+ if (!started) out.first = hashOf(line);
261
354
  started = true;
262
355
  const first = out.chained === 0 && out.unchained === 0;
263
356
  // A rotation marker leading a segment we have nothing to check it against —
@@ -266,14 +359,15 @@ function walk(file, { seed = GENESIS, startLine = 0 } = {}) {
266
359
  // called a break. For any later segment the seed is the real previous tail,
267
360
  // so the marker's `prev` IS checked, and a whole segment deleted or
268
361
  // reordered in the middle shows.
269
- const rotationStart = first && e?.event === "rotated" && seed === GENESIS;
362
+ const rotationStart = first && e?.event === "rotated" && prev !== undefined && seed === GENESIS;
270
363
  const expected = first ? seed : hashOf(previous);
271
- const ok = rotationStart || prev === expected || (first && prev === hashOf(previous));
364
+ const ok = prev !== undefined && (rotationStart || prev === expected || (first && prev === hashOf(previous)));
272
365
  if (!ok) out.breaks.push({ line: startLine + out.lines, expected, found: prev ?? null });
273
366
  out.chained++;
274
367
  previous = line;
275
368
  }
276
369
  out.tail = previous;
370
+ out.digest = out.unchained ? prefix.digest("hex").slice(0, 32) : null;
277
371
  return out;
278
372
  }
279
373
 
@@ -282,6 +376,54 @@ export function verifyChain(file, seed = GENESIS) {
282
376
  return { lines: r.lines, unchained: r.unchained, chained: r.chained, breaks: r.breaks };
283
377
  }
284
378
 
379
+ /**
380
+ * Where the chain begins, written once beside the log.
381
+ *
382
+ * The unchained prefix was taken on trust, and that was the hole: strip `prev`
383
+ * from every line and the whole log becomes "from before the chain", so an
384
+ * edit or a deletion anywhere in it verified as intact. The record pins the
385
+ * boundary the first time a chained line is written by a seisin that knows to
386
+ * — how many legacy lines there were, a digest of them, and the hash of the
387
+ * first chained line — so a log that no longer matches it is broken, not old.
388
+ *
389
+ * Written under the log lock, by `append`, and never rewritten. A log whose
390
+ * record is missing (written by an older seisin and not appended to since, or
391
+ * the record deleted) verifies as before and says the genesis is unrecorded.
392
+ *
393
+ * ── Recorded late ──
394
+ * The record is only as good as the moment it was taken. Written with the
395
+ * first chained line, it pins a start nobody could have edited yet. Written
396
+ * later — a log chained by an older seisin, or a record deleted and re-made by
397
+ * the next append — it pins whatever the log says by then, tampered or not.
398
+ * That is still worth pinning (nothing after it can be rewritten unseen), but
399
+ * it must not read as the same guarantee: such a record says `late`, how many
400
+ * chained lines already stood before it, and `verify` repeats both.
401
+ */
402
+ export function genesisPath(file) {
403
+ return file + ".genesis";
404
+ }
405
+
406
+ function recordGenesis(file) {
407
+ const segs = logSegments(file);
408
+ if (!segs.length) return;
409
+ const r = walk(segs[0]);
410
+ if (!r.first) return; // nothing chained yet: nothing to pin
411
+ // Chained lines that stood before the one `append` just wrote. Any at all
412
+ // means the start is being pinned after the fact.
413
+ let before = r.chained - 1;
414
+ segs.slice(1).forEach((seg) => { before += walk(seg, { legacy: false }).chained; });
415
+ const record = { at: new Date().toISOString(), legacy: r.unchained, digest: r.digest, first: r.first };
416
+ if (before > 0) Object.assign(record, { late: true, after: before });
417
+ writeFileSync(genesisPath(file), JSON.stringify(record) + "\n", { flag: "wx" });
418
+ }
419
+
420
+ function readGenesis(file) {
421
+ try {
422
+ const g = JSON.parse(readFileSync(genesisPath(file), "utf8"));
423
+ return g && Number.isInteger(g.legacy) && typeof g.first === "string" ? g : null;
424
+ } catch { return null; }
425
+ }
426
+
285
427
  /**
286
428
  * Verify the whole log, across every rotated segment, as one chain.
287
429
  *
@@ -291,40 +433,88 @@ export function verifyChain(file, seed = GENESIS) {
291
433
  * single segment and this is exactly `verifyChain`. What it cannot see is a
292
434
  * segment dropped off the *oldest* end by the keep limit: that is retention, not
293
435
  * tampering, and the count of segments is reported so the drop is not silent.
436
+ *
437
+ * `genesis` says whether the chain's starting point was checked against its
438
+ * record: `recorded`, `unrecorded` (no record yet — see recordGenesis), or
439
+ * `pruned` (the segment it described was dropped for retention). `late` is
440
+ * `{ at, after }` when the record was made after chained lines already stood.
441
+ *
442
+ * A record whose first chained hash differs, over a chain that otherwise
443
+ * holds, is not an edit inside this log: it describes a different start.
444
+ * That happens when the operator archives the log by hand and leaves the
445
+ * record behind — or when the log was rewritten from its first line. Both are
446
+ * reported as a break (the second is exactly what the record exists to catch),
447
+ * but marked `foreign`, so `verify` can say which file to move or remove.
294
448
  */
295
449
  export function verifyLog(file) {
296
450
  const segs = logSegments(file);
297
- const agg = { lines: 0, unchained: 0, chained: 0, breaks: [], segments: segs.length, dropped: countLines(droppedPath(file)) };
451
+ const agg = { lines: 0, unchained: 0, chained: 0, breaks: [], segments: segs.length, dropped: countLines(droppedPath(file)), genesis: "unrecorded", late: null };
452
+ const pinned = readGenesis(file);
298
453
  let seed = GENESIS;
299
- for (const seg of segs) {
300
- const r = walk(seg, { seed, startLine: agg.lines });
454
+ segs.forEach((seg, i) => {
455
+ const r = walk(seg, { seed, startLine: agg.lines, legacy: i === 0 });
456
+ if (i === 0 && pinned) {
457
+ if (r.rotated) agg.genesis = "pruned";
458
+ else {
459
+ agg.genesis = "recorded";
460
+ if (pinned.late) agg.late = { at: pinned.at ?? null, after: Number.isInteger(pinned.after) ? pinned.after : null };
461
+ // Where the first chained line should be, and what it should hash to.
462
+ const at = pinned.legacy + 1;
463
+ // A different first line in a chain that otherwise holds is a
464
+ // different start, not an edit — an edit to the first line would also
465
+ // break the line after it. A log with no chain left at all was not
466
+ // replaced by another seisin log (those chain from their first line):
467
+ // it was stripped.
468
+ if (r.first && r.first !== pinned.first && r.breaks.length === 0)
469
+ r.breaks.unshift({ line: at, expected: pinned.first, found: r.first, genesis: true, foreign: true });
470
+ else if (r.unchained !== pinned.legacy || r.first !== pinned.first)
471
+ r.breaks.unshift({ line: at, expected: pinned.first, found: r.unchained !== pinned.legacy ? null : r.first, genesis: true });
472
+ else if ((r.digest ?? null) !== (pinned.digest ?? null))
473
+ r.breaks.unshift({ line: 1, expected: pinned.digest, found: r.digest, genesis: true });
474
+ }
475
+ }
301
476
  agg.lines += r.lines;
302
477
  agg.unchained += r.unchained;
303
478
  agg.chained += r.chained;
304
479
  agg.breaks.push(...r.breaks);
305
480
  seed = hashOf(r.tail);
306
- }
481
+ });
307
482
  return agg;
308
483
  }
309
484
 
310
- /** Reads entries, newest last. A malformed line is skipped, never fatal. */
485
+ /**
486
+ * Reads entries, newest last, across every segment. A malformed line is
487
+ * skipped, never fatal.
488
+ *
489
+ * It read the current segment only, so the first rotation (8 MiB) took the
490
+ * history out from under everything that reads a window — the console, causes,
491
+ * walls, the staleness of a request — without a word. Segments are read newest
492
+ * first and the walk stops at the first one that ends before `since`, or once
493
+ * `limit` entries are in hand.
494
+ */
311
495
  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
496
+ const parts = [];
497
+ let have = 0;
498
+ for (const seg of logSegments(file).reverse()) {
499
+ if (since && seg !== file) {
500
+ // A retired segment that ended before the window has nothing in it.
501
+ let last = null;
502
+ try { last = JSON.parse(lastLine(seg)); } catch {}
503
+ if (typeof last?.at === "string" && last.at < since) break;
504
+ }
505
+ const got = [];
506
+ for (const e of readEntries(seg)) {
507
+ if (e.event === "rotated") continue; // an internal marker, not activity
508
+ if (role && e.role !== role) continue;
509
+ if (verdict && e.verdict !== verdict) continue;
510
+ if (since && e.at < since) continue;
511
+ got.push(e);
321
512
  }
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);
513
+ parts.unshift(got);
514
+ have += got.length;
515
+ if (limit > 0 && have >= limit) break;
327
516
  }
517
+ let out = parts.flat();
328
518
  if (limit > 0 && out.length > limit) out = out.slice(-limit);
329
519
  return out;
330
520
  }
@@ -354,7 +544,7 @@ export function observed(entries) {
354
544
  // it, or the line was not written by the hook at all. Either way it is not
355
545
  // evidence of what the role legitimately reached for, so it must not become
356
546
  // policy: `init --from-observations` builds `writes`/`keys` from here, and a
357
- // process inside the box could otherwise send `verdict:"observed"` lines for
547
+ // process inside the sandbox could otherwise send `verdict:"observed"` lines for
358
548
  // paths outside its territory and have them proposed as its own.
359
549
  if (e.disputed) continue;
360
550
  const r = roles.get(e.role) ?? { writes: new Set(), keys: new Set() };
package/src/mcp.js CHANGED
@@ -16,7 +16,7 @@
16
16
  *
17
17
  * If granting were a tool here, an agent holding it could widen its own
18
18
  * territory and the record would say a human did it. See
19
- * `docs/permission-requests.md`.
19
+ * `docs/requests.md`.
20
20
  *
21
21
  * ── Why there is no SDK ──
22
22
  * The official SDK pulls 91 packages and 26 MB — express, hono, cors, jose —
@@ -31,10 +31,11 @@ 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, shellId } from "./requests.js";
34
+ import { queueOf, queueData, refuseIfBarred, shellId } from "./requests.js";
35
35
  import { read, logPath } from "./log.js";
36
- import { causesOf } from "./serve.js";
36
+ import { causesOf, parseSince, queue as requestQueue, verdicts, wallsByRole } from "./views.js";
37
37
  import { walls, wasted } from "./walls.js";
38
+ import { lineReader } from "./lines.js";
38
39
 
39
40
  /**
40
41
  * Versions this server will agree to.
@@ -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,17 @@ 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 " +
84
- "available here, by design.",
82
+ "Requests: access agents asked for and cannot have yet, waiting on a person, with how many " +
83
+ "times each was asked and where each goes: `handoff` is seisin's decision from the policy " +
84
+ "(route to the owning role, or a person decides; a cycle or a chain too deep stops). " +
85
+ "A denial files one of these automatically, so this is where to look after being denied " +
86
+ "rather than retrying. Read-only — granting is not available here, and nothing here " +
87
+ "starts a role, by design.",
85
88
  inputSchema: { type: "object", properties: {} },
86
89
  },
87
90
  {
88
91
  name: "seisin_activity",
89
- description: "Recent allow/deny decisions, newest last. Read-only.",
92
+ description: "Recent log entries — allowed, denied or observed — newest last. Read-only.",
90
93
  inputSchema: {
91
94
  type: "object",
92
95
  properties: {
@@ -101,11 +104,16 @@ const TOOLS = [
101
104
  description:
102
105
  "Denials grouped by what was denied — by path, and one level coarser by the name at " +
103
106
  "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 " +
107
+ "what was denied. What is measured against the policy as it stands is where each " +
105
108
  "path is now — `standing`: unowned (a decision nobody has made), owned, protected " +
106
109
  "(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 " +
110
+ "outside the repository) — and, per cause, `stillDenied`, how many of the roles " +
111
+ "that hit it would hit it today. An unowned cause also carries `kind` and `hint`: " +
112
+ "git (lock or metadata), credential, temporary, build or territory, and the move " +
113
+ "that fits it (`standing.unowned.kinds` counts them). Only `territory` is a question " +
114
+ "of who owns what; do not propose an owner for the others — a lock, a test's scratch " +
115
+ "or a credential given to whoever asked widens a role. " +
116
+ "`seisin_activity` has the raw events; this has " +
109
117
  "them read against the policy. Reach for it before proposing a change: a day that " +
110
118
  "is three quarters one filename is a tooling problem, and the same volume spread " +
111
119
  "across unrelated unowned paths is a question about who owns what. Read-only.",
@@ -113,7 +121,7 @@ const TOOLS = [
113
121
  type: "object",
114
122
  properties: {
115
123
  limit: { type: "number" },
116
- since: { type: "string", description: "ISO date; only refusals at or after it" },
124
+ since: { type: "string", description: "ISO date, or a duration back from now (7d, 12h); only denials at or after it" },
117
125
  },
118
126
  },
119
127
  },
@@ -130,10 +138,10 @@ const TOOLS = [
130
138
  name: "seisin_draft_grant",
131
139
  description:
132
140
  "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. " +
141
+ "runs to apply it. Reach for this after a denial: it is already in " +
142
+ "seisin_requests, and this turns it into a concrete proposal a person can grant. " +
135
143
  "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.",
144
+ "does not grant. Granting is a person's action, in another channel.",
137
145
  inputSchema: {
138
146
  type: "object",
139
147
  properties: {
@@ -159,6 +167,8 @@ const HANDLERS = {
159
167
  return {
160
168
  config: cfg.path,
161
169
  keyDirectories: cfg.keyDirs,
170
+ read: cfg.read,
171
+ isolate: cfg.isolate,
162
172
  roles: report.roles.map((r) => ({
163
173
  ...r,
164
174
  sandbox: settingsFor(cfg, r.name).filesystem,
@@ -185,6 +195,11 @@ const HANDLERS = {
185
195
  if (!cfg.roles[role])
186
196
  return { error: `unknown role "${role}"`, known: Object.keys(cfg.roles) };
187
197
  if (action === "mcp") return explain(cfg, role, "mcp", target);
198
+ // A read of a file outside every key dir is open, as it is for the CLI.
199
+ // The server has no working directory of its own, so the root stands in.
200
+ if (action === "read") {
201
+ return explain(cfg, role, "read", target, cfg.root);
202
+ }
188
203
  const rel = toRepoRelative(cfg, target);
189
204
  const verdict = explain(cfg, role, action, rel);
190
205
  return { ...verdict, alsoOwnedBy: action === "write" ? ownersOf(cfg, rel) : undefined };
@@ -192,17 +207,16 @@ const HANDLERS = {
192
207
 
193
208
  seisin_requests() {
194
209
  const cfg = config();
195
- const queue = markStale(pending(requestsPath(cfg.root)), read(logPath(cfg.root)));
210
+ const queue = requestQueue(cfg, read(logPath(cfg.root)), { readOnly: true });
196
211
  return {
197
- pending: queue.map((r, i) => ({
198
- number: i + 1, id: r.key, role: r.role, action: r.action,
199
- wants: r.grant, ownedBy: r.owners, asked: r.times, lastAsked: r.last,
200
- ...(r.stale ? { stale: `not asked again in ${r.stale.runs} runs of ${r.role} since` } : {}),
201
- })),
212
+ // The same shape as `seisin requests --json`, each with its handoff
213
+ // decision. Reading it tells an agent where its request goes; it cannot
214
+ // change where, and nothing here starts the role it names.
215
+ ...queueData(queue),
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,8 @@ const HANDLERS = {
260
274
 
261
275
  seisin_draft_grant({ id, number } = {}) {
262
276
  const cfg = config();
263
- const queue = pending(requestsPath(cfg.root));
277
+ // Owners as the policy says now, the same as seisin_requests shows.
278
+ const queue = queueOf(cfg, null, { readOnly: true });
264
279
  let req;
265
280
  if (typeof id === "string" && id) {
266
281
  req = queue.find((r) => r.key === id);
@@ -334,9 +349,9 @@ function handle(message, { write, version }) {
334
349
  capabilities: { tools: {} },
335
350
  serverInfo: { name: "seisin", title: "seisin — folders and keys per agent", version },
336
351
  instructions:
337
- "Read-only view of this repo's agent permissions. Ask seisin_explain before " +
352
+ "Read-only view of this repo's roles, territories and keys. Ask seisin_explain before " +
338
353
  "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`.",
354
+ "here: surface seisin_requests and let a person run `seisin grant`.",
340
355
  });
341
356
  }
342
357
  case "ping":
@@ -374,30 +389,14 @@ function handle(message, { write, version }) {
374
389
  export function serveMcp(version = "0.0.0", input = process.stdin, output = process.stdout) {
375
390
  const ctx = { version, write: (s) => output.write(s) };
376
391
  input.setEncoding("utf8");
377
- let buffer = "";
378
-
379
- input.on("data", (chunk) => {
380
- buffer += chunk;
381
- // A megabyte with no newline in it is not a JSON-RPC message, it is a
382
- // client — or something wearing one — filling this process's memory.
383
- // spool.js already caps its input; this one did not.
384
- if (buffer.length > 1_000_000) {
385
- buffer = "";
386
- return;
387
- }
388
- let nl;
389
- while ((nl = buffer.indexOf("\n")) !== -1) {
390
- const line = buffer.slice(0, nl).trim();
391
- buffer = buffer.slice(nl + 1);
392
- if (!line) continue;
393
- try {
394
- handle(JSON.parse(line), ctx);
395
- } catch {
396
- // Unparseable input has no id to answer to, so there is nobody to tell.
397
- // Staying alive is the only useful response.
398
- }
392
+ input.on("data", lineReader((line) => {
393
+ try {
394
+ handle(JSON.parse(line), ctx);
395
+ } catch {
396
+ // Unparseable input has no id to answer to, so there is nobody to tell.
397
+ // Staying alive is the only useful response.
399
398
  }
400
- });
399
+ }));
401
400
 
402
401
  return new Promise((done) => input.on("end", done));
403
402
  }