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.
- package/README.md +506 -162
- package/package.json +14 -6
- package/src/browsers.js +52 -0
- package/src/canary.js +149 -0
- package/src/cli.js +175 -52
- package/src/commands/check.js +157 -5
- package/src/commands/explain.js +31 -13
- package/src/commands/hook.js +20 -6
- package/src/commands/init.js +183 -88
- package/src/commands/log.js +48 -9
- package/src/commands/requests.js +122 -70
- package/src/commands/review.js +73 -21
- package/src/commands/run.js +422 -78
- package/src/commands/scan.js +18 -3
- package/src/commands/ui.js +18 -5
- package/src/commands/walls.js +62 -11
- package/src/commands/whose.js +9 -7
- package/src/commands/wire.js +156 -17
- package/src/config.js +505 -37
- package/src/controls.js +244 -0
- package/src/diagnose.js +102 -49
- package/src/env.js +14 -8
- package/src/grants.js +122 -9
- package/src/handoff.js +285 -10
- package/src/help.js +179 -0
- package/src/hook.js +101 -42
- package/src/index.d.ts +217 -0
- package/src/index.js +24 -16
- package/src/inspect/codex.js +64 -0
- package/src/inspect/credentials.js +48 -0
- package/src/inspect/enforce.js +24 -0
- package/src/inspect/files.js +71 -0
- package/src/inspect/home.js +43 -0
- package/src/inspect/inject.js +68 -0
- package/src/inspect/keys.js +74 -0
- package/src/inspect/layout.js +258 -0
- package/src/inspect/limits.js +98 -0
- package/src/inspect/machine.js +107 -0
- package/src/inspect/network.js +37 -0
- package/src/inspect/providers.js +71 -0
- package/src/inspect/read-territory.js +105 -0
- package/src/inspect/role-keys.js +136 -0
- package/src/inspect/shared.js +37 -0
- package/src/inspect/warnings.js +58 -0
- package/src/inspect.js +20 -756
- package/src/intake.js +150 -35
- package/src/keys.js +231 -73
- package/src/kinds.js +249 -0
- package/src/lines.js +26 -0
- package/src/log.js +242 -52
- package/src/mcp.js +57 -58
- package/src/nested.js +12 -3
- package/src/notify.js +66 -20
- package/src/owners.js +358 -39
- package/src/paths.js +54 -2
- package/src/provider-run.js +81 -0
- package/src/redact.js +46 -13
- package/src/render.js +101 -17
- package/src/requests.js +506 -87
- package/src/review.js +50 -25
- package/src/rundir.js +33 -14
- package/src/sandboxes/command.js +93 -0
- package/src/sandboxes/index.js +113 -0
- package/src/sandboxes/mach-profile.js +31 -0
- package/src/sandboxes/srt-mach.mjs +43 -0
- package/src/sandboxes/srt.js +163 -0
- package/src/sandboxes/tartaros.js +80 -0
- package/src/scan.js +181 -19
- package/src/serve.js +396 -230
- package/src/spool.js +16 -45
- package/src/srt.js +296 -49
- package/src/suggest.js +58 -0
- package/src/surface.js +314 -54
- package/src/territory.js +261 -0
- package/src/trace.js +33 -0
- package/src/uilink.js +24 -9
- package/src/views.js +315 -0
- package/src/violations.js +247 -23
- package/src/walls.js +76 -46
- package/src/worktree.js +3 -11
- package/ui/index.html +1429 -538
- 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
|
|
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
|
/**
|
|
@@ -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; }
|
|
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
|
|
247
|
-
*
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
313
|
-
let
|
|
314
|
-
for (const
|
|
315
|
-
if (
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
if (
|
|
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
|
|
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/
|
|
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 {
|
|
34
|
+
import { queueOf, queueData, refuseIfBarred, shellId } from "./requests.js";
|
|
35
35
|
import { read, logPath } from "./log.js";
|
|
36
|
-
import { causesOf } from "./
|
|
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
|
|
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
|
-
"
|
|
82
|
-
"times each was asked
|
|
83
|
-
"to
|
|
84
|
-
"
|
|
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
|
|
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
|
|
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, `
|
|
108
|
-
"that hit it would hit it today.
|
|
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
|
|
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
|
|
134
|
-
"seisin_requests, and this turns it into a concrete proposal
|
|
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.
|
|
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 =
|
|
210
|
+
const queue = requestQueue(cfg, read(logPath(cfg.root)), { readOnly: true });
|
|
196
211
|
return {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
? "
|
|
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,8 @@ const HANDLERS = {
|
|
|
260
274
|
|
|
261
275
|
seisin_draft_grant({ id, number } = {}) {
|
|
262
276
|
const cfg = config();
|
|
263
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
}
|