@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.
- package/LICENSE +27 -0
- package/README.md +93 -0
- package/README.zh-CN.md +81 -0
- package/docs/CHAIN-GRADING-CONTRACT.md +97 -0
- package/index.js +18 -0
- package/lib/chain.js +642 -0
- package/package.json +45 -0
- package/test/core.test.mjs +478 -0
- package/tools/verify-chain.mjs +46 -0
- package/tools/verify-release.mjs +554 -0
package/lib/chain.js
ADDED
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-audit-chain — the one tamper-evident JSONL chain every guard shares.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from two published implementations that had drifted onto one file:
|
|
5
|
+
* `dsh-entropy-guard`'s `AuditLog` (the writer the control face uses) and
|
|
6
|
+
* `dsh-shape-guard`'s `ShapeLedger` + `verifyChain` (the lock and the
|
|
7
|
+
* contract-conformant grader). The defects that forced the extraction, all
|
|
8
|
+
* measured on live chains before they were fixed at the source:
|
|
9
|
+
*
|
|
10
|
+
* - two writers caching the tail once per instance forked the chain
|
|
11
|
+
* (`seq 245 repeats after 245`; entropy-guard defect 14);
|
|
12
|
+
* - a verifier that stopped at the first fork undercounted 8 forks as 1 and let
|
|
13
|
+
* a deleted entry pass as `verified` (defect 15 / shape-guard S1, S2);
|
|
14
|
+
* - a verifier that *wrote* to the chain it audited (+2 entries per run,
|
|
15
|
+
* defect 16);
|
|
16
|
+
* - a crash-torn tail line swallowed the next append (defect 19) and was graded
|
|
17
|
+
* as hostile on its own (shape-guard S4).
|
|
18
|
+
*
|
|
19
|
+
* The grading semantics are the shared `CHAIN-GRADING-CONTRACT.md` (eight
|
|
20
|
+
* clauses; a mirror ships in `docs/` with its fingerprint noted in the README).
|
|
21
|
+
* The hash payload is byte-compatible with both historical writers, so chains
|
|
22
|
+
* written before this package existed verify unchanged.
|
|
23
|
+
*
|
|
24
|
+
* @module dsh-audit-chain/chain
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { createHash } from 'node:crypto';
|
|
28
|
+
import {
|
|
29
|
+
appendFileSync, closeSync, mkdirSync, openSync, readFileSync, readSync, statSync, unlinkSync,
|
|
30
|
+
} from 'node:fs';
|
|
31
|
+
import { dirname, join } from 'node:path';
|
|
32
|
+
|
|
33
|
+
/** How long an append waits for the chain lock before failing closed, in ms. */
|
|
34
|
+
export const LOCK_TIMEOUT_MS = 5000;
|
|
35
|
+
|
|
36
|
+
/** How long to nap between lock attempts, in ms. */
|
|
37
|
+
export const LOCK_BACKOFF_MS = 2;
|
|
38
|
+
|
|
39
|
+
/** How much of the chain tail to read when recovering `seq` and `prev`. */
|
|
40
|
+
const TAIL_READ_BYTES = 65536;
|
|
41
|
+
|
|
42
|
+
/** Depth cap for the recursive non-finite sanitizer (guards against deep nesting). */
|
|
43
|
+
const SANITIZE_MAX_DEPTH = 32;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Render one non-finite number with its sign, preserving information the way the
|
|
47
|
+
* SDK's `_nonfinite` markers do.
|
|
48
|
+
* @param value - a non-finite number.
|
|
49
|
+
* @returns `'nan'`, `'+inf'`, or `'-inf'`.
|
|
50
|
+
*/
|
|
51
|
+
function nonFiniteLabel(value) {
|
|
52
|
+
return Number.isNaN(value) ? 'nan' : (value > 0 ? '+inf' : '-inf');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Recursively replace non-finite numbers inside an audit field with string
|
|
57
|
+
* markers, degrading unknown types to `String(value)`. Cycle- and depth-safe.
|
|
58
|
+
* @param value - the field value.
|
|
59
|
+
* @param depth - internal recursion depth.
|
|
60
|
+
* @param seen - internal identity set for the current path.
|
|
61
|
+
* @returns a JSON-safe value.
|
|
62
|
+
*/
|
|
63
|
+
function sanitizeNonFinite(value, depth = 0, seen = undefined) {
|
|
64
|
+
if (typeof value === 'number') return Number.isFinite(value) ? value : nonFiniteLabel(value);
|
|
65
|
+
if (value === null || typeof value === 'boolean' || typeof value === 'string') return value;
|
|
66
|
+
if (Array.isArray(value) || (typeof value === 'object' && value !== null)) {
|
|
67
|
+
const path = seen ?? new Set();
|
|
68
|
+
if (depth >= SANITIZE_MAX_DEPTH || path.has(value)) return '<truncated:depth-or-cycle>';
|
|
69
|
+
path.add(value);
|
|
70
|
+
try {
|
|
71
|
+
if (Array.isArray(value)) return value.map((item) => sanitizeNonFinite(item, depth + 1, path));
|
|
72
|
+
const out = {};
|
|
73
|
+
const seenKeys = new Set();
|
|
74
|
+
let collision = false;
|
|
75
|
+
for (const [key, item] of Object.entries(value)) {
|
|
76
|
+
const renderKey = typeof key === 'string' ? key : String(key);
|
|
77
|
+
if (seenKeys.has(renderKey)) {
|
|
78
|
+
collision = true;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
seenKeys.add(renderKey);
|
|
82
|
+
out[renderKey] = sanitizeNonFinite(item, depth + 1, path);
|
|
83
|
+
}
|
|
84
|
+
// `_audit_meta` is a reserved namespace; a user field of that name is
|
|
85
|
+
// preserved under `user_field_shadowed` rather than overwritten.
|
|
86
|
+
if (collision) {
|
|
87
|
+
out._audit_meta = '_audit_meta' in out
|
|
88
|
+
? { user_field_shadowed: out._audit_meta, key_collision: true }
|
|
89
|
+
: { key_collision: true };
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
} finally {
|
|
93
|
+
path.delete(value);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return String(value);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* JSON replacer retaining the "unknown types become their string form" contract.
|
|
101
|
+
* For plain-JSON values (everything on a chain, by construction) this is a
|
|
102
|
+
* pass-through, so the payload matches the sibling writers byte for byte.
|
|
103
|
+
*/
|
|
104
|
+
export function auditReplacer(_key, value) {
|
|
105
|
+
if (typeof value === 'bigint') return value.toString();
|
|
106
|
+
if (typeof value === 'function' || typeof value === 'symbol') return String(value);
|
|
107
|
+
return value;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The JSON encoding of an entry, on the chain or as hashed. `includeHash: false`
|
|
112
|
+
* reproduces the payload a hash is taken over — the `prev` link stays in, the
|
|
113
|
+
* entry's own `hash` is dropped, and key order is preserved.
|
|
114
|
+
*
|
|
115
|
+
* For plain-JSON entries this string is byte-identical to
|
|
116
|
+
* `JSON.stringify(entry)` — the property the two historical writers' hash
|
|
117
|
+
* payloads share, pinned by the suite so the formats can never drift apart again.
|
|
118
|
+
* @param {Record<string, unknown>} entry - the entry.
|
|
119
|
+
* @param {boolean} includeHash - whether to serialise the entry's own `hash`.
|
|
120
|
+
* @returns {string} the JSON line payload.
|
|
121
|
+
*/
|
|
122
|
+
export function encodeEntry(entry, includeHash) {
|
|
123
|
+
const parts = [];
|
|
124
|
+
for (const [key, value] of Object.entries(entry)) {
|
|
125
|
+
if (key === 'hash' && !includeHash) continue;
|
|
126
|
+
parts.push(`${JSON.stringify(key)}:${JSON.stringify(value)}`);
|
|
127
|
+
}
|
|
128
|
+
return `{${parts.join(',')}}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The SHA-256 of an entry's payload (everything but its own `hash`).
|
|
133
|
+
* @param entry - the entry without `hash`, or with it (dropped either way).
|
|
134
|
+
* @returns the hex digest.
|
|
135
|
+
*/
|
|
136
|
+
export function hashEntry(entry) {
|
|
137
|
+
const { hash: _drop, ...rest } = entry;
|
|
138
|
+
return createHash('sha256').update(JSON.stringify(rest, auditReplacer)).digest('hex');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Append-only, tamper-evident JSONL audit chain, safe for multiple writers.
|
|
143
|
+
*
|
|
144
|
+
* Every file-backed append does all three of these under an exclusive lock:
|
|
145
|
+
* re-read the on-disk tail (never a cached one), take `seq`/`prev` from it,
|
|
146
|
+
* append — terminating a crash-torn tail line first so the new entry is never
|
|
147
|
+
* fused into it. Reads are cached on `(mtimeMs, size)`; every read is
|
|
148
|
+
* deep-copied so a caller cannot mutate the ledger.
|
|
149
|
+
*/
|
|
150
|
+
export class ChainLog {
|
|
151
|
+
/**
|
|
152
|
+
* @param path - absolute JSONL path, or `null` for the in-memory ledger.
|
|
153
|
+
*/
|
|
154
|
+
constructor(path = null) {
|
|
155
|
+
/** @type {string|null} */
|
|
156
|
+
this.path = path ?? null;
|
|
157
|
+
/** @type {Record<string, unknown>[]} */
|
|
158
|
+
this._buffer = [];
|
|
159
|
+
/** Bad lines skipped by the most recent disk read. */
|
|
160
|
+
this.corruptLines = 0;
|
|
161
|
+
this._cacheKey = null;
|
|
162
|
+
this._cacheEntries = [];
|
|
163
|
+
/** Append failures observed — an operational alarm, surfaced by the hosts. */
|
|
164
|
+
this.failures = 0;
|
|
165
|
+
/** @type {string|null} */
|
|
166
|
+
this.lastError = null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Append one entry, serialised against any other writer of the same chain.
|
|
171
|
+
* @param kind - the event kind (`gate_decision`, `shape_flag`, …).
|
|
172
|
+
* @param fields - JSON-compatible fields.
|
|
173
|
+
* @returns a deep copy of the appended entry.
|
|
174
|
+
* @throws {Error} when the chain cannot be locked or written (fail-closed).
|
|
175
|
+
*/
|
|
176
|
+
record(kind, fields = {}) {
|
|
177
|
+
const clean = {};
|
|
178
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
179
|
+
if (typeof value === 'number' && !Number.isFinite(value)) {
|
|
180
|
+
clean[key] = null;
|
|
181
|
+
clean[`${key}_nonfinite`] = nonFiniteLabel(value);
|
|
182
|
+
} else {
|
|
183
|
+
clean[key] = sanitizeNonFinite(value);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (this.path === null) {
|
|
188
|
+
// In-memory: one buffer has exactly one writer, so no lock is taken.
|
|
189
|
+
const tail = this._tail();
|
|
190
|
+
const base = { ts: Date.now() / 1000, seq: tail.seq, ...clean, kind, prev: tail.hash };
|
|
191
|
+
const entry = { ...base, hash: hashEntry(base) };
|
|
192
|
+
this._buffer.push(entry);
|
|
193
|
+
return structuredClone(entry);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const release = this._lock();
|
|
197
|
+
try {
|
|
198
|
+
const tail = this._tail();
|
|
199
|
+
// The event kind is spread after the caller's fields, so a field named
|
|
200
|
+
// `kind` cannot silently rewrite the entry's own type.
|
|
201
|
+
const base = {
|
|
202
|
+
ts: Date.now() / 1000,
|
|
203
|
+
seq: tail.seq,
|
|
204
|
+
...clean,
|
|
205
|
+
kind,
|
|
206
|
+
prev: tail.hash,
|
|
207
|
+
};
|
|
208
|
+
const entry = { ...base, hash: hashEntry(base) };
|
|
209
|
+
mkdirSync(dirname(this.path), { recursive: true });
|
|
210
|
+
// A crash-torn tail line has no trailing newline; terminate it before
|
|
211
|
+
// appending or the new entry fuses into the corrupt line and is itself
|
|
212
|
+
// lost (defect 19; contract clause 8's writer half).
|
|
213
|
+
const line = `${JSON.stringify(entry, auditReplacer)}\n`;
|
|
214
|
+
appendFileSync(this.path, tail.tornTail ? `\n${line}` : line, 'utf8');
|
|
215
|
+
return structuredClone(entry);
|
|
216
|
+
} catch (error) {
|
|
217
|
+
this.failures += 1;
|
|
218
|
+
this.lastError = error?.message ?? String(error);
|
|
219
|
+
throw error;
|
|
220
|
+
} finally {
|
|
221
|
+
release();
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Acknowledge the operational history up to here, inside the chain itself.
|
|
227
|
+
*
|
|
228
|
+
* A fork or a mixed-generation run is not a rewrite, and letting it colour the
|
|
229
|
+
* standing reading forever is how a grade stops being read. Sealing records the
|
|
230
|
+
* boundary *and* the counts as an ordinary entry, so the acknowledgement is
|
|
231
|
+
* auditable rather than a configuration flag someone can flip — and `verify()`
|
|
232
|
+
* never lets a seal hide a tamper finding.
|
|
233
|
+
* @param note - optional human note recorded with the seal.
|
|
234
|
+
* @returns the verification reading taken just before sealing.
|
|
235
|
+
*/
|
|
236
|
+
seal(note = '') {
|
|
237
|
+
const before = this.verify();
|
|
238
|
+
// Read the tail fresh: the seq the seal names must be the chain's own, not
|
|
239
|
+
// a per-instance cache that a second writer may have moved past.
|
|
240
|
+
const tail = this._tail();
|
|
241
|
+
this.record('chain_seal', {
|
|
242
|
+
// Informational: `verify()` bounds unchained entries on the seal entry's
|
|
243
|
+
// file position, since they carry no seq to compare against.
|
|
244
|
+
sealed_through_seq: tail.seq - 1,
|
|
245
|
+
sealed_forks: before.forks - before.liveForks,
|
|
246
|
+
sealed_interleaved: before.interleaved - before.liveInterleaved,
|
|
247
|
+
chained_before: before.chained,
|
|
248
|
+
...(note.length > 0 ? { note } : {}),
|
|
249
|
+
});
|
|
250
|
+
return before;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Walk the chain and grade what it finds — the shared contract, eight clauses.
|
|
255
|
+
*
|
|
256
|
+
* - `tampered` — the entry's bytes changed after it was written (the content
|
|
257
|
+
* re-hash disagrees), a `prev` link dangles (it names a hash no entry in the
|
|
258
|
+
* file carries, so an entry that existed is gone), a chained root appears
|
|
259
|
+
* mid-file, or a sequence gap resumes in live history. Rewrite-shaped, the
|
|
260
|
+
* only grade an operator must treat as hostile, and **never suppressed by a
|
|
261
|
+
* seal**: acknowledging history must not launder a rewrite.
|
|
262
|
+
* - `forked` — a `seq` repeats: two writers appended from the same tail and
|
|
263
|
+
* the history branched. Operational, not adversarial. The walk never stops
|
|
264
|
+
* at the first one: every duplicate is counted, on every branch.
|
|
265
|
+
* - `discontinuity` — unchained entries sit among chained ones, which is what
|
|
266
|
+
* an in-place upgrade looks like while two generations write one file.
|
|
267
|
+
* - `verified` — nothing to note.
|
|
268
|
+
*
|
|
269
|
+
* The three checks are kept separate, and the walk always completes:
|
|
270
|
+
*
|
|
271
|
+
* - **Content** — every chained entry is re-hashed, always, on any branch.
|
|
272
|
+
* - **Link** — fork-aware: `prev` is resolved against every hash the file
|
|
273
|
+
* carries rather than compared to the previous line. A resolvable
|
|
274
|
+
* non-predecessor is a branch; a dangling one means an entry that existed
|
|
275
|
+
* is gone. Link checking pauses across an interleaved block and resumes at
|
|
276
|
+
* the next chained entry.
|
|
277
|
+
* - **Sequence** — a repeated `seq` is a fork, never a rewrite. A gap means a
|
|
278
|
+
* missing entry: `tampered` when it resumes in live history, reported-only
|
|
279
|
+
* when wholly inside sealed history.
|
|
280
|
+
*
|
|
281
|
+
* Seal semantics: a `chain_seal` is a person's acknowledgement. Fork and gap
|
|
282
|
+
* membership is decided by `seq` (a branch entry written after the seal can
|
|
283
|
+
* still carry a seq the seal covers); interleaved entries carry no `seq`, so
|
|
284
|
+
* their side of the seal is decided by file position. Findings at or below the
|
|
285
|
+
* last seal are reported as `sealed*` and excluded from the live grade.
|
|
286
|
+
*
|
|
287
|
+
* Corrupt lines (clause 8): always reported in `corrupt`, never graded on
|
|
288
|
+
* their own — a torn final line after a crash is an availability event, and a
|
|
289
|
+
* mid-chain deletion is already caught by the link check. The `reason` names
|
|
290
|
+
* them whenever there is nothing louder to say.
|
|
291
|
+
* @returns the verification reading.
|
|
292
|
+
*/
|
|
293
|
+
verify() {
|
|
294
|
+
const entries = this._readAll();
|
|
295
|
+
let legacy = 0;
|
|
296
|
+
let chained = 0;
|
|
297
|
+
let interleaved = 0;
|
|
298
|
+
let firstChainedIndex = -1;
|
|
299
|
+
let lastChainedIndex = -1;
|
|
300
|
+
|
|
301
|
+
// Findings, with the seal boundary applied at the end. A tamper is kept
|
|
302
|
+
// unfiltered — whichever side of a seal it falls on.
|
|
303
|
+
const tampered = [];
|
|
304
|
+
const forks = [];
|
|
305
|
+
const sealedGaps = [];
|
|
306
|
+
|
|
307
|
+
// Pass 0 — every hash the file actually carries, so a link can be resolved
|
|
308
|
+
// instead of only compared to the previous line.
|
|
309
|
+
const knownHashes = new Set();
|
|
310
|
+
for (const entry of entries) {
|
|
311
|
+
if (typeof entry?.hash === 'string') knownHashes.add(entry.hash);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Seal bookkeeping. The fork axis bounds by seq; the interleaved axis has
|
|
315
|
+
// no seq and bounds by file position.
|
|
316
|
+
let sealedCount = 0;
|
|
317
|
+
let lastSealIndex = -1;
|
|
318
|
+
let sealedThroughSeq = null;
|
|
319
|
+
|
|
320
|
+
let linkPaused = false;
|
|
321
|
+
/** File positions of the interleaved (unchained) entries. */
|
|
322
|
+
const interleavedAt = [];
|
|
323
|
+
const seenSeq = new Set();
|
|
324
|
+
|
|
325
|
+
for (let index = 0; index < entries.length; index += 1) {
|
|
326
|
+
const entry = entries[index];
|
|
327
|
+
const chainedEntry = typeof entry?.seq === 'number' && typeof entry?.hash === 'string';
|
|
328
|
+
if (!chainedEntry) {
|
|
329
|
+
// Before anything chained it is the legacy prefix; afterwards it is an
|
|
330
|
+
// interleaved generation, which is what an in-place upgrade looks like
|
|
331
|
+
// while two writers share one file.
|
|
332
|
+
if (chained === 0) legacy += 1;
|
|
333
|
+
else {
|
|
334
|
+
interleaved += 1;
|
|
335
|
+
interleavedAt.push(index);
|
|
336
|
+
linkPaused = true;
|
|
337
|
+
}
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
if (firstChainedIndex < 0) firstChainedIndex = index;
|
|
341
|
+
lastChainedIndex = index;
|
|
342
|
+
chained += 1;
|
|
343
|
+
|
|
344
|
+
if (entry.kind === 'chain_seal') {
|
|
345
|
+
sealedCount += 1;
|
|
346
|
+
lastSealIndex = index;
|
|
347
|
+
const through = typeof entry.sealed_through_seq === 'number'
|
|
348
|
+
? entry.sealed_through_seq
|
|
349
|
+
: entry.seq - 1;
|
|
350
|
+
sealedThroughSeq = sealedThroughSeq === null ? through : Math.max(sealedThroughSeq, through);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Content: always, for every chained entry, on any branch.
|
|
354
|
+
if (hashEntry(entry) !== entry.hash) {
|
|
355
|
+
tampered.push({ seq: entry.seq, reason: `seq ${entry.seq} was modified after it was written` });
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Link: dangling is a rewrite; a resolvable non-predecessor is a branch.
|
|
359
|
+
const prev = entry.prev ?? null;
|
|
360
|
+
if (prev !== null && !knownHashes.has(prev)) {
|
|
361
|
+
tampered.push({ seq: entry.seq, reason: `the link into seq ${entry.seq} names a hash no entry carries — an entry that existed is gone` });
|
|
362
|
+
} else if (prev === null && !linkPaused && index > 0 && seenSeq.size > 0) {
|
|
363
|
+
// A chained entry claiming to be a root, mid-file, with no interleaving
|
|
364
|
+
// to explain it: an earlier entry is gone.
|
|
365
|
+
tampered.push({ seq: entry.seq, reason: `seq ${entry.seq} restarts the chain mid-file — an earlier entry is gone` });
|
|
366
|
+
}
|
|
367
|
+
linkPaused = false;
|
|
368
|
+
|
|
369
|
+
// Sequence: a repeat is a fork, and the count never stops early.
|
|
370
|
+
if (seenSeq.has(entry.seq)) {
|
|
371
|
+
forks.push({ seq: entry.seq, reason: `duplicate seq ${entry.seq} — a second writer branched this chain` });
|
|
372
|
+
} else {
|
|
373
|
+
seenSeq.add(entry.seq);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Pass 2 — a sequence gap means an entry is missing. Graded as a rewrite
|
|
378
|
+
// when it resumes in live history; reported-only inside sealed history.
|
|
379
|
+
const sortedSeq = [...seenSeq].sort((a, b) => a - b);
|
|
380
|
+
for (let index = 1; index < sortedSeq.length; index += 1) {
|
|
381
|
+
if (sortedSeq[index] === sortedSeq[index - 1] + 1) continue;
|
|
382
|
+
const gap = {
|
|
383
|
+
seq: sortedSeq[index],
|
|
384
|
+
reason: `seq jumped from ${sortedSeq[index - 1]} to ${sortedSeq[index]} — an entry is missing`,
|
|
385
|
+
};
|
|
386
|
+
if (sealedThroughSeq !== null && sortedSeq[index] <= sealedThroughSeq) sealedGaps.push(gap);
|
|
387
|
+
else tampered.push(gap);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// A finding is live when it belongs to sequence history above the seal
|
|
391
|
+
// boundary — by `seq`, not by file position. Interleaved entries have no
|
|
392
|
+
// `seq`, so the seal is applied to them by position.
|
|
393
|
+
const isLive = (finding) => sealedThroughSeq === null || finding.seq > sealedThroughSeq;
|
|
394
|
+
const liveForks = forks.filter(isLive);
|
|
395
|
+
const liveInterleaved = lastSealIndex < 0
|
|
396
|
+
? interleaved
|
|
397
|
+
: interleavedAt.filter((at) => at > lastSealIndex).length;
|
|
398
|
+
|
|
399
|
+
const sealedForks = forks.length - liveForks.length;
|
|
400
|
+
const sealedInterleaved = interleaved - liveInterleaved;
|
|
401
|
+
const sealedNote = sealedCount === 0
|
|
402
|
+
? ''
|
|
403
|
+
: `${sealedForks} fork${sealedForks === 1 ? '' : 's'} and ${sealedInterleaved} interleaved `
|
|
404
|
+
+ `entr${sealedInterleaved === 1 ? 'y' : 'ies'} sealed as history`
|
|
405
|
+
+ `${sealedThroughSeq === null ? '' : ` through seq ${sealedThroughSeq}`}`;
|
|
406
|
+
// Clause 8: corrupt lines are never graded on their own, but the reason
|
|
407
|
+
// names them whenever there is nothing louder to say.
|
|
408
|
+
const corruptNote = this.corruptLines > 0
|
|
409
|
+
? `${this.corruptLines} unparsable line(s) (reported, not graded)`
|
|
410
|
+
: '';
|
|
411
|
+
const quietNote = [sealedNote, corruptNote].filter((note) => note.length > 0).join('; ');
|
|
412
|
+
|
|
413
|
+
const status = tampered.length > 0
|
|
414
|
+
? 'tampered'
|
|
415
|
+
: (liveForks.length > 0
|
|
416
|
+
? 'forked'
|
|
417
|
+
: (liveInterleaved > 0 ? 'discontinuity' : 'verified'));
|
|
418
|
+
return {
|
|
419
|
+
status,
|
|
420
|
+
// `ok` answers the operator's question — was anything rewritten? — while
|
|
421
|
+
// `clean` additionally requires that there is nothing left to look at, now
|
|
422
|
+
// that sealed history is no longer something to look at.
|
|
423
|
+
ok: status !== 'tampered',
|
|
424
|
+
tampered: status === 'tampered',
|
|
425
|
+
clean: status === 'verified',
|
|
426
|
+
entries: entries.length,
|
|
427
|
+
chained,
|
|
428
|
+
legacy,
|
|
429
|
+
interleaved,
|
|
430
|
+
liveInterleaved,
|
|
431
|
+
forks: forks.length,
|
|
432
|
+
liveForks: liveForks.length,
|
|
433
|
+
// Sequence gaps resuming in live history are graded `tampered` (a missing
|
|
434
|
+
// entry is rewrite-shaped), so this pair reports the sealed ones only.
|
|
435
|
+
discontinuities: sealedGaps.length,
|
|
436
|
+
liveDiscontinuities: 0,
|
|
437
|
+
corrupt: this.corruptLines,
|
|
438
|
+
brokenAt: tampered.length > 0
|
|
439
|
+
? tampered[0].seq
|
|
440
|
+
: (liveForks.length > 0 ? liveForks[0].seq : null),
|
|
441
|
+
reason: tampered.length > 0
|
|
442
|
+
? tampered[0].reason
|
|
443
|
+
: (liveForks.length > 0
|
|
444
|
+
? liveForks[0].reason
|
|
445
|
+
: (liveInterleaved > 0
|
|
446
|
+
? `${liveInterleaved} unchained entr${liveInterleaved === 1 ? 'y' : 'ies'} among chained ones (two generations wrote this chain)`
|
|
447
|
+
: quietNote)),
|
|
448
|
+
sealed: sealedCount === 0
|
|
449
|
+
? null
|
|
450
|
+
: {
|
|
451
|
+
count: sealedCount,
|
|
452
|
+
boundaryIndex: lastSealIndex,
|
|
453
|
+
throughSeq: sealedThroughSeq,
|
|
454
|
+
forks: sealedForks,
|
|
455
|
+
interleaved: sealedInterleaved,
|
|
456
|
+
},
|
|
457
|
+
chainedRange: firstChainedIndex < 0 ? null : [firstChainedIndex, lastChainedIndex],
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Every entry, oldest first, as detached copies.
|
|
463
|
+
* @returns the entries.
|
|
464
|
+
*/
|
|
465
|
+
get entries() {
|
|
466
|
+
return this._readAll().map((entry) => structuredClone(entry));
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** @returns {Record<string, unknown>[]} the cached read. */
|
|
470
|
+
_readAll() {
|
|
471
|
+
if (this.path !== null) {
|
|
472
|
+
let stats;
|
|
473
|
+
try {
|
|
474
|
+
stats = statSync(this.path);
|
|
475
|
+
} catch {
|
|
476
|
+
// A missing chain file reads as empty; deployers should alert on it.
|
|
477
|
+
this.corruptLines = 0;
|
|
478
|
+
return [];
|
|
479
|
+
}
|
|
480
|
+
const key = `${stats.mtimeMs}:${stats.size}`;
|
|
481
|
+
if (key !== this._cacheKey) {
|
|
482
|
+
const entries = [];
|
|
483
|
+
let corrupt = 0;
|
|
484
|
+
for (const line of readFileSync(this.path, 'utf8').split('\n')) {
|
|
485
|
+
const trimmed = line.trim();
|
|
486
|
+
if (trimmed.length === 0) continue;
|
|
487
|
+
try {
|
|
488
|
+
entries.push(JSON.parse(trimmed));
|
|
489
|
+
} catch {
|
|
490
|
+
corrupt += 1;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
this._cacheKey = key;
|
|
494
|
+
this._cacheEntries = entries;
|
|
495
|
+
this.corruptLines = corrupt;
|
|
496
|
+
}
|
|
497
|
+
return this._cacheEntries;
|
|
498
|
+
}
|
|
499
|
+
this.corruptLines = 0;
|
|
500
|
+
return this._buffer;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Read the chain's continuation state — the last entry carrying `seq` and
|
|
505
|
+
* `hash` — fresh from the source, never from a per-instance cache. Also
|
|
506
|
+
* reports whether the file ends mid-line (a crash-torn tail), so the next
|
|
507
|
+
* append can terminate the torn line instead of fusing into it.
|
|
508
|
+
* @returns {{ seq: number, hash: string|null, tornTail: boolean }} the next `seq` and `prev`.
|
|
509
|
+
*/
|
|
510
|
+
_tail() {
|
|
511
|
+
if (this.path === null) {
|
|
512
|
+
const last = [...this._buffer].reverse()
|
|
513
|
+
.find((entry) => typeof entry?.seq === 'number' && typeof entry?.hash === 'string');
|
|
514
|
+
return last === undefined
|
|
515
|
+
? { seq: this._buffer.length, hash: null, tornTail: false }
|
|
516
|
+
: { seq: last.seq + 1, hash: String(last.hash), tornTail: false };
|
|
517
|
+
}
|
|
518
|
+
let text = '';
|
|
519
|
+
try {
|
|
520
|
+
const size = statSync(this.path).size;
|
|
521
|
+
if (size > 0) {
|
|
522
|
+
const length = Math.min(size, TAIL_READ_BYTES);
|
|
523
|
+
const handle = openSync(this.path, 'r');
|
|
524
|
+
try {
|
|
525
|
+
const buffer = Buffer.alloc(length);
|
|
526
|
+
let offset = 0;
|
|
527
|
+
while (offset < length) {
|
|
528
|
+
const read = readSync(handle, buffer, offset, length - offset, size - length + offset);
|
|
529
|
+
if (read <= 0) break;
|
|
530
|
+
offset += read;
|
|
531
|
+
}
|
|
532
|
+
text = buffer.subarray(0, offset).toString('utf8');
|
|
533
|
+
} finally {
|
|
534
|
+
closeSync(handle);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
} catch {
|
|
538
|
+
return { seq: 0, hash: null, tornTail: false };
|
|
539
|
+
}
|
|
540
|
+
const tornTail = text.length > 0 && !text.endsWith('\n');
|
|
541
|
+
const lines = text.split('\n').filter((line) => line.trim().length > 0);
|
|
542
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
543
|
+
try {
|
|
544
|
+
const entry = JSON.parse(lines[index]);
|
|
545
|
+
if (typeof entry?.seq === 'number' && typeof entry?.hash === 'string') {
|
|
546
|
+
return { seq: entry.seq + 1, hash: entry.hash, tornTail };
|
|
547
|
+
}
|
|
548
|
+
} catch {
|
|
549
|
+
// A torn or corrupt trailing line is skipped: the verifier reports it
|
|
550
|
+
// rather than the writer refusing to continue.
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
// No chained entry in the tail window: the next entry becomes the root of
|
|
554
|
+
// the verifiable suffix, numbered after every line already there. The full
|
|
555
|
+
// line count is read only on this path — the common case never pays for it.
|
|
556
|
+
let count = 0;
|
|
557
|
+
try {
|
|
558
|
+
count = readFileSync(this.path, 'utf8').split('\n').filter((line) => line.trim().length > 0).length;
|
|
559
|
+
} catch {
|
|
560
|
+
count = 0;
|
|
561
|
+
}
|
|
562
|
+
return { seq: count, hash: null, tornTail };
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Take the chain lock, retrying briefly.
|
|
567
|
+
*
|
|
568
|
+
* `wx` is the atomic create — it fails when the lock already exists, which is
|
|
569
|
+
* the mutual exclusion. A stale lock older than the timeout is broken, so a
|
|
570
|
+
* crashed process cannot wedge every future append.
|
|
571
|
+
* @returns {() => void} the release function.
|
|
572
|
+
*/
|
|
573
|
+
_lock() {
|
|
574
|
+
const lockPath = `${this.path}.lock`;
|
|
575
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
576
|
+
mkdirSync(dirname(this.path), { recursive: true });
|
|
577
|
+
for (;;) {
|
|
578
|
+
try {
|
|
579
|
+
closeSync(openSync(lockPath, 'wx'));
|
|
580
|
+
return () => {
|
|
581
|
+
try {
|
|
582
|
+
unlinkSync(lockPath);
|
|
583
|
+
} catch {
|
|
584
|
+
// Another writer already cleared it; nothing to release.
|
|
585
|
+
}
|
|
586
|
+
};
|
|
587
|
+
} catch (error) {
|
|
588
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
589
|
+
try {
|
|
590
|
+
if (Date.now() - statSync(lockPath).mtimeMs > LOCK_TIMEOUT_MS) {
|
|
591
|
+
unlinkSync(lockPath);
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
} catch {
|
|
595
|
+
continue; // The lock vanished between the check and the stat: retry.
|
|
596
|
+
}
|
|
597
|
+
if (Date.now() > deadline) {
|
|
598
|
+
throw new Error(`dsh-audit-chain: chain lock ${lockPath} held longer than ${LOCK_TIMEOUT_MS}ms`);
|
|
599
|
+
}
|
|
600
|
+
// A short synchronous nap: `record` is called from a synchronous guard,
|
|
601
|
+
// so there is no event loop to yield to here.
|
|
602
|
+
const until = Date.now() + LOCK_BACKOFF_MS;
|
|
603
|
+
while (Date.now() < until) { /* spin */ }
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Re-walk a chain and grade it — the functional twin of `ChainLog.verify()`,
|
|
611
|
+
* for callers that only ever read.
|
|
612
|
+
* @param path - the chain path, or `null` to verify nothing.
|
|
613
|
+
* @returns the verification reading.
|
|
614
|
+
*/
|
|
615
|
+
export function verifyChain(path) {
|
|
616
|
+
return new ChainLog(path).verify();
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Resolve the chain directory the way the guards do, so all plugins can land
|
|
621
|
+
* in one chain by default.
|
|
622
|
+
* @param config - a resolved policy with an `audit.dir` field.
|
|
623
|
+
* @param defaultRoot - the fallback root (`$DSH_HOME` or `~/.dsh`).
|
|
624
|
+
* @returns the directory, or `null` to disable the on-disk chain.
|
|
625
|
+
*/
|
|
626
|
+
export function resolveChainDir(config, defaultRoot) {
|
|
627
|
+
const dir = config.audit.dir;
|
|
628
|
+
if (dir === '') return null;
|
|
629
|
+
if (typeof dir === 'string' && dir.length > 0) return dir;
|
|
630
|
+
return join(defaultRoot, 'entropy-guard');
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Turn one agent id into the same chain-file stem the guards use, so that
|
|
635
|
+
* "same chain" is literally the same file.
|
|
636
|
+
* @param agentId - the session/agent id.
|
|
637
|
+
* @returns the file stem.
|
|
638
|
+
*/
|
|
639
|
+
export function chainStem(agentId) {
|
|
640
|
+
if (agentId === null) return 'process';
|
|
641
|
+
return String(agentId).replace(/[^A-Za-z0-9._-]/gu, '_').slice(0, 96) || 'agent';
|
|
642
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cyd-prc/dsh-audit-chain",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "The one tamper-evident JSONL audit chain every dsh guard shares: a multi-writer-safe writer (exclusive lock, fresh tail, torn-tail termination) and the chain-grading contract verifier. Extracted from dsh-entropy-guard and dsh-shape-guard, where the copies drifted; one chain, one implementation.",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"author": "Wang Miaosheng (https://orcid.org/0009-0003-2767-2421)",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/CYD-PRC/dsh-audit-chain.git"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://github.com/CYD-PRC/dsh-audit-chain#readme",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/CYD-PRC/dsh-audit-chain/issues"
|
|
16
|
+
},
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
},
|
|
20
|
+
"exports": {
|
|
21
|
+
".": "./index.js",
|
|
22
|
+
"./package.json": "./package.json"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"index.js",
|
|
26
|
+
"lib",
|
|
27
|
+
"tools",
|
|
28
|
+
"test",
|
|
29
|
+
"docs",
|
|
30
|
+
"README.md",
|
|
31
|
+
"README.zh-CN.md",
|
|
32
|
+
"LICENSE"
|
|
33
|
+
],
|
|
34
|
+
"keywords": [
|
|
35
|
+
"dsh",
|
|
36
|
+
"audit-chain",
|
|
37
|
+
"tamper-evident",
|
|
38
|
+
"jsonl",
|
|
39
|
+
"sha256",
|
|
40
|
+
"chain-grading",
|
|
41
|
+
"entropy-guard",
|
|
42
|
+
"shape-guard",
|
|
43
|
+
"threat-gate"
|
|
44
|
+
]
|
|
45
|
+
}
|