@crewhaus/audit-log 0.1.0 → 0.1.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crewhaus/audit-log",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "description": "Per-tenant hash-chained JSONL audit trail for the managed-daemon target",
6
6
  "main": "src/index.ts",
@@ -12,13 +12,13 @@
12
12
  "test": "bun test src"
13
13
  },
14
14
  "dependencies": {
15
- "@crewhaus/errors": "0.0.0"
15
+ "@crewhaus/errors": "0.1.2"
16
16
  },
17
17
  "license": "Apache-2.0",
18
18
  "author": {
19
19
  "name": "Max Meier",
20
- "email": "max@studiomax.io",
21
- "url": "https://studiomax.io"
20
+ "email": "max@crewhaus.ai",
21
+ "url": "https://crewhaus.ai"
22
22
  },
23
23
  "repository": {
24
24
  "type": "git",
@@ -30,12 +30,7 @@
30
30
  "url": "https://github.com/crewhaus/factory/issues"
31
31
  },
32
32
  "publishConfig": {
33
- "access": "restricted"
33
+ "access": "public"
34
34
  },
35
- "files": [
36
- "src",
37
- "README.md",
38
- "LICENSE",
39
- "NOTICE"
40
- ]
35
+ "files": ["src", "README.md", "LICENSE", "NOTICE"]
41
36
  }
package/src/index.test.ts CHANGED
@@ -1,8 +1,17 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
- import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { mkdtempSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
- import { type AuditLog, GENESIS_HASH, openAuditLog, verify } from "./index";
5
+ import {
6
+ type AnchorRecord,
7
+ type AnchorStore,
8
+ type AuditLog,
9
+ AuditLogError,
10
+ GENESIS_HASH,
11
+ InMemoryAnchorStore,
12
+ openAuditLog,
13
+ verify,
14
+ } from "./index";
6
15
 
7
16
  let tmp: string;
8
17
 
@@ -54,14 +63,19 @@ describe("append + read", () => {
54
63
  });
55
64
 
56
65
  describe("verify (T4)", () => {
57
- test("clean chain reports ok=true", async () => {
66
+ test("clean chain reports ok=true and matches the tail anchor", async () => {
58
67
  const log = await makeLog();
59
68
  for (let i = 0; i < 10; i += 1) {
60
69
  await log.append({ kind: "policy_decision", payload: { i } });
61
70
  }
62
71
  const r = await verify(tmp);
63
72
  expect(r.ok).toBe(true);
64
- if (r.ok) expect(r.recordsChecked).toBe(10);
73
+ if (r.ok) {
74
+ expect(r.recordsChecked).toBe(10);
75
+ // The _chain-tail.json anchor exists and matches the surviving tail,
76
+ // so the truncation guard actually ran (not the missing-anchor path).
77
+ expect(r.anchorChecked).toBe(true);
78
+ }
65
79
  });
66
80
 
67
81
  test("empty rootDir reports ok=true with 0 records", async () => {
@@ -130,6 +144,144 @@ describe("verify (T4)", () => {
130
144
  expect(r.reason).toMatch(/malformed JSON/);
131
145
  }
132
146
  });
147
+
148
+ test("a corrupt _chain-tail.json is reported, not thrown (no verifier DoS)", async () => {
149
+ const log = await makeLog();
150
+ await log.append({ kind: "model_call", payload: 1 });
151
+ await log.append({ kind: "model_call", payload: 2 });
152
+
153
+ // Garble the on-host anchor (a crash mid-write, or a one-byte attacker
154
+ // edit, leaves invalid JSON). verify must surface this as a broken link
155
+ // rather than crash with an unhandled JSON.parse throw.
156
+ writeFileSync(join(tmp, "_chain-tail.json"), "{ this is not valid json");
157
+
158
+ const r = await verify(tmp);
159
+ expect(r.ok).toBe(false);
160
+ if (!r.ok) {
161
+ expect(r.reason).toMatch(/chain-tail anchor unreadable/);
162
+ expect(r.file).toMatch(/_chain-tail\.json$/);
163
+ // The chain walk itself completed before the anchor was consulted.
164
+ expect(r.recordsChecked).toBe(2);
165
+ }
166
+ });
167
+ });
168
+
169
+ describe("tail-truncation detection (CWE-345)", () => {
170
+ const readLines = (file: string): string[] =>
171
+ readFileSync(file, "utf8")
172
+ .split("\n")
173
+ .filter((l) => l !== "");
174
+
175
+ test("every record carries a 0-based, gapless seq", async () => {
176
+ const log = await makeLog();
177
+ for (let i = 0; i < 5; i += 1) {
178
+ await log.append({ kind: "model_call", payload: i });
179
+ }
180
+ const seqs: number[] = [];
181
+ for await (const r of log.read()) seqs.push(r.seq);
182
+ expect(seqs).toEqual([0, 1, 2, 3, 4]);
183
+ });
184
+
185
+ test("the persisted chain-tail anchor records the last seq", async () => {
186
+ const log = await makeLog();
187
+ await log.append({ kind: "model_call", payload: "a" });
188
+ await log.append({ kind: "model_call", payload: "b" });
189
+ await log.append({ kind: "model_call", payload: "c" });
190
+ const anchor = JSON.parse(readFileSync(join(tmp, "_chain-tail.json"), "utf8"));
191
+ expect(anchor.seq).toBe(2);
192
+ expect(anchor.day).toBe(FIXED_DAY);
193
+ expect(anchor.hash).toMatch(/^[0-9a-f]{64}$/);
194
+ });
195
+
196
+ test("truncating the last record is detected via the tail anchor", async () => {
197
+ const log = await makeLog();
198
+ await log.append({ kind: "secrets_access", payload: "sensitive-1" });
199
+ await log.append({ kind: "secrets_access", payload: "sensitive-2" });
200
+ await log.append({ kind: "secrets_access", payload: "sensitive-3" });
201
+
202
+ // Attacker deletes the trailing record to erase their activity, but
203
+ // leaves _chain-tail.json untouched. The survivors still chain cleanly
204
+ // and their seq run (0,1) is gapless — only the independent anchor,
205
+ // which still commits to the dropped record, reveals the truncation.
206
+ const file = join(tmp, `${FIXED_DAY}.jsonl`);
207
+ const lines = readLines(file);
208
+ writeFileSync(file, `${lines.slice(0, -1).join("\n")}\n`);
209
+
210
+ const r = await verify(tmp);
211
+ expect(r.ok).toBe(false);
212
+ if (!r.ok) {
213
+ expect(r.reason).toMatch(/chain-tail anchor mismatch/);
214
+ expect(r.recordsChecked).toBe(2);
215
+ }
216
+ });
217
+
218
+ test("deleting the entire newest-day file is detected", async () => {
219
+ const log = await makeLog();
220
+ await log.append({ kind: "gateway_request", payload: 1 });
221
+ await log.append({ kind: "gateway_request", payload: 2 });
222
+
223
+ // rm the only day file but keep the anchor: the surviving chain is now
224
+ // empty (ends at GENESIS) yet the anchor still points at a real hash.
225
+ unlinkSync(join(tmp, `${FIXED_DAY}.jsonl`));
226
+
227
+ const r = await verify(tmp);
228
+ expect(r.ok).toBe(false);
229
+ if (!r.ok) expect(r.reason).toMatch(/chain-tail anchor mismatch/);
230
+ });
231
+
232
+ test("deleting a record from the middle is detected as a seq gap", async () => {
233
+ const log = await makeLog();
234
+ await log.append({ kind: "model_call", payload: 0 });
235
+ await log.append({ kind: "model_call", payload: 1 });
236
+ await log.append({ kind: "model_call", payload: 2 });
237
+
238
+ // Remove the middle line. prevHash linkage would also break, but the
239
+ // gapless-seq assertion fires first on the now-missing seq 1.
240
+ const file = join(tmp, `${FIXED_DAY}.jsonl`);
241
+ const lines = readLines(file);
242
+ writeFileSync(file, `${[lines[0], lines[2]].join("\n")}\n`);
243
+
244
+ const r = await verify(tmp);
245
+ expect(r.ok).toBe(false);
246
+ if (!r.ok) expect(r.reason).toMatch(/seq gap|prevHash mismatch/);
247
+ });
248
+
249
+ test("a missing anchor is reported as a limitation, not a clean pass", async () => {
250
+ const log = await makeLog();
251
+ await log.append({ kind: "model_call", payload: 1 });
252
+ await log.append({ kind: "model_call", payload: 2 });
253
+
254
+ // Without the independent anchor, tail-truncation cannot be ruled out:
255
+ // the surviving chain is internally consistent, so verify still reports
256
+ // ok, but flags anchorChecked=false so callers know it is not proof of
257
+ // completeness.
258
+ unlinkSync(join(tmp, "_chain-tail.json"));
259
+
260
+ const r = await verify(tmp);
261
+ expect(r.ok).toBe(true);
262
+ if (r.ok) {
263
+ expect(r.recordsChecked).toBe(2);
264
+ expect(r.anchorChecked).toBe(false);
265
+ }
266
+ });
267
+
268
+ test("a legit append after verify still chains and verifies", async () => {
269
+ const log = await makeLog();
270
+ await log.append({ kind: "model_call", payload: 1 });
271
+ const first = await verify(tmp);
272
+ expect(first.ok).toBe(true);
273
+
274
+ // Guard against over-blocking: continuing to append must keep seq
275
+ // monotone and the anchor in sync, so a fresh verify still passes.
276
+ await log.append({ kind: "model_call", payload: 2 });
277
+ await log.append({ kind: "model_call", payload: 3 });
278
+ const second = await verify(tmp);
279
+ expect(second.ok).toBe(true);
280
+ if (second.ok) {
281
+ expect(second.recordsChecked).toBe(3);
282
+ expect(second.anchorChecked).toBe(true);
283
+ }
284
+ });
133
285
  });
134
286
 
135
287
  describe("file mode (T8)", () => {
@@ -143,3 +295,324 @@ describe("file mode (T8)", () => {
143
295
  expect(mode).toBe(0o600);
144
296
  });
145
297
  });
298
+
299
+ describe("off-host anchor (#156-followup)", () => {
300
+ const readLines = (file: string): string[] =>
301
+ readFileSync(file, "utf8")
302
+ .split("\n")
303
+ .filter((l) => l !== "");
304
+
305
+ const makeLogWith = (store: AnchorStore): Promise<AuditLog> =>
306
+ openAuditLog({ rootDir: tmp, now: fixedNow(), day: () => FIXED_DAY, anchorStore: store });
307
+
308
+ // Simulate a same-uid attacker who not only truncates the JSONL but ALSO
309
+ // rewrites _chain-tail.json to commit to the new (shorter) survivors — so
310
+ // the on-host anchor check is defeated and only the off-host store can tell.
311
+ const truncateAndRewriteOnHostAnchor = (keep: number): void => {
312
+ const file = join(tmp, `${FIXED_DAY}.jsonl`);
313
+ const lines = readLines(file);
314
+ const survivors = lines.slice(0, keep);
315
+ writeFileSync(file, `${survivors.join("\n")}\n`);
316
+ const last = JSON.parse(survivors[survivors.length - 1] as string);
317
+ writeFileSync(
318
+ join(tmp, "_chain-tail.json"),
319
+ JSON.stringify({ day: FIXED_DAY, hash: last.hash, seq: last.seq }),
320
+ );
321
+ };
322
+
323
+ test("append best-effort mirrors the tail to the store", async () => {
324
+ const store = new InMemoryAnchorStore();
325
+ const log = await makeLogWith(store);
326
+ const a = await log.append({ kind: "model_call", payload: 1 });
327
+ const b = await log.append({ kind: "model_call", payload: 2 });
328
+
329
+ const anchor = await store.getAnchor(tmp);
330
+ expect(anchor).toEqual({ seq: b.seq, hash: b.hash });
331
+ // The mirrored hash is the live tail, not a stale earlier record.
332
+ expect(anchor?.hash).not.toBe(a.hash);
333
+ });
334
+
335
+ test("THREAT: external anchor ahead of a truncated+rewritten chain fails verify", async () => {
336
+ const store = new InMemoryAnchorStore();
337
+ const log = await makeLogWith(store);
338
+ await log.append({ kind: "secrets_access", payload: "sensitive-1" });
339
+ await log.append({ kind: "secrets_access", payload: "sensitive-2" });
340
+ await log.append({ kind: "secrets_access", payload: "sensitive-3" });
341
+
342
+ // The external store now witnesses seq 2. Attacker drops the last record
343
+ // AND rewrites the on-host anchor to match the survivors (seq 1).
344
+ truncateAndRewriteOnHostAnchor(2);
345
+
346
+ // On-host-only verify is fooled — the lockstep rewrite makes survivors +
347
+ // _chain-tail.json mutually consistent. This is the gap the follow-up closes.
348
+ const fooled = await verify(tmp);
349
+ expect(fooled.ok).toBe(true);
350
+ if (fooled.ok) expect(fooled.externalAnchorChecked).toBe(false);
351
+
352
+ // With the off-host anchor consulted, the dropped seq 2 is caught.
353
+ const caught = await verify(tmp, { anchorStore: store });
354
+ expect(caught.ok).toBe(false);
355
+ if (!caught.ok) {
356
+ expect(caught.reason).toMatch(/external anchor mismatch/);
357
+ expect(caught.reason).toMatch(/seq 2/);
358
+ expect(caught.recordsChecked).toBe(2);
359
+ }
360
+ });
361
+
362
+ test("external anchor catches a wholesale delete that also removed the on-host anchor", async () => {
363
+ const store = new InMemoryAnchorStore();
364
+ const log = await makeLogWith(store);
365
+ await log.append({ kind: "gateway_request", payload: 1 });
366
+ await log.append({ kind: "gateway_request", payload: 2 });
367
+
368
+ // Attacker rm's the day file AND the on-host anchor — nothing local is
369
+ // left to contradict an empty trail. The external anchor still witnesses seq 1.
370
+ unlinkSync(join(tmp, `${FIXED_DAY}.jsonl`));
371
+ unlinkSync(join(tmp, "_chain-tail.json"));
372
+
373
+ const r = await verify(tmp, { anchorStore: store });
374
+ expect(r.ok).toBe(false);
375
+ if (!r.ok) expect(r.reason).toMatch(/external anchor mismatch/);
376
+ });
377
+
378
+ test("external anchor catches a same-seq in-place tail rewrite", async () => {
379
+ const store = new InMemoryAnchorStore();
380
+ const log = await makeLogWith(store);
381
+ await log.append({ kind: "model_call", payload: "a" });
382
+ const tail = await log.append({ kind: "model_call", payload: "b" });
383
+
384
+ // Re-forge the tail record's payload and re-chain it so the local walk +
385
+ // on-host anchor accept it; the external anchor still pins the old hash.
386
+ const file = join(tmp, `${FIXED_DAY}.jsonl`);
387
+ const lines = readLines(file);
388
+ const forged = JSON.parse(lines[1] as string);
389
+ forged.payload = "forged";
390
+ // Recompute a valid hash for the forged body so the hash-chain walk passes.
391
+ const { createHash } = await import("node:crypto");
392
+ const body = {
393
+ ts: forged.ts,
394
+ version: forged.version,
395
+ kind: forged.kind,
396
+ seq: forged.seq,
397
+ payload: forged.payload,
398
+ };
399
+ forged.hash = createHash("sha256")
400
+ .update(forged.prevHash)
401
+ .update("|")
402
+ .update(JSON.stringify(body))
403
+ .digest("hex");
404
+ lines[1] = JSON.stringify(forged);
405
+ writeFileSync(file, `${lines.join("\n")}\n`);
406
+ // Rewrite on-host anchor to the forged tip (lockstep), same seq.
407
+ writeFileSync(
408
+ join(tmp, "_chain-tail.json"),
409
+ JSON.stringify({ day: FIXED_DAY, hash: forged.hash, seq: forged.seq }),
410
+ );
411
+
412
+ expect(forged.hash).not.toBe(tail.hash);
413
+ const r = await verify(tmp, { anchorStore: store });
414
+ expect(r.ok).toBe(false);
415
+ if (!r.ok) expect(r.reason).toMatch(/external anchor mismatch/);
416
+ });
417
+
418
+ test("NO-REGRESSION: a chain that legitimately extends past a lagging anchor verifies", async () => {
419
+ // Anchor lags behind newer appends (benign best-effort put lag): verify
420
+ // must NOT flag it, or normal operation would be over-blocked.
421
+ const store = new InMemoryAnchorStore();
422
+ const log = await makeLogWith(store);
423
+ await log.append({ kind: "model_call", payload: 1 });
424
+ await log.append({ kind: "model_call", payload: 2 });
425
+ // Snapshot the store as it was at seq 1, then append more "live" records.
426
+ const lagging = await store.getAnchor(tmp);
427
+ const stale = new InMemoryAnchorStore();
428
+ await stale.putAnchor(tmp, lagging as AnchorRecord);
429
+ await log.append({ kind: "model_call", payload: 3 });
430
+ await log.append({ kind: "model_call", payload: 4 });
431
+
432
+ const r = await verify(tmp, { anchorStore: stale });
433
+ expect(r.ok).toBe(true);
434
+ if (r.ok) {
435
+ expect(r.recordsChecked).toBe(4);
436
+ expect(r.externalAnchorChecked).toBe(true);
437
+ expect(r.anchorChecked).toBe(true);
438
+ }
439
+ });
440
+
441
+ test("clean chain with a matching store reports externalAnchorChecked=true", async () => {
442
+ const store = new InMemoryAnchorStore();
443
+ const log = await makeLogWith(store);
444
+ for (let i = 0; i < 4; i += 1) await log.append({ kind: "policy_decision", payload: i });
445
+ const r = await verify(tmp, { anchorStore: store });
446
+ expect(r.ok).toBe(true);
447
+ if (r.ok) {
448
+ expect(r.recordsChecked).toBe(4);
449
+ expect(r.externalAnchorChecked).toBe(true);
450
+ }
451
+ });
452
+
453
+ test("verify without a store leaves externalAnchorChecked=false (no-op default)", async () => {
454
+ const log = await makeLog();
455
+ await log.append({ kind: "model_call", payload: 1 });
456
+ const r = await verify(tmp);
457
+ expect(r.ok).toBe(true);
458
+ if (r.ok) {
459
+ expect(r.anchorChecked).toBe(true);
460
+ expect(r.externalAnchorChecked).toBe(false);
461
+ }
462
+ });
463
+
464
+ test("a store with no anchor yet is reported as not-checked, not a failure", async () => {
465
+ const log = await makeLog(); // opened WITHOUT a store, so nothing was published
466
+ await log.append({ kind: "model_call", payload: 1 });
467
+ const emptyStore = new InMemoryAnchorStore();
468
+ const r = await verify(tmp, { anchorStore: emptyStore });
469
+ expect(r.ok).toBe(true);
470
+ if (r.ok) expect(r.externalAnchorChecked).toBe(false);
471
+ });
472
+
473
+ test("a getAnchor that throws degrades to not-checked (never fabricates tamper)", async () => {
474
+ const log = await makeLog();
475
+ await log.append({ kind: "model_call", payload: 1 });
476
+ const flaky: AnchorStore = {
477
+ async putAnchor(): Promise<void> {},
478
+ async getAnchor(): Promise<AnchorRecord | undefined> {
479
+ throw new Error("anchor backend offline");
480
+ },
481
+ };
482
+ const r = await verify(tmp, { anchorStore: flaky });
483
+ expect(r.ok).toBe(true);
484
+ if (r.ok) expect(r.externalAnchorChecked).toBe(false);
485
+ });
486
+
487
+ test("a putAnchor that throws does not break the durable local append", async () => {
488
+ const flaky: AnchorStore = {
489
+ async putAnchor(): Promise<void> {
490
+ throw new Error("WORM bucket unreachable");
491
+ },
492
+ async getAnchor(): Promise<AnchorRecord | undefined> {
493
+ return undefined;
494
+ },
495
+ };
496
+ const log = await makeLogWith(flaky);
497
+ // The append must still succeed and the local chain remain verifiable.
498
+ const rec = await log.append({ kind: "model_call", payload: "durable" });
499
+ expect(rec.seq).toBe(0);
500
+ const r = await verify(tmp);
501
+ expect(r.ok).toBe(true);
502
+ if (r.ok) expect(r.recordsChecked).toBe(1);
503
+ });
504
+
505
+ test("InMemoryAnchorStore is monotonic — a stale put cannot regress the tip", async () => {
506
+ const store = new InMemoryAnchorStore();
507
+ await store.putAnchor("log-a", { seq: 5, hash: "h5" });
508
+ // A late/replayed lower-seq put is ignored so the witnessed tip never regresses.
509
+ await store.putAnchor("log-a", { seq: 2, hash: "h2" });
510
+ expect(await store.getAnchor("log-a")).toEqual({ seq: 5, hash: "h5" });
511
+ // Distinct logIds are independent.
512
+ expect(await store.getAnchor("log-b")).toBeUndefined();
513
+ });
514
+ });
515
+
516
+ describe("openAuditLog input validation", () => {
517
+ test("an empty rootDir throws AuditLogError", async () => {
518
+ await expect(openAuditLog({ rootDir: "" })).rejects.toBeInstanceOf(AuditLogError);
519
+ });
520
+
521
+ test("a non-string rootDir throws AuditLogError with the config code", async () => {
522
+ // The guard is `typeof rootDir !== "string" || rootDir === ""`; exercise the
523
+ // typeof branch (distinct from the empty-string branch above).
524
+ await expect(
525
+ // biome-ignore lint/suspicious/noExplicitAny: deliberately bad input for the guard
526
+ openAuditLog({ rootDir: undefined as any }),
527
+ ).rejects.toMatchObject({ name: "AuditLogError", code: "config" });
528
+ });
529
+ });
530
+
531
+ describe("default clock + day selector", () => {
532
+ test("openAuditLog works with the built-in Date.now()/today defaults", async () => {
533
+ // Opening WITHOUT `now`/`day` exercises the default `() => Date.now()`
534
+ // clock and the default `todayStr` day selector (UTC ISO date). We assert
535
+ // structural invariants rather than a fixed value so the test stays
536
+ // deterministic despite using the real clock.
537
+ const log = await openAuditLog({ rootDir: tmp });
538
+ const before = Date.now();
539
+ const rec = await log.append({ kind: "model_call", payload: { ok: true } });
540
+ const after = Date.now();
541
+ expect(rec.seq).toBe(0);
542
+ expect(rec.prevHash).toBe(GENESIS_HASH);
543
+ expect(rec.ts).toBeGreaterThanOrEqual(before);
544
+ expect(rec.ts).toBeLessThanOrEqual(after);
545
+
546
+ // The day file is named for today's UTC date (what `todayStr` returns).
547
+ const today = new Date().toISOString().slice(0, 10);
548
+ const lines = readFileSync(join(tmp, `${today}.jsonl`), "utf8")
549
+ .split("\n")
550
+ .filter((l) => l !== "");
551
+ expect(lines).toHaveLength(1);
552
+
553
+ // read() with the default day selector must round-trip the record back.
554
+ const out: unknown[] = [];
555
+ for await (const r of log.read()) out.push(r.payload);
556
+ expect(out).toEqual([{ ok: true }]);
557
+
558
+ const v = await verify(tmp);
559
+ expect(v.ok).toBe(true);
560
+ });
561
+ });
562
+
563
+ describe("read with an explicit day", () => {
564
+ test("read({ day }) targets that day's file and round-trips its records", async () => {
565
+ const log = await openAuditLog({ rootDir: tmp, now: fixedNow(), day: () => FIXED_DAY });
566
+ await log.append({ kind: "model_call", payload: "x" });
567
+ await log.append({ kind: "model_call", payload: "y" });
568
+
569
+ // Explicitly request the known day instead of relying on the default.
570
+ const out: unknown[] = [];
571
+ for await (const r of log.read({ day: FIXED_DAY })) out.push(r.payload);
572
+ expect(out).toEqual(["x", "y"]);
573
+ });
574
+
575
+ test("read({ day }) for a day with no file yields nothing", async () => {
576
+ const log = await openAuditLog({ rootDir: tmp, now: fixedNow(), day: () => FIXED_DAY });
577
+ await log.append({ kind: "model_call", payload: "x" });
578
+
579
+ const out: unknown[] = [];
580
+ for await (const r of log.read({ day: "1999-01-01" })) out.push(r.payload);
581
+ expect(out).toEqual([]);
582
+ });
583
+ });
584
+
585
+ describe("append evaluates the day selector once (midnight-boundary safety)", () => {
586
+ test("the JSONL filename and the chain-tail day stay consistent across a boundary", async () => {
587
+ // A `day` selector that flips on its SECOND call simulates an append that
588
+ // straddles midnight. The record file and the persisted anchor's `day`
589
+ // must agree (both the FIRST value), and the chain must still verify.
590
+ const days = ["2026-05-08", "2026-05-09"];
591
+ let calls = 0;
592
+ const flippingDay = (): string => {
593
+ const d = days[Math.min(calls, days.length - 1)] as string;
594
+ calls += 1;
595
+ return d;
596
+ };
597
+
598
+ const log = await openAuditLog({ rootDir: tmp, now: fixedNow(), day: flippingDay });
599
+ const rec = await log.append({ kind: "model_call", payload: "boundary" });
600
+
601
+ // The record landed in the FIRST day's file...
602
+ const firstDayLines = readFileSync(join(tmp, "2026-05-08.jsonl"), "utf8")
603
+ .split("\n")
604
+ .filter((l) => l !== "");
605
+ expect(firstDayLines).toHaveLength(1);
606
+
607
+ // ...and the anchor's `day` matches that same file (not the flipped value).
608
+ const anchor = JSON.parse(readFileSync(join(tmp, "_chain-tail.json"), "utf8"));
609
+ expect(anchor.day).toBe("2026-05-08");
610
+ expect(anchor.hash).toBe(rec.hash);
611
+
612
+ // No second-day file was created by the single append.
613
+ expect(() => readFileSync(join(tmp, "2026-05-09.jsonl"), "utf8")).toThrow();
614
+
615
+ // The single append consumed exactly one `day()` evaluation post-fix.
616
+ expect(calls).toBe(1);
617
+ });
618
+ });
package/src/index.ts CHANGED
@@ -7,14 +7,53 @@
7
7
  *
8
8
  * {
9
9
  * ts: <ms epoch>, version: 1, kind: "policy_decision" | …,
10
+ * seq: <0-based strictly-increasing per-log counter>,
10
11
  * payload: <opaque JSON>,
11
12
  * prevHash: <hex of prior line, or "GENESIS">,
12
- * hash: <SHA-256(prevHash || JSON.stringify({ts,version,kind,payload}))>
13
+ * hash: <SHA-256(prevHash || JSON.stringify({ts,version,kind,seq,payload}))>
13
14
  * }
14
15
  *
15
16
  * `verify(rootDir)` walks the chain and reports the first broken link
16
17
  * (line number + reason). The chain is per-day; the previous day's
17
- * tail seeds the next day's `prevHash` via a one-line index file.
18
+ * tail seeds the next day's `prevHash` AND `seq` via a one-line index
19
+ * file (`_chain-tail.json` = `{ day, hash, seq }`).
20
+ *
21
+ * Tail-truncation detection. A pure hash chain is only tamper-evident
22
+ * for *interior* edits: deleting a suffix of the newest day file (or
23
+ * `rm`-ing the whole newest day) leaves the survivors internally
24
+ * consistent, so a naive walk would still pass. Two commitments close
25
+ * that gap: (1) the per-record `seq` lets `verify` assert the chain is
26
+ * gapless from 0 — a hole proves records were removed from the middle;
27
+ * (2) `verify` reads the `_chain-tail.json` anchor and fails if the
28
+ * surviving chain's last `{ hash, seq }` does not match the recorded
29
+ * tail, which catches a truncation that drops trailing records.
30
+ *
31
+ * IMPORTANT — on-host files are NOT tamper-proof against a same-uid
32
+ * attacker. The 0o600 mode only blocks *other* users; the gateway's own
33
+ * uid (or anything that compromises it) can rewrite both the JSONL and
34
+ * `_chain-tail.json` in lockstep, defeating the on-host anchor check
35
+ * above. True non-repudiation requires an anchor the audit-writer cannot
36
+ * rewrite — an off-host / WORM bucket, a separate-privilege service, or
37
+ * periodic publication to an external transparency log. A *missing*
38
+ * on-host anchor is therefore reported by `verify` as a known limitation
39
+ * (`anchorChecked: false`), never silently treated as a pass.
40
+ *
41
+ * Off-host anchor hook (`AnchorStore`). To close the same-uid lockstep
42
+ * gap, `openAuditLog` and `verify` accept an OPTIONAL pluggable
43
+ * `AnchorStore` — `{ putAnchor(logId, {seq, hash}), getAnchor(logId) }`.
44
+ * On each `append`, the new tail `{ seq, hash }` is *best-effort* mirrored
45
+ * to the store (a put failure never blocks the durable local write). On
46
+ * `verify`, the external anchor is consulted IN ADDITION to
47
+ * `_chain-tail.json`: if it witnesses a `seq` the surviving chain no
48
+ * longer reaches — or a tip hash that disagrees at the anchored seq —
49
+ * verify FAILS, catching a tail truncation that ALSO rewrote the on-host
50
+ * `_chain-tail.json` in lockstep (the attacker cannot reach back into the
51
+ * external store). An anchor that merely lags behind newer appends (the
52
+ * benign best-effort case) is NOT treated as a failure. This package
53
+ * ships only an in-memory `InMemoryAnchorStore` reference implementation
54
+ * for tests/dev; PRODUCTION DEPLOYMENTS SHOULD SUPPLY A WORM-BACKED STORE
55
+ * (object-lock bucket, append-only/separate-privilege service, or
56
+ * transparency log) so the audit writer's own uid cannot rewrite it.
18
57
  *
19
58
  * Files are created with mode 0o600 (owner-only) so the audit trail
20
59
  * cannot be read by other users on the host. Append uses
@@ -59,17 +98,32 @@ export type AuditKind =
59
98
  // summary. The egress-classifier's `summarizeEgress(result)` produces
60
99
  // the human-readable form that lands in the `payload_summary` field.
61
100
  | "egress_decision"
62
- // Pillar 3 intent gate — permission-engine emits one event per
63
- // justification-evaluated tool call. Payload shape (opaque JSON):
64
- // { toolName, justification, verdict: "allow"|"deny", reason, judgeModel }
101
+ // Pillar 3 intent gate — `runtime-core`'s justification gate appends one
102
+ // record per justification-evaluated tool call (allow OR deny) when a
103
+ // durable sink is wired via `runChatLoop({ justificationAuditSink })`. The
104
+ // CLI `run`/browser paths open a real audit-log rooted at `.crewhaus/audit`
105
+ // and pass it (disable with `--no-justification-audit`); the ephemeral
106
+ // `permission_decision` trace-bus event mirrors the same verdict + judge
107
+ // identity for live observability. Payload shape (opaque JSON):
108
+ // { toolName, justification, verdict: "allow"|"deny", reason, judgeModel,
109
+ // confidence? }
65
110
  // Stored verbatim because the justification IS the audit artifact;
66
- // redacting it would defeat the purpose.
111
+ // redacting it would defeat the purpose. (`runtime-core` declares a minimal
112
+ // structural `JustificationAuditSink` rather than importing this package, to
113
+ // avoid a dependency cycle; this `AuditLog` satisfies that seam.)
67
114
  | "permission_justification_evaluated";
68
115
 
69
116
  export type AuditRecord = {
70
117
  readonly ts: number;
71
118
  readonly version: 1;
72
119
  readonly kind: AuditKind;
120
+ /**
121
+ * Strictly-increasing per-log sequence number, 0-based and gapless.
122
+ * Committed into `hash` so it cannot be rewritten without breaking the
123
+ * chain; `verify` asserts the run starts at 0 and has no holes, which
124
+ * surfaces records removed from the middle of the chain.
125
+ */
126
+ readonly seq: number;
73
127
  readonly payload: unknown;
74
128
  readonly prevHash: string;
75
129
  readonly hash: string;
@@ -84,10 +138,75 @@ export class AuditLogError extends CrewhausError {
84
138
  }
85
139
  }
86
140
 
141
+ /**
142
+ * Off-host anchor commitment for a single log: the latest `{ seq, hash }`
143
+ * the writer has published externally. Deliberately minimal so a
144
+ * deployment can back it with a WORM bucket, an append-only/separate-
145
+ * privilege service, or a transparency log.
146
+ */
147
+ export type AnchorRecord = { readonly seq: number; readonly hash: string };
148
+
149
+ /**
150
+ * Pluggable hook for mirroring the hash-chain tail to an anchor the audit
151
+ * writer's own uid CANNOT rewrite. `append` calls `putAnchor` best-effort
152
+ * after the local write; `verify` calls `getAnchor` to cross-check the
153
+ * surviving chain (see file header). `logId` namespaces multiple logs in
154
+ * one store — `openAuditLog` defaults it to the absolute `rootDir`.
155
+ *
156
+ * Implementations should make `putAnchor` monotonic (never regress to a
157
+ * lower `seq`) when the backing store permits; a stale/lagging anchor is
158
+ * benign for `verify`, but a regressed one weakens truncation detection.
159
+ * PRODUCTION SHOULD SUPPLY A WORM-BACKED IMPLEMENTATION — the bundled
160
+ * {@link InMemoryAnchorStore} is for tests/dev only and offers no
161
+ * tamper-resistance on its own.
162
+ */
163
+ export interface AnchorStore {
164
+ putAnchor(logId: string, anchor: AnchorRecord): Promise<void>;
165
+ getAnchor(logId: string): Promise<AnchorRecord | undefined>;
166
+ }
167
+
168
+ /**
169
+ * Reference {@link AnchorStore} that keeps the latest anchor per `logId`
170
+ * in process memory, monotonic on `seq`. Useful for tests and for wiring
171
+ * the `verify` cross-check in a single process; it provides NO durability
172
+ * or tamper-resistance and MUST NOT be relied on for non-repudiation in
173
+ * production — back the seam with a WORM store instead.
174
+ */
175
+ export class InMemoryAnchorStore implements AnchorStore {
176
+ private readonly anchors: Map<string, AnchorRecord>;
177
+
178
+ constructor() {
179
+ this.anchors = new Map<string, AnchorRecord>();
180
+ }
181
+
182
+ async putAnchor(logId: string, anchor: AnchorRecord): Promise<void> {
183
+ const existing = this.anchors.get(logId);
184
+ // Monotonic: never let a later put regress the committed tip backwards.
185
+ if (existing !== undefined && anchor.seq < existing.seq) return;
186
+ this.anchors.set(logId, { seq: anchor.seq, hash: anchor.hash });
187
+ }
188
+
189
+ async getAnchor(logId: string): Promise<AnchorRecord | undefined> {
190
+ return this.anchors.get(logId);
191
+ }
192
+ }
193
+
87
194
  export type OpenAuditLogOptions = {
88
195
  readonly rootDir: string;
89
196
  readonly now?: () => number;
90
197
  readonly day?: () => string;
198
+ /**
199
+ * Optional off-host anchor store. When supplied, every `append`
200
+ * best-effort mirrors the new tail `{ seq, hash }` to it (a put failure
201
+ * is swallowed so the durable local write is never blocked). Pass the
202
+ * same store + `logId` to {@link verify} to cross-check the chain.
203
+ */
204
+ readonly anchorStore?: AnchorStore;
205
+ /**
206
+ * Key under which this log's anchor is stored in `anchorStore`. Defaults
207
+ * to the absolute `rootDir`, letting one store hold many logs.
208
+ */
209
+ readonly logId?: string;
91
210
  };
92
211
 
93
212
  export interface AuditLog {
@@ -96,7 +215,7 @@ export interface AuditLog {
96
215
  }
97
216
 
98
217
  function hashBody(
99
- body: { ts: number; version: 1; kind: AuditKind; payload: unknown },
218
+ body: { ts: number; version: 1; kind: AuditKind; seq: number; payload: unknown },
100
219
  prevHash: string,
101
220
  ): string {
102
221
  return createHash("sha256")
@@ -106,6 +225,24 @@ function hashBody(
106
225
  .digest("hex");
107
226
  }
108
227
 
228
+ /**
229
+ * Recompute a record's hash from its own fields — the exact derivation
230
+ * `append` and `verify` use. Lets an external consumer (e.g.
231
+ * `@crewhaus/compliance-controls`) re-check a record's body↔hash consistency
232
+ * without re-reading the log files. Compare the result against `record.hash`:
233
+ * a mismatch means the body (payload/kind/ts/seq) was altered after signing.
234
+ */
235
+ export function recomputeRecordHash(record: AuditRecord): string {
236
+ const body = {
237
+ ts: record.ts,
238
+ version: record.version,
239
+ kind: record.kind,
240
+ seq: record.seq,
241
+ payload: record.payload,
242
+ };
243
+ return hashBody(body, record.prevHash);
244
+ }
245
+
109
246
  function todayStr(): string {
110
247
  return new Date().toISOString().slice(0, 10);
111
248
  }
@@ -114,7 +251,7 @@ function indexPath(rootDir: string): string {
114
251
  return join(rootDir, "_chain-tail.json");
115
252
  }
116
253
 
117
- type ChainTail = { readonly day: string; readonly hash: string };
254
+ type ChainTail = { readonly day: string; readonly hash: string; readonly seq: number };
118
255
 
119
256
  function readChainTail(rootDir: string): ChainTail | undefined {
120
257
  const p = indexPath(rootDir);
@@ -122,9 +259,9 @@ function readChainTail(rootDir: string): ChainTail | undefined {
122
259
  return JSON.parse(readFileSync(p, "utf8")) as ChainTail;
123
260
  }
124
261
 
125
- function writeChainTail(rootDir: string, day: string, hash: string): void {
262
+ function writeChainTail(rootDir: string, day: string, hash: string, seq: number): void {
126
263
  const p = indexPath(rootDir);
127
- writeFileSync(p, JSON.stringify({ day, hash }), { mode: 0o600 });
264
+ writeFileSync(p, JSON.stringify({ day, hash, seq }), { mode: 0o600 });
128
265
  }
129
266
 
130
267
  export async function openAuditLog(opts: OpenAuditLogOptions): Promise<AuditLog> {
@@ -134,17 +271,46 @@ export async function openAuditLog(opts: OpenAuditLogOptions): Promise<AuditLog>
134
271
  mkdirSync(opts.rootDir, { recursive: true, mode: 0o700 });
135
272
  const now = opts.now ?? ((): number => Date.now());
136
273
  const day = opts.day ?? todayStr;
274
+ const anchorStore = opts.anchorStore;
275
+ const logId = opts.logId ?? opts.rootDir;
137
276
 
138
277
  return {
139
278
  async append(input: AppendInput): Promise<AuditRecord> {
140
279
  const tail = readChainTail(opts.rootDir);
141
280
  const prevHash = tail?.hash ?? GENESIS_HASH;
142
- const body = { ts: now(), version: 1 as const, kind: input.kind, payload: input.payload };
281
+ // First record gets seq 0; thereafter strictly increment the tail's
282
+ // seq. A pre-`seq` anchor (`seq` absent) is treated as -1 so the next
283
+ // record starts the gapless run at 0.
284
+ const seq = (tail?.seq ?? -1) + 1;
285
+ const body = {
286
+ ts: now(),
287
+ version: 1 as const,
288
+ kind: input.kind,
289
+ seq,
290
+ payload: input.payload,
291
+ };
143
292
  const hash = hashBody(body, prevHash);
144
293
  const record: AuditRecord = { ...body, prevHash, hash };
145
- const file = join(opts.rootDir, `${day()}.jsonl`);
294
+ // Evaluate the day selector exactly once: a second call could straddle
295
+ // a midnight boundary (with the default UTC-date `day`), writing the
296
+ // record to one day's file while committing the tail under the next
297
+ // day — leaving the JSONL filename and the anchor's `day` inconsistent.
298
+ const today = day();
299
+ const file = join(opts.rootDir, `${today}.jsonl`);
146
300
  appendFileSync(file, `${JSON.stringify(record)}\n`, { mode: 0o600 });
147
- writeChainTail(opts.rootDir, day(), hash);
301
+ writeChainTail(opts.rootDir, today, hash, seq);
302
+ // Best-effort: mirror the new tail to the off-host anchor. A failure
303
+ // here (network/WORM hiccup) must NOT fail the durable local append —
304
+ // the chain remains internally verifiable, and a lagging external
305
+ // anchor is benign for `verify` (it only ever flags an anchor that is
306
+ // AHEAD of a truncated chain, never one that trails).
307
+ if (anchorStore !== undefined) {
308
+ try {
309
+ await anchorStore.putAnchor(logId, { seq, hash });
310
+ } catch {
311
+ // Swallowed by design — see comment above.
312
+ }
313
+ }
148
314
  return record;
149
315
  },
150
316
  read(readOpts: { day?: string } = {}): AsyncIterable<AuditRecord> {
@@ -178,7 +344,30 @@ async function* readDay(rootDir: string, day: string): AsyncIterable<AuditRecord
178
344
  }
179
345
 
180
346
  export type VerifyResult =
181
- | { readonly ok: true; readonly recordsChecked: number }
347
+ | {
348
+ readonly ok: true;
349
+ readonly recordsChecked: number;
350
+ /**
351
+ * Whether the surviving chain was matched against the independent
352
+ * on-host `_chain-tail.json` anchor. `false` means the anchor was
353
+ * absent, so tail-truncation could NOT be ruled out — `ok: true` here
354
+ * attests only that the survivors are internally consistent and
355
+ * gapless from 0, not that nothing was dropped off the end. Callers
356
+ * that need non-repudiation must treat `ok && !anchorChecked` as a
357
+ * limitation, not a clean bill of health. (And see the file header:
358
+ * even a present on-host anchor is rewritable by a same-uid attacker.)
359
+ */
360
+ readonly anchorChecked: boolean;
361
+ /**
362
+ * Whether an off-host {@link AnchorStore} was supplied AND held an
363
+ * anchor that was cross-checked against the surviving chain. `false`
364
+ * means no store was passed, or it had no anchor yet — so a same-uid
365
+ * lockstep rewrite of the on-host anchor could NOT be ruled out. Only
366
+ * `ok && externalAnchorChecked` attests that an anchor the writer
367
+ * cannot rewrite agreed with the chain tip.
368
+ */
369
+ readonly externalAnchorChecked: boolean;
370
+ }
182
371
  | {
183
372
  readonly ok: false;
184
373
  readonly recordsChecked: number;
@@ -187,19 +376,69 @@ export type VerifyResult =
187
376
  readonly reason: string;
188
377
  };
189
378
 
379
+ /**
380
+ * Optional cross-check inputs for {@link verify}. Supply the same
381
+ * `anchorStore` (and matching `logId`) used when the log was opened to
382
+ * have `verify` consult the off-host anchor in addition to the on-host
383
+ * `_chain-tail.json`. Both are optional and back-compatible — calling
384
+ * `verify(rootDir)` keeps the original on-host-only behaviour.
385
+ */
386
+ export type VerifyOptions = {
387
+ readonly anchorStore?: AnchorStore;
388
+ readonly logId?: string;
389
+ };
390
+
190
391
  /**
191
392
  * Walk every `<rootDir>/*.jsonl` chain, verifying each record's
192
- * `hash` against `SHA-256(prevHash || canonical-body)` and that
193
- * `prevHash` matches the previous record's `hash`. Returns the first
194
- * broken link (file + line number + reason) or `{ ok: true }` if the
195
- * full chain is intact.
393
+ * `hash` against `SHA-256(prevHash || canonical-body)`, that `prevHash`
394
+ * matches the previous record's `hash`, and that `seq` is gapless from
395
+ * 0. After the walk, the surviving chain's last `{ hash, seq }` is
396
+ * matched against the independent on-host `_chain-tail.json` anchor so a
397
+ * dropped suffix (tail truncation / newest-day deletion) is caught.
398
+ *
399
+ * If a `{ anchorStore }` is supplied (see {@link VerifyOptions}), the
400
+ * off-host anchor is consulted IN ADDITION to `_chain-tail.json`: when it
401
+ * witnesses a `seq` the surviving chain no longer reaches — or a tip hash
402
+ * that disagrees at the anchored seq — verify FAILS. This catches a tail
403
+ * truncation that ALSO rewrote the on-host anchor in lockstep, since a
404
+ * same-uid attacker cannot reach into the external (ideally WORM) store.
405
+ * An external anchor that merely lags BEHIND newer appends (the benign
406
+ * best-effort case) is not a failure.
407
+ *
408
+ * Returns the first broken link (file + line number + reason), or
409
+ * `{ ok: true }` if the chain is intact. On success, `anchorChecked`
410
+ * reports whether the on-host tail anchor was present and matched, and
411
+ * `externalAnchorChecked` whether an off-host anchor was supplied and
412
+ * cross-checked; `false` on either means that gap could not be ruled out
413
+ * (a limitation, not a pass — see the file header on same-uid tamper).
196
414
  */
197
- export async function verify(rootDir: string): Promise<VerifyResult> {
198
- if (!existsSync(rootDir)) return { ok: true, recordsChecked: 0 };
415
+ export async function verify(rootDir: string, options: VerifyOptions = {}): Promise<VerifyResult> {
416
+ if (!existsSync(rootDir)) {
417
+ // No local chain at all. Even so, an external anchor may witness records
418
+ // that a wholesale deletion (rootDir rm'd) destroyed — cross-check it
419
+ // against the empty (GENESIS-tipped, seq -1) chain before passing.
420
+ const empty = await crossCheckExternalAnchor(rootDir, options, GENESIS_HASH, -1);
421
+ if (empty !== undefined && !empty.ok) {
422
+ return {
423
+ ok: false,
424
+ recordsChecked: 0,
425
+ file: empty.file,
426
+ line: empty.line,
427
+ reason: empty.reason,
428
+ };
429
+ }
430
+ return {
431
+ ok: true,
432
+ recordsChecked: 0,
433
+ anchorChecked: false,
434
+ externalAnchorChecked: empty?.externalAnchorChecked ?? false,
435
+ };
436
+ }
199
437
  const files = readdirSync(rootDir)
200
438
  .filter((f) => f.endsWith(".jsonl"))
201
439
  .sort();
202
440
  let prevHash = GENESIS_HASH;
441
+ let expectedSeq = 0;
203
442
  let recordsChecked = 0;
204
443
  for (const f of files) {
205
444
  const file = join(rootDir, f);
@@ -235,7 +474,18 @@ export async function verify(rootDir: string): Promise<VerifyResult> {
235
474
  reason: `prevHash mismatch — expected "${prevHash}", got "${r.prevHash}"`,
236
475
  };
237
476
  }
238
- const body = { ts: r.ts, version: r.version, kind: r.kind, payload: r.payload };
477
+ if (r.seq !== expectedSeq) {
478
+ stream.close();
479
+ rl.close();
480
+ return {
481
+ ok: false,
482
+ recordsChecked,
483
+ file,
484
+ line: lineNumber,
485
+ reason: `seq gap — expected ${expectedSeq}, got ${r.seq}`,
486
+ };
487
+ }
488
+ const body = { ts: r.ts, version: r.version, kind: r.kind, seq: r.seq, payload: r.payload };
239
489
  const expected = hashBody(body, r.prevHash);
240
490
  if (r.hash !== expected) {
241
491
  stream.close();
@@ -249,6 +499,7 @@ export async function verify(rootDir: string): Promise<VerifyResult> {
249
499
  };
250
500
  }
251
501
  prevHash = r.hash;
502
+ expectedSeq = r.seq + 1;
252
503
  recordsChecked += 1;
253
504
  }
254
505
  } finally {
@@ -256,5 +507,143 @@ export async function verify(rootDir: string): Promise<VerifyResult> {
256
507
  stream.close();
257
508
  }
258
509
  }
259
- return { ok: true, recordsChecked };
510
+
511
+ const lastSeq = expectedSeq - 1;
512
+
513
+ // Off-host anchor check FIRST: the external store is the only commitment a
514
+ // same-uid attacker cannot rewrite, so it is what catches a truncation that
515
+ // also rewrote `_chain-tail.json` in lockstep. (See file header.)
516
+ const external = await crossCheckExternalAnchor(rootDir, options, prevHash, lastSeq);
517
+ if (external !== undefined && !external.ok) {
518
+ return {
519
+ ok: false,
520
+ recordsChecked,
521
+ file: external.file,
522
+ line: external.line,
523
+ reason: external.reason,
524
+ };
525
+ }
526
+ const externalAnchorChecked = external?.externalAnchorChecked ?? false;
527
+
528
+ // On-host anchor check: compare the surviving chain's last { hash, seq }
529
+ // against the independent _chain-tail.json. A truncation that drops trailing
530
+ // records leaves the survivors internally consistent, so this anchor — not
531
+ // the walk above — is what catches it. A missing anchor is reported
532
+ // (anchorChecked: false), never silently treated as a clean pass. NOTE: an
533
+ // on-host anchor is rewritable by a same-uid attacker (see header); it
534
+ // defends only against truncation by a party that does NOT also rewrite the
535
+ // anchor in lockstep — which is exactly what the external anchor above adds.
536
+ // A corrupt/partially-written `_chain-tail.json` (truncated by a crash, or
537
+ // garbled by an attacker) must be REPORTED as a broken link, not crash the
538
+ // verifier with an unhandled JSON.parse throw — mirroring how a malformed
539
+ // JSONL line is surfaced above. Throwing here would let a one-byte anchor
540
+ // corruption turn `verify` into a denial of service.
541
+ let tail: ChainTail | undefined;
542
+ try {
543
+ tail = readChainTail(rootDir);
544
+ } catch (err) {
545
+ return {
546
+ ok: false,
547
+ recordsChecked,
548
+ file: indexPath(rootDir),
549
+ line: 0,
550
+ reason: `chain-tail anchor unreadable — ${(err as Error).message}`,
551
+ };
552
+ }
553
+ if (tail === undefined) {
554
+ return { ok: true, recordsChecked, anchorChecked: false, externalAnchorChecked };
555
+ }
556
+ if (tail.hash !== prevHash) {
557
+ return {
558
+ ok: false,
559
+ recordsChecked,
560
+ file: indexPath(rootDir),
561
+ line: 0,
562
+ reason:
563
+ `chain-tail anchor mismatch — anchor records hash "${tail.hash}" ` +
564
+ `but surviving chain ends at "${prevHash}" (records were dropped from the tail)`,
565
+ };
566
+ }
567
+ // The seq field is optional in legacy anchors; only assert when present.
568
+ if (typeof tail.seq === "number" && tail.seq !== lastSeq) {
569
+ return {
570
+ ok: false,
571
+ recordsChecked,
572
+ file: indexPath(rootDir),
573
+ line: 0,
574
+ reason:
575
+ `chain-tail anchor mismatch — anchor records seq ${tail.seq} ` +
576
+ `but surviving chain ends at seq ${lastSeq} (records were dropped from the tail)`,
577
+ };
578
+ }
579
+ return { ok: true, recordsChecked, anchorChecked: true, externalAnchorChecked };
580
+ }
581
+
582
+ /**
583
+ * Cross-check the surviving chain tip (`tipHash` at `lastSeq`; an empty
584
+ * chain is `GENESIS_HASH` at `-1`) against an off-host {@link AnchorStore}.
585
+ *
586
+ * Threat model — the store witnesses the highest tail the writer published.
587
+ * - external `seq > lastSeq` → the chain no longer reaches a record the
588
+ * anchor saw ⇒ tail truncation (even if `_chain-tail.json` was rewritten
589
+ * in lockstep, which the attacker cannot do to the external store) → FAIL.
590
+ * - external `seq === lastSeq` but `hash !== tipHash` → the tip was rewritten
591
+ * in place at the anchored height → FAIL.
592
+ * - external `seq < lastSeq` → the anchor merely lags behind newer appends
593
+ * (benign best-effort `putAnchor` lag) → pass.
594
+ *
595
+ * Returns `undefined` when no store was supplied; otherwise a partial result
596
+ * carrying `externalAnchorChecked` (true once an anchor was actually read and
597
+ * compared) plus, on mismatch, the failure fields. A `getAnchor` that throws
598
+ * is treated as "anchor unavailable" (not consulted), not as tamper.
599
+ */
600
+ type ExternalAnchorCheck =
601
+ | { readonly ok: true; readonly externalAnchorChecked: boolean }
602
+ | {
603
+ readonly ok: false;
604
+ readonly file: string;
605
+ readonly line: number;
606
+ readonly reason: string;
607
+ readonly externalAnchorChecked: boolean;
608
+ };
609
+
610
+ async function crossCheckExternalAnchor(
611
+ rootDir: string,
612
+ options: VerifyOptions,
613
+ tipHash: string,
614
+ lastSeq: number,
615
+ ): Promise<ExternalAnchorCheck | undefined> {
616
+ const store = options.anchorStore;
617
+ if (store === undefined) return undefined;
618
+ const logId = options.logId ?? rootDir;
619
+ let anchor: AnchorRecord | undefined;
620
+ try {
621
+ anchor = await store.getAnchor(logId);
622
+ } catch {
623
+ // Anchor backend unavailable: cannot rule the truncation gap in OR out,
624
+ // so report "not checked" rather than fabricating a pass or a failure.
625
+ return { ok: true, externalAnchorChecked: false };
626
+ }
627
+ if (anchor === undefined) return { ok: true, externalAnchorChecked: false };
628
+
629
+ if (anchor.seq > lastSeq) {
630
+ return {
631
+ ok: false,
632
+ file: "<external-anchor>",
633
+ line: 0,
634
+ reason: `external anchor mismatch — store witnesses seq ${anchor.seq} (hash "${anchor.hash}") but surviving chain ends at seq ${lastSeq} (records were dropped from the tail and the on-host anchor rewritten in lockstep)`,
635
+ externalAnchorChecked: true,
636
+ };
637
+ }
638
+ if (anchor.seq === lastSeq && anchor.hash !== tipHash) {
639
+ return {
640
+ ok: false,
641
+ file: "<external-anchor>",
642
+ line: 0,
643
+ reason: `external anchor mismatch — store records hash "${anchor.hash}" at seq ${anchor.seq} but surviving chain tip is "${tipHash}" (the tail record was rewritten in place)`,
644
+ externalAnchorChecked: true,
645
+ };
646
+ }
647
+ // anchor.seq < lastSeq → benign lag; or matched tip → consistent.
648
+ return { ok: true, externalAnchorChecked: true };
260
649
  }