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.
- package/README.md +142 -54
- package/package.json +3 -3
- package/src/cli.js +145 -46
- package/src/commands/check.js +3 -2
- package/src/commands/explain.js +34 -13
- package/src/commands/hook.js +14 -4
- package/src/commands/init.js +126 -38
- package/src/commands/log.js +48 -9
- package/src/commands/requests.js +109 -32
- package/src/commands/review.js +41 -16
- package/src/commands/run.js +159 -37
- package/src/commands/scan.js +18 -3
- package/src/commands/ui.js +18 -5
- package/src/commands/walls.js +25 -6
- package/src/commands/whose.js +7 -6
- package/src/commands/wire.js +156 -17
- package/src/config.js +187 -25
- package/src/controls.js +244 -0
- package/src/diagnose.js +35 -33
- package/src/env.js +11 -8
- package/src/grants.js +15 -7
- package/src/help.js +159 -0
- package/src/hook.js +49 -19
- package/src/index.js +4 -4
- package/src/inspect.js +137 -71
- package/src/intake.js +20 -15
- package/src/keys.js +13 -11
- package/src/kinds.js +207 -0
- package/src/log.js +236 -51
- package/src/mcp.js +42 -28
- package/src/notify.js +13 -8
- package/src/owners.js +198 -41
- package/src/paths.js +37 -1
- package/src/redact.js +19 -5
- package/src/render.js +68 -10
- package/src/requests.js +173 -72
- package/src/review.js +30 -17
- package/src/rundir.js +4 -6
- package/src/scan.js +177 -19
- package/src/serve.js +377 -229
- package/src/spool.js +9 -29
- package/src/srt.js +38 -21
- package/src/suggest.js +58 -0
- package/src/surface.js +191 -44
- package/src/uilink.js +10 -4
- package/src/views.js +289 -0
- package/src/violations.js +104 -16
- package/src/walls.js +19 -42
- package/src/worktree.js +3 -11
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
144
|
-
*
|
|
145
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
|
|
156
|
-
|
|
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
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
/**
|
|
217
|
-
|
|
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
|
|
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
|
|
247
|
-
*
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
313
|
-
let
|
|
314
|
-
for (const
|
|
315
|
-
if (
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
if (
|
|
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
|
|
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,
|
|
35
|
+
import { pending, requestsPath, refuseIfBarred, shellId } from "./requests.js";
|
|
35
36
|
import { read, logPath } from "./log.js";
|
|
36
|
-
import { causesOf } from "./
|
|
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
|
|
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
|
-
"
|
|
82
|
-
"times each was asked. A
|
|
83
|
-
"to look after being denied rather than retrying. Read-only —
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
134
|
-
"seisin_requests, and this turns it into a concrete proposal
|
|
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.
|
|
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 =
|
|
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
|
-
? "
|
|
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
|
|
226
|
-
const
|
|
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
|
|
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
|
|
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
|
|
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 "
|
|
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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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
|
|
49
|
-
`
|
|
50
|
-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">") }) };
|
|
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 "
|
|
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
|
}
|