seisin 0.4.0 → 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 +151 -67
- 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 -30
- package/src/commands/log.js +55 -10
- package/src/commands/requests.js +124 -39
- package/src/commands/review.js +41 -16
- package/src/commands/run.js +174 -39
- 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 +261 -27
- 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 +24 -16
- package/src/keys.js +13 -11
- package/src/kinds.js +207 -0
- package/src/log.js +312 -55
- package/src/mcp.js +96 -43
- package/src/notify.js +15 -10
- package/src/owners.js +323 -41
- package/src/paths.js +37 -1
- package/src/redact.js +19 -5
- package/src/render.js +81 -12
- package/src/requests.js +250 -72
- package/src/review.js +42 -21
- package/src/rundir.js +4 -6
- package/src/scan.js +177 -19
- package/src/serve.js +395 -205
- package/src/spool.js +9 -29
- package/src/srt.js +38 -21
- package/src/suggest.js +58 -0
- package/src/surface.js +203 -42
- 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 +1366 -529
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 } 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,16 +44,80 @@ 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");
|
|
50
|
-
|
|
53
|
+
// Once per log: pin where the chain begins. See recordGenesis.
|
|
54
|
+
if (!existsSync(genesisPath(file))) { try { recordGenesis(file); } catch {} }
|
|
55
|
+
}, { waitMs: envInt("SEISIN_LOG_LOCK_WAIT_MS", 6000) });
|
|
51
56
|
return true;
|
|
52
|
-
} catch {
|
|
57
|
+
} catch (e) {
|
|
58
|
+
// A line that could not get the lock is gone, and a missing line does not
|
|
59
|
+
// break the chain. Say so beside the log, so `verify` can.
|
|
60
|
+
if (e?.code === "ELOCKED") {
|
|
61
|
+
try { appendFileSync(droppedPath(file), JSON.stringify({ at: new Date().toISOString(), role: entry?.role, event: entry?.event }) + "\n"); } catch {}
|
|
62
|
+
}
|
|
53
63
|
return false;
|
|
54
64
|
}
|
|
55
65
|
}
|
|
56
66
|
|
|
67
|
+
/** Where entries that could not be written are counted. */
|
|
68
|
+
export function droppedPath(file) {
|
|
69
|
+
return file + ".dropped";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function countLines(file) {
|
|
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;
|
|
119
|
+
}
|
|
120
|
+
|
|
57
121
|
/**
|
|
58
122
|
* When the current segment fills, retire it and start a fresh one that still
|
|
59
123
|
* chains.
|
|
@@ -126,9 +190,9 @@ function maybeRotate(file) {
|
|
|
126
190
|
* from nono's audit trail; the Merkle root and the signature it adds on top
|
|
127
191
|
* are left for when someone asks for them.
|
|
128
192
|
*
|
|
129
|
-
* The first chained line of a
|
|
130
|
-
*
|
|
131
|
-
*
|
|
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.
|
|
132
196
|
*/
|
|
133
197
|
export const GENESIS = "0".repeat(32);
|
|
134
198
|
|
|
@@ -136,18 +200,34 @@ export function hashOf(line) {
|
|
|
136
200
|
return line == null ? GENESIS : createHash("sha256").update(line).digest("hex").slice(0, 32);
|
|
137
201
|
}
|
|
138
202
|
|
|
139
|
-
/**
|
|
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
|
+
*/
|
|
140
211
|
function lastLine(file) {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
if (size === 0) return null;
|
|
144
|
-
const from = Math.max(0, size - 64 * 1024);
|
|
145
|
-
const fd = openSync(file, "r");
|
|
212
|
+
let fd;
|
|
213
|
+
try { fd = openSync(file, "r"); } catch { return null; }
|
|
146
214
|
try {
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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;
|
|
151
231
|
} finally {
|
|
152
232
|
closeSync(fd);
|
|
153
233
|
}
|
|
@@ -156,44 +236,116 @@ function lastLine(file) {
|
|
|
156
236
|
/**
|
|
157
237
|
* One writer at a time. Several roles of a round run at once and write this
|
|
158
238
|
* same file; two of them reading the same last line would fork the chain.
|
|
159
|
-
*
|
|
239
|
+
*
|
|
240
|
+
* The lock names its holder (pid and a nonce). A lock whose holder is gone is
|
|
241
|
+
* taken over at once; one whose holder cannot be read is taken over once it is
|
|
242
|
+
* older than `staleMs`. The wait outlasts that age on purpose: a writer that
|
|
243
|
+
* died holding the lock can never make the next one give up, so the only entry
|
|
244
|
+
* ever dropped is one that waited on a writer still alive — and that drop is
|
|
245
|
+
* counted beside the log (see `append`), because a missing line does not break
|
|
246
|
+
* the chain and would otherwise not show.
|
|
160
247
|
*/
|
|
161
|
-
function withLock(file, fn) {
|
|
248
|
+
export function withLock(file, fn, { waitMs = 6000, staleMs = 5000 } = {}) {
|
|
162
249
|
const lock = file + ".lock";
|
|
163
|
-
const deadline = Date.now() +
|
|
250
|
+
const deadline = Date.now() + waitMs;
|
|
251
|
+
const mine = `${process.pid} ${createHash("sha256").update(String(Math.random()) + Date.now()).digest("hex").slice(0, 12)}\n`;
|
|
252
|
+
let acquired = false;
|
|
164
253
|
for (;;) {
|
|
165
254
|
try {
|
|
166
|
-
|
|
255
|
+
const fd = openSync(lock, "wx");
|
|
256
|
+
try { writeSync(fd, mine); } finally { closeSync(fd); }
|
|
257
|
+
acquired = true;
|
|
167
258
|
break;
|
|
168
259
|
} catch (e) {
|
|
169
260
|
if (e.code !== "EEXIST") throw e;
|
|
170
|
-
|
|
171
|
-
|
|
261
|
+
if (reapable(lock, staleMs)) continue;
|
|
262
|
+
// Never write without the lock. Doing so forks the hash chain; deleting
|
|
263
|
+
// the lock in `finally` then admits still more writers while its owner is
|
|
264
|
+
// active. The log is bookkeeping and append() may fail without taking the
|
|
265
|
+
// agent down, so dropping this entry is safer than fabricating evidence.
|
|
266
|
+
if (Date.now() > deadline) {
|
|
267
|
+
const error = new Error(`could not acquire log lock within ${waitMs} ms`);
|
|
268
|
+
error.code = "ELOCKED";
|
|
269
|
+
throw error;
|
|
270
|
+
}
|
|
172
271
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5);
|
|
173
272
|
}
|
|
174
273
|
}
|
|
175
|
-
try { return fn(); }
|
|
274
|
+
try { return fn(); }
|
|
275
|
+
finally {
|
|
276
|
+
// Remove it only while it is still ours: if it was taken over, it now
|
|
277
|
+
// belongs to someone else.
|
|
278
|
+
if (acquired) try { if (readFileSync(lock, "utf8") === mine) unlinkSync(lock); } catch {}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
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
|
|
297
|
+
try { process.kill(pid, 0); return true; }
|
|
298
|
+
catch (e) { return e?.code !== "ESRCH"; }
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Removes a lock whose holder is gone, and says whether it did.
|
|
303
|
+
*
|
|
304
|
+
* The content is read again just before removing it, so a waiter that decided
|
|
305
|
+
* on one lock does not remove a newer one another waiter has just taken.
|
|
306
|
+
*/
|
|
307
|
+
function reapable(lock, staleMs) {
|
|
308
|
+
let seen, mtime;
|
|
309
|
+
try { seen = readFileSync(lock, "utf8"); mtime = statSync(lock).mtimeMs; } catch { return true; }
|
|
310
|
+
const pid = Number(seen.split(" ")[0]);
|
|
311
|
+
const named = Number.isInteger(pid) && pid > 0;
|
|
312
|
+
// A named holder that is alive is waited on, however long it takes: its age
|
|
313
|
+
// says nothing about whether it is done. An unnamed one (an older seisin, or
|
|
314
|
+
// killed between creating the file and writing its name) goes by age.
|
|
315
|
+
const gone = named ? !alive(pid) : Date.now() - mtime > staleMs;
|
|
316
|
+
if (!gone) return false;
|
|
317
|
+
try { if (readFileSync(lock, "utf8") !== seen) return true; unlinkSync(lock); } catch {}
|
|
318
|
+
return true;
|
|
176
319
|
}
|
|
177
320
|
|
|
178
321
|
/**
|
|
179
322
|
* Walk the file and say where the chain breaks.
|
|
180
323
|
*
|
|
181
324
|
* Lines written before the chain existed have no `prev`; they are counted as an
|
|
182
|
-
* unchained prefix, not reported as tampering
|
|
183
|
-
*
|
|
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).
|
|
184
333
|
*/
|
|
185
|
-
function walk(file, { seed = GENESIS, startLine = 0 } = {}) {
|
|
186
|
-
const out = { lines: 0, unchained: 0, chained: 0, breaks: [], tail: null };
|
|
187
|
-
|
|
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");
|
|
188
337
|
let previous = null;
|
|
189
338
|
let started = false;
|
|
190
|
-
for (const line of
|
|
191
|
-
if (!line.trim()) continue;
|
|
339
|
+
for (const line of readLines(file)) {
|
|
192
340
|
out.lines++;
|
|
193
341
|
let e = null;
|
|
194
342
|
try { e = JSON.parse(line); } catch {}
|
|
195
343
|
const prev = e?.prev;
|
|
196
|
-
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);
|
|
197
349
|
started = true;
|
|
198
350
|
const first = out.chained === 0 && out.unchained === 0;
|
|
199
351
|
// A rotation marker leading a segment we have nothing to check it against —
|
|
@@ -202,14 +354,15 @@ function walk(file, { seed = GENESIS, startLine = 0 } = {}) {
|
|
|
202
354
|
// called a break. For any later segment the seed is the real previous tail,
|
|
203
355
|
// so the marker's `prev` IS checked, and a whole segment deleted or
|
|
204
356
|
// reordered in the middle shows.
|
|
205
|
-
const rotationStart = first && e?.event === "rotated" && seed === GENESIS;
|
|
357
|
+
const rotationStart = first && e?.event === "rotated" && prev !== undefined && seed === GENESIS;
|
|
206
358
|
const expected = first ? seed : hashOf(previous);
|
|
207
|
-
const ok = rotationStart || prev === expected || (first && prev === hashOf(previous));
|
|
359
|
+
const ok = prev !== undefined && (rotationStart || prev === expected || (first && prev === hashOf(previous)));
|
|
208
360
|
if (!ok) out.breaks.push({ line: startLine + out.lines, expected, found: prev ?? null });
|
|
209
361
|
out.chained++;
|
|
210
362
|
previous = line;
|
|
211
363
|
}
|
|
212
364
|
out.tail = previous;
|
|
365
|
+
out.digest = out.unchained ? prefix.digest("hex").slice(0, 32) : null;
|
|
213
366
|
return out;
|
|
214
367
|
}
|
|
215
368
|
|
|
@@ -218,6 +371,54 @@ export function verifyChain(file, seed = GENESIS) {
|
|
|
218
371
|
return { lines: r.lines, unchained: r.unchained, chained: r.chained, breaks: r.breaks };
|
|
219
372
|
}
|
|
220
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
|
+
|
|
221
422
|
/**
|
|
222
423
|
* Verify the whole log, across every rotated segment, as one chain.
|
|
223
424
|
*
|
|
@@ -227,40 +428,88 @@ export function verifyChain(file, seed = GENESIS) {
|
|
|
227
428
|
* single segment and this is exactly `verifyChain`. What it cannot see is a
|
|
228
429
|
* segment dropped off the *oldest* end by the keep limit: that is retention, not
|
|
229
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.
|
|
230
443
|
*/
|
|
231
444
|
export function verifyLog(file) {
|
|
232
445
|
const segs = logSegments(file);
|
|
233
|
-
const agg = { lines: 0, unchained: 0, chained: 0, breaks: [], segments: segs.length };
|
|
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);
|
|
234
448
|
let seed = GENESIS;
|
|
235
|
-
|
|
236
|
-
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
|
+
}
|
|
237
471
|
agg.lines += r.lines;
|
|
238
472
|
agg.unchained += r.unchained;
|
|
239
473
|
agg.chained += r.chained;
|
|
240
474
|
agg.breaks.push(...r.breaks);
|
|
241
475
|
seed = hashOf(r.tail);
|
|
242
|
-
}
|
|
476
|
+
});
|
|
243
477
|
return agg;
|
|
244
478
|
}
|
|
245
479
|
|
|
246
|
-
/**
|
|
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
|
+
*/
|
|
247
490
|
export function read(file, { role, verdict, since, limit = 0 } = {}) {
|
|
248
|
-
|
|
249
|
-
let
|
|
250
|
-
for (const
|
|
251
|
-
if (
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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);
|
|
257
507
|
}
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
if (
|
|
261
|
-
if (since && e.at < since) continue;
|
|
262
|
-
out.push(e);
|
|
508
|
+
parts.unshift(got);
|
|
509
|
+
have += got.length;
|
|
510
|
+
if (limit > 0 && have >= limit) break;
|
|
263
511
|
}
|
|
512
|
+
let out = parts.flat();
|
|
264
513
|
if (limit > 0 && out.length > limit) out = out.slice(-limit);
|
|
265
514
|
return out;
|
|
266
515
|
}
|
|
@@ -285,6 +534,14 @@ export function observed(entries) {
|
|
|
285
534
|
const roles = new Map();
|
|
286
535
|
for (const e of entries) {
|
|
287
536
|
if (!e.role || !e.target) continue;
|
|
537
|
+
// A line the parent flagged as `disputed` is one whose verdict did not
|
|
538
|
+
// match the policy the run started with — either the config changed under
|
|
539
|
+
// it, or the line was not written by the hook at all. Either way it is not
|
|
540
|
+
// evidence of what the role legitimately reached for, so it must not become
|
|
541
|
+
// policy: `init --from-observations` builds `writes`/`keys` from here, and a
|
|
542
|
+
// process inside the sandbox could otherwise send `verdict:"observed"` lines for
|
|
543
|
+
// paths outside its territory and have them proposed as its own.
|
|
544
|
+
if (e.disputed) continue;
|
|
288
545
|
const r = roles.get(e.role) ?? { writes: new Set(), keys: new Set() };
|
|
289
546
|
if (e.action === "read" && e.kind === "key") r.keys.add(e.target);
|
|
290
547
|
else if (e.action === "write") r.writes.add(e.target);
|