@compr/opscontext-mcp 2.9.1 → 2.11.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/CHANGELOG.md +170 -1
- package/dist/activation.d.ts +23 -1
- package/dist/activation.js +112 -21
- package/dist/adapters.d.ts +1 -1
- package/dist/adapters.js +6 -5
- package/dist/audit.d.ts +70 -3
- package/dist/audit.js +664 -82
- package/dist/ce-home.d.ts +7 -0
- package/dist/ce-home.js +45 -0
- package/dist/cli-commands.js +1 -0
- package/dist/cli.js +128 -23
- package/dist/collectors.d.ts +3 -0
- package/dist/collectors.js +37 -6
- package/dist/community-export.js +19 -22
- package/dist/community-sync.d.ts +3 -0
- package/dist/community-sync.js +41 -3
- package/dist/config.d.ts +6 -0
- package/dist/config.js +8 -3
- package/dist/firewall.js +7 -3
- package/dist/fleet-health.d.ts +23 -0
- package/dist/fleet-health.js +56 -0
- package/dist/framing.d.ts +2 -0
- package/dist/framing.js +13 -0
- package/dist/http-server.d.ts +34 -5
- package/dist/http-server.js +246 -57
- package/dist/index.js +105 -35
- package/dist/install-autostart.js +38 -17
- package/dist/install-claude-hook.d.ts +1 -0
- package/dist/install-claude-hook.js +135 -27
- package/dist/learnings.d.ts +1 -0
- package/dist/learnings.js +18 -1
- package/dist/license-sig.d.ts +9 -2
- package/dist/license-sig.js +35 -7
- package/dist/secret-shapes.d.ts +19 -0
- package/dist/secret-shapes.js +30 -2
- package/dist/server-registry.d.ts +8 -0
- package/dist/server-registry.js +38 -2
- package/dist/trusted-projects.d.ts +10 -0
- package/dist/trusted-projects.js +77 -0
- package/package.json +2 -2
package/dist/audit.js
CHANGED
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
// Records every state-changing operation. Each line carries the SHA-256 hash
|
|
47
47
|
// of the previous line's canonical content, so mutation of any historical
|
|
48
48
|
// record breaks chain verification at that index.
|
|
49
|
-
import { existsSync, mkdirSync, readFileSync, appendFileSync, openSync, closeSync, unlinkSync, statSync, writeSync, readSync, fsyncSync, renameSync, linkSync, readdirSync, constants, } from "fs";
|
|
49
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync, openSync, closeSync, unlinkSync, statSync, writeSync, readSync, fsyncSync, renameSync, linkSync, readdirSync, ftruncateSync, fstatSync, constants, } from "fs";
|
|
50
50
|
import { basename, join } from "path";
|
|
51
51
|
import { homedir } from "os";
|
|
52
52
|
import { createHash } from "crypto";
|
|
@@ -194,6 +194,10 @@ function readLastHash() {
|
|
|
194
194
|
* was rendered as the specific, plausible claim "there is no history".
|
|
195
195
|
* FIX: throw. appendAudit() must surface problems loudly (see [AUDIT-CHAIN]); call sites
|
|
196
196
|
* that need isolation already use safeAppend(), which logs to stderr and continues.
|
|
197
|
+
* 2026-09-27: a last record cut short (no final newline) is no longer left to block every append
|
|
198
|
+
* for good: it is set aside and noted on the chain first. [LOCK] [TORN-TAIL-IS-KEPT-AND-CHAINED]
|
|
199
|
+
* This throw remains for a complete last line that is not a record, and for a log that holds
|
|
200
|
+
* no complete record at all; neither ever chains onto genesis.
|
|
197
201
|
*/
|
|
198
202
|
function parseHeadOrThrow(line) {
|
|
199
203
|
let rec;
|
|
@@ -201,9 +205,9 @@ function parseHeadOrThrow(line) {
|
|
|
201
205
|
rec = JSON.parse(line);
|
|
202
206
|
}
|
|
203
207
|
catch {
|
|
204
|
-
throw new Error("Audit log tail is not valid JSON
|
|
205
|
-
"
|
|
206
|
-
"
|
|
208
|
+
throw new Error("Audit log tail is not valid JSON, refusing to append onto an unknown head. " +
|
|
209
|
+
"The last line of ~/.contextengine/audit.log is complete but is not a record (a record cut " +
|
|
210
|
+
"short is set aside automatically); inspect it, and 'contextengine audit-verify' says where it is.");
|
|
207
211
|
}
|
|
208
212
|
if (typeof rec.hash !== "string" || rec.hash.length !== 64) {
|
|
209
213
|
throw new Error("Audit log tail has no usable hash — refusing to append onto an unknown head.");
|
|
@@ -263,39 +267,239 @@ export function appendAudit(event, payload, actor = "system") {
|
|
|
263
267
|
ensureDir();
|
|
264
268
|
const release = acquireLockSync();
|
|
265
269
|
try {
|
|
266
|
-
|
|
267
|
-
// 🔒 LOCKED [AUDIT-HEAD-FROM-DISK] — 2026-08-17
|
|
268
|
-
// ⛔ NEVER derive the head hash from an in-process cache again.
|
|
269
|
-
// WHY: the previous code trusted `cachedLastHash` whenever `statSync().size` matched
|
|
270
|
-
// a locally-tracked `cachedSize` that was ARITHMETIC (`cachedSize += byteLength`),
|
|
271
|
-
// not observed. Any divergence between bytes-we-think-we-wrote and bytes-on-disk
|
|
272
|
-
// — a partial write, a concurrent writer whose bytes happened to sum the same, an
|
|
273
|
-
// externally rotated/truncated log — left us hashing onto a head that is not the
|
|
274
|
-
// real tail, forking the chain. It was a correctness guarantee resting on a
|
|
275
|
-
// perf cache, which the file's own [audit-001-write-race] LOCK explicitly warns
|
|
276
|
-
// against ("the in-process chain cache is a perf optimization, NOT a correctness
|
|
277
|
-
// guarantee").
|
|
278
|
-
// FIX: with [AUDIT-TAIL-READ-IS-O1] the true head costs ~0 ms, so there is nothing left
|
|
279
|
-
// to optimise. Read it from disk under the lock, every time. The cache is gone.
|
|
280
|
-
const prevHash = readLastHash();
|
|
281
|
-
const ts = new Date().toISOString();
|
|
282
|
-
const hash = computeHash(prevHash, ts, event, actor, payload);
|
|
283
|
-
const record = {
|
|
284
|
-
ts,
|
|
285
|
-
event,
|
|
286
|
-
actor,
|
|
287
|
-
payload,
|
|
288
|
-
prev_hash: prevHash,
|
|
289
|
-
hash,
|
|
290
|
-
};
|
|
291
|
-
const line = JSON.stringify(record) + "\n";
|
|
292
|
-
appendFileSync(path, line);
|
|
293
|
-
return record;
|
|
270
|
+
return appendHoldingLock(event, payload, actor);
|
|
294
271
|
}
|
|
295
272
|
finally {
|
|
296
273
|
release();
|
|
297
274
|
}
|
|
298
275
|
}
|
|
276
|
+
/** appendAudit() for a caller that already holds the append lock (the scrub of the live log). */
|
|
277
|
+
function appendHoldingLock(event, payload, actor) {
|
|
278
|
+
const path = auditPath();
|
|
279
|
+
settleTail(path);
|
|
280
|
+
// [LOCK] [A-REFUSED-APPEND-IS-COUNTED-AND-CHAINED]
|
|
281
|
+
const refused = takeRefusals();
|
|
282
|
+
if (refused) {
|
|
283
|
+
try {
|
|
284
|
+
writeRecord(path, "audit.append_failed", refused.summary, "system");
|
|
285
|
+
refused.done();
|
|
286
|
+
}
|
|
287
|
+
catch (e) {
|
|
288
|
+
refused.putBack();
|
|
289
|
+
throw e;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return writeRecord(path, event, payload, actor);
|
|
293
|
+
}
|
|
294
|
+
/** Under the append lock: a last record cut short is set aside and noted before anything else is
|
|
295
|
+
* written, so the log ends with a newline afterwards. [LOCK] [TORN-TAIL-IS-KEPT-AND-CHAINED] */
|
|
296
|
+
function settleTail(path) {
|
|
297
|
+
const torn = repairTornTail(path);
|
|
298
|
+
if (torn) {
|
|
299
|
+
writeRecord(path, "audit.torn_tail", {
|
|
300
|
+
kept: torn.kept,
|
|
301
|
+
bytes: torn.bytes,
|
|
302
|
+
sha256: torn.sha256,
|
|
303
|
+
note: "the last record was cut short (full disk or crash); its bytes were moved to the kept file and the log continues from the last complete record",
|
|
304
|
+
}, "system");
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
function writeRecord(path, event, payload, actor) {
|
|
308
|
+
// 🔒 LOCKED [AUDIT-HEAD-FROM-DISK] — 2026-08-17
|
|
309
|
+
// ⛔ NEVER derive the head hash from an in-process cache again.
|
|
310
|
+
// WHY: the previous code trusted `cachedLastHash` whenever `statSync().size` matched
|
|
311
|
+
// a locally-tracked `cachedSize` that was ARITHMETIC (`cachedSize += byteLength`),
|
|
312
|
+
// not observed. Any divergence between bytes-we-think-we-wrote and bytes-on-disk
|
|
313
|
+
// — a partial write, a concurrent writer whose bytes happened to sum the same, an
|
|
314
|
+
// externally rotated/truncated log — left us hashing onto a head that is not the
|
|
315
|
+
// real tail, forking the chain. It was a correctness guarantee resting on a
|
|
316
|
+
// perf cache, which the file's own [audit-001-write-race] LOCK explicitly warns
|
|
317
|
+
// against ("the in-process chain cache is a perf optimization, NOT a correctness
|
|
318
|
+
// guarantee").
|
|
319
|
+
// FIX: with [AUDIT-TAIL-READ-IS-O1] the true head costs ~0 ms, so there is nothing left
|
|
320
|
+
// to optimise. Read it from disk under the lock, every time. The cache is gone.
|
|
321
|
+
const prevHash = readLastHash();
|
|
322
|
+
const ts = new Date().toISOString();
|
|
323
|
+
const hash = computeHash(prevHash, ts, event, actor, payload);
|
|
324
|
+
const record = {
|
|
325
|
+
ts,
|
|
326
|
+
event,
|
|
327
|
+
actor,
|
|
328
|
+
payload,
|
|
329
|
+
prev_hash: prevHash,
|
|
330
|
+
hash,
|
|
331
|
+
};
|
|
332
|
+
const line = JSON.stringify(record) + "\n";
|
|
333
|
+
appendFileSync(path, line);
|
|
334
|
+
return record;
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* [LOCKED] [TORN-TAIL-IS-KEPT-AND-CHAINED] - 2026-09-27
|
|
338
|
+
* [NEVER] leave a cut-short final record in place (every later append is refused for good), delete
|
|
339
|
+
* its bytes, or chain the next record onto anything but the last complete record.
|
|
340
|
+
* WHY: a full disk cut one record in half. [UNREADABLE-HEAD-IS-NOT-GENESIS] then refused every
|
|
341
|
+
* later append, correctly never chaining onto genesis, but for good: 194,875 refusals in the
|
|
342
|
+
* replay, 5 of 5 still refused once space was back, each reported only on stderr, while the
|
|
343
|
+
* receiver answered {"ok":true} and `emit-event` printed "Appended". audit-verify said "0
|
|
344
|
+
* record(s) checked" about 3,564 intact ones (E2E_REVIEW_2026-09 B1-1, 3 of 3 runs). A torn
|
|
345
|
+
* write has one signature: the file does not end with a newline. `kill -9` never produced one
|
|
346
|
+
* (10 of 10 kills during 2 MB appends); a full disk did every time.
|
|
347
|
+
* FIX: under the append lock, when the file does not end with a newline: a fragment that is a
|
|
348
|
+
* whole record only lost its newline, which is added back. Anything else is copied to
|
|
349
|
+
* audit.torn-<time>.partial (fsynced, never deleted), the log is cut back to its last complete
|
|
350
|
+
* record, and an `audit.torn_tail` record naming the kept file, its length and SHA-256 is
|
|
351
|
+
* chained onto that record before the caller's. A file holding no complete record at all is
|
|
352
|
+
* left alone and the append throws, as before. A last line that ends with a newline and is
|
|
353
|
+
* not JSON is not a torn write: it still throws. [LOCK] [UNREADABLE-HEAD-IS-NOT-GENESIS]
|
|
354
|
+
*/
|
|
355
|
+
function repairTornTail(path) {
|
|
356
|
+
// One open and one fstat on the common path (runs before every append).
|
|
357
|
+
let fd;
|
|
358
|
+
try {
|
|
359
|
+
fd = openSync(path, constants.O_RDWR);
|
|
360
|
+
}
|
|
361
|
+
catch (e) {
|
|
362
|
+
if (e.code === "ENOENT")
|
|
363
|
+
return null;
|
|
364
|
+
throw e;
|
|
365
|
+
}
|
|
366
|
+
try {
|
|
367
|
+
const size = fstatSync(fd).size;
|
|
368
|
+
if (size === 0)
|
|
369
|
+
return null;
|
|
370
|
+
const last = Buffer.alloc(1);
|
|
371
|
+
readSync(fd, last, 0, 1, size - 1);
|
|
372
|
+
if (last[0] === 10)
|
|
373
|
+
return null; // ends with a complete line: nothing was cut short
|
|
374
|
+
// Find the last newline, one window at a time from the end.
|
|
375
|
+
let nl = -1;
|
|
376
|
+
let pos = size;
|
|
377
|
+
const win = Buffer.alloc(TAIL_READ_BYTES);
|
|
378
|
+
while (pos > 0 && nl === -1) {
|
|
379
|
+
const start = Math.max(0, pos - win.length);
|
|
380
|
+
const n = readSync(fd, win, 0, pos - start, start);
|
|
381
|
+
const i = win.subarray(0, n).lastIndexOf(10);
|
|
382
|
+
if (i !== -1)
|
|
383
|
+
nl = start + i;
|
|
384
|
+
pos = start;
|
|
385
|
+
}
|
|
386
|
+
const fragStart = nl + 1;
|
|
387
|
+
const frag = Buffer.alloc(size - fragStart);
|
|
388
|
+
readSync(fd, frag, 0, frag.length, fragStart);
|
|
389
|
+
let whole = false;
|
|
390
|
+
try {
|
|
391
|
+
const r = JSON.parse(frag.toString("utf-8"));
|
|
392
|
+
whole = typeof r?.hash === "string" && r.hash.length === 64;
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
whole = false;
|
|
396
|
+
}
|
|
397
|
+
if (whole) {
|
|
398
|
+
writeSync(fd, "\n", size); // a complete record that only lost its newline
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
401
|
+
if (nl === -1)
|
|
402
|
+
return null; // no complete record to continue from: the head read throws
|
|
403
|
+
const kept = `audit.torn-${new Date().toISOString().replace(/[:.]/g, "-")}.partial`;
|
|
404
|
+
try {
|
|
405
|
+
writeFileAndSync(join(auditDir(), kept), frag);
|
|
406
|
+
}
|
|
407
|
+
catch (e) {
|
|
408
|
+
safeUnlink(join(auditDir(), kept)); // still a full disk: no half-written copy per attempt; the log is untouched
|
|
409
|
+
throw e;
|
|
410
|
+
}
|
|
411
|
+
ftruncateSync(fd, fragStart);
|
|
412
|
+
fsyncSync(fd);
|
|
413
|
+
return { kept, bytes: frag.length, sha256: createHash("sha256").update(frag).digest("hex") };
|
|
414
|
+
}
|
|
415
|
+
finally {
|
|
416
|
+
closeSync(fd);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* [LOCKED] [A-REFUSED-APPEND-IS-COUNTED-AND-CHAINED] - 2026-09-27
|
|
421
|
+
* [NEVER] let safeAppend() drop an entry with a stderr line as the only trace, or report success
|
|
422
|
+
* for an append that failed.
|
|
423
|
+
* WHY: safeAppend() isolates hot paths from audit failures, and its only surface was stderr. A
|
|
424
|
+
* writer that crashed inside the append lock cost every other writer ~4 entries over 10 s
|
|
425
|
+
* (B1-2, 3 of 3 runs; 448 such lines in the real launchd log before 2.5.8), a torn tail cost
|
|
426
|
+
* all of them (B1-1), and nothing on the chain or in fleet health said so. The receiver and
|
|
427
|
+
* `emit-event` told their senders "written" regardless.
|
|
428
|
+
* FIX: safeAppend() returns whether it wrote. A refusal adds one line to audit-refused.jsonl (best
|
|
429
|
+
* effort: on a full disk nothing can be written anywhere). The next append that succeeds takes
|
|
430
|
+
* the file (rename, under the append lock) and chains one `audit.append_failed` record with
|
|
431
|
+
* the count, the time span, the kinds and the errors, so the gap is on the chain; if that
|
|
432
|
+
* record cannot be written the lines go back. Fleet health reads both. [LOCK] [HEALTH-SEES-THE-CHAIN]
|
|
433
|
+
*/
|
|
434
|
+
function refusedPath() {
|
|
435
|
+
return join(auditDir(), "audit-refused.jsonl");
|
|
436
|
+
}
|
|
437
|
+
function recordRefusal(event, error) {
|
|
438
|
+
try {
|
|
439
|
+
appendFileSync(refusedPath(), JSON.stringify({ ts: new Date().toISOString(), pid: process.pid, event, error: error.slice(0, 200) }) + "\n");
|
|
440
|
+
}
|
|
441
|
+
catch {
|
|
442
|
+
/* a full disk: nothing can be written anywhere, stderr already has it */
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
/** Pending refusals, taken out of the way; `done` drops them once chained, `putBack` restores them. */
|
|
446
|
+
function takeRefusals() {
|
|
447
|
+
const path = refusedPath();
|
|
448
|
+
if (!existsSync(path))
|
|
449
|
+
return null;
|
|
450
|
+
const taking = `${path}.${process.pid}.taking`;
|
|
451
|
+
let body;
|
|
452
|
+
try {
|
|
453
|
+
renameSync(path, taking);
|
|
454
|
+
body = readFileSync(taking, "utf-8");
|
|
455
|
+
}
|
|
456
|
+
catch {
|
|
457
|
+
return null; // another writer took it, or it vanished: nothing to chain from here
|
|
458
|
+
}
|
|
459
|
+
const events = {};
|
|
460
|
+
const errors = {};
|
|
461
|
+
const pids = new Set();
|
|
462
|
+
let count = 0;
|
|
463
|
+
let first = "";
|
|
464
|
+
let last = "";
|
|
465
|
+
for (const line of body.split("\n")) {
|
|
466
|
+
if (!line)
|
|
467
|
+
continue;
|
|
468
|
+
let r;
|
|
469
|
+
try {
|
|
470
|
+
r = JSON.parse(line);
|
|
471
|
+
}
|
|
472
|
+
catch {
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
count++;
|
|
476
|
+
if (r.ts && (!first || r.ts < first))
|
|
477
|
+
first = r.ts;
|
|
478
|
+
if (r.ts && r.ts > last)
|
|
479
|
+
last = r.ts;
|
|
480
|
+
if (r.event)
|
|
481
|
+
events[r.event] = (events[r.event] ?? 0) + 1;
|
|
482
|
+
if (r.error)
|
|
483
|
+
errors[r.error] = (errors[r.error] ?? 0) + 1;
|
|
484
|
+
if (typeof r.pid === "number")
|
|
485
|
+
pids.add(r.pid);
|
|
486
|
+
}
|
|
487
|
+
if (count === 0) {
|
|
488
|
+
safeUnlink(taking);
|
|
489
|
+
return null;
|
|
490
|
+
}
|
|
491
|
+
return {
|
|
492
|
+
summary: { count, first, last, events, errors, pids: [...pids] },
|
|
493
|
+
done: () => safeUnlink(taking),
|
|
494
|
+
putBack: () => {
|
|
495
|
+
try {
|
|
496
|
+
appendFileSync(path, body);
|
|
497
|
+
safeUnlink(taking);
|
|
498
|
+
}
|
|
499
|
+
catch { /* the taking file stays for the next run */ }
|
|
500
|
+
},
|
|
501
|
+
};
|
|
502
|
+
}
|
|
299
503
|
/**
|
|
300
504
|
* 🔒 LOCKED [ROTATION-MUST-NOT-ORPHAN-THE-CHAIN] — 2026-08-20
|
|
301
505
|
* ⛔ NEVER rotate by truncating, moving or deleting audit.log. NEVER let a rotated log
|
|
@@ -399,32 +603,53 @@ function placeWithoutOverwrite(tmp, target) {
|
|
|
399
603
|
safeUnlink(tmp);
|
|
400
604
|
return true;
|
|
401
605
|
}
|
|
402
|
-
function parseLines(data, label) {
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
606
|
+
function parseLines(data, label, unreadable, baseIndex = 0) {
|
|
607
|
+
const out = [];
|
|
608
|
+
const lines = data.split("\n");
|
|
609
|
+
for (let i = 0; i < lines.length; i++) {
|
|
610
|
+
const line = lines[i];
|
|
611
|
+
if (!line)
|
|
612
|
+
continue;
|
|
407
613
|
try {
|
|
408
|
-
|
|
614
|
+
out.push(JSON.parse(line));
|
|
409
615
|
}
|
|
410
616
|
catch {
|
|
411
|
-
|
|
617
|
+
if (!unreadable)
|
|
618
|
+
throw new Error(`Corrupt audit line ${i + 1} in ${label}: not valid JSON`);
|
|
619
|
+
unreadable.push({ file: label, line: i + 1, beforeIndex: baseIndex + out.length });
|
|
412
620
|
}
|
|
413
|
-
}
|
|
621
|
+
}
|
|
622
|
+
return out;
|
|
414
623
|
}
|
|
415
624
|
export function readAuditLog(opts = {}) {
|
|
416
|
-
|
|
625
|
+
return readHistory(opts.includeArchives !== false);
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* The history, segments then live log. With `unreadable`, a line that is not JSON is listed there
|
|
629
|
+
* and skipped instead of aborting the read.
|
|
630
|
+
*
|
|
631
|
+
* [LOCKED] [VERIFY-READS-PAST-AN-UNREADABLE-LINE] - 2026-09-27
|
|
632
|
+
* [NEVER] let one unreadable line stop the verifier from checking every other record.
|
|
633
|
+
* WHY: verifyChain() read through readAuditLog(), which throws on the first line that is not JSON,
|
|
634
|
+
* so one cut-short record made audit-verify print "FAILED, 0 record(s) checked" and "treat the
|
|
635
|
+
* affected records as unverified" about 3,564 intact ones (E2E_REVIEW_2026-09 B1-1): the
|
|
636
|
+
* [VERIFY-FORK-IS-NOT-TAMPER] failure again, a verdict on everything from one bad line.
|
|
637
|
+
* FIX: the verifier reads tolerantly: an unreadable line is reported with its file and line number,
|
|
638
|
+
* makes the report fail, and every other record is still checked. Every other reader keeps the
|
|
639
|
+
* strict read, which throws.
|
|
640
|
+
*/
|
|
641
|
+
function readHistory(includeArchives, unreadable) {
|
|
417
642
|
const path = auditPath();
|
|
418
|
-
const
|
|
643
|
+
const liveText = existsSync(path) ? readFileSync(path, "utf-8") : "";
|
|
419
644
|
if (!includeArchives)
|
|
420
|
-
return
|
|
645
|
+
return parseLines(liveText, "audit.log", unreadable);
|
|
421
646
|
const segments = listSegments();
|
|
422
647
|
if (segments.length === 0)
|
|
423
|
-
return
|
|
648
|
+
return parseLines(liveText, "audit.log", unreadable);
|
|
424
649
|
const history = [];
|
|
425
650
|
let lastSegmentHashes = new Set();
|
|
426
651
|
for (const f of segments) {
|
|
427
|
-
const recs = parseLines(readFileSync(join(archiveDir(), f), "utf-8"), f);
|
|
652
|
+
const recs = parseLines(readFileSync(join(archiveDir(), f), "utf-8"), f, unreadable, history.length);
|
|
428
653
|
// 🔒 LOCKED [NO-SPREAD-OVER-A-SEGMENT] — 2026-08-20
|
|
429
654
|
// ⛔ NEVER use push(...records) on a segment. Found on the first real rotation:
|
|
430
655
|
// a 494,152-record segment threw "Maximum call stack size exceeded" because the
|
|
@@ -434,6 +659,7 @@ export function readAuditLog(opts = {}) {
|
|
|
434
659
|
history.push(r);
|
|
435
660
|
lastSegmentHashes = new Set(recs.map((r) => r.hash));
|
|
436
661
|
}
|
|
662
|
+
const live = parseLines(liveText, "audit.log", unreadable, history.length);
|
|
437
663
|
// Seam de-dup — see [ROTATE-ARCHIVE-BEFORE-TRUNCATE]. A crash after the segment was
|
|
438
664
|
// renamed but before the live log was truncated leaves the archived prefix present in
|
|
439
665
|
// both files. Drop only the LEADING run of live records already in the last segment;
|
|
@@ -441,6 +667,12 @@ export function readAuditLog(opts = {}) {
|
|
|
441
667
|
let start = 0;
|
|
442
668
|
while (start < live.length && lastSegmentHashes.has(live[start].hash))
|
|
443
669
|
start++;
|
|
670
|
+
if (unreadable && start > 0) {
|
|
671
|
+
const base = history.length;
|
|
672
|
+
for (const u of unreadable)
|
|
673
|
+
if (u.file === "audit.log")
|
|
674
|
+
u.beforeIndex = Math.max(base, u.beforeIndex - start);
|
|
675
|
+
}
|
|
444
676
|
// [LOCK] [NO-SPREAD-OVER-A-SEGMENT] — same reason.
|
|
445
677
|
for (let i = start; i < live.length; i++)
|
|
446
678
|
history.push(live[i]);
|
|
@@ -522,6 +754,7 @@ export function rotateAuditLog(opts = {}) {
|
|
|
522
754
|
};
|
|
523
755
|
}
|
|
524
756
|
try {
|
|
757
|
+
finishInterruptedMoves(); // [LOCK] [AN-INTERRUPTED-MOVE-IS-FINISHED]
|
|
525
758
|
return rotateHoldingLock(opts);
|
|
526
759
|
}
|
|
527
760
|
finally {
|
|
@@ -575,8 +808,20 @@ function rotateHoldingLock(opts) {
|
|
|
575
808
|
mkdirSync(adir, { recursive: true });
|
|
576
809
|
// Snapshot outside the lock: parsing 500k records is far too slow to hold the append
|
|
577
810
|
// lock for, and acquireLockSync() force-breaks locks older than STALE_LOCK_MS.
|
|
578
|
-
|
|
579
|
-
|
|
811
|
+
//
|
|
812
|
+
// [LOCKED] [ROTATION-SNAPSHOT-IS-THE-BYTES-READ] - 2026-09-27
|
|
813
|
+
// [NEVER] take the snapshot size from statSync() and the records from a separate read.
|
|
814
|
+
// WHY: Node 20's readFileSync(path, "utf-8") reads to the end of the file, past the size a
|
|
815
|
+
// statSync() just before it returned, whenever a writer appends during the read (10 of 10
|
|
816
|
+
// reads in a replay). The records appended in between were then parsed into the remainder
|
|
817
|
+
// AND copied again as raw bytes from the old size: written twice. It happened for real:
|
|
818
|
+
// 20 learning.import records of 2026-09-25 19:45:46 sit twice in audit-0062.jsonl, and the
|
|
819
|
+
// verifier called it a concurrent-append fork. Replayed 6 of 6 (E2E_REVIEW_2026-09 B2-1).
|
|
820
|
+
// FIX: one read, cut at its last newline; that byte length IS the snapshot, so the raw copy
|
|
821
|
+
// below starts exactly where the parsed records end, whatever the read returned.
|
|
822
|
+
const snapshotBuf = readFileSync(path);
|
|
823
|
+
const snapshotSize = snapshotBuf.lastIndexOf(10) + 1;
|
|
824
|
+
const live = parseLines(snapshotBuf.subarray(0, snapshotSize).toString("utf-8"), "audit.log");
|
|
580
825
|
// [LOCK] [ROTATION-HOLDS-THE-LOCK-BEFORE-IT-PLANS]: archiveCount is a count from the plan's
|
|
581
826
|
// read. Appends at the tail since then are fine; a different head means someone else cut or
|
|
582
827
|
// rewrote the log, and slicing it with an old count archives the wrong records.
|
|
@@ -591,9 +836,20 @@ function rotateHoldingLock(opts) {
|
|
|
591
836
|
const segName = plan.segmentFile;
|
|
592
837
|
const segTmp = join(adir, `.${segName}.tmp`);
|
|
593
838
|
const segBody = archived.map((r) => JSON.stringify(r)).join("\n") + "\n";
|
|
839
|
+
const rotateRecord = {
|
|
840
|
+
segment: segName,
|
|
841
|
+
archived_records: archived.length,
|
|
842
|
+
first_hash: archived[0].hash,
|
|
843
|
+
last_hash: archived[archived.length - 1].hash,
|
|
844
|
+
cutoff: plan.cutoff,
|
|
845
|
+
};
|
|
846
|
+
// [LOCK] [AN-INTERRUPTED-MOVE-IS-FINISHED]: from here to the audit.rotate record, a crash leaves
|
|
847
|
+
// this note, and the next rotation, restore or scrub finishes the job from it.
|
|
848
|
+
writeIntent(ROTATE_INTENT, rotateRecord);
|
|
594
849
|
writeFileAndSync(segTmp, segBody);
|
|
595
850
|
// [LOCK] [SEGMENT-IS-NEVER-OVERWRITTEN]
|
|
596
851
|
if (!placeWithoutOverwrite(segTmp, join(adir, segName))) {
|
|
852
|
+
clearIntent(ROTATE_INTENT);
|
|
597
853
|
return { ...empty, refusedReason: `segment ${segName} already exists; refusing to overwrite archived history` };
|
|
598
854
|
}
|
|
599
855
|
// [ROTATE-ARCHIVE-BEFORE-TRUNCATE]: the segment is durable from here on. Only now may
|
|
@@ -610,6 +866,7 @@ function rotateHoldingLock(opts) {
|
|
|
610
866
|
// Our segment then only duplicates records that other rotation archived, so it goes.
|
|
611
867
|
if (currentSize < snapshotSize || readFirstRecordHash(path) !== plan.firstLiveHash) {
|
|
612
868
|
safeUnlink(join(adir, segName));
|
|
869
|
+
clearIntent(ROTATE_INTENT);
|
|
613
870
|
return {
|
|
614
871
|
...empty,
|
|
615
872
|
refusedReason: "the live log was cut by another writer during this rotation; our segment was removed and nothing else was written",
|
|
@@ -638,13 +895,8 @@ function rotateHoldingLock(opts) {
|
|
|
638
895
|
}
|
|
639
896
|
// Self-documenting evidence: the rotation itself is an audited event, chained onto the
|
|
640
897
|
// new head like any other record.
|
|
641
|
-
appendAudit("audit.rotate",
|
|
642
|
-
|
|
643
|
-
archived_records: archived.length,
|
|
644
|
-
first_hash: archived[0].hash,
|
|
645
|
-
last_hash: archived[archived.length - 1].hash,
|
|
646
|
-
cutoff: plan.cutoff,
|
|
647
|
-
}, "system");
|
|
898
|
+
appendAudit("audit.rotate", rotateRecord, "system");
|
|
899
|
+
clearIntent(ROTATE_INTENT);
|
|
648
900
|
return {
|
|
649
901
|
...plan,
|
|
650
902
|
rotated: true,
|
|
@@ -716,6 +968,172 @@ function acquireRotateLock() {
|
|
|
716
968
|
// Another process broke the same stale lock and won the create.
|
|
717
969
|
return { heldMs: 0 };
|
|
718
970
|
}
|
|
971
|
+
/**
|
|
972
|
+
* [LOCKED] [AN-INTERRUPTED-MOVE-IS-FINISHED] - 2026-09-27
|
|
973
|
+
* [NEVER] let a rotation or a restore that stopped halfway be archived again, stay unrecorded on the
|
|
974
|
+
* chain, or leave a temp file that outlives a scrub.
|
|
975
|
+
* WHY: replayed in real processes killed at each write (E2E_REVIEW_2026-09 B2-2, B2-3, B3-3):
|
|
976
|
+
* - segment placed, live log not yet cut (9 of 9 kills, and a full disk 3 of 3): the seam de-dup
|
|
977
|
+
* hid the overlap until the NEXT rotation archived the same 70,000 records again, and the
|
|
978
|
+
* verifier then said "190,011 records verified" for 120,011. [ROTATE-ARCHIVE-BEFORE-TRUNCATE]
|
|
979
|
+
* promised this state "loses nothing and is de-duplicated": true only until the next rotation;
|
|
980
|
+
* - live log cut, or a restore placed, and the process gone before its record (9 of 9): history
|
|
981
|
+
* moved or came back with no audit.rotate / audit.restore saying who, when and why, and a
|
|
982
|
+
* retried restore is refused because its records are already there;
|
|
983
|
+
* - segment linked, its temp name not yet removed (3 of 3): the hidden second name kept 700
|
|
984
|
+
* planted keys through a scrub that reported success.
|
|
985
|
+
* FIX: before a rotation writes its segment, and before a restore places its block, a small note
|
|
986
|
+
* (.rotate-intent.json, .restore-intent.json in audit-archive/) names the move. Every holder of
|
|
987
|
+
* the rotate lock (rotation, auto-rotation even below its trigger, restore, scrub) first runs
|
|
988
|
+
* finishInterruptedMoves(): temp files are removed (every writer of them holds this lock, and
|
|
989
|
+
* each is a copy of records present elsewhere); for a noted rotation whose segment exists, the
|
|
990
|
+
* leading live records already in that segment are dropped under the append lock and the
|
|
991
|
+
* missing audit.rotate record is chained, marked completed_after_interruption; for a noted
|
|
992
|
+
* restore whose segment exists, its audit.restore record, reason included, is chained the same
|
|
993
|
+
* way. A record already in the live log is never written twice.
|
|
994
|
+
*/
|
|
995
|
+
const ROTATE_INTENT = ".rotate-intent.json";
|
|
996
|
+
const RESTORE_INTENT = ".restore-intent.json";
|
|
997
|
+
const LIVE_TEMPS = [".audit.log.tmp", ".audit.log.scrub.tmp"];
|
|
998
|
+
function writeIntent(name, data) {
|
|
999
|
+
mkdirSync(archiveDir(), { recursive: true });
|
|
1000
|
+
writeFileAndSync(join(archiveDir(), name), JSON.stringify(data));
|
|
1001
|
+
}
|
|
1002
|
+
/** The note, or null. A note that exists but cannot be read (cut short by the same crash) is
|
|
1003
|
+
* removed and reported: it names nothing we can finish, and left in place it would keep every
|
|
1004
|
+
* hourly auto-rotation taking the lock for nothing. */
|
|
1005
|
+
function readIntent(name) {
|
|
1006
|
+
const path = join(archiveDir(), name);
|
|
1007
|
+
if (!existsSync(path))
|
|
1008
|
+
return null;
|
|
1009
|
+
try {
|
|
1010
|
+
const v = JSON.parse(readFileSync(path, "utf-8"));
|
|
1011
|
+
if (v && typeof v.segment === "string")
|
|
1012
|
+
return v;
|
|
1013
|
+
}
|
|
1014
|
+
catch { /* unreadable: dropped below */ }
|
|
1015
|
+
console.error(`[ContextEngine] audit: ${name} could not be read and was removed; audit-verify reports anything it left unfinished`);
|
|
1016
|
+
safeUnlink(path);
|
|
1017
|
+
return null;
|
|
1018
|
+
}
|
|
1019
|
+
function clearIntent(name) {
|
|
1020
|
+
safeUnlink(join(archiveDir(), name));
|
|
1021
|
+
}
|
|
1022
|
+
function leftoverTemps() {
|
|
1023
|
+
const out = [];
|
|
1024
|
+
if (existsSync(archiveDir())) {
|
|
1025
|
+
for (const f of readdirSync(archiveDir()))
|
|
1026
|
+
if (f.startsWith(".") && f.endsWith(".tmp"))
|
|
1027
|
+
out.push(join(archiveDir(), f));
|
|
1028
|
+
}
|
|
1029
|
+
for (const f of LIVE_TEMPS)
|
|
1030
|
+
if (existsSync(join(auditDir(), f)))
|
|
1031
|
+
out.push(join(auditDir(), f));
|
|
1032
|
+
return out;
|
|
1033
|
+
}
|
|
1034
|
+
/** Cheap: is there anything for finishInterruptedMoves() to do? */
|
|
1035
|
+
export function interruptedMovePending() {
|
|
1036
|
+
return existsSync(join(archiveDir(), ROTATE_INTENT)) || existsSync(join(archiveDir(), RESTORE_INTENT)) || leftoverTemps().length > 0;
|
|
1037
|
+
}
|
|
1038
|
+
function describeFinish(f) {
|
|
1039
|
+
const parts = [];
|
|
1040
|
+
if (f.rotation)
|
|
1041
|
+
parts.push(`rotation to ${f.rotation.segment} finished (${f.rotation.duplicatesDropped} record(s) already archived dropped from the live log, record ${f.rotation.recorded ? "chained" : "already there"})`);
|
|
1042
|
+
if (f.restore)
|
|
1043
|
+
parts.push(`restore of ${f.restore.segment} recorded ${f.restore.recorded ? "late" : "already"}`);
|
|
1044
|
+
if (f.tempsRemoved.length > 0)
|
|
1045
|
+
parts.push(`${f.tempsRemoved.length} leftover temp file(s) removed`);
|
|
1046
|
+
return parts.length > 0 ? parts.join("; ") : "nothing to finish";
|
|
1047
|
+
}
|
|
1048
|
+
/** Does the live log text hold a record of this kind naming this segment? */
|
|
1049
|
+
function liveNames(text, event, segment) {
|
|
1050
|
+
const hint = `"event":"${event}"`;
|
|
1051
|
+
for (const line of text.split("\n")) {
|
|
1052
|
+
if (!line.includes(hint) || !line.includes(segment))
|
|
1053
|
+
continue;
|
|
1054
|
+
try {
|
|
1055
|
+
const r = JSON.parse(line);
|
|
1056
|
+
if (r.event === event && r.payload.segment === segment)
|
|
1057
|
+
return true;
|
|
1058
|
+
}
|
|
1059
|
+
catch { /* not a record */ }
|
|
1060
|
+
}
|
|
1061
|
+
return false;
|
|
1062
|
+
}
|
|
1063
|
+
/** Run with the rotate lock held. [LOCK] [AN-INTERRUPTED-MOVE-IS-FINISHED] */
|
|
1064
|
+
export function finishInterruptedMoves() {
|
|
1065
|
+
const report = { tempsRemoved: [], rotation: null, restore: null };
|
|
1066
|
+
for (const t of leftoverTemps()) {
|
|
1067
|
+
safeUnlink(t);
|
|
1068
|
+
if (!existsSync(t))
|
|
1069
|
+
report.tempsRemoved.push(basename(t));
|
|
1070
|
+
}
|
|
1071
|
+
const ri = readIntent(ROTATE_INTENT);
|
|
1072
|
+
if (ri && typeof ri.segment === "string") {
|
|
1073
|
+
const seg = join(archiveDir(), ri.segment);
|
|
1074
|
+
if (existsSync(seg)) {
|
|
1075
|
+
const segHashes = new Set(parseLines(readFileSync(seg, "utf-8"), ri.segment).map((r) => r.hash));
|
|
1076
|
+
const path = auditPath();
|
|
1077
|
+
const release = acquireLockSync();
|
|
1078
|
+
try {
|
|
1079
|
+
settleTail(path);
|
|
1080
|
+
const text = existsSync(path) ? readFileSync(path, "utf-8") : "";
|
|
1081
|
+
const lines = text.split("\n");
|
|
1082
|
+
let dropped = 0;
|
|
1083
|
+
while (dropped < lines.length && lines[dropped]) {
|
|
1084
|
+
let h;
|
|
1085
|
+
try {
|
|
1086
|
+
h = JSON.parse(lines[dropped]).hash;
|
|
1087
|
+
}
|
|
1088
|
+
catch {
|
|
1089
|
+
break;
|
|
1090
|
+
}
|
|
1091
|
+
if (!segHashes.has(h))
|
|
1092
|
+
break;
|
|
1093
|
+
dropped++;
|
|
1094
|
+
}
|
|
1095
|
+
if (dropped > 0) {
|
|
1096
|
+
const tmp = join(auditDir(), ".audit.log.tmp");
|
|
1097
|
+
writeFileAndSync(tmp, lines.slice(dropped).join("\n"));
|
|
1098
|
+
renameSync(tmp, path);
|
|
1099
|
+
}
|
|
1100
|
+
const recorded = !liveNames(text, "audit.rotate", ri.segment);
|
|
1101
|
+
if (recorded) {
|
|
1102
|
+
writeRecord(path, "audit.rotate", { ...ri, completed_after_interruption: true, duplicates_dropped: dropped }, "system");
|
|
1103
|
+
}
|
|
1104
|
+
report.rotation = { segment: ri.segment, duplicatesDropped: dropped, recorded };
|
|
1105
|
+
}
|
|
1106
|
+
finally {
|
|
1107
|
+
release();
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
clearIntent(ROTATE_INTENT);
|
|
1111
|
+
}
|
|
1112
|
+
const si = readIntent(RESTORE_INTENT);
|
|
1113
|
+
if (si && typeof si.segment === "string") {
|
|
1114
|
+
if (existsSync(join(archiveDir(), si.segment))) {
|
|
1115
|
+
const path = auditPath();
|
|
1116
|
+
const release = acquireLockSync();
|
|
1117
|
+
try {
|
|
1118
|
+
settleTail(path);
|
|
1119
|
+
const text = existsSync(path) ? readFileSync(path, "utf-8") : "";
|
|
1120
|
+
const recorded = !liveNames(text, "audit.restore", si.segment);
|
|
1121
|
+
const { actor, ...payload } = si;
|
|
1122
|
+
if (recorded)
|
|
1123
|
+
writeRecord(path, "audit.restore", { ...payload, completed_after_interruption: true }, typeof actor === "string" ? actor : "system");
|
|
1124
|
+
report.restore = { segment: si.segment, recorded };
|
|
1125
|
+
}
|
|
1126
|
+
finally {
|
|
1127
|
+
release();
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
clearIntent(RESTORE_INTENT);
|
|
1131
|
+
}
|
|
1132
|
+
if (report.tempsRemoved.length > 0 || report.rotation || report.restore) {
|
|
1133
|
+
console.error(`[ContextEngine] audit: finished an interrupted move: ${describeFinish(report)}`);
|
|
1134
|
+
}
|
|
1135
|
+
return report;
|
|
1136
|
+
}
|
|
719
1137
|
/** Count newline-terminated lines without parsing. The live log is small by construction. */
|
|
720
1138
|
export function countLiveRecords() {
|
|
721
1139
|
const path = auditPath();
|
|
@@ -736,6 +1154,23 @@ export function autoRotateAuditLog(opts = {}) {
|
|
|
736
1154
|
}
|
|
737
1155
|
const liveRecords = countLiveRecords();
|
|
738
1156
|
if (liveRecords <= trigger) {
|
|
1157
|
+
// A move interrupted after the live log was cut leaves the log below the trigger: finish it now,
|
|
1158
|
+
// not when the log next grows past it. [LOCK] [AN-INTERRUPTED-MOVE-IS-FINISHED]
|
|
1159
|
+
if (interruptedMovePending()) {
|
|
1160
|
+
const lock = acquireRotateLock();
|
|
1161
|
+
if ("heldMs" in lock)
|
|
1162
|
+
return { action: "in_progress", liveRecords, detail: "another rotation holds the lock" };
|
|
1163
|
+
try {
|
|
1164
|
+
const f = finishInterruptedMoves();
|
|
1165
|
+
return { action: "finished", liveRecords, detail: describeFinish(f) };
|
|
1166
|
+
}
|
|
1167
|
+
catch (e) {
|
|
1168
|
+
return { action: "error", liveRecords, detail: e.message };
|
|
1169
|
+
}
|
|
1170
|
+
finally {
|
|
1171
|
+
lock.release();
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
739
1174
|
return { action: "below_trigger", liveRecords, detail: `${liveRecords} live record(s), trigger is ${trigger}` };
|
|
740
1175
|
}
|
|
741
1176
|
// One runner at a time: rotateAuditLog() takes the rotate lock itself, so this path and the
|
|
@@ -760,9 +1195,13 @@ export function autoRotateAuditLog(opts = {}) {
|
|
|
760
1195
|
}
|
|
761
1196
|
}
|
|
762
1197
|
function writeFileAndSync(target, body) {
|
|
1198
|
+
const buf = typeof body === "string" ? Buffer.from(body, "utf-8") : body;
|
|
763
1199
|
const fd = openSync(target, "w");
|
|
764
1200
|
try {
|
|
765
|
-
writeSync(
|
|
1201
|
+
// writeSync may write fewer bytes than asked (a disk filling up): loop until all are down.
|
|
1202
|
+
let off = 0;
|
|
1203
|
+
while (off < buf.length)
|
|
1204
|
+
off += writeSync(fd, buf, off, buf.length - off);
|
|
766
1205
|
fsyncSync(fd);
|
|
767
1206
|
}
|
|
768
1207
|
finally {
|
|
@@ -791,11 +1230,17 @@ function writeFileAndSync(target, body) {
|
|
|
791
1230
|
* `ok` is true when there are no tampered and no orphan records. Forks are surfaced
|
|
792
1231
|
* with counts and indices so the report stays honest in both directions — it must
|
|
793
1232
|
* never claim a forked log is pristine either.
|
|
1233
|
+
* 2026-09-27: a fourth class. A record whose hash was already seen is a DUPLICATE (a second copy
|
|
1234
|
+
* of the same record), counted once and skipped for linkage, so the record after a copied
|
|
1235
|
+
* block links to the original. Before, the first copy read as a "fork" and the total counted
|
|
1236
|
+
* every copy: 190,011 records "verified" for 120,011 real ones after an interrupted rotation
|
|
1237
|
+
* (E2E_REVIEW_2026-09 B2-1, B2-2). A copy's content is still checked against its own hash.
|
|
794
1238
|
*/
|
|
795
1239
|
export function verifyChain() {
|
|
796
1240
|
let records;
|
|
1241
|
+
const unreadable = [];
|
|
797
1242
|
try {
|
|
798
|
-
records =
|
|
1243
|
+
records = readHistory(true, unreadable); // [LOCK] [VERIFY-READS-PAST-AN-UNREADABLE-LINE]
|
|
799
1244
|
}
|
|
800
1245
|
catch (e) {
|
|
801
1246
|
return {
|
|
@@ -808,6 +1253,7 @@ export function verifyChain() {
|
|
|
808
1253
|
const tampered = [];
|
|
809
1254
|
const orphans = [];
|
|
810
1255
|
const forks = [];
|
|
1256
|
+
const duplicates = [];
|
|
811
1257
|
// Every hash observed so far, so a fork (parent = a known earlier head) can be told
|
|
812
1258
|
// apart from an orphan (parent never existed in this log).
|
|
813
1259
|
const seen = new Set([GENESIS_HASH]);
|
|
@@ -820,6 +1266,12 @@ export function verifyChain() {
|
|
|
820
1266
|
const expected = computeHash(r.prev_hash, r.ts, r.event, r.actor, r.payload);
|
|
821
1267
|
if (r.hash !== expected)
|
|
822
1268
|
tampered.push(i);
|
|
1269
|
+
// 1b. A second copy of a record already in the history: counted once, never relinked, so
|
|
1270
|
+
// the record after a copied block still links to the original. [LOCK] [VERIFY-FORK-IS-NOT-TAMPER]
|
|
1271
|
+
if (seen.has(r.hash)) {
|
|
1272
|
+
duplicates.push(i);
|
|
1273
|
+
continue;
|
|
1274
|
+
}
|
|
823
1275
|
// 2. Linkage — fork vs orphan.
|
|
824
1276
|
if (r.prev_hash !== prev) {
|
|
825
1277
|
if (seen.has(r.prev_hash))
|
|
@@ -861,12 +1313,15 @@ export function verifyChain() {
|
|
|
861
1313
|
}
|
|
862
1314
|
tampered.length = 0;
|
|
863
1315
|
tampered.push(...stillTampered);
|
|
864
|
-
const ok = tampered.length === 0 && orphans.length === 0;
|
|
865
|
-
const firstProblem = tampered.length > 0 ? tampered[0] : orphans.length > 0 ? orphans[0] : null;
|
|
1316
|
+
const ok = tampered.length === 0 && orphans.length === 0 && unreadable.length === 0;
|
|
1317
|
+
const firstProblem = tampered.length > 0 ? tampered[0] : unreadable.length > 0 ? unreadable[0].beforeIndex : orphans.length > 0 ? orphans[0] : null;
|
|
866
1318
|
let reason = null;
|
|
867
1319
|
if (tampered.length > 0) {
|
|
868
1320
|
reason = `${tampered.length} record(s) with altered content — first at index ${tampered[0]}`;
|
|
869
1321
|
}
|
|
1322
|
+
else if (unreadable.length > 0) {
|
|
1323
|
+
reason = `${unreadable.length} line(s) that are not records, first at ${unreadable[0].file} line ${unreadable[0].line}; every other record was checked, and the record after such a line cannot be linked`;
|
|
1324
|
+
}
|
|
870
1325
|
else if (orphans.length > 0) {
|
|
871
1326
|
reason = `${orphans.length} record(s) whose parent is absent from the log (deleted or truncated history) — first at index ${orphans[0]}`;
|
|
872
1327
|
}
|
|
@@ -878,9 +1333,108 @@ export function verifyChain() {
|
|
|
878
1333
|
tamperedIndices: tampered,
|
|
879
1334
|
orphanIndices: orphans,
|
|
880
1335
|
forkIndices: forks,
|
|
1336
|
+
duplicateIndices: duplicates,
|
|
1337
|
+
unreadable,
|
|
881
1338
|
redactedIndices: redacted,
|
|
882
1339
|
};
|
|
883
1340
|
}
|
|
1341
|
+
export function verifyStatePath() {
|
|
1342
|
+
return join(auditDir(), "audit-verify.json");
|
|
1343
|
+
}
|
|
1344
|
+
/** Keep the result of a full check. Best effort: a check that cannot record still printed its verdict. */
|
|
1345
|
+
export function recordVerifyState(report, ms, by) {
|
|
1346
|
+
const dups = (report.duplicateIndices ?? []).length;
|
|
1347
|
+
const state = {
|
|
1348
|
+
checkedAt: new Date().toISOString(),
|
|
1349
|
+
ms,
|
|
1350
|
+
by,
|
|
1351
|
+
ok: report.ok,
|
|
1352
|
+
total: report.total,
|
|
1353
|
+
unique: report.total - dups,
|
|
1354
|
+
altered: (report.tamperedIndices ?? []).length,
|
|
1355
|
+
orphans: (report.orphanIndices ?? []).length,
|
|
1356
|
+
unreadable: (report.unreadable ?? []).length,
|
|
1357
|
+
duplicates: dups,
|
|
1358
|
+
forks: (report.forkIndices ?? []).length,
|
|
1359
|
+
redacted: (report.redactedIndices ?? []).length,
|
|
1360
|
+
reason: report.breakReason,
|
|
1361
|
+
};
|
|
1362
|
+
try {
|
|
1363
|
+
ensureDir();
|
|
1364
|
+
const tmp = `${verifyStatePath()}.tmp-${process.pid}`;
|
|
1365
|
+
writeFileSync(tmp, JSON.stringify(state, null, 2) + "\n");
|
|
1366
|
+
renameSync(tmp, verifyStatePath());
|
|
1367
|
+
}
|
|
1368
|
+
catch {
|
|
1369
|
+
/* the verdict was printed; health will say the chain was not checked recently */
|
|
1370
|
+
}
|
|
1371
|
+
return state;
|
|
1372
|
+
}
|
|
1373
|
+
export function readVerifyState() {
|
|
1374
|
+
try {
|
|
1375
|
+
const s = JSON.parse(readFileSync(verifyStatePath(), "utf-8"));
|
|
1376
|
+
return typeof s.checkedAt === "string" && typeof s.ok === "boolean" ? s : null;
|
|
1377
|
+
}
|
|
1378
|
+
catch {
|
|
1379
|
+
return null;
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
/** Refusals written by safeAppend() and not chained yet: count, and the newest one. */
|
|
1383
|
+
export function pendingRefusals() {
|
|
1384
|
+
let body = "";
|
|
1385
|
+
try {
|
|
1386
|
+
body = readFileSync(refusedPath(), "utf-8");
|
|
1387
|
+
}
|
|
1388
|
+
catch {
|
|
1389
|
+
return { count: 0, last: null, error: null };
|
|
1390
|
+
}
|
|
1391
|
+
let count = 0;
|
|
1392
|
+
let last = null;
|
|
1393
|
+
let error = null;
|
|
1394
|
+
for (const line of body.split("\n")) {
|
|
1395
|
+
if (!line)
|
|
1396
|
+
continue;
|
|
1397
|
+
try {
|
|
1398
|
+
const r = JSON.parse(line);
|
|
1399
|
+
count++;
|
|
1400
|
+
if (r.ts && (!last || r.ts > last)) {
|
|
1401
|
+
last = r.ts;
|
|
1402
|
+
error = r.error ?? null;
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
catch { /* a line being written */ }
|
|
1406
|
+
}
|
|
1407
|
+
return { count, last, error };
|
|
1408
|
+
}
|
|
1409
|
+
/** One scheduled full check at a time across processes: O_EXCL, stale after two hours. */
|
|
1410
|
+
export function acquireVerifyLock() {
|
|
1411
|
+
const lock = join(auditDir(), "audit-verify.lock");
|
|
1412
|
+
ensureDir();
|
|
1413
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
1414
|
+
try {
|
|
1415
|
+
const fd = openSync(lock, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600);
|
|
1416
|
+
try {
|
|
1417
|
+
writeSync(fd, `${process.pid}\n`);
|
|
1418
|
+
}
|
|
1419
|
+
catch { /* courtesy */ }
|
|
1420
|
+
closeSync(fd);
|
|
1421
|
+
return () => safeUnlink(lock);
|
|
1422
|
+
}
|
|
1423
|
+
catch (e) {
|
|
1424
|
+
if (e.code !== "EEXIST")
|
|
1425
|
+
return null;
|
|
1426
|
+
try {
|
|
1427
|
+
if (Date.now() - statSync(lock).mtimeMs < 2 * 3_600_000)
|
|
1428
|
+
return null;
|
|
1429
|
+
}
|
|
1430
|
+
catch {
|
|
1431
|
+
continue;
|
|
1432
|
+
}
|
|
1433
|
+
safeUnlink(lock);
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
return null;
|
|
1437
|
+
}
|
|
884
1438
|
/**
|
|
885
1439
|
* Acknowledge that records were deliberately redacted (a secret removed from their content).
|
|
886
1440
|
*
|
|
@@ -1028,11 +1582,26 @@ export function restoreSegment(file, opts = {}) {
|
|
|
1028
1582
|
return refuse(`the verifier does not report the record after ${prev.name} as an orphan; nothing to restore`, base);
|
|
1029
1583
|
}
|
|
1030
1584
|
const adir = archiveDir();
|
|
1585
|
+
const restorePayload = {
|
|
1586
|
+
segment: segName,
|
|
1587
|
+
after: prev.name,
|
|
1588
|
+
records: block.length,
|
|
1589
|
+
first_hash: block[0].hash,
|
|
1590
|
+
last_hash: block[block.length - 1].hash,
|
|
1591
|
+
first_ts: block[0].ts,
|
|
1592
|
+
last_ts: block[block.length - 1].ts,
|
|
1593
|
+
source: basename(file),
|
|
1594
|
+
reason: opts.reason.trim(),
|
|
1595
|
+
};
|
|
1596
|
+
// [LOCK] [AN-INTERRUPTED-MOVE-IS-FINISHED]: a crash from here to the record leaves this note.
|
|
1597
|
+
writeIntent(RESTORE_INTENT, { ...restorePayload, actor: opts.actor ?? "system" });
|
|
1031
1598
|
const tmp = join(adir, `.${segName}.tmp`);
|
|
1032
1599
|
writeFileAndSync(tmp, block.map((x) => JSON.stringify(x)).join("\n") + "\n");
|
|
1033
1600
|
const target = join(adir, segName);
|
|
1034
|
-
if (!placeWithoutOverwrite(tmp, target))
|
|
1601
|
+
if (!placeWithoutOverwrite(tmp, target)) {
|
|
1602
|
+
clearIntent(RESTORE_INTENT);
|
|
1035
1603
|
return refuse(`segment ${segName} already exists; refusing to overwrite it`, base);
|
|
1604
|
+
}
|
|
1036
1605
|
const afterReport = verifyChain();
|
|
1037
1606
|
// `>=` on the total: live appends keep landing during two full verifies (seconds on a real
|
|
1038
1607
|
// log), and they only ever add records.
|
|
@@ -1041,19 +1610,11 @@ export function restoreSegment(file, opts = {}) {
|
|
|
1041
1610
|
afterReport.total >= beforeReport.total + block.length;
|
|
1042
1611
|
if (!improved) {
|
|
1043
1612
|
safeUnlink(target);
|
|
1613
|
+
clearIntent(RESTORE_INTENT);
|
|
1044
1614
|
return refuse("the chain did not come out exactly one orphan better; the restored segment was removed again", base);
|
|
1045
1615
|
}
|
|
1046
|
-
const record = appendAudit("audit.restore",
|
|
1047
|
-
|
|
1048
|
-
after: prev.name,
|
|
1049
|
-
records: block.length,
|
|
1050
|
-
first_hash: block[0].hash,
|
|
1051
|
-
last_hash: block[block.length - 1].hash,
|
|
1052
|
-
first_ts: block[0].ts,
|
|
1053
|
-
last_ts: block[block.length - 1].ts,
|
|
1054
|
-
source: basename(file),
|
|
1055
|
-
reason: opts.reason.trim(),
|
|
1056
|
-
}, opts.actor ?? "system");
|
|
1616
|
+
const record = appendAudit("audit.restore", restorePayload, opts.actor ?? "system");
|
|
1617
|
+
clearIntent(RESTORE_INTENT);
|
|
1057
1618
|
return { ...plan, restored: true, record };
|
|
1058
1619
|
};
|
|
1059
1620
|
if (!opts.apply)
|
|
@@ -1063,6 +1624,7 @@ export function restoreSegment(file, opts = {}) {
|
|
|
1063
1624
|
if ("heldMs" in lock)
|
|
1064
1625
|
return refuse("a rotation is in progress; try again in a minute", base);
|
|
1065
1626
|
try {
|
|
1627
|
+
finishInterruptedMoves(); // [LOCK] [AN-INTERRUPTED-MOVE-IS-FINISHED]
|
|
1066
1628
|
return planAndMaybeWrite();
|
|
1067
1629
|
}
|
|
1068
1630
|
finally {
|
|
@@ -1127,6 +1689,8 @@ function scrubText(text, redact) {
|
|
|
1127
1689
|
* the live log under the append lock (appends wait, none is lost), then one audit.redact
|
|
1128
1690
|
* record per 100 rewrites names each original hash and its new content hash
|
|
1129
1691
|
* ([REDACTION-IS-A-CHAINED-RECORD]). Running it again changes nothing.
|
|
1692
|
+
* 2026-09-27 (Yan's GO): the order is now acknowledgement first, rewrite second, for every file.
|
|
1693
|
+
* [LOCK] [SCRUB-ACKNOWLEDGES-BEFORE-IT-REWRITES]
|
|
1130
1694
|
*/
|
|
1131
1695
|
export function scrubAuditLog(opts) {
|
|
1132
1696
|
const report = { applied: false, refusedReason: null, files: [], redactedRecords: 0, counts: {}, acknowledgements: [] };
|
|
@@ -1138,13 +1702,23 @@ export function scrubAuditLog(opts) {
|
|
|
1138
1702
|
for (const [k, n] of Object.entries(s.counts))
|
|
1139
1703
|
report.counts[k] = (report.counts[k] ?? 0) + n;
|
|
1140
1704
|
};
|
|
1141
|
-
//
|
|
1142
|
-
//
|
|
1143
|
-
|
|
1705
|
+
// [LOCKED] [SCRUB-ACKNOWLEDGES-BEFORE-IT-REWRITES] - 2026-09-27
|
|
1706
|
+
// [NEVER] rewrite a file before the acknowledgements for its rewrites are on the chain.
|
|
1707
|
+
// WHY: the scrub renamed each rewritten segment into place, then acknowledged it. Killed between the
|
|
1708
|
+
// two (3 of 3 runs, E2E_REVIEW_2026-09 B3-1), 100 records read as "altered content: this is
|
|
1709
|
+
// tampering", and a rerun could not repair it: it found nothing left to change in them. The
|
|
1710
|
+
// product's own act was reported as an attack, recoverable only by listing 100 indices by hand.
|
|
1711
|
+
// FIX: acknowledge first, rewrite second. A crash in between leaves an acknowledgement for a rewrite
|
|
1712
|
+
// that did not happen: it binds content that is not there, so it is simply unused, and a rerun
|
|
1713
|
+
// rewrites and acknowledges again. The live log is acknowledged and rewritten under one hold of
|
|
1714
|
+
// the append lock: its acknowledgements are appended, then the rewritten records plus exactly
|
|
1715
|
+
// the bytes appended since go in place. [LOCK] [SCRUB-IS-ACKNOWLEDGED-REDACTION]
|
|
1716
|
+
const acknowledge = (entries, write) => {
|
|
1144
1717
|
for (let i = 0; i < entries.length; i += SCRUB_ACK_BATCH) {
|
|
1145
|
-
report.acknowledgements.push(
|
|
1718
|
+
report.acknowledgements.push(write({ reason: opts.reason.trim(), redacted: entries.slice(i, i + SCRUB_ACK_BATCH) }));
|
|
1146
1719
|
}
|
|
1147
1720
|
};
|
|
1721
|
+
const actor = opts.actor ?? "system";
|
|
1148
1722
|
const run = () => {
|
|
1149
1723
|
const adir = archiveDir();
|
|
1150
1724
|
for (const name of listSegments()) {
|
|
@@ -1154,8 +1728,8 @@ export function scrubAuditLog(opts) {
|
|
|
1154
1728
|
if (opts.apply && s.redacted.length > 0) {
|
|
1155
1729
|
const tmp = join(adir, `.${name}.scrub.tmp`);
|
|
1156
1730
|
writeFileAndSync(tmp, s.body);
|
|
1731
|
+
acknowledge(s.redacted, (payload) => appendAudit("audit.redact", payload, actor));
|
|
1157
1732
|
renameSync(tmp, path);
|
|
1158
|
-
acknowledge(s.redacted);
|
|
1159
1733
|
}
|
|
1160
1734
|
}
|
|
1161
1735
|
const live = auditPath();
|
|
@@ -1164,22 +1738,23 @@ export function scrubAuditLog(opts) {
|
|
|
1164
1738
|
tally("audit.log", scrubText(readFileSync(live, "utf-8"), opts.redact));
|
|
1165
1739
|
}
|
|
1166
1740
|
else {
|
|
1167
|
-
let liveAcks = [];
|
|
1168
1741
|
const release = acquireLockSync();
|
|
1169
1742
|
try {
|
|
1170
|
-
|
|
1743
|
+
settleTail(live); // the log ends with a newline from here on
|
|
1744
|
+
const before = readFileSync(live);
|
|
1745
|
+
const s = scrubText(before.toString("utf-8"), opts.redact);
|
|
1171
1746
|
tally("audit.log", s);
|
|
1172
1747
|
if (s.redacted.length > 0) {
|
|
1748
|
+
acknowledge(s.redacted, (payload) => writeRecord(live, "audit.redact", payload, actor));
|
|
1749
|
+
const grown = readFileSync(live).subarray(before.length); // our acknowledgements, nothing else: we hold the lock
|
|
1173
1750
|
const tmp = join(auditDir(), ".audit.log.scrub.tmp");
|
|
1174
|
-
writeFileAndSync(tmp, s.body);
|
|
1751
|
+
writeFileAndSync(tmp, Buffer.concat([Buffer.from(s.body, "utf-8"), grown]));
|
|
1175
1752
|
renameSync(tmp, live);
|
|
1176
|
-
liveAcks = s.redacted;
|
|
1177
1753
|
}
|
|
1178
1754
|
}
|
|
1179
1755
|
finally {
|
|
1180
1756
|
release();
|
|
1181
1757
|
}
|
|
1182
|
-
acknowledge(liveAcks); // after the release: appendAudit takes the same lock
|
|
1183
1758
|
}
|
|
1184
1759
|
}
|
|
1185
1760
|
return { ...report, applied: !!opts.apply };
|
|
@@ -1190,6 +1765,7 @@ export function scrubAuditLog(opts) {
|
|
|
1190
1765
|
if ("heldMs" in lock)
|
|
1191
1766
|
return { ...report, refusedReason: "a rotation is in progress; try again in a minute" };
|
|
1192
1767
|
try {
|
|
1768
|
+
finishInterruptedMoves(); // [LOCK] [AN-INTERRUPTED-MOVE-IS-FINISHED]: no leftover temp keeps a secret
|
|
1193
1769
|
return run();
|
|
1194
1770
|
}
|
|
1195
1771
|
finally {
|
|
@@ -1221,13 +1797,19 @@ export function resetCacheForTest() {
|
|
|
1221
1797
|
// Safe wrapper that never throws into hot paths. Use this from production
|
|
1222
1798
|
// call sites so a failed audit append cannot break a learning save or
|
|
1223
1799
|
// session write.
|
|
1800
|
+
// Returns whether the entry was written. A refusal is counted in audit-refused.jsonl and chained
|
|
1801
|
+
// as `audit.append_failed` by the next append that succeeds. [LOCK] [A-REFUSED-APPEND-IS-COUNTED-AND-CHAINED]
|
|
1224
1802
|
export function safeAppend(event, payload, actor = "system") {
|
|
1225
1803
|
try {
|
|
1226
1804
|
appendAudit(event, payload, actor);
|
|
1805
|
+
return true;
|
|
1227
1806
|
}
|
|
1228
1807
|
catch (e) {
|
|
1229
|
-
|
|
1230
|
-
|
|
1808
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
1809
|
+
// Never throw upward: stderr, plus the count the chain and fleet health will carry.
|
|
1810
|
+
process.stderr.write(`[ContextEngine] audit append failed: ${message}\n`);
|
|
1811
|
+
recordRefusal(event, message);
|
|
1812
|
+
return false;
|
|
1231
1813
|
}
|
|
1232
1814
|
}
|
|
1233
1815
|
//# sourceMappingURL=audit.js.map
|