@cyd-prc/dsh-audit-chain 0.1.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.
@@ -0,0 +1,478 @@
1
+ /**
2
+ * Behavioural tests for the shared audit chain.
3
+ *
4
+ * The suite is the union of the anchors that pinned the two implementations this
5
+ * package was extracted from: entropy-guard's defects 14/15/16/19/20 and
6
+ * shape-guard's I7–I13 / S1–S4, rewritten against `ChainLog` so the shared
7
+ * implementation cannot drift from either history. The chain-grading contract
8
+ * (docs/CHAIN-GRADING-CONTRACT.md) is the prose; this file is the recomputable
9
+ * definition.
10
+ *
11
+ * Run: node --test test/core.test.mjs
12
+ * (pass the file: the directory form `node --test test/` reports nothing on
13
+ * Node 24, which has twice been mistaken for a broken suite)
14
+ */
15
+
16
+ import assert from 'node:assert/strict';
17
+ import { spawnSync } from 'node:child_process';
18
+ import { createHash } from 'node:crypto';
19
+ import { appendFileSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
20
+ import { tmpdir } from 'node:os';
21
+ import { dirname, join } from 'node:path';
22
+ import { fileURLToPath } from 'node:url';
23
+ import { describe, it } from 'node:test';
24
+ import { gzipSync } from 'node:zlib';
25
+
26
+ import {
27
+ ChainLog,
28
+ auditReplacer,
29
+ chainStem,
30
+ encodeEntry,
31
+ hashEntry,
32
+ resolveChainDir,
33
+ verifyChain,
34
+ } from '../index.js';
35
+ import {
36
+ jsonEquivalent,
37
+ looksLikeText,
38
+ matchesReference,
39
+ readTarGz,
40
+ sameTextModuloLineEndings,
41
+ scanShippedReferences,
42
+ treeHeadOf,
43
+ } from '../tools/verify-release.mjs';
44
+
45
+ /** The repository root, for driving the shipped tools in a subprocess. */
46
+ const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
47
+
48
+ /** A hand-built entry with a self-consistent hash, for branch/tamper fixtures. */
49
+ function branchEntry(seq, prev, fields = {}) {
50
+ const base = { ts: 1, seq, ...fields, kind: fields.kind ?? 'gate_decision', prev };
51
+ return { ...base, hash: hashEntry(base) };
52
+ }
53
+
54
+ describe('writing', () => {
55
+ it('chains entries with monotonic seqs and hash links', () => {
56
+ const audit = new ChainLog();
57
+ const first = audit.record('gate_decision', { admitted: true });
58
+ const second = audit.record('gate_decision', { admitted: false });
59
+ assert.equal(first.seq, 0);
60
+ assert.equal(first.prev, null);
61
+ assert.equal(second.seq, 1);
62
+ assert.equal(second.prev, first.hash);
63
+ assert.match(first.hash, /^[0-9a-f]{64}$/);
64
+ });
65
+
66
+ it('sanitizes non-finite numbers so the chain stays strict JSON', () => {
67
+ const audit = new ChainLog();
68
+ const entry = audit.record('gate_decision', { utility: Number.NaN, nested: { v: Number.POSITIVE_INFINITY } });
69
+ assert.equal(entry.utility, null);
70
+ assert.equal(entry.utility_nonfinite, 'nan');
71
+ assert.equal(entry.nested.v, '+inf');
72
+ assert.doesNotThrow(() => JSON.parse(JSON.stringify(entry)));
73
+ });
74
+
75
+ it('degrades unknown values to their string form and survives cycles', () => {
76
+ const audit = new ChainLog();
77
+ const cyclic = { name: 'x' };
78
+ cyclic.self = cyclic;
79
+ const entry = audit.record('probe', { cyclic, fn: () => 1, big: 10n });
80
+ assert.equal(entry.cyclic.self, '<truncated:depth-or-cycle>');
81
+ assert.equal(entry.fn, '() => 1');
82
+ assert.equal(entry.big, '10');
83
+ });
84
+
85
+ it('returns deep copies so a caller cannot mutate the ledger', () => {
86
+ const audit = new ChainLog();
87
+ const returned = audit.record('probe', { nested: { a: 1 } });
88
+ returned.nested.a = 99;
89
+ assert.equal(audit.entries[0].nested.a, 1);
90
+ });
91
+
92
+ it('continues an existing on-disk chain instead of restarting it', () => {
93
+ const dir = mkdtempSync(join(tmpdir(), 'chain-continue-'));
94
+ const path = join(dir, 'chain.jsonl');
95
+ try {
96
+ const first = new ChainLog(path);
97
+ const a = first.record('one', {});
98
+ const b = first.record('two', {});
99
+ const reopened = new ChainLog(path);
100
+ const c = reopened.record('three', {});
101
+ assert.equal(a.prev, null);
102
+ assert.equal(c.seq, 2);
103
+ assert.equal(c.prev, b.hash);
104
+ assert.equal(reopened.verify().chained, 3);
105
+ } finally {
106
+ rmSync(dir, { recursive: true, force: true });
107
+ }
108
+ });
109
+
110
+ it('serialises two writers on one chain file: no duplicate seqs (defect 14)', () => {
111
+ const dir = mkdtempSync(join(tmpdir(), 'chain-lock-'));
112
+ const path = join(dir, 'chain.jsonl');
113
+ try {
114
+ const first = new ChainLog(path);
115
+ const second = new ChainLog(path);
116
+ first.record('a', {});
117
+ second.record('b', {});
118
+ first.record('c', {});
119
+ // A writer that cached the tail once per instance resumed from a stale
120
+ // tail here and wrote a duplicate seq 1.
121
+ const seqs = readFileSync(path, 'utf8').trim().split('\n').map((line) => JSON.parse(line).seq);
122
+ assert.deepEqual(seqs, [0, 1, 2]);
123
+ const reading = new ChainLog(path).verify();
124
+ assert.equal(reading.status, 'verified');
125
+ assert.equal(reading.forks, 0);
126
+ } finally {
127
+ rmSync(dir, { recursive: true, force: true });
128
+ }
129
+ });
130
+
131
+ it('terminates a crash-torn tail line before appending (defect 19)', () => {
132
+ const dir = mkdtempSync(join(tmpdir(), 'chain-torn-'));
133
+ const path = join(dir, 'chain.jsonl');
134
+ try {
135
+ const audit = new ChainLog(path);
136
+ audit.record('a', {});
137
+ // A crash mid-write leaves a tail line with no trailing newline.
138
+ writeFileSync(path, `${readFileSync(path, 'utf8')}{"ts":1,"kind":"gate_dec`, 'utf8');
139
+ audit.record('b', {});
140
+ // Without termination the append fuses into the torn line: the entry is
141
+ // lost and the line count does not even grow.
142
+ const lines = readFileSync(path, 'utf8').split('\n').filter((line) => line.trim().length > 0);
143
+ assert.equal(lines.length, 3, 'torn line terminated, new entry on its own line');
144
+ const last = JSON.parse(lines[2]);
145
+ assert.equal(last.kind, 'b');
146
+ assert.equal(last.seq, 1, 'the chain continues from the last good entry');
147
+ const reading = new ChainLog(path).verify();
148
+ assert.equal(reading.status, 'verified');
149
+ assert.equal(reading.chained, 2);
150
+ assert.equal(reading.corrupt, 1, 'the torn line is reported, not silently absorbed');
151
+ assert.match(reading.reason, /unparsable/, 'clause 8: the reason names the corrupt line');
152
+ // Sealing a torn-tail chain writes a parseable entry (the M4 shape).
153
+ const before = audit.seal('after a torn tail');
154
+ assert.equal(before.corrupt, 1);
155
+ const afterSeal = readFileSync(path, 'utf8').split('\n').filter((line) => line.trim().length > 0);
156
+ assert.equal(JSON.parse(afterSeal.at(-1)).kind, 'chain_seal');
157
+ } finally {
158
+ rmSync(dir, { recursive: true, force: true });
159
+ }
160
+ });
161
+ });
162
+
163
+ describe('grading (the contract, pinned)', () => {
164
+ it('verifies an untouched chain', () => {
165
+ const audit = new ChainLog();
166
+ audit.record('a', {});
167
+ audit.record('b', {});
168
+ const reading = audit.verify();
169
+ assert.equal(reading.status, 'verified');
170
+ assert.equal(reading.ok, true);
171
+ assert.equal(reading.chained, 2);
172
+ assert.equal(reading.brokenAt, null);
173
+ });
174
+
175
+ it('detects an edited entry', () => {
176
+ const audit = new ChainLog();
177
+ audit.record('a', {});
178
+ audit.record('b', {});
179
+ audit._buffer[0].admitted = false;
180
+ const reading = audit.verify();
181
+ assert.equal(reading.status, 'tampered');
182
+ assert.equal(reading.brokenAt, 0);
183
+ assert.match(reading.reason, /modified/);
184
+ });
185
+
186
+ it('detects a dropped entry twice: the link dangles and the sequence gaps', () => {
187
+ const audit = new ChainLog();
188
+ audit.record('a', {});
189
+ audit.record('b', {});
190
+ audit.record('c', {});
191
+ audit._buffer.splice(1, 1);
192
+ const reading = audit.verify();
193
+ assert.equal(reading.status, 'tampered');
194
+ assert.match(reading.reason, /names a hash no entry carries/);
195
+ });
196
+
197
+ it('grades a repeated seq as a fork between writers, not as a rewrite', () => {
198
+ const audit = new ChainLog();
199
+ audit.record('a', {});
200
+ audit.record('b', {});
201
+ // A second writer that loaded the same tail: a valid entry, bytes intact,
202
+ // hash correct — which is exactly why a fork is not a rewrite.
203
+ audit._buffer.push({ ...audit._buffer[1] });
204
+ const reading = audit.verify();
205
+ assert.equal(reading.status, 'forked');
206
+ assert.equal(reading.forks, 1);
207
+ assert.equal(reading.tampered, false);
208
+ assert.match(reading.reason, /duplicate seq 1/);
209
+ });
210
+
211
+ it('counts every fork instead of stopping at the first (S2/M2)', () => {
212
+ const audit = new ChainLog();
213
+ audit.record('a', {});
214
+ audit.record('b', {});
215
+ audit.record('c', {});
216
+ audit._buffer.push({ ...audit._buffer[1] });
217
+ audit._buffer.push({ ...audit._buffer[2] });
218
+ const reading = audit.verify();
219
+ assert.equal(reading.status, 'forked');
220
+ assert.equal(reading.forks, 2, 'a walk that stops at the first fork undercounts');
221
+ assert.equal(reading.liveForks, 2);
222
+ });
223
+
224
+ it('keeps checking links after a fork: a deleted branch entry is a rewrite', () => {
225
+ const a0 = branchEntry(0, null);
226
+ const a1 = branchEntry(1, a0.hash);
227
+ const b1 = branchEntry(1, a0.hash, { tool: 'writer-2' });
228
+ const b2 = branchEntry(2, b1.hash, { tool: 'writer-2' });
229
+ const audit = new ChainLog();
230
+ audit._buffer.push(a0, a1, b1, b2);
231
+ assert.equal(audit.verify().status, 'forked', 'control: the branch itself is a fork, not a rewrite');
232
+
233
+ audit._buffer.splice(2, 1);
234
+ const reading = audit.verify();
235
+ assert.equal(reading.status, 'tampered', 'a deletion after the fork must still be caught');
236
+ assert.match(reading.reason, /names a hash no entry carries/);
237
+ });
238
+
239
+ it('seals acknowledged history without dropping it', () => {
240
+ const audit = new ChainLog();
241
+ audit.record('a', {});
242
+ audit.record('b', {});
243
+ audit._buffer.push({ ...audit._buffer[1] });
244
+ assert.equal(audit.verify().status, 'forked');
245
+ const before = audit.seal('acknowledged a test fork');
246
+ assert.equal(before.status, 'forked');
247
+ const sealed = audit.verify();
248
+ assert.equal(sealed.status, 'verified', 'the acknowledged fork stops colouring the reading');
249
+ assert.equal(sealed.forks, 1, 'history is counted, not dropped');
250
+ assert.equal(sealed.liveForks, 0);
251
+ assert.equal(sealed.sealed.forks, 1);
252
+ assert.match(sealed.reason, /sealed as history/);
253
+ });
254
+
255
+ it('never seals a tamper away', () => {
256
+ const audit = new ChainLog();
257
+ audit.record('a', {});
258
+ audit.record('b', {});
259
+ audit.seal('clean at sealing time');
260
+ audit._buffer[0].name = 'rewritten';
261
+ const reading = audit.verify();
262
+ assert.equal(reading.status, 'tampered');
263
+ assert.equal(reading.ok, false);
264
+ });
265
+
266
+ it('still reports a fork on a live seq written after the seal', () => {
267
+ const audit = new ChainLog();
268
+ audit.record('a', {});
269
+ audit.seal('');
270
+ audit.record('b', {});
271
+ audit._buffer.push({ ...audit._buffer[2] });
272
+ const reading = audit.verify();
273
+ assert.equal(reading.status, 'forked');
274
+ assert.equal(reading.liveForks, 1);
275
+ });
276
+
277
+ it('keeps a sealed seq sealed even when the branch entry is written after the seal (contract §3)', () => {
278
+ const audit = new ChainLog();
279
+ audit.record('a', {});
280
+ audit.record('b', {});
281
+ audit._buffer.push({ ...audit._buffer[1] });
282
+ audit.seal('acknowledged the fork');
283
+ // A stale writer wakes up *after* the seal and appends another copy of the
284
+ // seq the seal already covers: acknowledged history, not a new finding.
285
+ audit._buffer.push({ ...audit._buffer[1] });
286
+ const reading = audit.verify();
287
+ assert.equal(reading.status, 'verified');
288
+ assert.equal(reading.forks, 2);
289
+ assert.equal(reading.liveForks, 0);
290
+ });
291
+
292
+ it('reports a sequence gap inside sealed history without grading it', () => {
293
+ const a0 = branchEntry(0, null);
294
+ const a1 = branchEntry(1, a0.hash);
295
+ const a5 = branchEntry(5, a1.hash);
296
+ const audit = new ChainLog();
297
+ audit._buffer.push(a0, a1, a5);
298
+ assert.equal(audit.verify().status, 'tampered', 'control: a live gap is a missing entry');
299
+ assert.match(audit.verify().reason, /seq jumped from 1 to 5/);
300
+ audit.seal('the gap is acknowledged history');
301
+ const reading = audit.verify();
302
+ assert.equal(reading.status, 'verified', 'the seal covers the seq the gap resumes at');
303
+ assert.equal(reading.discontinuities, 1, 'the gap is still reported');
304
+ });
305
+
306
+ it('grades a live interleaved generation as a discontinuity, and a sealed one as history', () => {
307
+ const audit = new ChainLog();
308
+ audit.record('a', {});
309
+ audit._buffer.push({ ts: 9, kind: 'gate_decision' }); // an unchained generation
310
+ audit.record('b', {});
311
+ const live = audit.verify();
312
+ assert.equal(live.status, 'discontinuity');
313
+ assert.equal(live.interleaved, 1);
314
+ assert.equal(live.liveInterleaved, 1);
315
+ audit.seal('upgrade acknowledged');
316
+ const sealed = audit.verify();
317
+ assert.equal(sealed.status, 'verified');
318
+ assert.equal(sealed.interleaved, 1, 'reported, not dropped');
319
+ assert.equal(sealed.liveInterleaved, 0);
320
+ });
321
+
322
+ it('keeps an unchained legacy prefix visible instead of trusted', () => {
323
+ const audit = new ChainLog();
324
+ audit._buffer.push({ ts: 1, kind: 'gate_decision', admitted: true });
325
+ audit.record('gate_decision', { admitted: true });
326
+ const reading = audit.verify();
327
+ assert.equal(reading.legacy, 1);
328
+ assert.equal(reading.chained, 1);
329
+ assert.equal(reading.status, 'verified');
330
+ });
331
+
332
+ it('reads a missing chain file as empty and counts corrupt lines', () => {
333
+ const dir = mkdtempSync(join(tmpdir(), 'chain-missing-'));
334
+ const path = join(dir, 'chain.jsonl');
335
+ try {
336
+ assert.equal(new ChainLog(path).verify().entries, 0);
337
+ writeFileSync(path, `${JSON.stringify({ ts: 1, kind: 'a' })}\nnot json\n`);
338
+ const reading = new ChainLog(path).verify();
339
+ assert.equal(reading.entries, 1);
340
+ assert.equal(reading.corrupt, 1);
341
+ assert.equal(reading.status, 'verified', 'clause 8: corrupt alone never grades hostile');
342
+ } finally {
343
+ rmSync(dir, { recursive: true, force: true });
344
+ }
345
+ });
346
+ });
347
+
348
+ describe('format compatibility with the historical writers', () => {
349
+ it('the hash payload is byte-identical under both serialisations', () => {
350
+ // entropy-guard hashed `JSON.stringify(base, auditReplacer)`; shape-guard
351
+ // hashed `encodeEntry(base, false)`. For the plain-JSON entries every
352
+ // writer stores, the two strings must be the same bytes — or chains written
353
+ // before this package existed would stop verifying.
354
+ const base = { ts: 1791000605.278, seq: 659, tool: 'pwsh', nested: { a: [1, 2, null], b: 'x' }, kind: 'call_rejected', prev: null };
355
+ assert.equal(JSON.stringify(base, auditReplacer), encodeEntry(base, false));
356
+ assert.equal(hashEntry(base), createHash('sha256').update(encodeEntry(base, false)).digest('hex'));
357
+ assert.equal(hashEntry({ ...base, hash: 'f'.repeat(64) }), hashEntry(base), 'a stored hash never re-enters its own payload');
358
+ });
359
+ });
360
+
361
+ describe('naming and directory helpers', () => {
362
+ it('turns agent ids into chain-file stems the way the guards do', () => {
363
+ assert.equal(chainStem('session-42'), 'session-42');
364
+ assert.equal(chainStem('a/b c'), 'a_b_c');
365
+ assert.equal(chainStem(null), 'process');
366
+ assert.equal(resolveChainDir({ audit: { dir: '' } }, 'x'), null, 'empty string disables the chain');
367
+ assert.equal(resolveChainDir({ audit: { dir: null } }, join('C:', 'dsh')), join('C:', 'dsh', 'entropy-guard'));
368
+ });
369
+ });
370
+
371
+ describe('release tooling', () => {
372
+ /** A minimal tar.gz writer, so the reader is exercised against real bytes. */
373
+ function buildTarGz(files) {
374
+ const blocks = [];
375
+ for (const [path, text] of Object.entries(files)) {
376
+ const body = Buffer.from(text, 'utf8');
377
+ const header = Buffer.alloc(512);
378
+ header.write(path, 0, 'utf8');
379
+ header.write(body.length.toString(8).padStart(11, '0'), 124, 'utf8');
380
+ header[156] = 48;
381
+ header.write('ustar', 257, 'utf8');
382
+ header.write(' ', 148, 'utf8');
383
+ let sum = 0;
384
+ for (const byte of header) sum += byte;
385
+ header.write(sum.toString(8).padStart(6, '0'), 148, 'utf8');
386
+ blocks.push(header, body, Buffer.alloc((512 - (body.length % 512)) % 512));
387
+ }
388
+ blocks.push(Buffer.alloc(1024));
389
+ return gzipSync(Buffer.concat(blocks));
390
+ }
391
+
392
+ it('treats a packer-rewritten package.json as equivalent, not as a difference', () => {
393
+ assert.equal(jsonEquivalent('{\n "a": 1\n}\n', '{\n "a": 1\n}'), true);
394
+ assert.equal(jsonEquivalent('{"a":1,"b":2}', '{"b":2,"a":1}'), true);
395
+ assert.equal(jsonEquivalent('{"a":1}', '{"a":2}'), false);
396
+ });
397
+
398
+ it('resolves metadata references as files, directories or globs', () => {
399
+ const paths = ['index.js', 'lib/chain.js', 'docs/CONTRACT.md'];
400
+ assert.equal(matchesReference('./index.js', paths), true);
401
+ assert.equal(matchesReference('lib', paths), true);
402
+ assert.equal(matchesReference('./docs/*.md', paths), true);
403
+ assert.equal(matchesReference('./missing.js', paths), false);
404
+ });
405
+
406
+ it('reads a .tar.gz in process, without tar', () => {
407
+ const artifact = readTarGz(buildTarGz({ 'package/index.js': 'export const a = 1;\n' }));
408
+ assert.equal(artifact.get('index.js').toString('utf8'), 'export const a = 1;\n');
409
+ });
410
+
411
+ it('compares text modulo line endings, so a Windows checkout cannot fake a DIFF', () => {
412
+ assert.equal(sameTextModuloLineEndings('a\nb\n', 'a\r\nb\r\n'), true);
413
+ assert.equal(sameTextModuloLineEndings('a\nb\n', 'a\nc\n'), false);
414
+ assert.equal(looksLikeText(Buffer.from('MIT License\n', 'utf8')), true, 'LICENSE has no extension and is text');
415
+ assert.equal(looksLikeText(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00])), false, 'a PNG header is not text');
416
+ });
417
+
418
+ it('scans shipped code for references the artifact does not carry', () => {
419
+ const artifact = readTarGz(buildTarGz({
420
+ 'package/lib/tool.mjs': 'const x = readFileSync(join(root, \'DESIGN-v1.md\'));\n',
421
+ 'package/index.js': 'export {};\n',
422
+ }));
423
+ assert.deepEqual(scanShippedReferences(artifact).unresolved, [{ file: 'lib/tool.mjs', reference: 'DESIGN-v1.md' }]);
424
+ });
425
+
426
+ it('names the tree HEAD it compared against', () => {
427
+ const probe = spawnSync('git', ['rev-parse', '--short=7', 'HEAD'], { cwd: REPO_ROOT, encoding: 'utf8' });
428
+ const expected = probe.status === 0 ? probe.stdout.trim() : null;
429
+ assert.equal(treeHeadOf(REPO_ROOT), expected);
430
+ assert.equal(treeHeadOf(join(tmpdir(), 'definitely-not-a-repo')), null);
431
+ });
432
+
433
+ it('refuses to verify a different package with this copy', () => {
434
+ const tool = join(REPO_ROOT, 'tools', 'verify-release.mjs');
435
+ const result = spawnSync(process.execPath, [tool, '--spec', '@cyd-prc/dsh-shape-guard@0.1.6'], { encoding: 'utf8' });
436
+ assert.equal(result.status, 1);
437
+ assert.match(result.stderr, /names "@cyd-prc\/dsh-shape-guard" but this copy ships with/);
438
+ });
439
+
440
+ it('validates --wait as a non-negative number of seconds', () => {
441
+ const tool = join(REPO_ROOT, 'tools', 'verify-release.mjs');
442
+ const result = spawnSync(process.execPath, [tool, '--wait', '-5'], { encoding: 'utf8' });
443
+ assert.equal(result.status, 1);
444
+ assert.match(result.stderr, /--wait wants a non-negative number/);
445
+ });
446
+ });
447
+
448
+ describe('tools/verify-chain.mjs', () => {
449
+ it('never writes to the chain it audits, and grades by exit code (defect 16)', () => {
450
+ const dir = mkdtempSync(join(tmpdir(), 'chain-tool-'));
451
+ const path = join(dir, 'chain.jsonl');
452
+ try {
453
+ const audit = new ChainLog(path);
454
+ audit.record('gate_decision', { admitted: true });
455
+ audit.record('gate_decision', { admitted: false });
456
+ const digest = () => createHash('sha256').update(readFileSync(path)).digest('hex');
457
+ const before = digest();
458
+ const tool = join(REPO_ROOT, 'tools', 'verify-chain.mjs');
459
+ for (let run = 0; run < 2; run += 1) {
460
+ const result = spawnSync(process.execPath, [tool, path], { encoding: 'utf8' });
461
+ assert.equal(result.status, 0, result.stderr);
462
+ assert.match(result.stdout, /^VERIFIED · entries 2 · chained 2/u);
463
+ }
464
+ assert.equal(digest(), before, 'the audited chain is byte-identical after two runs');
465
+ // A forked chain exits 1 and says why: duplicate the second entry — its
466
+ // prev resolves, so this is a branch, not a mid-file root.
467
+ const secondLine = readFileSync(path, 'utf8').trim().split('\n')[1];
468
+ appendFileSync(path, `${secondLine}\n`, 'utf8');
469
+ const forked = spawnSync(process.execPath, [tool, path], { encoding: 'utf8' });
470
+ assert.equal(forked.status, 1);
471
+ assert.match(forked.stdout, /^FORKED · /u);
472
+ const json = spawnSync(process.execPath, [tool, path, '--json'], { encoding: 'utf8' });
473
+ assert.equal(JSON.parse(json.stdout).status, 'forked');
474
+ } finally {
475
+ rmSync(dir, { recursive: true, force: true });
476
+ }
477
+ });
478
+ });
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Offline chain verifier.
4
+ *
5
+ * Lets anyone check the accountability claim without a running Harness: point it
6
+ * at an audit chain and it re-walks the SHA-256 chain, grading it by the shared
7
+ * contract. Read-only by construction — the tool never constructs a writer over
8
+ * the target, so the audited file's bytes are the same after the run as before
9
+ * (entropy-guard's defect 16 is why this tool has no report mode at all).
10
+ *
11
+ * Usage:
12
+ * node tools/verify-chain.mjs <chain.jsonl> # one greppable line
13
+ * node tools/verify-chain.mjs <chain.jsonl> --json # the full reading
14
+ *
15
+ * Exit code 0 = `verified`, 1 = any louder grade (or a usage failure).
16
+ *
17
+ * @module dsh-audit-chain/tools/verify-chain
18
+ */
19
+
20
+ import { verifyChain } from '../index.js';
21
+
22
+ const [target, flag] = process.argv.slice(2);
23
+
24
+ if (target === undefined || target.startsWith('--')) {
25
+ process.stderr.write('usage: node tools/verify-chain.mjs <chain.jsonl> [--json]\n');
26
+ process.exit(2);
27
+ }
28
+
29
+ const reading = verifyChain(target);
30
+
31
+ if (flag === '--json') {
32
+ process.stdout.write(`${JSON.stringify(reading, null, 2)}\n`);
33
+ } else {
34
+ process.stdout.write(
35
+ `${reading.status.toUpperCase()} · entries ${reading.entries} · chained ${reading.chained} · `
36
+ + `legacy ${reading.legacy} · interleaved ${reading.interleaved} (live ${reading.liveInterleaved}) · `
37
+ + `forks ${reading.forks} (live ${reading.liveForks}) · corrupt ${reading.corrupt}`
38
+ + (reading.sealed === null ? '' : ` · sealed ${reading.sealed.count} through seq ${reading.sealed.throughSeq}`)
39
+ + (reading.reason.length > 0 ? ` · ${reading.reason}` : '')
40
+ + '\n',
41
+ );
42
+ }
43
+
44
+ // The operator's question is "is there anything left to look at": a sealed or
45
+ // mixed-history chain is fine, a forked or tampered one is not.
46
+ process.exitCode = reading.clean ? 0 : 1;