verifyhash 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.
Files changed (64) hide show
  1. package/README.md +5 -3
  2. package/cli/agent-hook.js +431 -0
  3. package/docs/ADOPT.md +15 -5
  4. package/docs/AGENT-HOOK.md +111 -0
  5. package/docs/ANCHORING.md +43 -22
  6. package/docs/PUBLISH-VERIFY-VH.md +45 -0
  7. package/examples/README.md +185 -0
  8. package/examples/policy.lenient.json +5 -0
  9. package/examples/policy.strict.json +6 -0
  10. package/examples/run.js +366 -0
  11. package/examples/sample-dataset/README.txt +10 -0
  12. package/examples/sample-dataset/corpus/cc-by-poem.txt +8 -0
  13. package/examples/sample-dataset/corpus/mit-notes.txt +4 -0
  14. package/examples/sample-dataset/data/unlabeled.txt +5 -0
  15. package/examples/sample-dataset/vendored/gpl-snippet.txt +5 -0
  16. package/examples/sample-dataset.hints.json +7 -0
  17. package/examples/sample-parcel/data/manifest-of-contents.txt +7 -0
  18. package/examples/sample-parcel/data/records.csv +4 -0
  19. package/examples/sample-parcel/delivery-note.txt +9 -0
  20. package/package.json +26 -3
  21. package/verifier/README.md +584 -0
  22. package/verifier/action/README.md +87 -0
  23. package/verifier/action/action.yml +146 -0
  24. package/verifier/build-standalone-html.js +1287 -0
  25. package/verifier/build-standalone.js +989 -0
  26. package/verifier/ci/journal.generic.sh +96 -0
  27. package/verifier/ci/journal.github-actions.yml +99 -0
  28. package/verifier/ci/reproduce-vh.generic.sh +59 -0
  29. package/verifier/ci/reproduce-vh.github-actions.yml +49 -0
  30. package/verifier/ci/verify-service.generic.sh +96 -0
  31. package/verifier/ci/verify-service.github-actions.yml +88 -0
  32. package/verifier/ci/verify-vh.generic.sh +75 -0
  33. package/verifier/ci/verify-vh.github-actions.yml +56 -0
  34. package/verifier/dist/BUILD-PROVENANCE.json +210 -0
  35. package/verifier/dist/seal-vh-standalone.js +876 -0
  36. package/verifier/dist/seal-vh-standalone.js.sha256 +1 -0
  37. package/verifier/dist/verify-vh-standalone.html +3373 -0
  38. package/verifier/dist/verify-vh-standalone.html.sha256 +1 -0
  39. package/verifier/dist/verify-vh-standalone.js +5123 -0
  40. package/verifier/dist/verify-vh-standalone.js.sha256 +1 -0
  41. package/verifier/lib/canonical.js +141 -0
  42. package/verifier/lib/keccak.js +30 -0
  43. package/verifier/lib/keccak256-vendored.js +206 -0
  44. package/verifier/lib/merkle.js +145 -0
  45. package/verifier/lib/revocation-core.js +606 -0
  46. package/verifier/lib/revocation.js +200 -0
  47. package/verifier/lib/seal-cli.js +374 -0
  48. package/verifier/lib/seal-evidence.js +237 -0
  49. package/verifier/lib/secp256k1-recover.js +249 -0
  50. package/verifier/package.json +39 -0
  51. package/verifier/verify-vh.js +3376 -0
  52. package/docs/ADOPTION.json +0 -11
  53. package/docs/AUDIT.md +0 -55
  54. package/docs/DECIDE.md +0 -47
  55. package/docs/DECISIONS-PENDING.md +0 -27
  56. package/docs/DEPLOY-PUBLIC-SITE.md +0 -301
  57. package/docs/ENGINE-LEDGER.json +0 -12
  58. package/docs/LOOP-AUDIT-2026-07-03.json +0 -580
  59. package/docs/LOOP-HARDENING-PLAN.md +0 -44
  60. package/docs/METRICS.jsonl +0 -31
  61. package/docs/MORNING.md +0 -204
  62. package/docs/STRATEGY-ARCHIVE.md +0 -5055
  63. package/docs/SUPERVISOR-RUNBOOK.md +0 -52
  64. package/docs/USAGE-BUDGET.json +0 -121
@@ -0,0 +1,3376 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ // verifier/verify-vh.js — the STANDALONE, read-only, OFFLINE verifier (T-31.2).
5
+ //
6
+ // WHY THIS EXISTS
7
+ // The whole verifyhash family sells one promise: "you do NOT have to trust the producer — verify it
8
+ // OFFLINE, independently." `verify-vh` is the artifact that makes that promise real for the party who
9
+ // matters most for a sale: the COUNTERPARTY (an auditor, opposing counsel, a buyer's security team, a
10
+ // design partner). They drop one `*.vhevidence.json` / `*.vhseal` / dataset attestation / proof bundle
11
+ // in front of this command and get a deterministic verdict — WITHOUT installing the producer's heavy
12
+ // ethers/hardhat stack. This tree depends on ONLY `js-sha3` (+ a tiny vendored secp256k1 routine), so a
13
+ // third party can `npm install` it alone and audit it in an afternoon.
14
+ //
15
+ // WHAT IT DOES
16
+ // * AUTO-DETECTS the artifact `kind` (evidence seal, reconciliation/trust seal, dataset attestation,
17
+ // proof bundle — bare or signed).
18
+ // * RE-DERIVES the keccak Merkle root from the bytes REFERENCED by the artifact (resolving sibling
19
+ // files relative to the artifact's own directory, with a `--dir <d>` override), NEVER trusting the
20
+ // artifact's own stored hashes.
21
+ // * RECOVERS the signer of a signed artifact via the independent EIP-191 secp256k1 recovery (T-31.1),
22
+ // PINS it to a caller-supplied `--vendor <0xaddr>` (or REPORTS the recovered signer when no pin is
23
+ // given).
24
+ // * Prints a deterministic verdict: OK / which file CHANGED / MISSING / UNEXPECTED / `bad_signature`
25
+ // / `wrong_issuer`.
26
+ //
27
+ // POSTURE — READ-ONLY. It holds NO key, opens nothing for write, and NEVER writes the cwd (or anywhere).
28
+ // It reads ONLY the artifact and the sibling files it references. Same exit-code contract as
29
+ // `vh verify-seal` / `vh evidence verify`: 0 ok / 3 rejected / 2 usage / 1 IO.
30
+ //
31
+ // FILE-SOURCE SEAM (T-66.1). The verify cores are written against ONE tiny abstraction — a `readEntry`
32
+ // function `(relPath) -> { status: "ok", bytes } | { status: "missing" } | { status: "escaped" }` — so
33
+ // the SAME engine verifies from the DISK (the CLI path below, byte-identical to before) or from an
34
+ // IN-MEMORY `{ relPath: Uint8Array }` map (`verifyArtifactFromBytes`, the seam a browser page / vm
35
+ // sandbox drives with ZERO fs/os/path/process on its code path). The whole pure engine sits between the
36
+ // BEGIN/END markers below; test/verifier.browser-core.test.js proves (statically AND dynamically) that
37
+ // no impure builtin use is reachable from the bytes entry, and that disk/bytes verdicts are DEEP-EQUAL.
38
+
39
+ const fs = require("fs");
40
+ const os = require("os");
41
+ const path = require("path");
42
+ // Node CORE sha256 (no npm dependency — the same zero-install class as fs/path; the bundle already
43
+ // allows `crypto` for its embedded --self-attest). Used ONLY by the T-70.4 anchored-receipt section
44
+ // below (the dataset/parcel attestation digest legs), which lives OUTSIDE the pure engine block.
45
+ const nodeCrypto = require("crypto");
46
+
47
+ const merkle = require("./lib/merkle");
48
+ const canonical = require("./lib/canonical");
49
+ const { recoverPersonalSignAddress } = require("./lib/secp256k1-recover");
50
+ const revocation = require("./lib/revocation");
51
+
52
+ // ============================ BEGIN VERIFY-VH PURE ENGINE (T-66.1) ============================
53
+ // EVERYTHING between this marker and the matching END marker is the PURE verify engine: it performs NO
54
+ // I/O of its own and never touches fs / os / path / process / child_process — every byte it verifies
55
+ // arrives through the injected `readEntry` seam (or as an argument). Its only outside references are the
56
+ // four module bindings above, all of which resolve to PURE modules for the functions used here:
57
+ // `merkle`, `canonical`, `recoverPersonalSignAddress`, and the PURE decision half of `revocation`
58
+ // (./lib/revocation-core.js re-exports — never the fs-backed readRevocationsFromPath/loadAndApply).
59
+ // test/verifier.browser-core.test.js enforces all of this mechanically; the markers also make the block
60
+ // mechanically extractable (vm / browser bundling, EPIC-66).
61
+
62
+ // CI-gateable exit contract, mirroring the producer family (vh verify-seal / vh evidence verify):
63
+ // 0 ok / 3 rejected / 2 usage / 1 IO. Stable; a future CI/indexer keys on these.
64
+ const EXIT = Object.freeze({ OK: 0, IO: 1, USAGE: 2, REJECTED: 3 });
65
+
66
+ // A usage error the CLI maps to exit 2 (vs an IO error -> 1, vs a clean REJECTED verdict -> 3).
67
+ class UsageError extends Error {}
68
+ class IOError extends Error {}
69
+
70
+ // The on-disk `kind` discriminators of every artifact family this verifier understands. Bare and signed
71
+ // variants are listed so auto-detect routes correctly. Disjoint, versioned strings — a foreign/random
72
+ // JSON file falls through to a clear "unrecognized artifact" usage error rather than a misread.
73
+ const KINDS = Object.freeze({
74
+ EVIDENCE_SEAL: "vh.evidence-seal",
75
+ EVIDENCE_SEAL_SIGNED: "vh.evidence-seal-signed",
76
+ TRUST_SEAL: "trustledger.reconcile-seal",
77
+ TRUST_SEAL_SIGNED: "trustledger.reconcile-seal-signed",
78
+ DATASET_ATTESTATION: "verifyhash.dataset-attestation",
79
+ DATASET_ATTESTATION_SIGNED: "verifyhash.dataset-attestation-signed",
80
+ DATASET_ATTESTATION_TIMESTAMPED: "verifyhash.dataset-attestation-timestamped",
81
+ PROOF: "verifyhash.merkle-proof",
82
+ AGENT_PACKET: "vh.agent-session-packet",
83
+ });
84
+
85
+ const TRUST_NOTE =
86
+ "verify-vh is an INDEPENDENT, read-only, OFFLINE verifier. It RE-DERIVES the keccak root from the " +
87
+ "bytes you hold and recovers the signer with no producer stack. It proves TAMPER-EVIDENCE + WHO " +
88
+ "vouched — NOT a trusted timestamp and NOT a legal opinion.";
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // Address normalization + recovery helpers. The verifier compares addresses as LOWERCASE 0x-hex (the
92
+ // canonical byte-deterministic form the producer records); a caller may paste an EIP-55-checksummed
93
+ // --vendor and we lowercase it (a checksum mismatch is not our concern — we compare 20 raw bytes).
94
+ // ---------------------------------------------------------------------------
95
+
96
+ const ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
97
+
98
+ function normalizeAddress(addr, label) {
99
+ if (typeof addr !== "string" || !ADDRESS_RE.test(addr)) {
100
+ throw new UsageError(`${label} must be a 0x-prefixed 20-byte hex address, got: ${String(addr)}`);
101
+ }
102
+ return addr.toLowerCase();
103
+ }
104
+
105
+ // Recover the EIP-191 signer over the embedded canonical bytes. A tampered/corrupt signature can be
106
+ // UNRECOVERABLE (no valid curve point) — that throws, which the caller turns into a `bad_signature`
107
+ // REJECTED verdict, never a crash. Returns lowercase 0x-hex, or null if recovery failed.
108
+ function tryRecover(message, signature) {
109
+ try {
110
+ return recoverPersonalSignAddress(message, signature);
111
+ } catch (_) {
112
+ return null;
113
+ }
114
+ }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // Signed-container decoding. A signed artifact carries the embedded UNSIGNED payload as the EXACT
118
+ // canonical bytes (a STRING) in `attestation`, plus a { scheme, signer, signature } block. The signed
119
+ // MESSAGE is that embedded string verbatim, so signer recovery runs over `container.attestation`.
120
+ // ---------------------------------------------------------------------------
121
+
122
+ function decodeSigned(container) {
123
+ const sig = container && container.signature;
124
+ if (sig == null || typeof sig !== "object" || Array.isArray(sig)) {
125
+ throw new IOError("signed artifact is missing a { scheme, signer, signature } signature block");
126
+ }
127
+ if (sig.scheme !== "eip191-personal-sign") {
128
+ throw new IOError(
129
+ `unsupported signature scheme: ${JSON.stringify(sig.scheme)} ` +
130
+ "(this verifier understands eip191-personal-sign)"
131
+ );
132
+ }
133
+ if (typeof container.attestation !== "string") {
134
+ throw new IOError("signed artifact must embed the canonical UNSIGNED bytes as a string `attestation`");
135
+ }
136
+ if (typeof sig.signature !== "string" || !/^0x[0-9a-fA-F]{130}$/.test(sig.signature)) {
137
+ throw new IOError("signed artifact signature must be a 65-byte (r||s||v) 0x-hex string");
138
+ }
139
+ if (typeof sig.signer !== "string" || !ADDRESS_RE.test(sig.signer)) {
140
+ throw new IOError("signed artifact signer must be a 0x-prefixed 20-byte hex address");
141
+ }
142
+ let embedded;
143
+ try {
144
+ embedded = JSON.parse(container.attestation);
145
+ } catch (e) {
146
+ throw new IOError(`embedded attestation is not valid JSON: ${e.message}`);
147
+ }
148
+ return { embedded, message: container.attestation, claimedSigner: sig.signer.toLowerCase(), signature: sig.signature };
149
+ }
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // Per-file re-derivation, shared by every seal kind AND by both file sources. Given the sealed
153
+ // { relPath, contentHash } entries and a `readEntry` source, fetch each referenced file's bytes through
154
+ // the source, recompute its contentHash, and localize the outcome to MATCH / CHANGED / MISSING /
155
+ // ESCAPED; a file present under a sealed relPath that is NOT in the seal cannot occur here (we only read
156
+ // sealed relPaths) — UNEXPECTED is reported only for seals where the producer enumerates a directory
157
+ // (evidence seal verify re-walks the dir). For artifact verification we follow the producer's read
158
+ // model: read exactly the relPaths the artifact names from the source.
159
+ //
160
+ // SECURITY — CONFINEMENT LIVES IN THE SOURCE. `relPath` values come straight from the attacker-controlled
161
+ // artifact JSON (the threat model is attacker-controls-the-input, victim-runs-on-their-own-machine: a
162
+ // malicious producer hands a counterparty a "verify me" artifact, hoping its relPaths probe the
163
+ // counterparty's filesystem). Each source therefore CONFINES every read BEFORE touching its backing
164
+ // store and answers `{ status: "escaped" }` for a hostile relPath (absolute, a `..` traversal component,
165
+ // or — for the disk source — a resolved/realpath escape of baseDir). An escaped entry is recorded ONLY by
166
+ // relPath (the attacker's string) — we NEVER hash it and NEVER emit an actualContentHash for it, so the
167
+ // verdict can never become a content-confirmation / hash-disclosure oracle over a file outside the
168
+ // source. A `path_escape` entry is a hard REJECTED verdict.
169
+ // ---------------------------------------------------------------------------
170
+
171
+ function classifyFilesWith(sealedEntries, readEntry) {
172
+ const changed = [];
173
+ const missing = [];
174
+ const matched = [];
175
+ const escaped = []; // { relPath } only — NEVER a hash; a confinement reject, read nothing
176
+ const flat = []; // { relPath, contentHash } actually-present, for the root re-derivation
177
+
178
+ for (const e of sealedEntries) {
179
+ const relPath = e.relPath;
180
+ const r = readEntry(relPath);
181
+ if (r.status === "escaped") {
182
+ escaped.push({ relPath: String(relPath) });
183
+ continue;
184
+ }
185
+ if (r.status === "missing") {
186
+ missing.push({ relPath });
187
+ continue;
188
+ }
189
+ const actual = merkle.hashBytes(r.bytes);
190
+ flat.push({ relPath, contentHash: actual });
191
+ if (actual.toLowerCase() === String(e.contentHash).toLowerCase()) {
192
+ matched.push({ relPath, contentHash: actual });
193
+ } else {
194
+ changed.push({ relPath, expectedContentHash: e.contentHash, actualContentHash: actual });
195
+ }
196
+ }
197
+ return { matched, changed, missing, escaped, flat };
198
+ }
199
+
200
+ // ---------------------------------------------------------------------------
201
+ // Verify an EVIDENCE seal (bare or the embedded seal of a signed container). The seal lists `files`
202
+ // [{ relPath, contentHash, leaf }] + `root`. We re-derive the root from the bytes the source holds and
203
+ // localize any tamper. NO header (evidence seals bind only the file set). UNEXPECTED files (present
204
+ // under a sealed-sibling tree but not named) are NOT scanned here — the artifact names exactly what it
205
+ // commits to; the producer's `vh evidence verify` re-walks the dir, but the standalone verifier verifies
206
+ // what the artifact REFERENCES (read-only, no directory walk). NOTE an "extra" file is still caught
207
+ // structurally: the sealed root commits to the FULL file set, so a seal doctored to omit an entry can
208
+ // never keep its root (root_mismatch), and a signed seal edited that way breaks its signature.
209
+ // ---------------------------------------------------------------------------
210
+
211
+ function verifyEvidenceSealWith(seal, readEntry) {
212
+ if (!Array.isArray(seal.files) || seal.files.length === 0) {
213
+ throw new IOError("evidence seal `files` must be a non-empty array");
214
+ }
215
+ if (typeof seal.root !== "string" || !merkle.HEX32_RE.test(seal.root)) {
216
+ throw new IOError("evidence seal `root` must be a 0x-prefixed 32-byte hex string");
217
+ }
218
+ const { matched, changed, missing, escaped, flat } = classifyFilesWith(seal.files, readEntry);
219
+
220
+ // The AUTHORITATIVE root is re-derived from the bytes actually held — never the seal's stored root.
221
+ // A partial/changed set yields a different root; rootMatches goes false.
222
+ let recomputedRoot = null;
223
+ if (flat.length > 0) {
224
+ try {
225
+ recomputedRoot = merkle.rootFromFlat(flat);
226
+ } catch (_) {
227
+ recomputedRoot = null;
228
+ }
229
+ }
230
+ const rootMatches =
231
+ missing.length === 0 &&
232
+ changed.length === 0 &&
233
+ escaped.length === 0 &&
234
+ recomputedRoot != null &&
235
+ recomputedRoot.toLowerCase() === seal.root.toLowerCase();
236
+
237
+ return {
238
+ matched,
239
+ changed,
240
+ missing,
241
+ escaped,
242
+ unexpected: [],
243
+ sealedRoot: seal.root,
244
+ recomputedRoot,
245
+ rootMatches,
246
+ filesOk: changed.length === 0 && missing.length === 0 && escaped.length === 0 && rootMatches,
247
+ };
248
+ }
249
+
250
+ // ---------------------------------------------------------------------------
251
+ // Verify a TRUST (reconciliation) seal (bare or embedded). The seal lists `inputs` (role+relPath+
252
+ // contentHash+leaf) and `outputs` (relPath+contentHash+leaf), plus a `verdict` + `root`. The root commits
253
+ // to all inputs + outputs PLUS a synthetic verdict/role HEADER leaf. We re-derive the root from the held
254
+ // bytes AND the header content recomputed from the seal's OWN verdict + input role bindings — so a
255
+ // verdict/role edit (which lives in the seal, not a file) still changes the recomputed root. Inputs are
256
+ // sealed by basename and resolve through the source (the portable handoff ships sources next to the seal).
257
+ // ---------------------------------------------------------------------------
258
+
259
+ function verifyTrustSealWith(seal, readEntry) {
260
+ if (!Array.isArray(seal.inputs) || seal.inputs.length === 0) {
261
+ throw new IOError("trust seal `inputs` must be a non-empty array");
262
+ }
263
+ if (!Array.isArray(seal.outputs) || seal.outputs.length === 0) {
264
+ throw new IOError("trust seal `outputs` must be a non-empty array");
265
+ }
266
+ if (typeof seal.root !== "string" || !merkle.HEX32_RE.test(seal.root)) {
267
+ throw new IOError("trust seal `root` must be a 0x-prefixed 32-byte hex string");
268
+ }
269
+ if (seal.verdict == null || typeof seal.verdict !== "object") {
270
+ throw new IOError("trust seal is missing its `verdict` block");
271
+ }
272
+
273
+ const sealedEntries = [
274
+ ...seal.inputs.map((e) => ({ relPath: e.relPath, contentHash: e.contentHash, role: e.role })),
275
+ ...seal.outputs.map((e) => ({ relPath: e.relPath, contentHash: e.contentHash, role: null })),
276
+ ];
277
+ const { matched, changed, missing, escaped, flat } = classifyFilesWith(sealedEntries, readEntry);
278
+
279
+ // Re-derive the root: the held file leaves PLUS the verdict/role HEADER leaf (content recomputed
280
+ // from the seal's own verdict + input role bindings). The header is folded in as one more (relPath,
281
+ // content) pair under the reserved header relPath — exactly the producer's binding.
282
+ let recomputedRoot = null;
283
+ // Only attempt the root re-derivation when no file is MISSING or ESCAPED (a partial set can never
284
+ // re-derive the sealed root anyway, and the header binds the FULL committed structure).
285
+ if (missing.length === 0 && escaped.length === 0 && flat.length === seal.inputs.length + seal.outputs.length) {
286
+ try {
287
+ const headerBytes = canonical.trustSealHeaderBytes(
288
+ seal.verdict,
289
+ seal.inputs.map((e) => ({ role: e.role, relPath: e.relPath }))
290
+ );
291
+ const committed = [
292
+ ...flat,
293
+ { relPath: canonical.TRUST_SEAL_HEADER_RELPATH, contentHash: merkle.hashBytes(headerBytes) },
294
+ ];
295
+ recomputedRoot = merkle.rootFromFlat(committed);
296
+ } catch (_) {
297
+ recomputedRoot = null;
298
+ }
299
+ }
300
+ const rootMatches =
301
+ escaped.length === 0 &&
302
+ recomputedRoot != null &&
303
+ recomputedRoot.toLowerCase() === seal.root.toLowerCase();
304
+
305
+ return {
306
+ matched,
307
+ changed,
308
+ missing,
309
+ escaped,
310
+ unexpected: [],
311
+ sealedRoot: seal.root,
312
+ recomputedRoot,
313
+ rootMatches,
314
+ filesOk: changed.length === 0 && missing.length === 0 && escaped.length === 0 && rootMatches,
315
+ };
316
+ }
317
+
318
+ // ---------------------------------------------------------------------------
319
+ // Verify a DATASET attestation (bare/signed/timestamped). A dataset attestation commits to the dataset
320
+ // IDENTITY (root, fileCount, manifestDigest) — it does NOT carry the per-file list, so there are no
321
+ // sibling bytes to re-derive a Merkle root from without the original manifest. The independent verifier
322
+ // therefore confirms the embedded identity is well-formed + (for signed) recovers/pins the signer; the
323
+ // `root` is the dataset's, carried as-is. (`vh dataset verify <dir> --manifest` is the path that
324
+ // re-derives a root from a live tree; the attestation alone has no tree to re-walk.)
325
+ // ---------------------------------------------------------------------------
326
+
327
+ function verifyDatasetAttestation(att) {
328
+ for (const f of ["root", "manifestDigest"]) {
329
+ if (typeof att[f] !== "string" || !merkle.HEX32_RE.test(att[f])) {
330
+ throw new IOError(`dataset attestation ${f} must be a 0x-prefixed 32-byte hex string`);
331
+ }
332
+ }
333
+ if (!Number.isInteger(att.fileCount) || att.fileCount < 1) {
334
+ throw new IOError("dataset attestation fileCount must be a positive integer");
335
+ }
336
+ return {
337
+ matched: [],
338
+ changed: [],
339
+ missing: [],
340
+ escaped: [],
341
+ unexpected: [],
342
+ sealedRoot: att.root,
343
+ recomputedRoot: null,
344
+ rootMatches: null, // no sibling bytes to re-derive a root from (identity-only artifact)
345
+ filesOk: true, // structural identity is sound; the binding is via the signature for signed variants
346
+ identityOnly: true,
347
+ };
348
+ }
349
+
350
+ // ---------------------------------------------------------------------------
351
+ // Verify a PROOF bundle. A proof artifact carries { root, leaf, contentHash, relPath, proof[] }. We
352
+ // RE-DERIVE the leaf from relPath + contentHash, then fold leafHash(leaf) up through the proof siblings
353
+ // with nodeHash and confirm it reproduces `root` — byte-identically to the on-chain verifyLeaf, but
354
+ // fully OFFLINE. (The on-chain "is this root anchored" check is out of scope for the offline verifier.)
355
+ // ---------------------------------------------------------------------------
356
+
357
+ function verifyProofBundle(art) {
358
+ for (const f of ["root", "leaf", "contentHash"]) {
359
+ if (typeof art[f] !== "string" || !merkle.HEX32_RE.test(art[f])) {
360
+ throw new IOError(`proof artifact ${f} must be a 0x-prefixed 32-byte hex string`);
361
+ }
362
+ }
363
+ if (typeof art.relPath !== "string" || art.relPath.length === 0) {
364
+ throw new IOError("proof artifact relPath must be a non-empty string");
365
+ }
366
+ if (!Array.isArray(art.proof)) {
367
+ throw new IOError("proof artifact `proof` must be an array of 0x 32-byte hex siblings");
368
+ }
369
+ const derivedLeaf = merkle.pathLeaf(art.relPath, art.contentHash);
370
+ const leafMatches = derivedLeaf.toLowerCase() === art.leaf.toLowerCase();
371
+ let computed = merkle.leafHash(art.leaf);
372
+ for (const sib of art.proof) {
373
+ computed = merkle.nodeHash(computed, sib);
374
+ }
375
+ const foldsToRoot = computed.toLowerCase() === art.root.toLowerCase();
376
+ return {
377
+ matched: leafMatches && foldsToRoot ? [{ relPath: art.relPath, contentHash: art.contentHash }] : [],
378
+ changed:
379
+ leafMatches && foldsToRoot ? [] : [{ relPath: art.relPath, expectedContentHash: art.root, actualContentHash: computed }],
380
+ missing: [],
381
+ escaped: [],
382
+ unexpected: [],
383
+ sealedRoot: art.root,
384
+ recomputedRoot: computed,
385
+ rootMatches: leafMatches && foldsToRoot,
386
+ filesOk: leafMatches && foldsToRoot,
387
+ proof: { derivedLeaf, leafMatches, foldsToRoot },
388
+ };
389
+ }
390
+
391
+ // ---------------------------------------------------------------------------
392
+ // Verify an AGENT-SESSION packet (T-68.3 — the AgentTrace funnel leg, FREE surface only).
393
+ //
394
+ // A `*.vhagent.json` packet is SELF-CONTAINED: it carries its ordered event list (full and/or
395
+ // REDACTED), a per-event leaf expectation list, and an RFC-6962-style ordered Merkle head
396
+ // { size, root } — there are NO sibling files to read, so `readEntry` is never consulted. This block
397
+ // RE-DERIVES every event leaf and the root from the events the packet holds, exactly as the producer's
398
+ // `vh agent verify` does, but from an INDEPENDENT implementation surface: everything below is written
399
+ // against the verifier's OWN dependency-free keccak (merkle.hashBytes) — it imports NOTHING from cli/.
400
+ //
401
+ // THE CONVENTION (must match cli/core/agent-session.js + cli/journal-log.js VERBATIM):
402
+ // * payloadHash = keccak256(utf8(payload)) (the payload COMMITMENT)
403
+ // * event leaf = keccak256(utf8(JSON.stringify([
404
+ // LEAF_DOMAIN, seq, ts, actor, type, payloadHash, canonicalMetaJson|null ])))
405
+ // — the payload participates ONLY via its commitment, so a FULL event and its REDACTED twin
406
+ // (payload dropped, commitment carried, `redacted: true`) derive the IDENTICAL leaf: redaction
407
+ // changes neither the leaves nor the root (it can WITHHOLD, never silently ALTER).
408
+ // * the ordered tree (RFC 6962, position-bound, NO sorting — the OPPOSITE of the evidence tree):
409
+ // leaf node = keccak256(0x00 || leaf) interior = keccak256(0x01 || left || right)
410
+ // MTH(D[0:n]) = interior(MTH(D[0:k]), MTH(D[k:n])), k = largest power of two < n
411
+ // empty log root = keccak256(utf8("vh.journal-log/v1:empty-root"))
412
+ // * a SIGNED packet carries `headAttestation`: a detached EIP-191 personal-sign over the EXACT
413
+ // canonical head-payload bytes (the embedded `attestation` string). The signature wraps the HEAD,
414
+ // so ONE signature stays valid for every redacted copy of the same sealed session.
415
+ //
416
+ // VERDICTS: event-level tamper (a payload that no longer matches its carried commitment — including a
417
+ // REDACTED event whose commitment was forged — or a leaf that no longer matches its expectation) is a
418
+ // REJECT NAMING THE SEQ; a tampered head is `root_mismatch`; a forged signature is `bad_signature`; a
419
+ // sound signature by the wrong signer under a --vendor pin is `wrong_issuer`; a --vendor pin on an
420
+ // UNSIGNED packet is `unsigned_cannot_pin_vendor` (a stripped signature never passes a pinned verify).
421
+ // The recompute is AUTHORITATIVE: the packet is an untrusted container and its stored hashes are only
422
+ // EXPECTATIONS checked against.
423
+ // ---------------------------------------------------------------------------
424
+
425
+ // The producer's in-band trust note, REQUIRED verbatim (the packetseal discipline: the caveat may not
426
+ // drift; a packet whose note was edited is structurally invalid, exactly as `vh agent verify` treats it).
427
+ const AGENT_TRUST_NOTE =
428
+ "This agent-session packet is TAMPER-EVIDENT + OFFLINE-RECOMPUTABLE, NOT a trusted timestamp and " +
429
+ "NOT a claim the agent behaved well. Its ordered Merkle `head` {size, root} (RFC-6962-style, " +
430
+ "position-bound) commits to every event: verify RE-DERIVES each event leaf — recomputing the " +
431
+ "payload hash commitment for a FULL event, checking the carried commitment for a REDACTED one — " +
432
+ "and the root from the events you hold, and a REJECT names the first offending event seq. " +
433
+ "Redaction WITHHOLDS a payload behind its hash commitment without changing any leaf or the root: " +
434
+ "it can hide, never silently alter. Event `ts` fields are SELF-ASSERTED metadata (recorded, never " +
435
+ 'verified against any clock); "sealed at time T" rides the human-owned signing/timestamp ' +
436
+ "trust-root (STRATEGY.md P-3). Garbage-in is out of scope: the head proves the LOG is intact and " +
437
+ "append-only, not that the log faithfully records what the agent actually did. The packet is an " +
438
+ "UNTRUSTED transport container: verify never trusts the packet's own stored hashes.";
439
+
440
+ const AGENT_SIGNED_HEAD_TRUST_NOTE =
441
+ "This is a SIGNED agent-session HEAD attestation: it WRAPS (never edits) the EXACT canonical head " +
442
+ "bytes in `attestation` and attaches a detached EIP-191 signature. It asserts the holder of the " +
443
+ "`signer` key vouched for THIS session head {size, root} at signing time. Because event leaves " +
444
+ "are redaction-safe, the SAME signature stays valid for every redacted copy of the sealed session " +
445
+ "(redaction changes neither leaves nor root). It does NOT prove a timestamp (no \"sealed since " +
446
+ "T\" — still the human trust-root P-3) and is NOT a legal opinion. Every caveat of the packet " +
447
+ "applies. " +
448
+ AGENT_TRUST_NOTE;
449
+
450
+ const AGENT_HEAD_KIND = "vh.agent-head";
451
+ const AGENT_SIGNED_HEAD_KIND = "vh.agent-head-signed";
452
+ const AGENT_PACKET_SCHEMA_VERSIONS = Object.freeze([1]);
453
+ const AGENT_EVENT_TYPES = Object.freeze(["prompt", "completion", "tool_call", "tool_result", "note"]);
454
+ const AGENT_EVENT_FIELDS = Object.freeze([
455
+ "seq",
456
+ "ts",
457
+ "actor",
458
+ "type",
459
+ "payload",
460
+ "payloadHash",
461
+ "redacted",
462
+ "meta",
463
+ ]);
464
+ const AGENT_LEAF_DOMAIN = "vh.agent-session/v1:event-leaf";
465
+ const AGENT_EMPTY_ROOT_DOMAIN = "vh.journal-log/v1:empty-root";
466
+ const AGENT_META_MAX_DEPTH = 32;
467
+ const AGENT_META_MAX_NODES = 100000;
468
+
469
+ // Canonical-case wire shapes (the producer emits lowercase-only hex; mixed case is a foreign artifact).
470
+ const AGENT_HEX32_LC_RE = /^0x[0-9a-f]{64}$/;
471
+ const AGENT_ADDRESS_LC_RE = /^0x[0-9a-f]{40}$/;
472
+ const AGENT_SIG_LC_RE = /^0x[0-9a-f]{130}$/;
473
+
474
+ // STRICT UTF-8 encoder that MIRRORS the producer's ethers `toUtf8Bytes` byte-for-byte (verified over
475
+ // the whole 0x0000..0xFFFF code-unit space + surrogate edge cases). ethers' default error mode THROWS
476
+ // only on a lone HIGH surrogate (an unfinished pair, no code point) — so this returns null there — but
477
+ // it ENCODES a lone LOW surrogate as its literal 3-byte sequence (U+DC00 -> ed b0 80), NOT an error;
478
+ // so a lone low surrogate falls straight through to the c<0x10000 branch below (matching the producer,
479
+ // whose commitment over such a payload is well-defined). Pure JS; no TextEncoder (which would silently
480
+ // substitute U+FFFD and DIVERGE from the producer). null => the event's commitment is undefined here
481
+ // exactly as it is for the producer, so both sides reject in lockstep (fail-closed, never a mismatch).
482
+ function agentUtf8Bytes(str) {
483
+ const out = [];
484
+ for (let i = 0; i < str.length; i++) {
485
+ let c = str.charCodeAt(i);
486
+ if (c >= 0xd800 && c <= 0xdbff) {
487
+ const lo = i + 1 < str.length ? str.charCodeAt(i + 1) : -1;
488
+ if (lo < 0xdc00 || lo > 0xdfff) return null; // lone HIGH surrogate (ethers THROWS; no code point)
489
+ c = (c - 0xd800) * 0x400 + (lo - 0xdc00) + 0x10000;
490
+ i++;
491
+ }
492
+ // A lone LOW surrogate (0xdc00..0xdfff) is NOT special-cased: ethers encodes it as its 3-byte form
493
+ // via the c<0x10000 branch, so we do too — deleting the old lone-low `return null` that FALSELY
494
+ // rejected genuine packets carrying truncated-UTF-16 / arbitrary-tool-result bytes.
495
+ if (c < 0x80) out.push(c);
496
+ else if (c < 0x800) out.push(0xc0 | (c >> 6), 0x80 | (c & 63));
497
+ else if (c < 0x10000) out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63));
498
+ else out.push(0xf0 | (c >> 18), 0x80 | ((c >> 12) & 63), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63));
499
+ }
500
+ return new Uint8Array(out);
501
+ }
502
+
503
+ // 0x-hex -> bytes, and a tiny concat — the only byte plumbing the ordered tree needs.
504
+ function agentHexToBytes(hex) {
505
+ const s = hex.slice(2);
506
+ const out = new Uint8Array(s.length / 2);
507
+ for (let i = 0; i < out.length; i++) out[i] = parseInt(s.slice(i * 2, i * 2 + 2), 16);
508
+ return out;
509
+ }
510
+ function agentConcatBytes(list) {
511
+ let total = 0;
512
+ for (const b of list) total += b.length;
513
+ const out = new Uint8Array(total);
514
+ let off = 0;
515
+ for (const b of list) {
516
+ out.set(b, off);
517
+ off += b.length;
518
+ }
519
+ return out;
520
+ }
521
+
522
+ // RFC-6962 domain-separated hashing over the verifier's OWN keccak (merkle.hashBytes — the same
523
+ // independent primitive every other artifact family here is re-derived with). Children fold in TREE
524
+ // ORDER (never sorted): position IS meaning in an ordered session log.
525
+ function agentLeafNodeHash(leafHex) {
526
+ return merkle.hashBytes(agentConcatBytes([Uint8Array.of(0x00), agentHexToBytes(leafHex)]));
527
+ }
528
+ function agentInteriorHash(leftHex, rightHex) {
529
+ return merkle.hashBytes(
530
+ agentConcatBytes([Uint8Array.of(0x01), agentHexToBytes(leftHex), agentHexToBytes(rightHex)])
531
+ );
532
+ }
533
+
534
+ // MTH (RFC 6962 §2.1) over the ORDERED leaf values; the empty log has a domain-separated constant root.
535
+ function agentTreeRoot(leaves) {
536
+ if (leaves.length === 0) return merkle.hashBytes(agentUtf8Bytes(AGENT_EMPTY_ROOT_DOMAIN));
537
+ function mth(lo, hi) {
538
+ const n = hi - lo;
539
+ if (n === 1) return agentLeafNodeHash(leaves[lo]);
540
+ let k = 1;
541
+ while (k * 2 < n) k *= 2;
542
+ return agentInteriorHash(mth(lo, lo + k), mth(lo + k, hi));
543
+ }
544
+ return mth(0, leaves.length);
545
+ }
546
+
547
+ // A "plain" JSON-shaped object (prototype Object.prototype or null) — the same strictness the producer
548
+ // applies, so what is hashed is exactly what could be written to disk and read back.
549
+ function agentIsPlainObject(v) {
550
+ if (v === null || typeof v !== "object" || Array.isArray(v)) return false;
551
+ const proto = Object.getPrototypeOf(v);
552
+ return proto === Object.prototype || proto === null;
553
+ }
554
+
555
+ // Canonical JSON for `meta`: keys SORTED, only JSON-representable values, depth capped, and a TOTAL
556
+ // work budget so a shared-reference DAG can never hang the verifier. Returns the canonical text or
557
+ // null (reject) — byte-identical to the producer's canonicalization for every accepted value.
558
+ function agentCanonicalJson(value, depth, budget) {
559
+ if (depth > AGENT_META_MAX_DEPTH) return null;
560
+ if (++budget.n > AGENT_META_MAX_NODES) return null;
561
+ if (value === null) return "null";
562
+ const t = typeof value;
563
+ if (t === "boolean") return value ? "true" : "false";
564
+ if (t === "number") return Number.isFinite(value) ? JSON.stringify(value) : null;
565
+ if (t === "string") return JSON.stringify(value);
566
+ if (Array.isArray(value)) {
567
+ const parts = [];
568
+ for (const item of value) {
569
+ const p = agentCanonicalJson(item, depth + 1, budget);
570
+ if (p === null) return null;
571
+ parts.push(p);
572
+ }
573
+ return "[" + parts.join(",") + "]";
574
+ }
575
+ if (agentIsPlainObject(value)) {
576
+ const keys = Object.keys(value).sort();
577
+ const parts = [];
578
+ for (const k of keys) {
579
+ const p = agentCanonicalJson(value[k], depth + 1, budget);
580
+ if (p === null) return null;
581
+ parts.push(JSON.stringify(k) + ":" + p);
582
+ }
583
+ return "{" + parts.join(",") + "}";
584
+ }
585
+ return null;
586
+ }
587
+
588
+ // The payload COMMITMENT: keccak256 over the payload's UTF-8 bytes. null on a non-string or a string
589
+ // with no UTF-8 encoding (a lone HIGH surrogate — where ethers throws) — TOTAL, mirrors the producer
590
+ // exactly (a lone LOW surrogate IS encodable, so it commits rather than rejecting).
591
+ function agentPayloadHash(payload) {
592
+ if (typeof payload !== "string") return null;
593
+ const bytes = agentUtf8Bytes(payload);
594
+ return bytes === null ? null : merkle.hashBytes(bytes);
595
+ }
596
+
597
+ // STRICT validation of one canonical event — an INDEPENDENT re-implementation of the producer's rules
598
+ // (closed field set; exactly the FULL or REDACTED shape; a carried commitment on a full event must
599
+ // equal the recomputed one). Never throws; every failure is a named { ok:false, reason, field? } (the
600
+ // commitment-mismatch reject also carries carried/recomputed so the caller can localize the change).
601
+ function agentValidateEvent(event) {
602
+ try {
603
+ if (!agentIsPlainObject(event)) return { ok: false, reason: "EVENT_NOT_OBJECT" };
604
+ for (const k of Object.keys(event)) {
605
+ if (!AGENT_EVENT_FIELDS.includes(k)) return { ok: false, reason: "EVENT_UNKNOWN_FIELD", field: k };
606
+ }
607
+ if (!Number.isSafeInteger(event.seq) || event.seq < 0) {
608
+ return { ok: false, reason: "EVENT_BAD_SEQ", field: "seq" };
609
+ }
610
+ if (typeof event.ts !== "string") return { ok: false, reason: "EVENT_BAD_TS", field: "ts" };
611
+ if (typeof event.actor !== "string" || event.actor.length === 0) {
612
+ return { ok: false, reason: "EVENT_BAD_ACTOR", field: "actor" };
613
+ }
614
+ if (!AGENT_EVENT_TYPES.includes(event.type)) return { ok: false, reason: "EVENT_BAD_TYPE", field: "type" };
615
+ const hasPayload = "payload" in event;
616
+ const hasHash = "payloadHash" in event;
617
+ if (hasPayload && typeof event.payload !== "string") {
618
+ return { ok: false, reason: "EVENT_BAD_PAYLOAD", field: "payload" };
619
+ }
620
+ if (hasHash && !(typeof event.payloadHash === "string" && merkle.HEX32_RE.test(event.payloadHash))) {
621
+ return { ok: false, reason: "EVENT_BAD_PAYLOAD_HASH", field: "payloadHash" };
622
+ }
623
+ if ("redacted" in event && typeof event.redacted !== "boolean") {
624
+ return { ok: false, reason: "EVENT_BAD_REDACTED_FLAG", field: "redacted" };
625
+ }
626
+ if (!hasPayload && !hasHash) return { ok: false, reason: "EVENT_MISSING_PAYLOAD", field: "payload" };
627
+ if (event.redacted === true && hasPayload) {
628
+ return { ok: false, reason: "EVENT_REDACTED_WITH_PAYLOAD", field: "redacted" };
629
+ }
630
+ if (event.redacted === true && !hasHash) {
631
+ return { ok: false, reason: "EVENT_BAD_PAYLOAD_HASH", field: "payloadHash" };
632
+ }
633
+ if (!hasPayload && event.redacted !== true) {
634
+ return { ok: false, reason: "EVENT_UNFLAGGED_REDACTION", field: "redacted" };
635
+ }
636
+ let commitment;
637
+ if (hasPayload) {
638
+ commitment = agentPayloadHash(event.payload);
639
+ if (commitment === null) return { ok: false, reason: "EVENT_BAD_PAYLOAD", field: "payload" };
640
+ if (hasHash && commitment !== event.payloadHash.toLowerCase()) {
641
+ return {
642
+ ok: false,
643
+ reason: "EVENT_PAYLOAD_HASH_MISMATCH",
644
+ field: "payloadHash",
645
+ carried: event.payloadHash.toLowerCase(),
646
+ recomputed: commitment,
647
+ };
648
+ }
649
+ } else {
650
+ commitment = event.payloadHash.toLowerCase();
651
+ }
652
+ let metaJson = null;
653
+ if ("meta" in event) {
654
+ metaJson = agentCanonicalJson(event.meta, 0, { n: 0 });
655
+ if (metaJson === null) return { ok: false, reason: "EVENT_BAD_META", field: "meta" };
656
+ }
657
+ return { ok: true, redacted: !hasPayload, payloadHash: commitment, metaJson };
658
+ } catch (_) {
659
+ return { ok: false, reason: "HOSTILE_INPUT" };
660
+ }
661
+ }
662
+
663
+ // The redaction-safe LEAF VALUE of one validated event: the fixed-position JSON array preimage with
664
+ // the payload represented ONLY by its commitment (so a full event and its redacted twin derive the
665
+ // identical leaf). Returns null only for an encoding fault (kept total).
666
+ function agentEventLeaf(event, validated) {
667
+ const encoded = JSON.stringify([
668
+ AGENT_LEAF_DOMAIN,
669
+ event.seq,
670
+ event.ts,
671
+ event.actor,
672
+ event.type,
673
+ validated.payloadHash,
674
+ validated.metaJson,
675
+ ]);
676
+ const bytes = agentUtf8Bytes(encoded);
677
+ return bytes === null ? null : merkle.hashBytes(bytes);
678
+ }
679
+
680
+ // The shared { size, root } head shape. Throws IOError (a malformed/foreign artifact, exit 1 — the same
681
+ // class `vh agent verify` gives a structurally invalid packet).
682
+ function validateAgentHeadShape(head, label) {
683
+ if (head == null || typeof head !== "object" || Array.isArray(head)) {
684
+ throw new IOError(`${label} \`head\` must be a { size, root } object`);
685
+ }
686
+ for (const k of Object.keys(head)) {
687
+ if (k !== "size" && k !== "root") {
688
+ throw new IOError(`${label} head has unknown field: ${JSON.stringify(k)}`);
689
+ }
690
+ }
691
+ if (!Number.isSafeInteger(head.size) || head.size < 0) {
692
+ throw new IOError(`${label} head.size must be a non-negative integer, got: ${String(head.size)}`);
693
+ }
694
+ if (typeof head.root !== "string" || !AGENT_HEX32_LC_RE.test(head.root)) {
695
+ throw new IOError(
696
+ `${label} head.root must be a LOWERCASE 0x-bytes32 hex string, got: ${String(head.root)}`
697
+ );
698
+ }
699
+ }
700
+
701
+ // STRICT structural validation of the OPTIONAL signed-head container: the exact canonical embedded
702
+ // bytes, a known scheme, lowercase signer/signature, and an embedded head payload in canonical form.
703
+ // Returns { embeddedHead } for the binding check. Throws IOError on any structural defect.
704
+ function validateAgentSignedHead(container) {
705
+ const label = "agent-session packet headAttestation";
706
+ if (container == null || typeof container !== "object" || Array.isArray(container)) {
707
+ throw new IOError(`${label} must be a JSON object`);
708
+ }
709
+ const KNOWN = ["kind", "schemaVersion", "note", "attestation", "signature"];
710
+ for (const k of Object.keys(container)) {
711
+ if (!KNOWN.includes(k)) throw new IOError(`${label} has unknown field: ${JSON.stringify(k)}`);
712
+ }
713
+ if (container.kind !== AGENT_SIGNED_HEAD_KIND) {
714
+ throw new IOError(
715
+ `${label} kind must be ${JSON.stringify(AGENT_SIGNED_HEAD_KIND)}, got: ${JSON.stringify(container.kind)}`
716
+ );
717
+ }
718
+ if (container.schemaVersion !== 1) {
719
+ throw new IOError(`${label} has unsupported schemaVersion: ${JSON.stringify(container.schemaVersion)}`);
720
+ }
721
+ if (container.note !== AGENT_SIGNED_HEAD_TRUST_NOTE) {
722
+ throw new IOError(`${label} note must be the standing signed-head trust note (caveat must not drift)`);
723
+ }
724
+ if (typeof container.attestation !== "string") {
725
+ throw new IOError(`${label} must embed the canonical UNSIGNED head bytes as a string \`attestation\``);
726
+ }
727
+ let embedded;
728
+ try {
729
+ embedded = JSON.parse(container.attestation);
730
+ } catch (e) {
731
+ throw new IOError(`${label} embedded attestation is not valid JSON: ${e.message}`);
732
+ }
733
+ if (
734
+ embedded == null ||
735
+ typeof embedded !== "object" ||
736
+ Array.isArray(embedded) ||
737
+ embedded.kind !== AGENT_HEAD_KIND ||
738
+ embedded.schemaVersion !== 1 ||
739
+ embedded.note !== AGENT_TRUST_NOTE
740
+ ) {
741
+ throw new IOError(`${label} embedded payload is not a canonical ${JSON.stringify(AGENT_HEAD_KIND)} payload`);
742
+ }
743
+ validateAgentHeadShape(embedded.head, `${label} embedded payload`);
744
+ // The embedded string must be the EXACT canonical serialization (the byte-unambiguous signed message);
745
+ // an insignificant-whitespace/reordered variant is a foreign artifact.
746
+ const canonicalText =
747
+ JSON.stringify({
748
+ kind: embedded.kind,
749
+ schemaVersion: embedded.schemaVersion,
750
+ note: embedded.note,
751
+ head: { size: embedded.head.size, root: embedded.head.root },
752
+ }) + "\n";
753
+ if (container.attestation !== canonicalText) {
754
+ throw new IOError(`${label} embedded attestation is not in canonical form (the signed-over bytes are ambiguous)`);
755
+ }
756
+ const sig = container.signature;
757
+ if (sig == null || typeof sig !== "object" || Array.isArray(sig)) {
758
+ throw new IOError(`${label} signature must be a { scheme, signer, signature } object`);
759
+ }
760
+ if (sig.scheme !== "eip191-personal-sign") {
761
+ throw new IOError(
762
+ `${label} has unsupported signature scheme: ${JSON.stringify(sig.scheme)} (this verifier understands eip191-personal-sign)`
763
+ );
764
+ }
765
+ if (typeof sig.signer !== "string" || !AGENT_ADDRESS_LC_RE.test(sig.signer)) {
766
+ throw new IOError(`${label} signer must be a LOWERCASE 0x-prefixed 20-byte hex address`);
767
+ }
768
+ if (typeof sig.signature !== "string" || !AGENT_SIG_LC_RE.test(sig.signature)) {
769
+ throw new IOError(`${label} signature must be a 65-byte (r||s||v) LOWERCASE 0x-hex string`);
770
+ }
771
+ return { embeddedHead: { size: embedded.head.size, root: embedded.head.root } };
772
+ }
773
+
774
+ // STRICT structural validation of a parsed packet (SHAPE only — the per-event/leaf/root RECOMPUTE is
775
+ // verifyAgentSeal's job, so event-level tamper stays a NAMED verdict naming the seq, never a throw).
776
+ // Mirrors the producer's validatePacketShape defect-for-defect. Throws IOError.
777
+ function validateAgentPacketStructure(obj) {
778
+ const label = "agent-session packet";
779
+ const KNOWN = ["kind", "schemaVersion", "note", "head", "counts", "events", "leaves", "headAttestation"];
780
+ for (const k of Object.keys(obj)) {
781
+ if (!KNOWN.includes(k)) throw new IOError(`${label} has unknown field: ${JSON.stringify(k)}`);
782
+ }
783
+ if (!AGENT_PACKET_SCHEMA_VERSIONS.includes(obj.schemaVersion)) {
784
+ throw new IOError(
785
+ `unsupported ${label} schemaVersion: ${JSON.stringify(obj.schemaVersion)} ` +
786
+ `(this verifier understands ${JSON.stringify(AGENT_PACKET_SCHEMA_VERSIONS)})`
787
+ );
788
+ }
789
+ if (obj.note !== AGENT_TRUST_NOTE) {
790
+ throw new IOError(`${label} \`note\` must be the standing trust note (caveat must not drift)`);
791
+ }
792
+ validateAgentHeadShape(obj.head, label);
793
+ if (obj.counts == null || typeof obj.counts !== "object" || Array.isArray(obj.counts)) {
794
+ throw new IOError(`${label} \`counts\` must be a { events, full, redacted } object`);
795
+ }
796
+ for (const k of Object.keys(obj.counts)) {
797
+ if (!["events", "full", "redacted"].includes(k)) {
798
+ throw new IOError(`${label} counts has unknown field: ${JSON.stringify(k)}`);
799
+ }
800
+ }
801
+ for (const k of ["events", "full", "redacted"]) {
802
+ if (!Number.isSafeInteger(obj.counts[k]) || obj.counts[k] < 0) {
803
+ throw new IOError(`${label} counts.${k} must be a non-negative integer, got: ${String(obj.counts[k])}`);
804
+ }
805
+ }
806
+ if (!Array.isArray(obj.events)) throw new IOError(`${label} \`events\` must be an array`);
807
+ if (!Array.isArray(obj.leaves) || obj.leaves.length !== obj.events.length) {
808
+ throw new IOError(`${label} \`leaves\` must be an array with EXACTLY one leaf expectation per event`);
809
+ }
810
+ obj.leaves.forEach((l, i) => {
811
+ if (typeof l !== "string" || !AGENT_HEX32_LC_RE.test(l)) {
812
+ throw new IOError(`${label} leaves[${i}] must be a LOWERCASE 0x-bytes32 hex string, got: ${String(l)}`);
813
+ }
814
+ });
815
+ if (obj.head.size !== obj.events.length) {
816
+ throw new IOError(
817
+ `${label} head.size (${obj.head.size}) does not match the events length (${obj.events.length})`
818
+ );
819
+ }
820
+ if (obj.counts.events !== obj.events.length || obj.counts.full + obj.counts.redacted !== obj.counts.events) {
821
+ throw new IOError(
822
+ `${label} \`counts\` is internally inconsistent (events must equal the events length; full + redacted must equal events)`
823
+ );
824
+ }
825
+ let signedHead = null;
826
+ if (obj.headAttestation !== undefined) signedHead = validateAgentSignedHead(obj.headAttestation);
827
+ return { packet: obj, signedHead };
828
+ }
829
+
830
+ // The AUTHORITATIVE per-event/leaf/root/counts RECOMPUTE over a shape-validated packet. Returns the
831
+ // engine's standard fileResult shape (matched/changed/... + roots) PLUS an `agent` sub-verdict block
832
+ // and a `reasonKind` in the verifier's reason vocabulary. Event faults are localized to the FIRST
833
+ // offending seq, exactly as the producer's verify names it. Never throws.
834
+ function verifyAgentSeal(packet) {
835
+ const matched = [];
836
+ const changed = [];
837
+ const withheld = [];
838
+ const agent = {
839
+ head: { size: packet.head.size, root: packet.head.root },
840
+ recomputedHead: null,
841
+ counts: null,
842
+ withheld: null,
843
+ seq: null,
844
+ reason: null,
845
+ };
846
+ const base = {
847
+ matched,
848
+ changed,
849
+ missing: [],
850
+ escaped: [],
851
+ unexpected: [],
852
+ sealedRoot: packet.head.root,
853
+ recomputedRoot: null,
854
+ rootMatches: null,
855
+ filesOk: false,
856
+ reasonKind: null,
857
+ agent,
858
+ };
859
+ const events = packet.events;
860
+ const leaves = [];
861
+ for (let i = 0; i < events.length; i++) {
862
+ const v = agentValidateEvent(events[i]);
863
+ if (!v.ok) {
864
+ agent.seq = i;
865
+ agent.reason = v.reason;
866
+ if (v.field !== undefined) agent.field = v.field;
867
+ if (v.reason === "EVENT_PAYLOAD_HASH_MISMATCH") {
868
+ // The payload no longer matches its carried commitment: a CONTENT change localized to its seq
869
+ // (this is also how a REDACTED event's FORGED commitment surfaces once its leaf is checked).
870
+ changed.push({ relPath: `events[${i}]`, expectedContentHash: v.carried, actualContentHash: v.recomputed });
871
+ base.reasonKind = "CHANGED";
872
+ } else {
873
+ base.reasonKind = "event_invalid";
874
+ }
875
+ return base;
876
+ }
877
+ if (events[i].seq !== i) {
878
+ agent.seq = i;
879
+ agent.reason = "SESSION_SEQ_NOT_CONTIGUOUS";
880
+ base.reasonKind = "event_invalid";
881
+ return base;
882
+ }
883
+ const leaf = agentEventLeaf(events[i], v);
884
+ if (leaf === null || leaf !== packet.leaves[i]) {
885
+ // A bound-field edit (ts/actor/type/meta) or a forged redacted commitment: the re-derived leaf no
886
+ // longer matches the packet's own expectation — named by seq, recompute authoritative.
887
+ agent.seq = i;
888
+ agent.reason = "EVENT_LEAF_MISMATCH";
889
+ changed.push({ relPath: `events[${i}]`, expectedContentHash: packet.leaves[i], actualContentHash: leaf });
890
+ base.reasonKind = "CHANGED";
891
+ return base;
892
+ }
893
+ leaves.push(leaf);
894
+ matched.push({ relPath: `events[${i}]`, contentHash: leaf });
895
+ if (v.redacted) withheld.push(i);
896
+ }
897
+ const recomputedRoot = agentTreeRoot(leaves);
898
+ base.recomputedRoot = recomputedRoot;
899
+ agent.recomputedHead = { size: leaves.length, root: recomputedRoot };
900
+ base.rootMatches = leaves.length === packet.head.size && recomputedRoot === packet.head.root;
901
+ if (!base.rootMatches) {
902
+ agent.reason = "HEAD_MISMATCH";
903
+ base.reasonKind = "root_mismatch";
904
+ return base;
905
+ }
906
+ const full = events.length - withheld.length;
907
+ agent.counts = { events: events.length, full, redacted: withheld.length };
908
+ agent.withheld = withheld;
909
+ if (packet.counts.full !== full || packet.counts.redacted !== withheld.length) {
910
+ agent.reason = "COUNTS_MISMATCH";
911
+ base.reasonKind = "counts_mismatch";
912
+ return base;
913
+ }
914
+ base.filesOk = true;
915
+ return base;
916
+ }
917
+
918
+ // The artifact-level orchestrator for KINDS.AGENT_PACKET — both entrypoints (disk + bytes) route here
919
+ // through verifyParsedArtifact, so the two paths' verdicts are one code path (deep-equal by
920
+ // construction). Precedence mirrors the producer's `vh agent verify`: event/leaf/head/counts faults
921
+ // (naming the seq) dominate; then head binding, signature genuineness, and the vendor pin.
922
+ function verifyAgentPacketArtifact({ artifact, obj, pinned }) {
923
+ const { signedHead } = validateAgentPacketStructure(obj); // throws IOError on a malformed/foreign packet
924
+ const fileResult = verifyAgentSeal(obj);
925
+ const agent = fileResult.agent;
926
+
927
+ const signed = obj.headAttestation !== undefined;
928
+ let recoveredSigner = null;
929
+ let claimedSigner = null;
930
+ let signatureOk = null;
931
+ let signerMatchesVendor = null;
932
+ let headBound = null;
933
+ if (signed) {
934
+ claimedSigner = obj.headAttestation.signature.signer; // lowercase, structurally enforced
935
+ recoveredSigner = tryRecover(obj.headAttestation.attestation, obj.headAttestation.signature.signature);
936
+ signatureOk = recoveredSigner != null && recoveredSigner === claimedSigner;
937
+ if (agent.recomputedHead != null) {
938
+ // The signature must vouch for THIS session's RECOMPUTED head — a signature pasted from a
939
+ // different session recovers fine but binds a different { size, root }.
940
+ headBound =
941
+ signedHead.embeddedHead.size === agent.recomputedHead.size &&
942
+ signedHead.embeddedHead.root === agent.recomputedHead.root;
943
+ }
944
+ if (signatureOk && pinned != null) signerMatchesVendor = recoveredSigner === pinned;
945
+ }
946
+
947
+ let accepted = true;
948
+ let reason = "OK";
949
+ if (!fileResult.filesOk) {
950
+ accepted = false;
951
+ reason = fileResult.reasonKind;
952
+ } else if (signed && headBound === false) {
953
+ accepted = false;
954
+ reason = "head_not_bound";
955
+ agent.reason = "HEAD_NOT_BOUND";
956
+ } else if (signed && !signatureOk) {
957
+ accepted = false;
958
+ reason = "bad_signature";
959
+ agent.reason = "SIGNATURE_FORGED";
960
+ } else if (signed && pinned != null && signerMatchesVendor !== true) {
961
+ accepted = false;
962
+ reason = "wrong_issuer";
963
+ agent.reason = "WRONG_VENDOR";
964
+ } else if (!signed && pinned != null) {
965
+ // Fail-closed pin: a stripped signature can never pass a pinned verify.
966
+ accepted = false;
967
+ reason = "unsigned_cannot_pin_vendor";
968
+ agent.reason = "NOT_SIGNED";
969
+ }
970
+
971
+ const result = {
972
+ artifact,
973
+ kind: KINDS.AGENT_PACKET,
974
+ payloadKind: KINDS.AGENT_PACKET,
975
+ signed,
976
+ verdict: accepted ? "OK" : "REJECTED",
977
+ reason,
978
+ accepted,
979
+ recoveredSigner,
980
+ claimedSigner,
981
+ pinnedVendor: pinned,
982
+ signatureOk,
983
+ signerMatchesVendor,
984
+ sealedRoot: fileResult.sealedRoot,
985
+ recomputedRoot: fileResult.recomputedRoot,
986
+ rootMatches: fileResult.rootMatches,
987
+ counts: {
988
+ matched: fileResult.matched.length,
989
+ changed: fileResult.changed.length,
990
+ missing: 0,
991
+ escaped: 0,
992
+ unexpected: 0,
993
+ },
994
+ matched: fileResult.matched,
995
+ changed: fileResult.changed,
996
+ missing: [],
997
+ escaped: [],
998
+ unexpected: [],
999
+ agent,
1000
+ note: TRUST_NOTE,
1001
+ };
1002
+ return { result, code: accepted ? EXIT.OK : EXIT.REJECTED };
1003
+ }
1004
+
1005
+ // ---------------------------------------------------------------------------
1006
+ // The core verify orchestration over an ALREADY-PARSED artifact object + an injected file source. This
1007
+ // is the ONE engine BOTH entrypoints drive — `verifyArtifact` (disk: the CLI contract, byte-identical to
1008
+ // before this seam existed) and `verifyArtifactFromBytes` (in-memory map). It auto-detects the artifact
1009
+ // kind, decodes a signed container (recovering + pinning the signer), re-derives the root from
1010
+ // referenced bytes, and assembles a deterministic verdict. PURE: every read goes through `readEntry`.
1011
+ // Returns { result, code } — code is the EXIT-contract integer.
1012
+ // ---------------------------------------------------------------------------
1013
+
1014
+ function verifyParsedArtifact({ artifact, obj, vendor, readEntry }) {
1015
+ const kind = obj.kind;
1016
+ const pinned = vendor != null ? normalizeAddress(vendor, "--vendor") : null;
1017
+
1018
+ // AGENT-SESSION packet (T-68.3): SELF-CONTAINED — no sibling bytes, its own leaf/root convention and
1019
+ // its own in-packet signed head. Routed to the dedicated orchestrator above (`readEntry` unused).
1020
+ if (kind === KINDS.AGENT_PACKET) {
1021
+ return verifyAgentPacketArtifact({ artifact, obj, pinned });
1022
+ }
1023
+
1024
+ // Detect signed vs bare and the underlying payload kind. A signed container wraps the embedded payload.
1025
+ let signed = false;
1026
+ let recoveredSigner = null;
1027
+ let claimedSigner = null;
1028
+ let signatureOk = null; // null = no signature on this artifact
1029
+ let payload = obj; // the (possibly embedded) thing whose root we re-derive
1030
+ let payloadKind = kind;
1031
+
1032
+ if (
1033
+ kind === KINDS.EVIDENCE_SEAL_SIGNED ||
1034
+ kind === KINDS.TRUST_SEAL_SIGNED ||
1035
+ kind === KINDS.DATASET_ATTESTATION_SIGNED ||
1036
+ kind === KINDS.DATASET_ATTESTATION_TIMESTAMPED
1037
+ ) {
1038
+ signed = true;
1039
+ const dec = decodeSigned(obj);
1040
+ payload = dec.embedded;
1041
+ payloadKind = dec.embedded.kind;
1042
+ claimedSigner = dec.claimedSigner;
1043
+ recoveredSigner = tryRecover(dec.message, dec.signature);
1044
+ // signatureOk: the signature recovers AND matches the CLAIMED signer recorded in the container.
1045
+ signatureOk = recoveredSigner != null && recoveredSigner === claimedSigner;
1046
+ } else if (!Object.values(KINDS).includes(kind)) {
1047
+ throw new UsageError(
1048
+ `unrecognized artifact kind: ${JSON.stringify(kind)} ` +
1049
+ "(verify-vh understands evidence seals, reconciliation seals, dataset attestations, and proof bundles)"
1050
+ );
1051
+ }
1052
+
1053
+ // Re-derive the root from the referenced bytes per the (underlying) kind.
1054
+ let fileResult;
1055
+ if (payloadKind === KINDS.EVIDENCE_SEAL) {
1056
+ fileResult = verifyEvidenceSealWith(payload, readEntry);
1057
+ } else if (payloadKind === KINDS.TRUST_SEAL) {
1058
+ fileResult = verifyTrustSealWith(payload, readEntry);
1059
+ } else if (payloadKind === KINDS.DATASET_ATTESTATION) {
1060
+ fileResult = verifyDatasetAttestation(payload);
1061
+ } else if (payloadKind === KINDS.PROOF) {
1062
+ fileResult = verifyProofBundle(payload);
1063
+ } else {
1064
+ throw new UsageError(
1065
+ `unrecognized embedded artifact kind: ${JSON.stringify(payloadKind)}`
1066
+ );
1067
+ }
1068
+
1069
+ // --- Decide the verdict + the deterministic reason. ---
1070
+ // Precedence: a structural file tamper (CHANGED/MISSING/root mismatch) is a clean REJECTED. For a
1071
+ // SIGNED artifact, a broken signature is `bad_signature`; a recovered signer that does not equal the
1072
+ // pinned --vendor is `wrong_issuer`. Both are clean REJECTED verdicts (exit 3), never a crash.
1073
+ let reason = "OK";
1074
+ let accepted = true;
1075
+
1076
+ const escaped = fileResult.escaped || [];
1077
+ if (!fileResult.filesOk) {
1078
+ accepted = false;
1079
+ // path_escape DOMINATES: an artifact that tries to read outside its source is malicious by
1080
+ // construction (the threat model is a hostile producer probing the counterparty's filesystem), so it
1081
+ // is reported FIRST — never as a benign CHANGED/MISSING, and never with a leaked out-of-tree content
1082
+ // hash.
1083
+ if (escaped.length > 0) reason = "path_escape";
1084
+ else if (fileResult.changed.length > 0) reason = "CHANGED";
1085
+ else if (fileResult.missing.length > 0) reason = "MISSING";
1086
+ else if (fileResult.unexpected.length > 0) reason = "UNEXPECTED";
1087
+ else reason = "root_mismatch";
1088
+ }
1089
+
1090
+ // Signature checks (only for signed artifacts). A bad signature dominates the "issuer" check (you
1091
+ // cannot trust an issuer you cannot recover).
1092
+ let signerMatchesVendor = null;
1093
+ if (signed) {
1094
+ if (!signatureOk) {
1095
+ accepted = false;
1096
+ // bad_signature is the dominant reason ONLY if files were otherwise OK; if a file also changed we
1097
+ // still surface bad_signature because the signature is the trust root of a signed artifact.
1098
+ reason = "bad_signature";
1099
+ } else if (pinned != null) {
1100
+ signerMatchesVendor = recoveredSigner === pinned;
1101
+ if (!signerMatchesVendor) {
1102
+ accepted = false;
1103
+ // wrong_issuer only when the signature itself is sound but the signer is not the pinned vendor.
1104
+ if (fileResult.filesOk) reason = "wrong_issuer";
1105
+ else if (reason === "OK") reason = "wrong_issuer";
1106
+ }
1107
+ }
1108
+ } else if (pinned != null) {
1109
+ // A --vendor pin on an UNSIGNED artifact cannot be satisfied (there is no signer to recover); this is
1110
+ // a clean REJECTED wrong_issuer-style verdict so a CI gate expecting a signed-by-vendor artifact fails.
1111
+ accepted = false;
1112
+ reason = "unsigned_cannot_pin_vendor";
1113
+ }
1114
+
1115
+ const verdict = accepted ? "OK" : "REJECTED";
1116
+ const code = accepted ? EXIT.OK : EXIT.REJECTED;
1117
+
1118
+ const result = {
1119
+ artifact,
1120
+ kind,
1121
+ payloadKind,
1122
+ signed,
1123
+ verdict,
1124
+ reason,
1125
+ accepted,
1126
+ recoveredSigner,
1127
+ claimedSigner,
1128
+ pinnedVendor: pinned,
1129
+ signatureOk,
1130
+ signerMatchesVendor,
1131
+ sealedRoot: fileResult.sealedRoot,
1132
+ recomputedRoot: fileResult.recomputedRoot,
1133
+ rootMatches: fileResult.rootMatches,
1134
+ counts: {
1135
+ matched: fileResult.matched.length,
1136
+ changed: fileResult.changed.length,
1137
+ missing: fileResult.missing.length,
1138
+ escaped: escaped.length,
1139
+ unexpected: fileResult.unexpected.length,
1140
+ },
1141
+ matched: fileResult.matched,
1142
+ changed: fileResult.changed,
1143
+ missing: fileResult.missing,
1144
+ escaped,
1145
+ unexpected: fileResult.unexpected,
1146
+ note: TRUST_NOTE,
1147
+ };
1148
+ if (fileResult.identityOnly) result.identityOnly = true;
1149
+ if (fileResult.proof) result.proof = fileResult.proof;
1150
+
1151
+ return { result, code };
1152
+ }
1153
+
1154
+ // ---------------------------------------------------------------------------
1155
+ // The PURE revocation fold for the bytes path. Semantically identical to revocation.loadAndApply (the
1156
+ // disk integration) once the entries are in hand: resolve the as-of instant (defaulting to nowISO),
1157
+ // normalize the caller-supplied revocations input (a JSON string, a container object, or an array of
1158
+ // either), fold the decision onto the result, and recompute the exit code. Uses ONLY the pure decision
1159
+ // functions (./lib/revocation-core.js via the revocation re-exports) — never the fs-backed reader.
1160
+ // ---------------------------------------------------------------------------
1161
+
1162
+ function applyRevocationsDecision(result, revocationsInput, asOf, nowISO) {
1163
+ const resolved = revocation.resolveAsOf(asOf, nowISO);
1164
+ const entries = revocation.normalizeRevocationsInput(revocationsInput);
1165
+ const downgraded = revocation.applyToVerifyResult({ result, revocations: entries, asOf: resolved.asOf });
1166
+ downgraded.trustAsOfDefaulted = resolved.defaulted;
1167
+ return { result: downgraded, code: downgraded.accepted ? EXIT.OK : EXIT.REJECTED };
1168
+ }
1169
+
1170
+ // ---------------------------------------------------------------------------
1171
+ // THE IN-MEMORY FILE SOURCE + BYTES ENTRYPOINT (T-66.1).
1172
+ //
1173
+ // `verifyArtifactFromBytes({ artifactText, files, vendor, revocationsText, asOf, nowISO, artifactName })`
1174
+ // drives the EXACT engine above over caller-supplied bytes:
1175
+ // * `artifactText` — the artifact JSON as a STRING (what a browser read out of a dropped file);
1176
+ // * `files` — a plain `{ relPath: Uint8Array|Buffer }` map of the packet's referenced bytes;
1177
+ // * `vendor` — optional 0x-address pin (same semantics as `--vendor`);
1178
+ // * `revocationsText` — optional revocations input (JSON text / container / array; same semantics as
1179
+ // the CONTENT of a `--revocations` file), with optional `asOf` (canonical ISO instant) + `nowISO`;
1180
+ // * `artifactName` — optional label used verbatim as `result.artifact` (defaults below).
1181
+ //
1182
+ // CONTRACT — NEVER THROWS. Hostile input (non-JSON artifact text, an oversized / absolute / `..` map
1183
+ // key, a non-bytes map value, a malformed vendor or asOf) is NAMED-rejected: the return value is
1184
+ // { ok, code, result, error }
1185
+ // where a computed verdict carries `result` (the SAME structured shape `verifyArtifact` returns — the
1186
+ // two are DEEP-EQUAL on identical inputs) + `error: null`, and an input problem carries `result: null` +
1187
+ // `error: { name: "UsageError"|"IOError", code, message }` with the exact defect named. The verdict
1188
+ // classes (missing / extra / content-mismatch / wrong-vendor / tampered-signature / path_escape /
1189
+ // revoked) derive from the MAP exactly as the disk path derives them from the directory.
1190
+ // ---------------------------------------------------------------------------
1191
+
1192
+ // The largest relPath key the in-memory map accepts. Sealed relPaths are short; a multi-kilobyte "key"
1193
+ // is hostile input (an attempted resource-exhaustion / log-flooding vector), rejected by NAME up front.
1194
+ const MAX_RELPATH_CHARS = 4096;
1195
+
1196
+ // PURE string-level confinement for an in-memory relPath — the map-source mirror of the disk source's
1197
+ // string checks (absolute anywhere, or any `..` traversal component, is hostile). Windows-style drive
1198
+ // and UNC prefixes are treated as absolute here too: an in-memory map NEVER has a legitimate absolute
1199
+ // key, whatever platform authored the artifact.
1200
+ function isTraversalOrAbsoluteRelPath(relPath) {
1201
+ if (typeof relPath !== "string" || relPath.length === 0) return true;
1202
+ if (relPath.charAt(0) === "/" || relPath.charAt(0) === "\\") return true;
1203
+ if (/^[A-Za-z]:[\\/]/.test(relPath)) return true;
1204
+ if (relPath.split(/[\\/]/).includes("..")) return true;
1205
+ return false;
1206
+ }
1207
+
1208
+ // Validate the caller's `{ relPath: bytes }` map SHAPE up front so a hostile map is NAMED-rejected
1209
+ // before any verification work (and before any key is dereferenced). Throws UsageError; the entrypoint
1210
+ // converts that into the structured `{ error }` return — never an uncaught throw.
1211
+ function validateFilesMap(files) {
1212
+ if (files == null || typeof files !== "object" || Array.isArray(files)) {
1213
+ throw new UsageError(
1214
+ "verifyArtifactFromBytes requires `files` as a plain { relPath: Uint8Array|Buffer } object map"
1215
+ );
1216
+ }
1217
+ for (const key of Object.keys(files)) {
1218
+ if (key.length === 0) {
1219
+ throw new UsageError("files map contains an empty relPath key");
1220
+ }
1221
+ if (key.length > MAX_RELPATH_CHARS) {
1222
+ throw new UsageError(
1223
+ `files map key exceeds ${MAX_RELPATH_CHARS} characters (oversized relPath, starts: ` +
1224
+ `${JSON.stringify(key.slice(0, 64))})`
1225
+ );
1226
+ }
1227
+ if (isTraversalOrAbsoluteRelPath(key)) {
1228
+ throw new UsageError(
1229
+ `files map key is not a confined relative path: ${JSON.stringify(key.slice(0, 256))}`
1230
+ );
1231
+ }
1232
+ const v = files[key];
1233
+ if (!(v instanceof Uint8Array)) {
1234
+ throw new UsageError(
1235
+ `files map value for ${JSON.stringify(key.slice(0, 256))} must be a Uint8Array/Buffer of the file's bytes`
1236
+ );
1237
+ }
1238
+ }
1239
+ }
1240
+
1241
+ // The in-memory `readEntry` source over an (already-validated) map: a hostile relPath from the ARTIFACT
1242
+ // is `escaped` (the same string-level rules as the disk source — so absolute/`..` seal entries produce
1243
+ // the identical path_escape verdict), an absent key is `missing`, and a present key answers its bytes.
1244
+ // Lookups use an own-property check so `__proto__`/`constructor` style keys can never smuggle
1245
+ // prototype-chain values in as file bytes.
1246
+ function makeMapReadEntry(files) {
1247
+ return function readEntry(relPath) {
1248
+ if (isTraversalOrAbsoluteRelPath(relPath)) return { status: "escaped" };
1249
+ if (!Object.prototype.hasOwnProperty.call(files, relPath)) return { status: "missing" };
1250
+ return { status: "ok", bytes: files[relPath] };
1251
+ };
1252
+ }
1253
+
1254
+ function verifyArtifactFromBytes(params) {
1255
+ try {
1256
+ if (params == null || typeof params !== "object" || Array.isArray(params)) {
1257
+ throw new UsageError(
1258
+ "verifyArtifactFromBytes requires a params object: " +
1259
+ "{ artifactText, files, vendor?, revocationsText?, asOf?, nowISO?, artifactName? }"
1260
+ );
1261
+ }
1262
+ const { artifactText, files, vendor, revocationsText, asOf, nowISO, artifactName } = params;
1263
+ if (typeof artifactText !== "string") {
1264
+ throw new UsageError("verifyArtifactFromBytes requires `artifactText` (the artifact JSON as a string)");
1265
+ }
1266
+ validateFilesMap(files);
1267
+
1268
+ // Mirror the CLI's flag-shape gate (parseArgs): asOf only means something alongside revocations, and
1269
+ // must be a canonical ISO-8601 UTC instant — a malformed one is a NAMED usage rejection up front,
1270
+ // never a mid-verify throw.
1271
+ if (asOf !== undefined && asOf !== null && (revocationsText === undefined || revocationsText === null)) {
1272
+ throw new UsageError(
1273
+ "asOf requires revocationsText (it pins the instant the revocation decision is made AS OF)"
1274
+ );
1275
+ }
1276
+ if (asOf !== undefined && asOf !== null) {
1277
+ const ms = Date.parse(asOf);
1278
+ if (
1279
+ typeof asOf !== "string" ||
1280
+ !revocation.ISO_INSTANT_RE.test(asOf) ||
1281
+ Number.isNaN(ms) ||
1282
+ new Date(ms).toISOString() !== asOf
1283
+ ) {
1284
+ throw new UsageError(
1285
+ `invalid asOf: ${String(asOf)} (expected a canonical ISO-8601 UTC instant, e.g. 2026-06-01T00:00:00.000Z)`
1286
+ );
1287
+ }
1288
+ }
1289
+
1290
+ const label = artifactName != null ? String(artifactName) : "(in-memory artifact)";
1291
+ let obj;
1292
+ try {
1293
+ obj = JSON.parse(artifactText);
1294
+ } catch (e) {
1295
+ throw new IOError(`artifact ${label} is not valid JSON: ${e.message}`);
1296
+ }
1297
+ if (obj == null || typeof obj !== "object" || Array.isArray(obj)) {
1298
+ throw new IOError(`artifact ${label} must be a JSON object`);
1299
+ }
1300
+
1301
+ const { result, code } = verifyParsedArtifact({
1302
+ artifact: label,
1303
+ obj,
1304
+ vendor,
1305
+ readEntry: makeMapReadEntry(files),
1306
+ });
1307
+
1308
+ // OPTIONAL recipient-side TRUST-DECISION-AS-OF, from caller-supplied revocations INPUT (never a
1309
+ // filesystem read). Same downgrade math as the disk path's revocation.loadAndApply, so the two
1310
+ // paths' results stay deep-equal on identical inputs.
1311
+ if (revocationsText !== undefined && revocationsText !== null) {
1312
+ let applied;
1313
+ try {
1314
+ applied = applyRevocationsDecision(result, revocationsText, asOf, nowISO || new Date().toISOString());
1315
+ } catch (e) {
1316
+ // A non-JSON / wrong-shape revocations input is the bytes-path analogue of an unreadable
1317
+ // --revocations file: a NAMED IO-class rejection, never a silently-skipped downgrade.
1318
+ throw new IOError(`cannot evaluate revocations: ${e.message}`);
1319
+ }
1320
+ return { ok: applied.result.accepted, code: applied.code, result: applied.result, error: null };
1321
+ }
1322
+
1323
+ return { ok: result.accepted, code, result, error: null };
1324
+ } catch (e) {
1325
+ const isUsage = e instanceof UsageError;
1326
+ const code = isUsage ? EXIT.USAGE : EXIT.IO;
1327
+ return {
1328
+ ok: false,
1329
+ code,
1330
+ result: null,
1331
+ error: {
1332
+ name: isUsage ? "UsageError" : "IOError",
1333
+ code,
1334
+ message: String(e && e.message ? e.message : e),
1335
+ },
1336
+ };
1337
+ }
1338
+ }
1339
+
1340
+ // ============================= END VERIFY-VH PURE ENGINE (T-66.1) =============================
1341
+
1342
+ // ===================================================================================================
1343
+ // ANCHORED-RECEIPT OFFLINE BINDING VERIFY (T-70.4) — `verify-vh <receipt> --anchored-artifact <seal>`.
1344
+ //
1345
+ // WHY THIS EXISTS
1346
+ // `vh anchor-artifact` (EPIC-70) emits a canonical `vh-anchored-receipt@1` container binding ONE
1347
+ // sealed artifact's digest to an on-chain registry record. Its OFFLINE binding leg is pure hashing —
1348
+ // but until T-70.4 it ran ONLY through the producer `cli/` stack (which loads `ethers` at module
1349
+ // load), so the family's zero-install "verify without the producer's stack" promise did not reach
1350
+ // the receipt. This section closes that gap: it is an INDEPENDENT, dependency-free port of the
1351
+ // producer core `cli/core/anchor-binding.js` — the receipt container validation, the CLOSED
1352
+ // six-kind digest table, and the binding verdict — written entirely against the verifier's OWN
1353
+ // primitives (lib/merkle keccak, lib/canonical, Node-core sha256). NO `ethers`, NO `cli/` import.
1354
+ //
1355
+ // WHAT IT CHECKS (and what it does NOT)
1356
+ // OFFLINE binding leg ONLY: the receipt is validated STRICTLY (unknown/missing fields, a drifted
1357
+ // trust note, malformed chain facts — each a named `bad-receipt`), the artifact's ONE canonical
1358
+ // digest is RECOMPUTED through the SAME closed kind table the producer uses (each leg re-validating
1359
+ // the artifact through a strict port of its shipped validator first), and the full
1360
+ // { kind, digest, how } triple must match — `kind-mismatch` / `digest-mismatch` / `how-mismatch`
1361
+ // are the specific named rejects, exactly the producer's verdict vocabulary. The receipt's `chain`
1362
+ // facts remain the ANCHORER'S CLAIM: re-checking them against the chain needs a chain endpoint by
1363
+ // definition and stays with the producer cli (`vh verify-anchored --rpc --contract`).
1364
+ //
1365
+ // PARITY DISCIPLINE (pinned by test/verifier.standalone.test.js)
1366
+ // Every wire-format constant here (the receipt kind, the verbatim ANCHOR_TRUST_NOTE, the reason
1367
+ // codes, the closed kind list, the per-kind derivation-rule `how` strings) MUST equal the producer
1368
+ // core's byte-for-byte, and the verdicts on identical inputs MUST match the producer's — the test
1369
+ // asserts both mechanically, so neither side can drift alone. TOTAL: hostile input yields a named
1370
+ // { ok:false, reason, field?, detail? }, never a throw.
1371
+ // ===================================================================================================
1372
+
1373
+ // The container kind + the standing trust note, VERBATIM the producer's (cli/core/anchor-binding.js).
1374
+ const ANCHORED_RECEIPT_KIND = "vh-anchored-receipt@1";
1375
+
1376
+ const ANCHOR_TRUST_NOTE =
1377
+ "This anchored receipt binds the artifact digest above to an on-chain registry record. A receipt " +
1378
+ "from a LOCAL dev chain proves MECHANISM only and is worth NOTHING publicly until a human deploys " +
1379
+ "the registry (STRATEGY.md P-2). On a public chain it proves ONLY that an on-chain record binds " +
1380
+ "this exact digest at a block whose timestamp BOUNDS existence — as trustworthy as the chain + " +
1381
+ "YOUR pinned contract address — NOT the artifact's truth, NOT faithful recording, NOT attribution " +
1382
+ "beyond the anchoring key. The `chain` facts in this receipt are the anchorer's claim until " +
1383
+ "re-checked against the chain (`vh verify-anchored --rpc`).";
1384
+
1385
+ // The stable, named reason codes — the producer's verdict contract, byte-for-byte.
1386
+ const ANCHOR_REASONS = Object.freeze({
1387
+ NOT_AN_OBJECT: "not-an-object",
1388
+ UNKNOWN_KIND: "unknown-kind",
1389
+ EVIDENCE_SEAL_INVALID: "evidence-seal-invalid",
1390
+ AGENT_PACKET_INVALID: "agent-packet-invalid",
1391
+ JOURNAL_TREE_HEAD_INVALID: "journal-tree-head-invalid",
1392
+ TRUSTLEDGER_SEAL_INVALID: "trustledger-seal-invalid",
1393
+ DATASET_ATTESTATION_INVALID: "dataset-attestation-invalid",
1394
+ PARCEL_ATTESTATION_INVALID: "parcel-attestation-invalid",
1395
+ BAD_ARGS: "bad-args",
1396
+ BAD_DIGEST: "bad-digest",
1397
+ BAD_HOW: "bad-how",
1398
+ BAD_LABEL: "bad-label",
1399
+ BAD_CHAIN: "bad-chain",
1400
+ BAD_RECEIPT: "bad-receipt",
1401
+ DIGEST_MISMATCH: "digest-mismatch",
1402
+ KIND_MISMATCH: "kind-mismatch",
1403
+ HOW_MISMATCH: "how-mismatch",
1404
+ });
1405
+
1406
+ // The two closed-table kinds this verifier did not already name (the other four reuse KINDS above).
1407
+ const ANCHOR_JOURNAL_TREE_HEAD_KIND = "vh.journal-tree-head";
1408
+ const ANCHOR_PARCEL_ATTESTATION_KIND = "verifyhash.parcel-attestation";
1409
+
1410
+ // The CLOSED, frozen kind table — same six kinds, same order as the producer core.
1411
+ const ANCHOR_ARTIFACT_KINDS = Object.freeze([
1412
+ KINDS.EVIDENCE_SEAL, // "vh.evidence-seal"
1413
+ KINDS.AGENT_PACKET, // "vh.agent-session-packet"
1414
+ ANCHOR_JOURNAL_TREE_HEAD_KIND, // "vh.journal-tree-head"
1415
+ KINDS.TRUST_SEAL, // "trustledger.reconcile-seal"
1416
+ KINDS.DATASET_ATTESTATION, // "verifyhash.dataset-attestation"
1417
+ ANCHOR_PARCEL_ATTESTATION_KIND, // "verifyhash.parcel-attestation"
1418
+ ]);
1419
+
1420
+ // Canonical-case wire shapes (the receipt is canonical LOWERCASE; artifacts may carry mixed-case hex
1421
+ // exactly where the producer validators accept it).
1422
+ const ANCHOR_HEX32_LC_RE = /^0x[0-9a-f]{64}$/;
1423
+ const ANCHOR_ADDRESS_LC_RE = /^0x[0-9a-f]{40}$/;
1424
+ const ANCHOR_CONTROL_CHAR_RE = /[\u0000-\u001f\u007f]/;
1425
+ const ANCHOR_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
1426
+
1427
+ function anchorIsPlainObject(v) {
1428
+ return v != null && typeof v === "object" && !Array.isArray(v);
1429
+ }
1430
+
1431
+ // The per-kind derivation rules (`how`) — VERBATIM the producer's HOW_FIXED table. These are WIRE
1432
+ // FORMAT (bound into every receipt), so they name the producer's files even though THIS verifier
1433
+ // re-derives the digest with its own independent code: the rule describes the derivation, and the
1434
+ // parity test pins these strings against the producer core byte-for-byte.
1435
+ const ANCHOR_HOW_FIXED = Object.freeze({
1436
+ [KINDS.EVIDENCE_SEAL]:
1437
+ "digest = the evidence packet's `root` (sorted-pair Merkle root over its path-bound file leaves), " +
1438
+ "re-derived by cli/evidence.js readSeal before extraction",
1439
+ [KINDS.AGENT_PACKET]:
1440
+ "digest = the agent-session packet's verified head `root` (RFC-6962 ordered Merkle root over the " +
1441
+ "event leaves), re-derived by cli/agent.js verifyPacket before extraction",
1442
+ [KINDS.TRUST_SEAL]:
1443
+ "digest = the TrustLedger sealfile's `root` (Merkle root over its committed input/output leaves + " +
1444
+ "verdict header), re-derived by trustledger/seal.js readSeal before extraction",
1445
+ [KINDS.DATASET_ATTESTATION]:
1446
+ "digest = 0x + sha256 over the canonical UNSIGNED dataset-attestation bytes, exactly as " +
1447
+ "`vh dataset timestamp-request` computes it (cli/core/timestamp.js sha256Hex)",
1448
+ [ANCHOR_PARCEL_ATTESTATION_KIND]:
1449
+ "digest = 0x + sha256 over the canonical UNSIGNED parcel-attestation bytes, exactly as " +
1450
+ "`vh parcel timestamp-request` computes it (cli/core/timestamp.js sha256Hex)",
1451
+ });
1452
+
1453
+ function anchorJournalHow(size) {
1454
+ return (
1455
+ `digest = the journal tree head \`root\` (RFC-6962 ordered Merkle root, cli/journal-log.js ` +
1456
+ `treeHead) over ${size} entries; the head size is bound into this derivation rule`
1457
+ );
1458
+ }
1459
+
1460
+ const ANCHOR_JOURNAL_HOW_RE =
1461
+ /^digest = the journal tree head `root` \(RFC-6962 ordered Merkle root, cli\/journal-log\.js treeHead\) over (0|[1-9][0-9]*) entries; the head size is bound into this derivation rule$/;
1462
+
1463
+ function anchorHowValidFor(kind, how) {
1464
+ if (typeof how !== "string") return false;
1465
+ if (kind === ANCHOR_JOURNAL_TREE_HEAD_KIND) {
1466
+ const m = ANCHOR_JOURNAL_HOW_RE.exec(how);
1467
+ return m !== null && Number.isSafeInteger(Number(m[1]));
1468
+ }
1469
+ return how === ANCHOR_HOW_FIXED[kind];
1470
+ }
1471
+
1472
+ function anchorOk(digest, kind, how) {
1473
+ return { ok: true, digest, kind, how };
1474
+ }
1475
+ function anchorNo(reason, detail) {
1476
+ return detail === undefined ? { ok: false, reason } : { ok: false, reason, detail };
1477
+ }
1478
+
1479
+ // ---------------------------------------------------------------------------------------------------
1480
+ // The per-kind STRICT validators + digest extraction — independent ports of the artifacts' shipped
1481
+ // validators (the messages mirror the producers' so the named verdict a counterparty reads is the
1482
+ // same either way). Each leg is TOTAL: a defect is a named reject, never a throw out of this section.
1483
+ // ---------------------------------------------------------------------------------------------------
1484
+
1485
+ // vh.evidence-seal — a strict port of cli/core/packetseal.js validateSeal under the evidence config
1486
+ // (kind/schemaVersion/note pinned, per-entry leaf self-consistency, NO header, and the LOAD-BEARING
1487
+ // root re-derivation from the seal's OWN (relPath, contentHash) leaves via the verifier's merkle lib).
1488
+ const ANCHOR_EVIDENCE_TRUST_NOTE =
1489
+ "This evidence seal is TAMPER-EVIDENT + OFFLINE-RECOMPUTABLE, NOT a trusted timestamp. Its Merkle " +
1490
+ "`root` commits to the full set of (relPath, content) pairs in the directory: any edit, rename, add, " +
1491
+ "or remove changes the root, and verify RE-DERIVES the root from the bytes you hold and LOCALIZES the " +
1492
+ "change to the exact file (MATCH / CHANGED / MISSING / UNEXPECTED). It does NOT prove WHEN the sealing " +
1493
+ 'happened ("sealed at T" rides the human-owned signing/timestamp trust-root, STRATEGY.md P-3) and it ' +
1494
+ "is NOT a legal opinion. The packet is an UNTRUSTED transport container: verify never trusts the " +
1495
+ "packet's own stored hashes.";
1496
+ const ANCHOR_EVIDENCE_SCHEMA_VERSIONS = Object.freeze([1]);
1497
+
1498
+ // Shared strict per-entry + root checks for the two packetseal-family legs. `label` carries the
1499
+ // product wording; `headerLeaf` (when non-null) is folded into the root as the reserved header entry.
1500
+ function anchorCheckSealEntries(entries, label, where, seenRelPath, flat, headerRelPath) {
1501
+ entries.forEach((entry, i) => {
1502
+ if (!anchorIsPlainObject(entry)) {
1503
+ throw new Error(`${label} ${where}[${i}] must be an object`);
1504
+ }
1505
+ if (typeof entry.relPath !== "string" || entry.relPath.length === 0) {
1506
+ throw new Error(`${label} ${where}[${i}].relPath must be a non-empty string`);
1507
+ }
1508
+ if (headerRelPath !== null && entry.relPath === headerRelPath) {
1509
+ throw new Error(
1510
+ `${label} ${where}[${i}].relPath ${JSON.stringify(entry.relPath)} is reserved for the seal header`
1511
+ );
1512
+ }
1513
+ if (seenRelPath.has(entry.relPath)) {
1514
+ throw new Error(`${label} has a duplicate relPath across the file set: ${JSON.stringify(entry.relPath)}`);
1515
+ }
1516
+ seenRelPath.add(entry.relPath);
1517
+ for (const f of ["contentHash", "leaf"]) {
1518
+ if (typeof entry[f] !== "string" || !merkle.HEX32_RE.test(entry[f])) {
1519
+ throw new Error(
1520
+ `${label} ${where}[${i}].${f} must be a 0x-prefixed 32-byte hex string, got: ${String(entry[f])}`
1521
+ );
1522
+ }
1523
+ }
1524
+ const expectedLeaf = merkle.pathLeaf(entry.relPath, entry.contentHash);
1525
+ if (entry.leaf.toLowerCase() !== expectedLeaf.toLowerCase()) {
1526
+ throw new Error(
1527
+ `${label} ${where}[${i}].leaf is inconsistent with its relPath+contentHash ` +
1528
+ `(expected ${expectedLeaf}, got ${entry.leaf})`
1529
+ );
1530
+ }
1531
+ flat.push({ relPath: entry.relPath, contentHash: entry.contentHash });
1532
+ });
1533
+ }
1534
+
1535
+ function anchorValidateEvidenceSeal(obj) {
1536
+ const label = "evidence seal";
1537
+ if (!anchorIsPlainObject(obj)) throw new Error(`${label} must be a JSON object`);
1538
+ if (obj.kind !== KINDS.EVIDENCE_SEAL) {
1539
+ throw new Error(`not a ${label} (kind: ${JSON.stringify(obj.kind)}; expected ${JSON.stringify(KINDS.EVIDENCE_SEAL)})`);
1540
+ }
1541
+ if (!ANCHOR_EVIDENCE_SCHEMA_VERSIONS.includes(obj.schemaVersion)) {
1542
+ throw new Error(
1543
+ `unsupported ${label} schemaVersion: ${JSON.stringify(obj.schemaVersion)} ` +
1544
+ `(this build understands ${JSON.stringify(ANCHOR_EVIDENCE_SCHEMA_VERSIONS)})`
1545
+ );
1546
+ }
1547
+ if (obj.note !== ANCHOR_EVIDENCE_TRUST_NOTE) {
1548
+ throw new Error(`${label} \`note\` must be the standing trust note (caveat must not drift)`);
1549
+ }
1550
+ if (typeof obj.root !== "string" || !merkle.HEX32_RE.test(obj.root)) {
1551
+ throw new Error(`${label} root must be a 0x-prefixed 32-byte hex string, got: ${String(obj.root)}`);
1552
+ }
1553
+ if (!Array.isArray(obj.files) || obj.files.length === 0) {
1554
+ throw new Error(`${label} \`files\` must be a non-empty array`);
1555
+ }
1556
+ const flat = [];
1557
+ anchorCheckSealEntries(obj.files, label, "files", new Set(), flat, null);
1558
+ if (obj.fileCount !== undefined && obj.fileCount !== obj.files.length) {
1559
+ throw new Error(`${label} fileCount (${String(obj.fileCount)}) does not match the files length (${obj.files.length})`);
1560
+ }
1561
+ if (obj.header !== undefined) {
1562
+ throw new Error(`${label} carries a header but its config declares none`);
1563
+ }
1564
+ const rederived = merkle.rootFromFlat(flat);
1565
+ if (rederived.toLowerCase() !== obj.root.toLowerCase()) {
1566
+ throw new Error(
1567
+ `${label} root does not re-derive from its listed entries ` +
1568
+ `(expected ${rederived}, got ${obj.root}) — the seal is internally inconsistent ` +
1569
+ "(a file was edited without updating the root)"
1570
+ );
1571
+ }
1572
+ return obj;
1573
+ }
1574
+
1575
+ function anchorEvidenceDigest(artifact) {
1576
+ try {
1577
+ anchorValidateEvidenceSeal(artifact);
1578
+ } catch (e) {
1579
+ return anchorNo(ANCHOR_REASONS.EVIDENCE_SEAL_INVALID, e && e.message ? e.message : String(e));
1580
+ }
1581
+ return anchorOk(artifact.root.toLowerCase(), KINDS.EVIDENCE_SEAL, ANCHOR_HOW_FIXED[KINDS.EVIDENCE_SEAL]);
1582
+ }
1583
+
1584
+ // vh.agent-session-packet — REUSES this verifier's OWN independent agent engine verbatim: the strict
1585
+ // packet-structure validation + the authoritative per-event/leaf/root/counts recompute, PLUS (when a
1586
+ // headAttestation is present) the head-binding and signature-genuineness checks — the exact facts the
1587
+ // producer's `agent.verifyPacket` gates the digest on (a vendor pin is not part of digest extraction).
1588
+ function anchorAgentDigest(artifact) {
1589
+ let structure;
1590
+ try {
1591
+ structure = validateAgentPacketStructure(artifact);
1592
+ } catch (e) {
1593
+ return anchorNo(ANCHOR_REASONS.AGENT_PACKET_INVALID, e && e.message ? e.message : String(e));
1594
+ }
1595
+ const fileResult = verifyAgentSeal(artifact);
1596
+ const agent = fileResult.agent;
1597
+ const seqOf = () => (agent.seq !== null && agent.seq !== undefined ? ` at seq ${agent.seq}` : "");
1598
+ if (!fileResult.filesOk) {
1599
+ const reason = agent.reason || fileResult.reasonKind || "REJECTED";
1600
+ return anchorNo(ANCHOR_REASONS.AGENT_PACKET_INVALID, `packet verify REJECTED: ${reason}${seqOf()}`);
1601
+ }
1602
+ if (artifact.headAttestation !== undefined) {
1603
+ const embedded = structure.signedHead.embeddedHead;
1604
+ const bound =
1605
+ embedded.size === agent.recomputedHead.size && embedded.root === agent.recomputedHead.root;
1606
+ if (!bound) {
1607
+ return anchorNo(ANCHOR_REASONS.AGENT_PACKET_INVALID, "packet verify REJECTED: HEAD_NOT_BOUND");
1608
+ }
1609
+ const claimed = artifact.headAttestation.signature.signer; // lowercase, structurally enforced
1610
+ const recovered = tryRecover(artifact.headAttestation.attestation, artifact.headAttestation.signature.signature);
1611
+ if (recovered == null || recovered !== claimed) {
1612
+ return anchorNo(ANCHOR_REASONS.AGENT_PACKET_INVALID, "packet verify REJECTED: SIGNATURE_FORGED");
1613
+ }
1614
+ }
1615
+ return anchorOk(fileResult.recomputedRoot, KINDS.AGENT_PACKET, ANCHOR_HOW_FIXED[KINDS.AGENT_PACKET]);
1616
+ }
1617
+
1618
+ // vh.journal-tree-head — the bare { size, root } commitment or its kind-tagged twin. The empty-root
1619
+ // constant is re-derived HERE from the family's domain string with the verifier's own keccak (equal
1620
+ // to cli/journal-log.js EMPTY_ROOT — pinned by the parity test).
1621
+ const ANCHOR_JOURNAL_EMPTY_ROOT = merkle.hashBytes(Buffer.from(AGENT_EMPTY_ROOT_DOMAIN, "utf8"));
1622
+
1623
+ function anchorJournalHeadDigest(artifact, tagged) {
1624
+ const allowed = tagged ? ["kind", "size", "root"] : ["size", "root"];
1625
+ for (const k of Object.keys(artifact)) {
1626
+ if (!allowed.includes(k)) {
1627
+ return anchorNo(
1628
+ ANCHOR_REASONS.JOURNAL_TREE_HEAD_INVALID,
1629
+ `journal tree head has unknown field: ${JSON.stringify(k)}`
1630
+ );
1631
+ }
1632
+ }
1633
+ if (!Number.isSafeInteger(artifact.size) || artifact.size < 0) {
1634
+ return anchorNo(
1635
+ ANCHOR_REASONS.JOURNAL_TREE_HEAD_INVALID,
1636
+ `journal tree head size must be a non-negative integer, got: ${String(artifact.size)}`
1637
+ );
1638
+ }
1639
+ if (typeof artifact.root !== "string" || !ANCHOR_HEX32_LC_RE.test(artifact.root)) {
1640
+ return anchorNo(
1641
+ ANCHOR_REASONS.JOURNAL_TREE_HEAD_INVALID,
1642
+ `journal tree head root must be a LOWERCASE 0x-bytes32 hex string, got: ${String(artifact.root)}`
1643
+ );
1644
+ }
1645
+ if (artifact.size === 0 && artifact.root !== ANCHOR_JOURNAL_EMPTY_ROOT) {
1646
+ return anchorNo(
1647
+ ANCHOR_REASONS.JOURNAL_TREE_HEAD_INVALID,
1648
+ `an EMPTY journal tree head (size 0) must carry the documented empty root ${ANCHOR_JOURNAL_EMPTY_ROOT}`
1649
+ );
1650
+ }
1651
+ if (artifact.size > 0 && artifact.root === ANCHOR_JOURNAL_EMPTY_ROOT) {
1652
+ return anchorNo(
1653
+ ANCHOR_REASONS.JOURNAL_TREE_HEAD_INVALID,
1654
+ "a non-empty journal tree head cannot carry the domain-separated EMPTY root"
1655
+ );
1656
+ }
1657
+ return anchorOk(artifact.root, ANCHOR_JOURNAL_TREE_HEAD_KIND, anchorJournalHow(artifact.size));
1658
+ }
1659
+
1660
+ // trustledger.reconcile-seal — a strict port of trustledger/seal.js validateSeal: the verdict/role/
1661
+ // inputs/outputs checks, per-entry leaf self-consistency, and the LOAD-BEARING root re-derivation from
1662
+ // the seal's OWN leaves PLUS the synthetic verdict/role HEADER leaf (content re-derived from the
1663
+ // seal's recorded verdict + input role bindings via the verifier's own lib/canonical port).
1664
+ const ANCHOR_TRUST_SEAL_NOTE =
1665
+ "This reconciliation seal is TAMPER-EVIDENT, not a trusted timestamp and not a legal opinion. Its " +
1666
+ "Merkle `root` commits to the full set of (relPath, content) pairs across the source inputs AND " +
1667
+ "every emitted packet file, PLUS a reserved HEADER leaf binding the recorded verdict " +
1668
+ "(pass/reportDate/period) and each input's logical role: any edit, rename, add, or remove of a " +
1669
+ "file — or any edit of the verdict/date/period or swap of an input role — changes the root, and " +
1670
+ "verifySeal localizes a file change to the exact file and a verdict/role change to the header. It " +
1671
+ "does NOT prove WHEN the sealing actually happened (the bound reportDate cannot be edited " +
1672
+ "undetected, but a self-asserted date still rides the human trust-root P-3 — standing up a real " +
1673
+ "signing key or timestamp anchor is needs-human) and it does NOT validate the legal MEANING of " +
1674
+ "the reconciliation (the CPA review still governs). The seal is an UNTRUSTED transport container: " +
1675
+ "verifySeal RE-DERIVES the root from the bytes you supply — it never trusts the seal's own hashes.";
1676
+ const ANCHOR_TRUST_SEAL_SCHEMA_VERSIONS = Object.freeze([1]);
1677
+ const ANCHOR_TRUST_SEAL_INPUT_ROLES = Object.freeze(["bank", "book", "rentroll"]);
1678
+ const ANCHOR_TRUST_SEAL_CORE_LABEL = "trustledger reconciliation seal";
1679
+
1680
+ function anchorValidateTrustSeal(obj) {
1681
+ if (!anchorIsPlainObject(obj)) throw new Error("seal must be a JSON object");
1682
+ if (obj.kind !== KINDS.TRUST_SEAL) {
1683
+ throw new Error(
1684
+ `not a trustledger reconciliation seal (kind: ${JSON.stringify(obj.kind)}; expected ` +
1685
+ `${JSON.stringify(KINDS.TRUST_SEAL)})`
1686
+ );
1687
+ }
1688
+ if (!ANCHOR_TRUST_SEAL_SCHEMA_VERSIONS.includes(obj.schemaVersion)) {
1689
+ throw new Error(
1690
+ `unsupported seal schemaVersion: ${JSON.stringify(obj.schemaVersion)} ` +
1691
+ `(this build understands ${JSON.stringify(ANCHOR_TRUST_SEAL_SCHEMA_VERSIONS)})`
1692
+ );
1693
+ }
1694
+ if (obj.note !== ANCHOR_TRUST_SEAL_NOTE) {
1695
+ throw new Error("seal `note` must be the standing SEAL_TRUST_NOTE (caveat must not drift)");
1696
+ }
1697
+ if (typeof obj.root !== "string" || !merkle.HEX32_RE.test(obj.root)) {
1698
+ throw new Error(`seal root must be a 0x-prefixed 32-byte hex string, got: ${String(obj.root)}`);
1699
+ }
1700
+ if (!anchorIsPlainObject(obj.verdict)) {
1701
+ throw new Error("seal is missing `verdict` { pass, reportDate }");
1702
+ }
1703
+ if (typeof obj.verdict.pass !== "boolean") {
1704
+ throw new Error("seal verdict.pass must be a boolean");
1705
+ }
1706
+ if (!ANCHOR_DATE_RE.test(String(obj.verdict.reportDate || ""))) {
1707
+ throw new Error('seal verdict.reportDate must be a "YYYY-MM-DD" string');
1708
+ }
1709
+ if (!("period" in obj.verdict)) {
1710
+ throw new Error("seal verdict is missing `period` (may be null)");
1711
+ }
1712
+ if (obj.verdict.period !== null && typeof obj.verdict.period !== "string") {
1713
+ throw new Error("seal verdict.period must be a string or null");
1714
+ }
1715
+ if (!Array.isArray(obj.inputs) || obj.inputs.length === 0) {
1716
+ throw new Error("seal `inputs` must be a non-empty array");
1717
+ }
1718
+ if (!Array.isArray(obj.outputs) || obj.outputs.length === 0) {
1719
+ throw new Error("seal `outputs` must be a non-empty array");
1720
+ }
1721
+
1722
+ const seenRelPath = new Set();
1723
+ const seenRole = new Set();
1724
+ const flat = [];
1725
+ // Per-entry checks use the trustledger wording (`seal inputs[0]...`); the reserved-header check uses
1726
+ // the core-config label, exactly as the producer's core-delegated view reports it.
1727
+ const checkEntries = (entries, where) => {
1728
+ entries.forEach((entry, i) => {
1729
+ if (!anchorIsPlainObject(entry)) throw new Error(`seal ${where}[${i}] must be an object`);
1730
+ if (typeof entry.relPath !== "string" || entry.relPath.length === 0) {
1731
+ throw new Error(`seal ${where}[${i}].relPath must be a non-empty string`);
1732
+ }
1733
+ if (entry.relPath === canonical.TRUST_SEAL_HEADER_RELPATH) {
1734
+ throw new Error(
1735
+ `${ANCHOR_TRUST_SEAL_CORE_LABEL} files[${flat.length}].relPath ` +
1736
+ `${JSON.stringify(entry.relPath)} is reserved for the seal header`
1737
+ );
1738
+ }
1739
+ if (seenRelPath.has(entry.relPath)) {
1740
+ throw new Error(`seal has a duplicate relPath across the file set: ${JSON.stringify(entry.relPath)}`);
1741
+ }
1742
+ seenRelPath.add(entry.relPath);
1743
+ for (const f of ["contentHash", "leaf"]) {
1744
+ if (typeof entry[f] !== "string" || !merkle.HEX32_RE.test(entry[f])) {
1745
+ throw new Error(
1746
+ `seal ${where}[${i}].${f} must be a 0x-prefixed 32-byte hex string, got: ${String(entry[f])}`
1747
+ );
1748
+ }
1749
+ }
1750
+ const expectedLeaf = merkle.pathLeaf(entry.relPath, entry.contentHash);
1751
+ if (entry.leaf.toLowerCase() !== expectedLeaf.toLowerCase()) {
1752
+ throw new Error(
1753
+ `seal ${where}[${i}].leaf is inconsistent with its relPath+contentHash ` +
1754
+ `(expected ${expectedLeaf}, got ${entry.leaf})`
1755
+ );
1756
+ }
1757
+ flat.push({ relPath: entry.relPath, contentHash: entry.contentHash });
1758
+ });
1759
+ };
1760
+ checkEntries(obj.inputs, "inputs");
1761
+ obj.inputs.forEach((entry, i) => {
1762
+ if (!ANCHOR_TRUST_SEAL_INPUT_ROLES.includes(entry.role)) {
1763
+ throw new Error(
1764
+ `seal inputs[${i}].role must be one of ${JSON.stringify(ANCHOR_TRUST_SEAL_INPUT_ROLES)}, got: ` +
1765
+ `${JSON.stringify(entry.role)}`
1766
+ );
1767
+ }
1768
+ if (seenRole.has(entry.role)) {
1769
+ throw new Error(`seal has a duplicate input role: ${JSON.stringify(entry.role)}`);
1770
+ }
1771
+ seenRole.add(entry.role);
1772
+ });
1773
+ checkEntries(obj.outputs, "outputs");
1774
+ obj.outputs.forEach((entry, i) => {
1775
+ if (entry.role !== undefined && entry.role !== null) {
1776
+ throw new Error(
1777
+ `seal outputs[${i}] must not carry a role (roles partition INPUTS only), got: ` +
1778
+ `${JSON.stringify(entry.role)}`
1779
+ );
1780
+ }
1781
+ });
1782
+ const total = obj.inputs.length + obj.outputs.length;
1783
+ if (obj.fileCount !== undefined && obj.fileCount !== total) {
1784
+ throw new Error(`seal fileCount (${String(obj.fileCount)}) does not match the entry total (${total})`);
1785
+ }
1786
+
1787
+ // THE LOAD-BEARING CHECK: re-derive the root from the listed leaves PLUS the verdict/role HEADER leaf.
1788
+ const headerBytes = canonical.trustSealHeaderBytes(
1789
+ obj.verdict,
1790
+ obj.inputs.map((e) => ({ role: e.role, relPath: e.relPath }))
1791
+ );
1792
+ const committed = [
1793
+ ...flat,
1794
+ { relPath: canonical.TRUST_SEAL_HEADER_RELPATH, contentHash: merkle.hashBytes(headerBytes) },
1795
+ ];
1796
+ const rederived = merkle.rootFromFlat(committed);
1797
+ if (rederived.toLowerCase() !== obj.root.toLowerCase()) {
1798
+ throw new Error(
1799
+ "seal root does not re-derive from its listed entries + verdict/role header " +
1800
+ "(the seal is internally inconsistent: a file, the verdict, or an input role was edited " +
1801
+ "without updating the root)"
1802
+ );
1803
+ }
1804
+ return obj;
1805
+ }
1806
+
1807
+ function anchorTrustledgerDigest(artifact) {
1808
+ try {
1809
+ anchorValidateTrustSeal(artifact);
1810
+ } catch (e) {
1811
+ return anchorNo(ANCHOR_REASONS.TRUSTLEDGER_SEAL_INVALID, e && e.message ? e.message : String(e));
1812
+ }
1813
+ return anchorOk(artifact.root.toLowerCase(), KINDS.TRUST_SEAL, ANCHOR_HOW_FIXED[KINDS.TRUST_SEAL]);
1814
+ }
1815
+
1816
+ // verifyhash.dataset-attestation / verifyhash.parcel-attestation — strict ports of the shipped
1817
+ // validators (cli/dataset.js validateAttestation / cli/parcel.js validateParcelAttestation), then the
1818
+ // SAME canonical bytes the producers serialize (via the verifier's own lib/canonical port — the two
1819
+ // attestation shapes share the identical canonical key order), hashed with Node-core sha256. The
1820
+ // closed field set is enforced FIRST, exactly as the producer core does: an unknown key would ride
1821
+ // along unbound by the digest, so it is rejected rather than silently dropped.
1822
+ const ANCHOR_ATTESTATION_FIELDS = Object.freeze([
1823
+ "kind",
1824
+ "schemaVersion",
1825
+ "note",
1826
+ "root",
1827
+ "fileCount",
1828
+ "manifestDigest",
1829
+ "signed",
1830
+ "signature",
1831
+ ]);
1832
+ const ANCHOR_ATTESTATION_SCHEMA_VERSIONS = Object.freeze([1]);
1833
+
1834
+ function anchorValidateAttestation(obj, kind, noun) {
1835
+ if (!anchorIsPlainObject(obj)) throw new Error(`${noun} attestation must be a JSON object`);
1836
+ if (obj.kind !== kind) {
1837
+ throw new Error(
1838
+ `not a verifyhash ${noun} attestation (kind: ${JSON.stringify(obj.kind)}; expected ${JSON.stringify(kind)})`
1839
+ );
1840
+ }
1841
+ if (!ANCHOR_ATTESTATION_SCHEMA_VERSIONS.includes(obj.schemaVersion)) {
1842
+ throw new Error(
1843
+ `unsupported ${noun} attestation schemaVersion: ${JSON.stringify(obj.schemaVersion)} ` +
1844
+ `(this build understands ${JSON.stringify(ANCHOR_ATTESTATION_SCHEMA_VERSIONS)})`
1845
+ );
1846
+ }
1847
+ for (const f of ["root", "manifestDigest"]) {
1848
+ if (typeof obj[f] !== "string" || !merkle.HEX32_RE.test(obj[f])) {
1849
+ throw new Error(`${noun} attestation ${f} must be a 0x-prefixed 32-byte hex string, got: ${String(obj[f])}`);
1850
+ }
1851
+ }
1852
+ if (!Number.isInteger(obj.fileCount) || obj.fileCount < 1) {
1853
+ throw new Error(`${noun} attestation fileCount must be a positive integer, got: ${String(obj.fileCount)}`);
1854
+ }
1855
+ if (obj.signed !== false) {
1856
+ throw new Error(
1857
+ `${noun} attestation signed must be false (this build emits/reads only the UNSIGNED payload; ` +
1858
+ `attaching a real signature is the human-owned trust-root, P-3), got: ${String(obj.signed)}`
1859
+ );
1860
+ }
1861
+ if (obj.signature !== null) {
1862
+ throw new Error(`${noun} attestation signature must be null in the UNSIGNED payload, got: ${String(obj.signature)}`);
1863
+ }
1864
+ return obj;
1865
+ }
1866
+
1867
+ function anchorAttestationDigest(artifact, kind, noun, reason) {
1868
+ for (const k of Object.keys(artifact)) {
1869
+ if (!ANCHOR_ATTESTATION_FIELDS.includes(k)) {
1870
+ return anchorNo(reason, `attestation has unknown field ${JSON.stringify(k)} (the canonical bytes would not bind it)`);
1871
+ }
1872
+ }
1873
+ let canonicalBytes;
1874
+ try {
1875
+ anchorValidateAttestation(artifact, kind, noun);
1876
+ // The verifier's own canonical serializer: the SAME fixed key order + trailing newline the
1877
+ // producer emits (dataset and parcel attestations share the identical canonical shape).
1878
+ canonicalBytes = canonical.serializeUnsignedDatasetAttestation(artifact);
1879
+ } catch (e) {
1880
+ return anchorNo(reason, e && e.message ? e.message : String(e));
1881
+ }
1882
+ const digest = "0x" + nodeCrypto.createHash("sha256").update(canonicalBytes, "utf8").digest("hex");
1883
+ return anchorOk(digest, kind, ANCHOR_HOW_FIXED[kind]);
1884
+ }
1885
+
1886
+ /**
1887
+ * Extract the ONE canonical 32-byte digest a chain record binds for `artifact` — the standalone port
1888
+ * of the producer core's artifactDigest, dispatching over the SAME closed kind table. TOTAL.
1889
+ */
1890
+ function anchorArtifactDigest(artifact) {
1891
+ try {
1892
+ if (!anchorIsPlainObject(artifact)) {
1893
+ return anchorNo(ANCHOR_REASONS.NOT_AN_OBJECT, "artifact must be a parsed JSON object");
1894
+ }
1895
+ const kind = artifact.kind;
1896
+ if (kind === undefined) {
1897
+ if ("size" in artifact || "root" in artifact) {
1898
+ return anchorJournalHeadDigest(artifact, false);
1899
+ }
1900
+ return anchorNo(
1901
+ ANCHOR_REASONS.UNKNOWN_KIND,
1902
+ "artifact carries no `kind` and is not a { size, root } journal tree head"
1903
+ );
1904
+ }
1905
+ if (typeof kind !== "string") {
1906
+ return anchorNo(ANCHOR_REASONS.UNKNOWN_KIND, "artifact `kind` must be a string");
1907
+ }
1908
+ switch (kind) {
1909
+ case KINDS.EVIDENCE_SEAL:
1910
+ return anchorEvidenceDigest(artifact);
1911
+ case KINDS.AGENT_PACKET:
1912
+ return anchorAgentDigest(artifact);
1913
+ case ANCHOR_JOURNAL_TREE_HEAD_KIND:
1914
+ return anchorJournalHeadDigest(artifact, true);
1915
+ case KINDS.TRUST_SEAL:
1916
+ return anchorTrustledgerDigest(artifact);
1917
+ case KINDS.DATASET_ATTESTATION:
1918
+ return anchorAttestationDigest(
1919
+ artifact,
1920
+ KINDS.DATASET_ATTESTATION,
1921
+ "dataset",
1922
+ ANCHOR_REASONS.DATASET_ATTESTATION_INVALID
1923
+ );
1924
+ case ANCHOR_PARCEL_ATTESTATION_KIND:
1925
+ return anchorAttestationDigest(
1926
+ artifact,
1927
+ ANCHOR_PARCEL_ATTESTATION_KIND,
1928
+ "parcel",
1929
+ ANCHOR_REASONS.PARCEL_ATTESTATION_INVALID
1930
+ );
1931
+ default:
1932
+ return anchorNo(
1933
+ ANCHOR_REASONS.UNKNOWN_KIND,
1934
+ `unknown artifact kind ${JSON.stringify(kind)} (the closed table: ${ANCHOR_ARTIFACT_KINDS.join(", ")})`
1935
+ );
1936
+ }
1937
+ } catch (e) {
1938
+ return anchorNo(ANCHOR_REASONS.NOT_AN_OBJECT, e && e.message ? e.message : String(e));
1939
+ }
1940
+ }
1941
+
1942
+ // ---------------------------------------------------------------------------------------------------
1943
+ // Receipt validation + the binding verdict — verbatim ports of the producer core's _validateReceipt /
1944
+ // verifyAnchoredReceipt (strict form checks; every deviation a named `bad-receipt` naming the field).
1945
+ // ---------------------------------------------------------------------------------------------------
1946
+
1947
+ const ANCHOR_CHAIN_FIELDS = Object.freeze([
1948
+ "authorBound",
1949
+ "blockNumber",
1950
+ "blockTime",
1951
+ "chainId",
1952
+ "contract",
1953
+ "contributor",
1954
+ "txHash",
1955
+ ]);
1956
+ const ANCHOR_RECEIPT_FIELDS = Object.freeze(["artifactKind", "artifactLabel", "chain", "digest", "how", "kind", "note"]);
1957
+ const ANCHOR_RECEIPT_REQUIRED = Object.freeze(["artifactKind", "chain", "digest", "how", "kind", "note"]);
1958
+
1959
+ function anchorBadReceipt(field, detail) {
1960
+ return { ok: false, reason: ANCHOR_REASONS.BAD_RECEIPT, field, detail };
1961
+ }
1962
+
1963
+ function anchorCheckChain(chain) {
1964
+ if (!anchorIsPlainObject(chain)) {
1965
+ return { ok: false, field: "chain", detail: "chain must be an object of the seven recorded chain facts" };
1966
+ }
1967
+ for (const k of Object.keys(chain)) {
1968
+ if (!ANCHOR_CHAIN_FIELDS.includes(k)) {
1969
+ return { ok: false, field: `chain.${k}`, detail: `chain has unknown field: ${JSON.stringify(k)}` };
1970
+ }
1971
+ }
1972
+ for (const k of ANCHOR_CHAIN_FIELDS) {
1973
+ if (!(k in chain)) {
1974
+ return { ok: false, field: `chain.${k}`, detail: `chain is missing required field: ${JSON.stringify(k)}` };
1975
+ }
1976
+ }
1977
+ if (typeof chain.authorBound !== "boolean") {
1978
+ return { ok: false, field: "chain.authorBound", detail: "authorBound must be a boolean" };
1979
+ }
1980
+ for (const k of ["blockNumber", "blockTime"]) {
1981
+ if (!Number.isSafeInteger(chain[k]) || chain[k] < 0) {
1982
+ return { ok: false, field: `chain.${k}`, detail: `${k} must be a non-negative integer, got: ${String(chain[k])}` };
1983
+ }
1984
+ }
1985
+ if (!Number.isSafeInteger(chain.chainId) || chain.chainId < 1) {
1986
+ return { ok: false, field: "chain.chainId", detail: `chainId must be a positive integer, got: ${String(chain.chainId)}` };
1987
+ }
1988
+ for (const k of ["contract", "contributor"]) {
1989
+ if (typeof chain[k] !== "string" || !ANCHOR_ADDRESS_LC_RE.test(chain[k])) {
1990
+ return {
1991
+ ok: false,
1992
+ field: `chain.${k}`,
1993
+ detail: `${k} must be a LOWERCASE 0x-address (canonical case), got: ${String(chain[k])}`,
1994
+ };
1995
+ }
1996
+ }
1997
+ if (typeof chain.txHash !== "string" || !ANCHOR_HEX32_LC_RE.test(chain.txHash)) {
1998
+ return {
1999
+ ok: false,
2000
+ field: "chain.txHash",
2001
+ detail: `txHash must be a LOWERCASE 0x-bytes32 hex string, got: ${String(chain.txHash)}`,
2002
+ };
2003
+ }
2004
+ return { ok: true };
2005
+ }
2006
+
2007
+ function anchorCanonicalChain(chain) {
2008
+ return {
2009
+ authorBound: chain.authorBound,
2010
+ blockNumber: chain.blockNumber,
2011
+ blockTime: chain.blockTime,
2012
+ chainId: chain.chainId,
2013
+ contract: chain.contract,
2014
+ contributor: chain.contributor,
2015
+ txHash: chain.txHash,
2016
+ };
2017
+ }
2018
+
2019
+ function anchorValidateReceipt(receipt) {
2020
+ if (!anchorIsPlainObject(receipt)) {
2021
+ return anchorBadReceipt("receipt", "receipt must be a parsed JSON object");
2022
+ }
2023
+ for (const k of Object.keys(receipt)) {
2024
+ if (!ANCHOR_RECEIPT_FIELDS.includes(k)) {
2025
+ return anchorBadReceipt(k, `receipt has unknown field: ${JSON.stringify(k)}`);
2026
+ }
2027
+ }
2028
+ for (const k of ANCHOR_RECEIPT_REQUIRED) {
2029
+ if (!(k in receipt)) {
2030
+ return anchorBadReceipt(k, `receipt is missing required field: ${JSON.stringify(k)}`);
2031
+ }
2032
+ }
2033
+ if (receipt.kind !== ANCHORED_RECEIPT_KIND) {
2034
+ return anchorBadReceipt(
2035
+ "kind",
2036
+ `not an anchored receipt this build understands (kind: ${JSON.stringify(receipt.kind)}; expected ${JSON.stringify(ANCHORED_RECEIPT_KIND)})`
2037
+ );
2038
+ }
2039
+ if (receipt.note !== ANCHOR_TRUST_NOTE) {
2040
+ return anchorBadReceipt("note", "receipt `note` must be the standing trust note VERBATIM (the caveat must not drift)");
2041
+ }
2042
+ if (typeof receipt.digest !== "string" || !ANCHOR_HEX32_LC_RE.test(receipt.digest)) {
2043
+ return anchorBadReceipt("digest", `receipt digest must be a LOWERCASE 0x-bytes32 hex string, got: ${String(receipt.digest)}`);
2044
+ }
2045
+ if (typeof receipt.artifactKind !== "string" || !ANCHOR_ARTIFACT_KINDS.includes(receipt.artifactKind)) {
2046
+ return anchorBadReceipt(
2047
+ "artifactKind",
2048
+ `receipt artifactKind ${JSON.stringify(receipt.artifactKind)} is not in the closed table (${ANCHOR_ARTIFACT_KINDS.join(", ")})`
2049
+ );
2050
+ }
2051
+ if (!anchorHowValidFor(receipt.artifactKind, receipt.how)) {
2052
+ return anchorBadReceipt("how", `receipt \`how\` is not the documented derivation rule for ${receipt.artifactKind}`);
2053
+ }
2054
+ if (receipt.artifactLabel !== undefined) {
2055
+ const l = receipt.artifactLabel;
2056
+ if (typeof l !== "string" || l.length === 0 || l.length > 200 || ANCHOR_CONTROL_CHAR_RE.test(l)) {
2057
+ return anchorBadReceipt(
2058
+ "artifactLabel",
2059
+ "artifactLabel, when present, must be a 1..200-char string with no control characters"
2060
+ );
2061
+ }
2062
+ }
2063
+ const c = anchorCheckChain(receipt.chain);
2064
+ if (!c.ok) return anchorBadReceipt(c.field, c.detail);
2065
+ return { ok: true };
2066
+ }
2067
+
2068
+ /**
2069
+ * Verify that `receipt` is a well-formed `vh-anchored-receipt@1` AND that it binds EXACTLY the
2070
+ * supplied `artifact` — the OFFLINE binding leg, standalone: the digest is RECOMPUTED from the
2071
+ * artifact via the closed table (never trusted from either side) and the full { kind, digest, how }
2072
+ * triple must match. NEVER consults a network; the receipt's chain facts are returned as the
2073
+ * anchorer's CLAIM. TOTAL: named rejects, no throws. Same verdicts as the producer core.
2074
+ *
2075
+ * @param {object} args { receipt, artifact } — both caller-supplied PARSED objects
2076
+ * @returns {{ ok:true, digest:string, chain:object } |
2077
+ * { ok:false, reason:string, field?:string, detail?:string }}
2078
+ */
2079
+ function verifyAnchoredReceipt(args) {
2080
+ try {
2081
+ if (!anchorIsPlainObject(args)) {
2082
+ return anchorNo(ANCHOR_REASONS.BAD_ARGS, "verifyAnchoredReceipt requires { receipt, artifact }");
2083
+ }
2084
+ const r = anchorValidateReceipt(args.receipt);
2085
+ if (!r.ok) return r;
2086
+ const d = anchorArtifactDigest(args.artifact);
2087
+ if (!d.ok) return d; // the artifact's OWN named validation reject, propagated verbatim
2088
+ const receipt = args.receipt;
2089
+ if (d.kind !== receipt.artifactKind) {
2090
+ return anchorNo(
2091
+ ANCHOR_REASONS.KIND_MISMATCH,
2092
+ `receipt anchors a ${receipt.artifactKind} but the supplied artifact is a ${d.kind}`
2093
+ );
2094
+ }
2095
+ if (d.digest !== receipt.digest) {
2096
+ return anchorNo(
2097
+ ANCHOR_REASONS.DIGEST_MISMATCH,
2098
+ `recomputed digest ${d.digest} != receipt digest ${receipt.digest} — this receipt does not bind this artifact`
2099
+ );
2100
+ }
2101
+ if (d.how !== receipt.how) {
2102
+ return anchorNo(ANCHOR_REASONS.HOW_MISMATCH, `recomputed derivation rule != receipt \`how\` (recomputed: ${d.how})`);
2103
+ }
2104
+ return { ok: true, digest: d.digest, chain: anchorCanonicalChain(receipt.chain) };
2105
+ } catch (e) {
2106
+ return anchorNo(ANCHOR_REASONS.BAD_ARGS, e && e.message ? e.message : String(e));
2107
+ }
2108
+ }
2109
+
2110
+ // ---------------------------------------------------------------------------------------------------
2111
+ // The anchored-receipt CLI leg: read + parse the two files, run the pure binding verify, render the
2112
+ // stable human/JSON verdict. READ-ONLY (no receipt/temp/side-effect file is ever written); exit
2113
+ // contract 0 ACCEPTED / 3 REJECTED (named) / 2 usage / 1 IO — the family's shared verify contract.
2114
+ // ---------------------------------------------------------------------------------------------------
2115
+
2116
+ // The in-band honesty of the offline leg, stated once for both output shapes.
2117
+ const ANCHOR_OFFLINE_NOTE =
2118
+ "OFFLINE binding check: the receipt binds this exact artifact, but its chain facts were NOT " +
2119
+ "re-checked (this standalone verifier opens no network). Confirm them against the chain with the " +
2120
+ "producer cli: vh verify-anchored <receipt> <sealed-file> --rpc <url> --contract <addr>.";
2121
+
2122
+ // ---------------------------------------------------------------------------------------------------
2123
+ // CHAIN-CLASS trust guidance for the OFFLINE leg. The offline binding leg proves the receipt binds
2124
+ // THIS artifact; it can NEVER (offline, by definition) confirm the digest is actually anchored on any
2125
+ // chain. But it CAN classify the chain the receipt CLAIMS — and that classification is the single most
2126
+ // load-bearing thing a counterparty needs to avoid this vertical's worst overclaim: mistaking a
2127
+ // receipt from a worthless LOCAL DEV chain (STRATEGY.md P-2 — a local-chain anchor proves MECHANISM
2128
+ // only and is worth NOTHING publicly) for a public-chain proof. Surfacing it HERE puts the check in
2129
+ // the INDEPENDENT verifier a counterparty actually runs, not only in the producer's prose, and makes
2130
+ // it MACHINE-GATEABLE (`chainClass` / `publiclyMeaningful` in --json — a stable, additive contract a
2131
+ // future indexer/UI keys on). The id sets MIRROR the producer's cli/anchor.js KNOWN_TESTNET_CHAIN_IDS
2132
+ // (test/verifier.standalone.test.js pins them against it byte-for-byte so the two sides cannot drift):
2133
+ // the two generic dev chains are LOCAL-DEV, the remaining known ids are PUBLIC TESTNETS, and every
2134
+ // other id is UNKNOWN (a chain — possibly a mainnet — whose weight this offline leg cannot judge).
2135
+ //
2136
+ // This guidance is STRICTLY ADDITIVE: it never changes the accept/reject decision (a bound receipt is
2137
+ // still ACCEPTED at exit 0) and it never touches the pure `verifyAnchoredReceipt` verdict object,
2138
+ // which stays a byte-faithful port of the producer core. It is presentation-layer trust context only.
2139
+ const ANCHOR_LOCAL_DEV_CHAIN_IDS = Object.freeze([31337, 1337]);
2140
+ const ANCHOR_PUBLIC_TESTNET_CHAIN_IDS = Object.freeze([
2141
+ 80002, 80001, 11155111, 17000, 5, 11155420, 84532, 421614,
2142
+ ]);
2143
+
2144
+ // Classify the chainId a receipt CLAIMS into { chainClass, publiclyMeaningful, advisory }. TOTAL — a
2145
+ // non-integer/out-of-set id falls through to the honest "unknown" bucket (never throws). `chainId`
2146
+ // arrives already strict-validated (a positive safe integer) from anchorCheckChain.
2147
+ function anchorClassifyChainId(chainId) {
2148
+ if (ANCHOR_LOCAL_DEV_CHAIN_IDS.includes(chainId)) {
2149
+ return {
2150
+ chainClass: "local-dev",
2151
+ publiclyMeaningful: false,
2152
+ advisory:
2153
+ `this receipt's chain (chainId ${chainId}) is a LOCAL DEV chain: the anchor proves MECHANISM ` +
2154
+ `ONLY and is worth NOTHING publicly until a human deploys the registry to a public chain ` +
2155
+ `(STRATEGY.md P-2). Do NOT treat a local-dev receipt as a public proof.`,
2156
+ };
2157
+ }
2158
+ if (ANCHOR_PUBLIC_TESTNET_CHAIN_IDS.includes(chainId)) {
2159
+ return {
2160
+ chainClass: "public-testnet",
2161
+ publiclyMeaningful: false,
2162
+ advisory:
2163
+ `this receipt's chain (chainId ${chainId}) is a PUBLIC TESTNET: an anchor there demonstrates ` +
2164
+ `the mechanism on a public chain but carries NO economic finality — treat it as a testnet ` +
2165
+ `proof, never a mainnet one.`,
2166
+ };
2167
+ }
2168
+ return {
2169
+ chainClass: "unknown",
2170
+ publiclyMeaningful: null,
2171
+ advisory:
2172
+ `this receipt's chainId ${chainId} is outside this verifier's known local/testnet set (it may ` +
2173
+ `be a mainnet): the OFFLINE leg cannot weigh the chain — re-check the anchor against that chain ` +
2174
+ `before relying on it.`,
2175
+ };
2176
+ }
2177
+
2178
+ function anchorReadJson(label, filePath) {
2179
+ let text;
2180
+ try {
2181
+ text = fs.readFileSync(path.resolve(filePath), "utf8");
2182
+ } catch (e) {
2183
+ throw new IOError(`cannot read ${label} ${filePath}: ${e.message}`);
2184
+ }
2185
+ let obj;
2186
+ try {
2187
+ obj = JSON.parse(text);
2188
+ } catch (e) {
2189
+ throw new IOError(`${label} ${filePath} is not valid JSON: ${e.message}`);
2190
+ }
2191
+ if (obj == null || typeof obj !== "object" || Array.isArray(obj)) {
2192
+ throw new IOError(`${label} ${filePath} must be a JSON object`);
2193
+ }
2194
+ return obj;
2195
+ }
2196
+
2197
+ function runVerifyAnchoredOffline(opts, write, writeErr) {
2198
+ let receipt;
2199
+ let artifact;
2200
+ try {
2201
+ receipt = anchorReadJson("receipt", opts.artifact);
2202
+ artifact = anchorReadJson("artifact", opts.anchoredArtifact);
2203
+ } catch (e) {
2204
+ writeErr(`error: ${e.message}\n`);
2205
+ return EXIT.IO;
2206
+ }
2207
+
2208
+ const v = verifyAnchoredReceipt({ receipt, artifact });
2209
+ if (!v.ok) {
2210
+ if (opts.json) {
2211
+ write(
2212
+ JSON.stringify(
2213
+ { ok: false, verdict: "REJECTED", mode: "offline", reason: v.reason, field: v.field, detail: v.detail },
2214
+ null,
2215
+ 2
2216
+ ) + "\n"
2217
+ );
2218
+ } else {
2219
+ writeErr(`verify-vh anchored-receipt: REJECTED (${v.reason})${v.detail ? `: ${v.detail}` : ""}\n`);
2220
+ }
2221
+ return EXIT.REJECTED;
2222
+ }
2223
+
2224
+ // Classify the chain the receipt CLAIMS (additive trust context — never changes the ACCEPT verdict).
2225
+ const cls = anchorClassifyChainId(v.chain.chainId);
2226
+
2227
+ if (opts.json) {
2228
+ write(
2229
+ JSON.stringify(
2230
+ {
2231
+ ok: true,
2232
+ verdict: "ACCEPTED",
2233
+ mode: "offline",
2234
+ digest: v.digest,
2235
+ artifactKind: receipt.artifactKind,
2236
+ chain: v.chain,
2237
+ chainClass: cls.chainClass,
2238
+ publiclyMeaningful: cls.publiclyMeaningful,
2239
+ chainAdvisory: cls.advisory,
2240
+ registry: null,
2241
+ note: ANCHOR_OFFLINE_NOTE,
2242
+ },
2243
+ null,
2244
+ 2
2245
+ ) + "\n"
2246
+ );
2247
+ } else {
2248
+ const c = v.chain;
2249
+ write("verify-vh anchored-receipt: ACCEPTED (offline binding check)\n");
2250
+ write(` digest: ${v.digest}\n`);
2251
+ write(` kind: ${receipt.artifactKind}\n`);
2252
+ write(
2253
+ ` chain CLAIM: chainId ${c.chainId}, contract ${c.contract}, tx ${c.txHash}, ` +
2254
+ `block ${c.blockNumber}, blockTime ${c.blockTime}, contributor ${c.contributor}, ` +
2255
+ `authorBound ${c.authorBound}\n`
2256
+ );
2257
+ write(` chain class: ${cls.chainClass} (publiclyMeaningful: ${cls.publiclyMeaningful})\n`);
2258
+ // For anything not proven publicly meaningful, lead with a WARNING so a counterparty cannot skim
2259
+ // past the caveat; a local-dev receipt (the committed-fixture case) is worth NOTHING publicly.
2260
+ write(` ${cls.publiclyMeaningful === true ? "ADVISORY" : "WARNING"}: ${cls.advisory}\n`);
2261
+ write(
2262
+ " NOTE: the OFFLINE binding leg only — the chain facts above are the anchorer's CLAIM, not " +
2263
+ "re-checked against any chain. Confirm them with the producer cli: " +
2264
+ "vh verify-anchored <receipt> <sealed-file> --rpc <url> --contract <addr>.\n"
2265
+ );
2266
+ }
2267
+ return EXIT.OK;
2268
+ }
2269
+
2270
+ // ---------------------------------------------------------------------------
2271
+ // Argument parsing.
2272
+ // SINGLE-ARTIFACT (the original, byte-for-byte unchanged contract):
2273
+ // verify-vh <artifact> [--vendor <0xaddr>] [--dir <d>] [--json]
2274
+ // BATCH/MANIFEST (T-33.1 — one invocation gates EVERY release artifact, one CI exit code):
2275
+ // verify-vh <artifact> <artifact> ... [--vendor <0xaddr>] [--dir <d>] [--json]
2276
+ // verify-vh --manifest <file> [--vendor <0xaddr>] [--dir <d>] [--json]
2277
+ // Batch mode is a pure SUPERSET: it engages ONLY when more than one positional <artifact> is given OR
2278
+ // `--manifest <file>` is supplied. A lone positional with no --manifest takes the identical single path,
2279
+ // so existing callers/tests never shift. A top-level `--vendor`/`--dir` is a DEFAULT each entry inherits
2280
+ // unless the entry (a manifest line) overrides it with its own per-entry `--vendor`/`--dir`.
2281
+ // ---------------------------------------------------------------------------
2282
+
2283
+ function parseArgs(argv) {
2284
+ const opts = {
2285
+ artifact: undefined,
2286
+ vendor: undefined,
2287
+ dir: undefined,
2288
+ json: false,
2289
+ help: false,
2290
+ manifest: undefined,
2291
+ revocations: undefined,
2292
+ asOf: undefined,
2293
+ anchoredArtifact: undefined,
2294
+ _pos: [],
2295
+ };
2296
+ for (let i = 0; i < argv.length; i++) {
2297
+ const a = argv[i];
2298
+ const need = (flag) => {
2299
+ const v = argv[++i];
2300
+ if (v === undefined) throw new UsageError(`${flag} requires a value`);
2301
+ return v;
2302
+ };
2303
+ switch (a) {
2304
+ case "--vendor":
2305
+ opts.vendor = need("--vendor");
2306
+ break;
2307
+ case "--dir":
2308
+ opts.dir = need("--dir");
2309
+ break;
2310
+ case "--manifest":
2311
+ opts.manifest = need("--manifest");
2312
+ break;
2313
+ case "--revocations":
2314
+ opts.revocations = need("--revocations");
2315
+ break;
2316
+ case "--anchored-artifact":
2317
+ opts.anchoredArtifact = need("--anchored-artifact");
2318
+ break;
2319
+ case "--as-of":
2320
+ opts.asOf = need("--as-of");
2321
+ break;
2322
+ case "--json":
2323
+ opts.json = true;
2324
+ break;
2325
+ case "-h":
2326
+ case "--help":
2327
+ case "help":
2328
+ opts.help = true;
2329
+ break;
2330
+ default:
2331
+ if (a && a.startsWith("--")) throw new UsageError(`unknown flag: ${a}`);
2332
+ opts._pos.push(a);
2333
+ }
2334
+ }
2335
+ // batch === any path that aggregates MULTIPLE per-artifact verdicts under ONE exit code:
2336
+ // either a --manifest file, or more than one repeated positional <artifact>.
2337
+ opts.batch = opts.manifest !== undefined || opts._pos.length > 1;
2338
+ if (opts.manifest !== undefined && opts._pos.length > 0) {
2339
+ throw new UsageError(
2340
+ `--manifest <file> lists the artifacts; do not also pass positional <artifact> args (got: ${opts._pos[0]})`
2341
+ );
2342
+ }
2343
+ // Validate the OPTIONAL recipient-side trust-decision flags (--revocations / --as-of, T-51.4) SHAPE up
2344
+ // front so a malformed --as-of (or --as-of without --revocations) is a usage error (2), never a runtime
2345
+ // throw mid-verify. Mirrors `vh evidence verify-signed`'s validateAsOfFlags so the two stacks reject the
2346
+ // same inputs the same way.
2347
+ if (opts.asOf !== undefined && !opts.revocations) {
2348
+ throw new UsageError(
2349
+ "--as-of requires --revocations (it pins the instant the revocation decision is made AS OF)"
2350
+ );
2351
+ }
2352
+ if (opts.asOf !== undefined) {
2353
+ const ms = Date.parse(opts.asOf);
2354
+ if (
2355
+ typeof opts.asOf !== "string" ||
2356
+ !revocation.ISO_INSTANT_RE.test(opts.asOf) ||
2357
+ Number.isNaN(ms) ||
2358
+ new Date(ms).toISOString() !== opts.asOf
2359
+ ) {
2360
+ throw new UsageError(
2361
+ `invalid --as-of: ${opts.asOf} (expected a canonical ISO-8601 UTC instant, e.g. 2026-06-01T00:00:00.000Z)`
2362
+ );
2363
+ }
2364
+ }
2365
+ // ANCHORED-RECEIPT leg (T-70.4): `--anchored-artifact <sealed-file>` pairs ONE receipt positional
2366
+ // with ONE sealed artifact. It is a dedicated two-file binding check, so the sibling-verify flags
2367
+ // (--vendor/--dir/--revocations/--as-of) and the batch/manifest modes do not compose with it — each
2368
+ // incompatible combination is a NAMED usage error up front, never a silently-ignored flag.
2369
+ if (opts.anchoredArtifact !== undefined) {
2370
+ if (opts.manifest !== undefined) {
2371
+ throw new UsageError("--anchored-artifact verifies ONE receipt; it cannot be combined with --manifest");
2372
+ }
2373
+ for (const [flag, val] of [
2374
+ ["--vendor", opts.vendor],
2375
+ ["--dir", opts.dir],
2376
+ ["--revocations", opts.revocations],
2377
+ ["--as-of", opts.asOf],
2378
+ ]) {
2379
+ if (val !== undefined) {
2380
+ throw new UsageError(
2381
+ `${flag} does not apply to the anchored-receipt binding check (--anchored-artifact reads exactly two files: the receipt and the sealed artifact)`
2382
+ );
2383
+ }
2384
+ }
2385
+ if (opts._pos.length !== 1) {
2386
+ throw new UsageError(
2387
+ "--anchored-artifact requires exactly ONE <receipt> positional: verify-vh <receipt> --anchored-artifact <sealed-file>"
2388
+ );
2389
+ }
2390
+ opts.batch = false;
2391
+ }
2392
+ // Preserve the SINGLE-artifact contract verbatim: exactly one positional and no --manifest.
2393
+ opts.artifact = opts._pos[0];
2394
+ return opts;
2395
+ }
2396
+
2397
+ // ---------------------------------------------------------------------------
2398
+ // Manifest parsing. A manifest is a newline list OR a JSON array of artifact entries; each entry names an
2399
+ // artifact path and may carry a per-entry `--vendor`/`--dir` that overrides the top-level defaults.
2400
+ //
2401
+ // NEWLINE form — one entry per line, shell-style tokens. Blank lines and `#` comments are skipped:
2402
+ // releases/a.vhevidence.json
2403
+ // releases/b.vhseal --vendor 0xabc... --dir ./out
2404
+ // JSON form — an array of strings and/or objects:
2405
+ // ["a.vhevidence.json", {"artifact":"b.vhseal","vendor":"0xabc...","dir":"./out"}]
2406
+ //
2407
+ // Paths in the manifest resolve relative to the MANIFEST FILE's own directory (a release ships its
2408
+ // manifest next to its artifacts), unless the path is given a per-entry `--dir` for its SIBLINGS — note
2409
+ // `dir` localizes where an artifact's SIBLING files are read, exactly as the single-artifact `--dir` does;
2410
+ // the artifact path itself resolves against the manifest dir. The manifest is parsed in-process; NO new
2411
+ // crypto and NO network — it is a list, nothing more.
2412
+ // ---------------------------------------------------------------------------
2413
+
2414
+ // Minimal whitespace tokenizer for a newline-form manifest line. No quoting support is needed (artifact
2415
+ // paths and 0x addresses contain no spaces); a token is any run of non-whitespace.
2416
+ function tokenizeManifestLine(line) {
2417
+ return line.split(/\s+/).filter((t) => t.length > 0);
2418
+ }
2419
+
2420
+ function parseManifestLine(line, lineNo) {
2421
+ const toks = tokenizeManifestLine(line);
2422
+ const entry = { artifact: undefined, vendor: undefined, dir: undefined };
2423
+ for (let i = 0; i < toks.length; i++) {
2424
+ const t = toks[i];
2425
+ const need = (flag) => {
2426
+ const v = toks[++i];
2427
+ if (v === undefined) throw new UsageError(`manifest line ${lineNo}: ${flag} requires a value`);
2428
+ return v;
2429
+ };
2430
+ if (t === "--vendor") entry.vendor = need("--vendor");
2431
+ else if (t === "--dir") entry.dir = need("--dir");
2432
+ else if (t.startsWith("--")) throw new UsageError(`manifest line ${lineNo}: unknown flag: ${t}`);
2433
+ else if (entry.artifact === undefined) entry.artifact = t;
2434
+ else throw new UsageError(`manifest line ${lineNo}: unexpected extra token: ${t}`);
2435
+ }
2436
+ if (entry.artifact === undefined) {
2437
+ throw new UsageError(`manifest line ${lineNo}: no artifact path`);
2438
+ }
2439
+ return entry;
2440
+ }
2441
+
2442
+ function parseManifest(text, manifestPath) {
2443
+ const trimmed = text.replace(/^/, "").trim();
2444
+ const entries = [];
2445
+ if (trimmed.startsWith("[")) {
2446
+ // JSON array form.
2447
+ let arr;
2448
+ try {
2449
+ arr = JSON.parse(trimmed);
2450
+ } catch (e) {
2451
+ throw new IOError(`manifest ${manifestPath} is not valid JSON: ${e.message}`);
2452
+ }
2453
+ if (!Array.isArray(arr)) throw new IOError(`manifest ${manifestPath} JSON must be an array of entries`);
2454
+ arr.forEach((raw, idx) => {
2455
+ if (typeof raw === "string") {
2456
+ entries.push({ artifact: raw, vendor: undefined, dir: undefined });
2457
+ } else if (raw && typeof raw === "object" && !Array.isArray(raw)) {
2458
+ if (typeof raw.artifact !== "string" || raw.artifact.length === 0) {
2459
+ throw new IOError(`manifest ${manifestPath} entry ${idx}: "artifact" must be a non-empty string`);
2460
+ }
2461
+ entries.push({
2462
+ artifact: raw.artifact,
2463
+ vendor: raw.vendor != null ? String(raw.vendor) : undefined,
2464
+ dir: raw.dir != null ? String(raw.dir) : undefined,
2465
+ });
2466
+ } else {
2467
+ throw new IOError(`manifest ${manifestPath} entry ${idx} must be a string or { artifact, vendor?, dir? }`);
2468
+ }
2469
+ });
2470
+ } else {
2471
+ // Newline form: one entry per non-blank, non-comment line.
2472
+ const lines = trimmed.split(/\r?\n/);
2473
+ for (let i = 0; i < lines.length; i++) {
2474
+ const line = lines[i];
2475
+ const bare = line.trim();
2476
+ if (bare.length === 0 || bare.startsWith("#")) continue;
2477
+ entries.push(parseManifestLine(line, i + 1));
2478
+ }
2479
+ }
2480
+ if (entries.length === 0) {
2481
+ throw new UsageError(`manifest ${manifestPath} lists no artifacts`);
2482
+ }
2483
+ return entries;
2484
+ }
2485
+
2486
+ // ---------------------------------------------------------------------------
2487
+ // THE DISK FILE SOURCE — the CLI's `readEntry` implementation, carrying the FULL path-confinement
2488
+ // discipline the disk path always had (byte-identical classification):
2489
+ // (1) string-level confinement, BEFORE any filesystem access: an ABSOLUTE relPath, or any relPath with
2490
+ // a `..` path COMPONENT, is REJECTED unread;
2491
+ // (2) resolved-path confinement: a resolved path that ESCAPES baseDir (string-wise, against the
2492
+ // realpath of baseDir) is REJECTED;
2493
+ // (3) post-open symlink confinement: after opening a present file we realpath it and re-assert
2494
+ // containment, defeating a sibling that is a SYMLINK pointing out of baseDir (fs.readFileSync
2495
+ // follows symlinks regardless of the string check) — the just-read bytes are DROPPED, never hashed.
2496
+ // ---------------------------------------------------------------------------
2497
+
2498
+ // True when a resolved absolute path escapes the (already realpath'd) base directory. A path equal to the
2499
+ // base or under it does not escape; anything that path.relative()'s to "" / ".." / an absolute drive is out.
2500
+ function escapesBase(baseReal, abs) {
2501
+ const rel = path.relative(baseReal, abs);
2502
+ return rel === ".." || rel.startsWith(".." + path.sep) || path.isAbsolute(rel);
2503
+ }
2504
+
2505
+ function makeDiskReadEntry(baseDir) {
2506
+ // Anchor confinement on the REALPATH of baseDir so a symlinked baseDir itself (e.g. /tmp -> /private/tmp
2507
+ // on macOS) does not spuriously trip the containment check on otherwise-legitimate siblings.
2508
+ let baseReal;
2509
+ try {
2510
+ baseReal = fs.realpathSync(baseDir);
2511
+ } catch (_) {
2512
+ baseReal = path.resolve(baseDir);
2513
+ }
2514
+
2515
+ return function readEntry(relPath) {
2516
+ // (1) String-level confinement, BEFORE any filesystem access.
2517
+ if (
2518
+ typeof relPath !== "string" ||
2519
+ relPath.length === 0 ||
2520
+ path.isAbsolute(relPath) ||
2521
+ relPath.split(/[\\/]/).includes("..")
2522
+ ) {
2523
+ return { status: "escaped" };
2524
+ }
2525
+
2526
+ // (2) Resolved-path confinement: the resolved absolute path must stay under baseReal.
2527
+ const abs = path.resolve(baseDir, relPath);
2528
+ if (escapesBase(baseReal, abs)) {
2529
+ return { status: "escaped" };
2530
+ }
2531
+
2532
+ let bytes;
2533
+ try {
2534
+ bytes = fs.readFileSync(abs);
2535
+ } catch (_) {
2536
+ return { status: "missing" };
2537
+ }
2538
+
2539
+ // (3) Post-open symlink confinement.
2540
+ let real;
2541
+ try {
2542
+ real = fs.realpathSync(abs);
2543
+ } catch (_) {
2544
+ real = abs;
2545
+ }
2546
+ if (escapesBase(baseReal, real)) {
2547
+ return { status: "escaped" };
2548
+ }
2549
+
2550
+ return { status: "ok", bytes };
2551
+ };
2552
+ }
2553
+
2554
+ // The original disk-shaped helpers, kept with their exact signatures + behavior (thin wrappers over the
2555
+ // engine with a disk source). `relResolver` was always accepted-and-unused on classifyFiles; retained so
2556
+ // the signature does not shift.
2557
+ function classifyFiles(sealedEntries, baseDir, relResolver) { // eslint-disable-line no-unused-vars
2558
+ return classifyFilesWith(sealedEntries, makeDiskReadEntry(baseDir));
2559
+ }
2560
+
2561
+ function verifyEvidenceSeal(seal, baseDir) {
2562
+ return verifyEvidenceSealWith(seal, makeDiskReadEntry(baseDir));
2563
+ }
2564
+
2565
+ function verifyTrustSeal(seal, baseDir) {
2566
+ return verifyTrustSealWith(seal, makeDiskReadEntry(baseDir));
2567
+ }
2568
+
2569
+ // ---------------------------------------------------------------------------
2570
+ // The DISK verify entrypoint — the original CLI contract, byte-identical: reads + JSON-parses the
2571
+ // artifact, then drives the SAME pure engine with the disk file source. Returns { result, code }.
2572
+ // ---------------------------------------------------------------------------
2573
+
2574
+ function verifyArtifact(opts) {
2575
+ if (!opts.artifact) throw new UsageError("verify-vh requires an <artifact>");
2576
+
2577
+ const artifactPath = path.resolve(opts.artifact);
2578
+ let text;
2579
+ try {
2580
+ text = fs.readFileSync(artifactPath, "utf8");
2581
+ } catch (e) {
2582
+ throw new IOError(`cannot read artifact ${opts.artifact}: ${e.message}`);
2583
+ }
2584
+ let obj;
2585
+ try {
2586
+ obj = JSON.parse(text);
2587
+ } catch (e) {
2588
+ throw new IOError(`artifact ${opts.artifact} is not valid JSON: ${e.message}`);
2589
+ }
2590
+ if (obj == null || typeof obj !== "object" || Array.isArray(obj)) {
2591
+ throw new IOError(`artifact ${opts.artifact} must be a JSON object`);
2592
+ }
2593
+
2594
+ // A bare anchored receipt reached the sibling-verify path: point the caller at the two-file binding
2595
+ // check instead of the generic "unrecognized kind" (a receipt alone carries nothing to re-derive).
2596
+ if (obj.kind === ANCHORED_RECEIPT_KIND) {
2597
+ throw new UsageError(
2598
+ `${opts.artifact} is a ${ANCHORED_RECEIPT_KIND} anchored receipt — verify its OFFLINE binding ` +
2599
+ "leg against the sealed artifact it anchors: verify-vh <receipt> --anchored-artifact <sealed-file>"
2600
+ );
2601
+ }
2602
+
2603
+ // The base directory siblings resolve against: --dir override else the artifact's own directory.
2604
+ const baseDir = opts.dir != null ? path.resolve(opts.dir) : path.dirname(artifactPath);
2605
+
2606
+ const { result, code } = verifyParsedArtifact({
2607
+ artifact: opts.artifact,
2608
+ obj,
2609
+ vendor: opts.vendor,
2610
+ readEntry: makeDiskReadEntry(baseDir),
2611
+ });
2612
+
2613
+ // OPTIONAL recipient-side TRUST-DECISION-AS-OF (EPIC-51 / T-51.4). Runs ONLY under --revocations — with no
2614
+ // flag the result + code are byte-identical to the pre-T-51.4 baseline (regression-pinned). A signer
2615
+ // revoked-before-as-of downgrades an otherwise-ACCEPTED artifact to REVOKED (exit 3); a later-dated
2616
+ // revocation is informational; a forged/tampered/third-party one is ignored with a warning. OFFLINE /
2617
+ // key-free on the read side; the revocations file/dir is the ONLY new I/O. This reaches the SAME downgrade
2618
+ // `vh ... verify-signed --revocations` does, byte-for-byte on identical inputs.
2619
+ if (opts.revocations) {
2620
+ let applied;
2621
+ try {
2622
+ applied = revocation.loadAndApply({
2623
+ result,
2624
+ revocationsPath: opts.revocations,
2625
+ asOf: opts.asOf,
2626
+ nowISO: opts.nowISO || new Date().toISOString(),
2627
+ });
2628
+ } catch (e) {
2629
+ // A malformed --as-of is caught at parse time; here the only failures are an unreadable path or a
2630
+ // non-JSON single revocations file — a genuine IO error (exit 1), surfaced (never a stack), never a
2631
+ // silently-skipped downgrade.
2632
+ throw new IOError(`cannot evaluate --revocations ${opts.revocations}: ${e.message}`);
2633
+ }
2634
+ // A REVOKED decision flips an otherwise-ACCEPTED verdict to REVOKED (exit 3); an already-REJECTED verdict
2635
+ // is left rejected (the trust-as-of never upgrades). The trustAsOf block + defaulted flag ride along for
2636
+ // the renderer.
2637
+ const downgraded = applied.result;
2638
+ downgraded.trustAsOfDefaulted = applied.defaulted;
2639
+ const newCode = downgraded.accepted ? EXIT.OK : EXIT.REJECTED;
2640
+ return { result: downgraded, code: newCode };
2641
+ }
2642
+
2643
+ return { result, code };
2644
+ }
2645
+
2646
+ // ---------------------------------------------------------------------------
2647
+ // BATCH / MANIFEST orchestration (T-33.1). One invocation gates EVERY artifact a release produces and
2648
+ // returns ONE CI exit code. Each entry is verified READ-ONLY through the SAME `verifyArtifact` core (NO
2649
+ // new crypto, NO new artifact kind, path-escape/no-network guarantees preserved per entry); the per-entry
2650
+ // `--json` body is the IDENTICAL single-artifact shape, so there is no divergence to drift.
2651
+ //
2652
+ // AGGREGATE EXIT CONTRACT:
2653
+ // * exit 0 (OK) — and only if — EVERY artifact verifies (each accepted).
2654
+ // * exit 3 (REJECTED) — if ANY artifact is rejected (CHANGED/MISSING/bad_signature/wrong_issuer/…);
2655
+ // the report names WHICH artifact failed and why.
2656
+ // * exit 2 (USAGE) — a malformed flag / per-entry --vendor (raised before any verify runs).
2657
+ // * exit 1 (IO) — an artifact (or the manifest itself) is unreadable / not the expected shape.
2658
+ // Usage/IO are evaluated PER ENTRY and SHORT-CIRCUIT the whole run with the matching code, exactly as the
2659
+ // single-artifact path does — a release gate must not "pass" while one of its artifacts could not even be
2660
+ // read or parsed. The IO/USAGE code wins over a REJECTED tally (you cannot certify a batch you could not
2661
+ // fully evaluate).
2662
+ // ---------------------------------------------------------------------------
2663
+
2664
+ function buildBatchEntries(opts) {
2665
+ // Returns [{ artifact, vendor, dir }] with top-level --vendor/--dir applied as DEFAULTS each entry may
2666
+ // override. Artifact paths from a manifest resolve against the manifest file's own directory.
2667
+ if (opts.manifest !== undefined) {
2668
+ const manifestPath = path.resolve(opts.manifest);
2669
+ let text;
2670
+ try {
2671
+ text = fs.readFileSync(manifestPath, "utf8");
2672
+ } catch (e) {
2673
+ throw new IOError(`cannot read manifest ${opts.manifest}: ${e.message}`);
2674
+ }
2675
+ const manifestDir = path.dirname(manifestPath);
2676
+ return parseManifest(text, opts.manifest).map((e) => ({
2677
+ // The artifact path resolves relative to the manifest's directory (a release ships them together).
2678
+ artifact: path.resolve(manifestDir, e.artifact),
2679
+ // Per-entry --vendor/--dir override the top-level defaults; a --dir resolves against the manifest dir.
2680
+ vendor: e.vendor != null ? e.vendor : opts.vendor,
2681
+ dir: e.dir != null ? path.resolve(manifestDir, e.dir) : opts.dir,
2682
+ }));
2683
+ }
2684
+ // Repeated positional <artifact> args: each inherits the (single) top-level --vendor/--dir.
2685
+ return opts._pos.map((a) => ({ artifact: a, vendor: opts.vendor, dir: opts.dir }));
2686
+ }
2687
+
2688
+ function verifyBatch(opts) {
2689
+ const entries = buildBatchEntries(opts);
2690
+ const results = [];
2691
+ for (const e of entries) {
2692
+ // Verify each entry through the SAME core. A USAGE/IO problem with any single entry short-circuits the
2693
+ // whole batch with that code (the gate cannot certify a release it could not fully evaluate). The
2694
+ // top-level --revocations/--as-of (T-51.4) apply to EVERY entry as a default, so one revocations
2695
+ // file/dir gates a whole release's signed artifacts under one as-of instant.
2696
+ const { result } = verifyArtifact({
2697
+ artifact: e.artifact,
2698
+ vendor: e.vendor,
2699
+ dir: e.dir,
2700
+ revocations: opts.revocations,
2701
+ asOf: opts.asOf,
2702
+ nowISO: opts.nowISO,
2703
+ });
2704
+ results.push(result);
2705
+ }
2706
+ const total = results.length;
2707
+ const passed = results.filter((r) => r.accepted).length;
2708
+ const failed = total - passed;
2709
+ const ok = failed === 0;
2710
+ const aggregate = { ok, total, passed, failed, results };
2711
+ return { aggregate, code: ok ? EXIT.OK : EXIT.REJECTED };
2712
+ }
2713
+
2714
+ // ---------------------------------------------------------------------------
2715
+ // Human + JSON rendering.
2716
+ // ---------------------------------------------------------------------------
2717
+
2718
+ function renderHuman(r) {
2719
+ const L = [];
2720
+ L.push(TRUST_NOTE);
2721
+ L.push("");
2722
+ L.push(`# verify-vh — ${r.artifact}`);
2723
+ L.push(`kind: ${r.kind}`);
2724
+ if (r.payloadKind !== r.kind) L.push(`embedded kind: ${r.payloadKind}`);
2725
+ L.push(`signed: ${r.signed ? "yes" : "no"}`);
2726
+ if (r.signed) {
2727
+ L.push(`recovered signer:${r.recoveredSigner ? " " + r.recoveredSigner : " (unrecoverable)"}`);
2728
+ L.push(`claimed signer: ${r.claimedSigner}`);
2729
+ if (r.pinnedVendor != null) {
2730
+ L.push(`pinned --vendor: ${r.pinnedVendor}`);
2731
+ L.push(`signer matches vendor: ${r.signerMatchesVendor ? "yes" : "NO"}`);
2732
+ } else {
2733
+ L.push("(no --vendor pin: the recovered signer above is reported, not pinned)");
2734
+ }
2735
+ } else if (r.recoveredSigner == null && r.pinnedVendor != null) {
2736
+ L.push("note: --vendor was supplied but this artifact is UNSIGNED (no signer to pin)");
2737
+ }
2738
+ if (r.sealedRoot != null) L.push(`sealed root: ${r.sealedRoot}`);
2739
+ if (r.recomputedRoot != null) L.push(`recomputed root: ${r.recomputedRoot}`);
2740
+ if (r.rootMatches != null) L.push(`root matches: ${r.rootMatches ? "yes" : "NO"}`);
2741
+ if (r.identityOnly) {
2742
+ L.push("(identity-only artifact: it commits to a dataset root/digest, not a re-walkable file set)");
2743
+ }
2744
+ L.push(
2745
+ `files: ${r.counts.matched} matched, ${r.counts.changed} changed, ` +
2746
+ `${r.counts.missing} missing, ${r.counts.escaped || 0} rejected, ${r.counts.unexpected} unexpected`
2747
+ );
2748
+ // AGENT-SESSION packet block (T-68.3) — present ONLY for r.agent results, so every other kind's
2749
+ // output stays byte-identical.
2750
+ if (r.agent) {
2751
+ L.push(`declared head: { size: ${r.agent.head.size}, root: ${r.agent.head.root} }`);
2752
+ if (r.agent.counts) {
2753
+ L.push(
2754
+ `events: ${r.agent.counts.events} (${r.agent.counts.full} full, ${r.agent.counts.redacted} redacted)`
2755
+ );
2756
+ L.push(
2757
+ `withheld seqs: ${r.agent.withheld.length === 0 ? "(none — every payload disclosed)" : r.agent.withheld.join(", ")}`
2758
+ );
2759
+ }
2760
+ }
2761
+ // OPTIONAL recipient-side TRUST-DECISION-AS-OF block (T-51.4) — printed ONLY when --revocations was
2762
+ // supplied (r.trustAsOf is attached then). With no flag this block is absent, so the output is byte-
2763
+ // identical to the pre-T-51.4 baseline. The block reads the SAME way the producer's verify-signed does.
2764
+ if (r.trustAsOf) {
2765
+ L.push("");
2766
+ for (const line of revocation.renderTrustAsOf(r.trustAsOf, { defaulted: r.trustAsOfDefaulted })) {
2767
+ L.push(line);
2768
+ }
2769
+ }
2770
+ L.push("");
2771
+ if (r.accepted) {
2772
+ L.push("OK — the artifact verifies.");
2773
+ } else if (r.reason === "key_revoked_as_of") {
2774
+ // The signature + bytes checked out, but the signing key was revoked AT OR BEFORE the as-of instant — a
2775
+ // distinct REVOKED verdict (exit 3), matching the producer's verify-signed downgrade.
2776
+ const g = r.trustAsOf && r.trustAsOf.governing;
2777
+ L.push("REVOKED (key_revoked_as_of):");
2778
+ if (g) {
2779
+ L.push(
2780
+ ` key_revoked_as_of: the signing key (${g.vendorAddress}) was REVOKED as of ${g.revokedAt} ` +
2781
+ `(reason: ${g.reason})${g.supersededBy ? `, superseded by ${g.supersededBy}` : ""} — at or before ` +
2782
+ `the as-of instant. The bytes + signature check out, but the key was no longer trustworthy then.`
2783
+ );
2784
+ }
2785
+ } else {
2786
+ L.push(`REJECTED (${r.reason}):`);
2787
+ for (const c of r.changed) {
2788
+ L.push(` CHANGED ${c.relPath}: sealed ${c.expectedContentHash} != on-disk ${c.actualContentHash}`);
2789
+ }
2790
+ for (const m of r.missing) {
2791
+ L.push(` MISSING ${m.relPath}: referenced but not found on disk`);
2792
+ }
2793
+ for (const x of r.escaped || []) {
2794
+ // SECURITY: print the attacker's relPath string ONLY — never a content hash of the out-of-tree target.
2795
+ L.push(` REJECTED ${x.relPath}: path escapes the artifact directory (refused to read; no hash computed)`);
2796
+ }
2797
+ for (const u of r.unexpected) {
2798
+ L.push(` UNEXPECTED ${u.relPath}: on disk but not referenced`);
2799
+ }
2800
+ if (r.reason === "bad_signature") {
2801
+ L.push(" bad_signature: the signature does not recover to the claimed signer (tampered or forged).");
2802
+ }
2803
+ if (r.reason === "wrong_issuer") {
2804
+ L.push(
2805
+ ` wrong_issuer: recovered ${r.recoveredSigner} but you pinned --vendor ${r.pinnedVendor}.`
2806
+ );
2807
+ }
2808
+ if (r.reason === "unsigned_cannot_pin_vendor") {
2809
+ L.push(" --vendor was pinned but the artifact carries no signature to recover a signer from.");
2810
+ }
2811
+ if (r.reason === "root_mismatch") {
2812
+ L.push(" root_mismatch: the recomputed root does not equal the sealed root.");
2813
+ }
2814
+ if (r.reason === "path_escape") {
2815
+ L.push(
2816
+ " path_escape: the artifact references a file OUTSIDE its own directory (absolute path, `..` " +
2817
+ "traversal, or an out-of-tree symlink). A genuine artifact never does this; refused to read it."
2818
+ );
2819
+ }
2820
+ // AGENT-SESSION packet reject details (T-68.3): name the first offending event seq + the named fault.
2821
+ if (r.agent) {
2822
+ if (r.agent.seq !== null && r.agent.seq !== undefined) {
2823
+ L.push(` first offending event seq: ${r.agent.seq}${r.agent.reason ? ` (${r.agent.reason})` : ""}`);
2824
+ }
2825
+ if (r.reason === "event_invalid") {
2826
+ L.push(
2827
+ ` event_invalid: an event failed strict canonical validation` +
2828
+ `${r.agent.field ? ` (field: ${r.agent.field})` : ""} — the packet cannot be trusted.`
2829
+ );
2830
+ }
2831
+ if (r.reason === "counts_mismatch") {
2832
+ L.push(" counts_mismatch: the packet's declared full/redacted counts do not match a recount.");
2833
+ }
2834
+ if (r.reason === "head_not_bound") {
2835
+ L.push(
2836
+ " head_not_bound: the headAttestation signs a DIFFERENT { size, root } than this packet's " +
2837
+ "events derive — the signature belongs to another session."
2838
+ );
2839
+ }
2840
+ }
2841
+ }
2842
+ L.push("");
2843
+ return L.join("\n");
2844
+ }
2845
+
2846
+ // Human rendering of a batch aggregate: a per-artifact PASS/FAIL line (FAIL names the reason), then the
2847
+ // one-line roll-up + the final verdict. The trust note is printed ONCE at the top.
2848
+ function renderBatchHuman(agg) {
2849
+ const L = [];
2850
+ L.push(TRUST_NOTE);
2851
+ L.push("");
2852
+ L.push(`# verify-vh — BATCH (${agg.total} artifact${agg.total === 1 ? "" : "s"})`);
2853
+ for (const r of agg.results) {
2854
+ if (r.accepted) {
2855
+ L.push(` PASS ${r.artifact}`);
2856
+ } else {
2857
+ L.push(` FAIL ${r.artifact} (${r.reason})`);
2858
+ // Localize the first failing detail so a CI log names exactly what moved, per artifact.
2859
+ for (const c of r.changed) {
2860
+ L.push(` CHANGED ${c.relPath}: sealed ${c.expectedContentHash} != on-disk ${c.actualContentHash}`);
2861
+ }
2862
+ for (const m of r.missing) {
2863
+ L.push(` MISSING ${m.relPath}`);
2864
+ }
2865
+ for (const x of r.escaped || []) {
2866
+ L.push(` REJECTED ${x.relPath}: path escapes the artifact directory (no hash computed)`);
2867
+ }
2868
+ }
2869
+ }
2870
+ L.push("");
2871
+ L.push(`total: ${agg.total}, passed: ${agg.passed}, failed: ${agg.failed}`);
2872
+ L.push(agg.ok ? "OK — every artifact verifies." : `REJECTED — ${agg.failed} artifact(s) failed.`);
2873
+ L.push("");
2874
+ return L.join("\n");
2875
+ }
2876
+
2877
+ // ---------------------------------------------------------------------------
2878
+ // `demo` — the ZERO-CONFIG, zero-flag, zero-key-knowledge quickstart (T-55.2).
2879
+ //
2880
+ // WHY THIS EXISTS
2881
+ // A cold prospect should be able to go from NOTHING to a VERIFIED packet in one command — `verify-vh demo`
2882
+ // (or `npx … demo`) — with NO flags, NO `--vendor` to paste, and NO key knowledge. The whole sales promise
2883
+ // ("don't trust us — verify it yourself, offline") is unfalsifiable until they have RUN the tool once and
2884
+ // watched it ACCEPT a genuine packet, name the signer, then REJECT a one-byte-tampered copy. `demo` IS that
2885
+ // first run: it ships a tiny, self-contained, GENUINELY-SIGNED evidence packet baked into this file, plays
2886
+ // it through the EXACT same `verifyArtifact` core every real verify uses, and prints the honest verdict.
2887
+ //
2888
+ // HOW IT STAYS HONEST (no special-case verify path)
2889
+ // The fixture below is a REAL `vh.evidence-seal-signed` container: a keccak Merkle seal over two referenced
2890
+ // files, signed with a FIXED, well-known TEST-ONLY key (NEVER a real key, NEVER real funds — its address is
2891
+ // the standard hardhat account #1, published precisely so no one mistakes it for a production signer). The
2892
+ // signature was produced once with the family's real EIP-191 personal-sign path; the demo RECOVERS it with
2893
+ // the SAME vendored secp256k1 recovery a real verify uses, so the signer address printed is genuinely
2894
+ // recovered from the bytes — not echoed. `demo` materializes the packet + its two files into a throwaway
2895
+ // temp dir, runs the real `verifyArtifact` twice (genuine -> ACCEPT pinned to the recovered signer; a
2896
+ // one-byte-tampered copy -> REJECT/CHANGED), then deletes the temp dir. It writes NOTHING under cwd.
2897
+ // ---------------------------------------------------------------------------
2898
+
2899
+ // The fixed TEST-ONLY signer (hardhat account #1). Published so it can NEVER be confused with a real key.
2900
+ const DEMO_SIGNER = "0x70997970c51812dc3a010c7d01b50e0d17dc79c8";
2901
+
2902
+ // The two referenced files the demo seal commits to, by relPath -> exact UTF-8 content.
2903
+ const DEMO_FILES = Object.freeze({
2904
+ "model-card.md": "# Demo model card\nThis file is sealed by the verify-vh demo.\n",
2905
+ "weights.txt": "0.10 0.20 0.30\n",
2906
+ });
2907
+
2908
+ // The GENUINELY-SIGNED evidence container. `attestation` is the EXACT bytes the signature is over (the same
2909
+ // plain serialization the producer's evidence path emits for the embedded seal); the signature is a real
2910
+ // 65-byte EIP-191 personal-sign over those bytes by DEMO_SIGNER. Re-derived from DEMO_FILES (a build-time
2911
+ // check would re-seal the same bytes), so the root binds the real file content above.
2912
+ const DEMO_CONTAINER = Object.freeze({
2913
+ kind: "vh.evidence-seal-signed",
2914
+ attestation:
2915
+ '{"kind":"vh.evidence-seal","files":[{"relPath":"model-card.md","contentHash":"0x1aeca0ad922f53e9c30186234c5d1a62ffda62a828988bdd266fa93240675db0","leaf":"0xbbb3052a7359188aed3f114e15b721cf5d707a8bdf09109d1d51ec5765b3c58c"},{"relPath":"weights.txt","contentHash":"0x7716d380e062d1daf7ca58897b55f6b58900ed4fd1eda79445956c5c3d336cdf","leaf":"0x34ce488c6fb49a32d356a2553196dc817a439c13a03ce9a2a2ff2710fcf9eea2"}],"root":"0x621a5eb924a9887f88d4b05ccdf19834cdae2f4ed2399921acc7b8a45d48da9b"}',
2916
+ signature: {
2917
+ scheme: "eip191-personal-sign",
2918
+ signer: DEMO_SIGNER,
2919
+ signature:
2920
+ "0x1aabba1530df192e87498bbf1a26f63a7e30d84d72c14bf5d08b2d872df9810b672efcf26f30ec6a38a00ffc158be53633daeff9e99f344b6c1a2e99522d61a01b",
2921
+ },
2922
+ });
2923
+
2924
+ // The packet filename the demo materializes (shared by the throwaway-temp round-trip and the `demo <dir>`
2925
+ // keepable scaffold) so the "NEXT" command the demo prints names the file it actually wrote.
2926
+ const DEMO_PACKET_NAME = "demo-packet.vhevidence.json";
2927
+
2928
+ // ---------------------------------------------------------------------------
2929
+ // The DEMO AGENT-SESSION packet (T-68.3): a small, GENUINE `vh.agent-session-packet` produced by the
2930
+ // REAL `vh agent seal` + `vh agent redact` path (never re-authored by hand) — a 4-event session
2931
+ // (prompt -> tool_call -> tool_result -> completion) whose tool_call payload (seq 1) is REDACTED
2932
+ // behind its hash commitment, so the fixture demonstrates the load-bearing property: a redacted
2933
+ // packet STILL VERIFIES (identical leaves + root). UNSIGNED — the whole agent verify surface is the
2934
+ // FREE funnel leg. The standalone HTML page inlines these constants verbatim (next to DEMO_FILES /
2935
+ // DEMO_CONTAINER above) for its built-in agent demo: click -> ACCEPT; tamper ONE byte of a payload in
2936
+ // the page -> REJECT naming event seq DEMO_AGENT_TAMPER_SEQ. The TAMPER_FROM/TO pair is a one-byte
2937
+ // substring edit that occurs EXACTLY once in the packet text (pinned by test/verifier.agent.test.js).
2938
+ // ---------------------------------------------------------------------------
2939
+ const DEMO_AGENT_PACKET_NAME = "demo-session.vhagent.json";
2940
+ const DEMO_AGENT_PACKET_TEXT = "{\"kind\":\"vh.agent-session-packet\",\"schemaVersion\":1,\"note\":\"This agent-session packet is TAMPER-EVIDENT + OFFLINE-RECOMPUTABLE, NOT a trusted timestamp and NOT a claim the agent behaved well. Its ordered Merkle `head` {size, root} (RFC-6962-style, position-bound) commits to every event: verify RE-DERIVES each event leaf — recomputing the payload hash commitment for a FULL event, checking the carried commitment for a REDACTED one — and the root from the events you hold, and a REJECT names the first offending event seq. Redaction WITHHOLDS a payload behind its hash commitment without changing any leaf or the root: it can hide, never silently alter. Event `ts` fields are SELF-ASSERTED metadata (recorded, never verified against any clock); \\\"sealed at time T\\\" rides the human-owned signing/timestamp trust-root (STRATEGY.md P-3). Garbage-in is out of scope: the head proves the LOG is intact and append-only, not that the log faithfully records what the agent actually did. The packet is an UNTRUSTED transport container: verify never trusts the packet's own stored hashes.\",\"head\":{\"size\":4,\"root\":\"0xd455ad3f8050f1d863d65003532055326629bf92574cf8919b022222abdf66d1\"},\"counts\":{\"events\":4,\"full\":3,\"redacted\":1},\"events\":[{\"seq\":0,\"ts\":\"2026-07-01T09:00:00.000Z\",\"actor\":\"user\",\"type\":\"prompt\",\"payload\":\"Summarize the vendor contract and flag any auto-renewal clause.\",\"payloadHash\":\"0x1e2d99e683d2623c77a82721f633f27206cd8051be8c848509f63bb570bd5be4\"},{\"seq\":1,\"ts\":\"2026-07-01T09:00:01.000Z\",\"actor\":\"agent:assistant\",\"type\":\"tool_call\",\"payloadHash\":\"0x32133a5998ab97eaef8850a7a47cec6e1056b964a050e6e5561f97ec22b24498\",\"redacted\":true,\"meta\":{\"tool\":\"contract_search\"}},{\"seq\":2,\"ts\":\"2026-07-01T09:00:02.000Z\",\"actor\":\"tool:contract_search\",\"type\":\"tool_result\",\"payload\":\"Section 12.3: renews automatically for successive 12-month terms unless cancelled 60 days prior.\",\"payloadHash\":\"0x57bed64393fb6ed461a5b00143cc239cf705e4a1ea5d0ee84a8f5f7ecc85bdc1\"},{\"seq\":3,\"ts\":\"2026-07-01T09:00:03.000Z\",\"actor\":\"agent:assistant\",\"type\":\"completion\",\"payload\":\"Flagged: Section 12.3 auto-renews for successive 12-month terms and requires 60 days cancellation notice.\",\"payloadHash\":\"0x43649f64cb62093be040484c6858b80f0973e6aa2bd9bc4df75c0c725dcd5bb4\"}],\"leaves\":[\"0x5a3354160c02d09a5b653227ebd35d8f0a1ade1284e402049b91c4f8acd873e3\",\"0x57ac83bf53104a1d952cf9d00e904f15e31d4cc17bc6ff0aedacd1b6ca40904a\",\"0xb3ee61a8dc496b92e05db48b990edee212bda46ca29e5480efb056a5c2cf817f\",\"0x1000b07e45f6151bcf49be6266358cec551a690654f22dc5dae279e7d6bfb7d1\"]}\n";
2941
+ const DEMO_AGENT_TAMPER_SEQ = 0;
2942
+ const DEMO_AGENT_TAMPER_FROM = "\"payload\":\"Summarize the vendor contract";
2943
+ const DEMO_AGENT_TAMPER_TO = "\"payload\":\"SUMMARIZE the vendor contract";
2944
+
2945
+ // Materialize the demo packet + its referenced files into `dir`. Returns the packet path.
2946
+ function writeDemoFixture(dir) {
2947
+ for (const [rel, content] of Object.entries(DEMO_FILES)) {
2948
+ fs.writeFileSync(path.join(dir, rel), content);
2949
+ }
2950
+ const packetPath = path.join(dir, DEMO_PACKET_NAME);
2951
+ fs.writeFileSync(packetPath, JSON.stringify(DEMO_CONTAINER, null, 2));
2952
+ return packetPath;
2953
+ }
2954
+
2955
+ // Run the zero-config demo: seal -> ACCEPT (pinned to the recovered signer) -> tamper -> REJECT. Uses the
2956
+ // REAL verifyArtifact core for BOTH runs (no bespoke verify path), so the verdicts are exactly what a real
2957
+ // counterparty would see. Returns the EXIT-contract code (0 only when the whole demo behaved as designed).
2958
+ function runDemo(write, writeErr) {
2959
+ // A throwaway temp dir so the demo needs no input and writes NOTHING under cwd. Cleaned in finally.
2960
+ let tmp;
2961
+ try {
2962
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), "verify-vh-demo-"));
2963
+ } catch (e) {
2964
+ writeErr(`error: demo could not create a temp working dir: ${e.message}\n`);
2965
+ return EXIT.IO;
2966
+ }
2967
+ try {
2968
+ const packetPath = writeDemoFixture(tmp);
2969
+
2970
+ const L = [];
2971
+ L.push(TRUST_NOTE);
2972
+ L.push("");
2973
+ L.push("# verify-vh demo — a self-contained, GENUINELY-SIGNED packet, verified OFFLINE with zero config.");
2974
+ L.push("# (No flags, no key to paste: the demo ships a real signed seal + its files and checks them for you.)");
2975
+ L.push(`# Working dir (throwaway, deleted on exit): ${tmp}`);
2976
+ L.push("");
2977
+
2978
+ // (1) GENUINE packet: recover the signer first, then PIN it (so the demo proves both recovery AND the
2979
+ // vendor-pin path) — exactly what a real counterparty does once they learn the producer's address.
2980
+ const recovered = tryRecover(DEMO_CONTAINER.attestation, DEMO_CONTAINER.signature.signature);
2981
+ if (recovered !== DEMO_SIGNER) {
2982
+ writeErr(
2983
+ `error: demo fixture is corrupt — embedded signature recovered ${String(recovered)} != ${DEMO_SIGNER}\n`
2984
+ );
2985
+ return EXIT.IO;
2986
+ }
2987
+ L.push("STEP 1 — verify the genuine packet (signer recovered from the bytes, then pinned):");
2988
+ const good = verifyArtifact({ artifact: packetPath, vendor: recovered, dir: tmp });
2989
+ if (!good.result.accepted || good.code !== EXIT.OK) {
2990
+ // Should never happen for the shipped fixture; treat as an internal fault, not a silent pass.
2991
+ writeErr(`error: demo genuine packet did NOT verify (reason: ${good.result.reason})\n`);
2992
+ write(renderHuman(good.result));
2993
+ return EXIT.IO;
2994
+ }
2995
+ L.push(` ACCEPT — the artifact verifies. signer: ${good.result.recoveredSigner}`);
2996
+ L.push(` sealed root: ${good.result.sealedRoot}`);
2997
+ L.push(` recomputed root: ${good.result.recomputedRoot} (re-derived from the bytes on disk)`);
2998
+ L.push(` files: ${good.result.counts.matched} matched, 0 changed, 0 missing.`);
2999
+ L.push("");
3000
+
3001
+ // (2) TAMPER one byte of a referenced file, re-verify the SAME packet -> a clean REJECT naming the file.
3002
+ const victim = path.join(tmp, "model-card.md");
3003
+ fs.writeFileSync(victim, DEMO_FILES["model-card.md"] + "X"); // one extra byte
3004
+ L.push("STEP 2 — tamper ONE byte of a referenced file, then re-verify the SAME packet:");
3005
+ const bad = verifyArtifact({ artifact: packetPath, vendor: recovered, dir: tmp });
3006
+ if (bad.result.accepted || bad.code !== EXIT.REJECTED) {
3007
+ writeErr(`error: demo tampered packet was NOT rejected (reason: ${bad.result.reason})\n`);
3008
+ return EXIT.IO;
3009
+ }
3010
+ L.push(` REJECT (${bad.result.reason}) — the tampered copy is caught:`);
3011
+ for (const c of bad.result.changed) {
3012
+ L.push(` CHANGED ${c.relPath}: sealed ${c.expectedContentHash} != on-disk ${c.actualContentHash}`);
3013
+ }
3014
+ L.push("");
3015
+
3016
+ L.push("That is the whole promise: a genuine packet is ACCEPTED and its signer named, while a one-byte");
3017
+ L.push("change is REJECTED — re-derived from the bytes you hold, offline, with no producer stack.");
3018
+ L.push("");
3019
+ // The bare demo is a closed loop in a temp dir — gone the instant it exits. Hand the user the ONE command
3020
+ // that turns "I watched a demo" into "I have a real packet on disk I can poke at": `demo <dir>` writes the
3021
+ // same genuine packet somewhere they KEEP, with copy-paste verify/tamper/restore commands. That is the
3022
+ // working on-ramp from the canned proof to verifying their OWN bytes (where the paid `--sign` pull begins).
3023
+ // NOTE: we name the command literally (NOT process.argv[1]) so the bare-demo output is byte-identical
3024
+ // whether run in-process, as `node verify-vh.js`, or from the standalone bundle — the demo's own
3025
+ // determinism is a tested invariant (the standalone must byte-match the in-tree demo).
3026
+ L.push("TRY IT YOURSELF: keep a copy you can tamper with by hand —");
3027
+ L.push(" node verify-vh.js demo ./vh-demo # writes the same signed packet + files into ./vh-demo,");
3028
+ L.push(" # then prints the exact verify / tamper / restore commands.");
3029
+ L.push("");
3030
+ L.push("NEXT: run it on a REAL packet you were handed:");
3031
+ L.push(" node verify-vh.js <packet> --vendor 0xPRODUCER_ADDRESS (exit 0 = verifies; 3 = REJECTED)");
3032
+ L.push("");
3033
+ write(L.join("\n"));
3034
+ return EXIT.OK;
3035
+ } catch (e) {
3036
+ writeErr(`error: demo failed unexpectedly: ${e.message}\n`);
3037
+ return EXIT.IO;
3038
+ } finally {
3039
+ try {
3040
+ fs.rmSync(tmp, { recursive: true, force: true });
3041
+ } catch (_) {
3042
+ /* best-effort cleanup; the OS reaps temp dirs anyway */
3043
+ }
3044
+ }
3045
+ }
3046
+
3047
+ // ---------------------------------------------------------------------------
3048
+ // `demo <dir>` — the KEEPABLE scaffold (T-55.2 rework). The bare `demo` proves the round-trip in a throwaway
3049
+ // temp dir and is GONE the instant it exits — a closed loop the prospect can WATCH but cannot TOUCH. That is
3050
+ // the funnel dead-end the review panel flagged: the demo's own "NEXT: run it on a REAL packet" is unactionable
3051
+ // because a brand-new user HAS no packet yet. `demo <dir>` closes that gap: it MATERIALIZES the same genuine
3052
+ // signed packet + its two referenced files into a directory the user names and KEEPS, then prints the exact,
3053
+ // copy-pasteable REAL commands to (a) verify it with the real (non-canned) verify path, (b) tamper one byte
3054
+ // and watch the real REJECT, and (c) restore and re-ACCEPT. The prospect's FIRST hands-on artifact is now one
3055
+ // they hold on disk and can poke at with the production code path — the working on-ramp from "watched a demo"
3056
+ // to "verified my own bytes", which is where the free→paid pull (sign YOUR OWN files: `vh evidence seal
3057
+ // --sign` / the `evidence_unlimited` upgrade) actually begins.
3058
+ //
3059
+ // It is a PURE SUPERSET of the flagless quickstart: it engages ONLY when a single <dir> token follows `demo`
3060
+ // (`verify-vh demo` with no token stays the byte-identical throwaway round-trip above). It WRITES — by design,
3061
+ // into the dir the user explicitly named — so it is never reached by the bare flagless path the "writes
3062
+ // nothing under cwd" contract pins. The packet it writes is byte-identical to the round-trip's, signed by the
3063
+ // same fixed TEST-ONLY key (hardhat #1 — never a real key / real funds).
3064
+ // ---------------------------------------------------------------------------
3065
+
3066
+ function runDemoEmit(targetDir, write, writeErr) {
3067
+ // Confirm the shipped fixture is internally sound BEFORE writing anything (recover the signer from the
3068
+ // embedded bytes, exactly as a real verify does) — a corrupt fixture is an internal fault, not a scaffold.
3069
+ const recovered = tryRecover(DEMO_CONTAINER.attestation, DEMO_CONTAINER.signature.signature);
3070
+ if (recovered !== DEMO_SIGNER) {
3071
+ writeErr(
3072
+ `error: demo fixture is corrupt — embedded signature recovered ${String(recovered)} != ${DEMO_SIGNER}\n`
3073
+ );
3074
+ return EXIT.IO;
3075
+ }
3076
+
3077
+ const dir = path.resolve(targetDir);
3078
+ // mkdir -p the target. We create the user-named dir if absent; an existing dir is fine (we only add files).
3079
+ try {
3080
+ fs.mkdirSync(dir, { recursive: true });
3081
+ } catch (e) {
3082
+ writeErr(`error: demo could not create ${targetDir}: ${e.message}\n`);
3083
+ return EXIT.IO;
3084
+ }
3085
+
3086
+ let packetPath;
3087
+ try {
3088
+ packetPath = writeDemoFixture(dir);
3089
+ } catch (e) {
3090
+ writeErr(`error: demo could not write the scaffold into ${targetDir}: ${e.message}\n`);
3091
+ return EXIT.IO;
3092
+ }
3093
+
3094
+ // Verify the just-written packet through the REAL core (no canned path), so the scaffold is proven good on
3095
+ // disk before we tell the user to trust it — and so the ACCEPT line the user will reproduce is the truth.
3096
+ const good = verifyArtifact({ artifact: packetPath, vendor: recovered, dir });
3097
+ if (!good.result.accepted || good.code !== EXIT.OK) {
3098
+ writeErr(`error: demo scaffold did NOT verify after writing (reason: ${good.result.reason})\n`);
3099
+ return EXIT.IO;
3100
+ }
3101
+
3102
+ // The command name as the user invoked us (verify-vh.js in-tree, verify-vh-standalone.js as the bundle), so
3103
+ // the copy-paste commands below name the EXACT file they ran — not a guessed path.
3104
+ // Name the command the user actually ran (verify-vh.js in-tree, verify-vh-standalone.js as the bundle) so the
3105
+ // copy-paste lines below name the EXACT file they invoked. If argv[1] is not one of our scripts (e.g. running
3106
+ // in-process under a test harness), fall back to the canonical name rather than printing the harness binary.
3107
+ const argv1 = path.basename(process.argv[1] || "");
3108
+ const self = /verify-vh/.test(argv1) ? argv1 : "verify-vh.js";
3109
+ // Print a path that is copy-pasteable from the user's CURRENT shell: the relative path when the target sits
3110
+ // at/under cwd (the common `demo ./vh-demo` case -> a tidy `vh-demo/...`), else the absolute path (a `../../`
3111
+ // chain to a far-off dir is unreadable and brittle — the absolute path always resolves).
3112
+ const rel = (p) => {
3113
+ const r = path.relative(process.cwd(), p);
3114
+ return r && !r.startsWith("..") && !path.isAbsolute(r) ? r : p;
3115
+ };
3116
+ const pkt = rel(packetPath);
3117
+ const card = rel(path.join(dir, "model-card.md"));
3118
+
3119
+ const L = [];
3120
+ L.push(TRUST_NOTE);
3121
+ L.push("");
3122
+ L.push(`# verify-vh demo — wrote a real, KEEPABLE signed packet you can verify yourself, hands-on.`);
3123
+ L.push(`# Signed by a fixed TEST-ONLY key (hardhat #1 — never a real key / real funds).`);
3124
+ L.push("");
3125
+ L.push(`Wrote into ${dir}:`);
3126
+ L.push(` ${DEMO_PACKET_NAME} (a genuinely-signed evidence packet)`);
3127
+ for (const r of Object.keys(DEMO_FILES)) L.push(` ${r}`);
3128
+ L.push(` signer (recovered from the bytes): ${recovered}`);
3129
+ L.push("");
3130
+ L.push("It already VERIFIES — run it yourself (the real verify path, no canned demo):");
3131
+ L.push(` node ${self} ${pkt} --vendor ${recovered}`);
3132
+ L.push(" # exit 0 = ACCEPT (root re-derived from YOUR bytes on disk; signer pinned).");
3133
+ L.push("");
3134
+ L.push("Now PROVE tamper-evidence with your own hands — change one byte, then re-verify:");
3135
+ L.push(` printf 'X' >> ${card}`);
3136
+ L.push(` node ${self} ${pkt} --vendor ${recovered} # exit 3 = REJECT (CHANGED ${path.basename(card)})`);
3137
+ L.push("");
3138
+ L.push("Restore it and watch it ACCEPT again (the change was the ONLY reason it rejected):");
3139
+ L.push(` node ${self} ${pkt} --vendor ${recovered} # after restoring the byte`);
3140
+ L.push("");
3141
+ L.push("NEXT — verify a packet someone handed YOU (same command, their address):");
3142
+ L.push(` node ${self} <their-packet> --vendor 0xTHEIR_ADDRESS`);
3143
+ L.push("");
3144
+ L.push("Want to SIGN your OWN files so a counterparty can pin YOU? That is the paid producer side:");
3145
+ L.push(" vh evidence seal <your-folder> --sign (an EIP-191 signer-pin; the `evidence_unlimited`");
3146
+ L.push(" upgrade lifts the free 25-file cap) — see verifier/README.md §0a.");
3147
+ L.push("");
3148
+ write(L.join("\n"));
3149
+ return EXIT.OK;
3150
+ }
3151
+
3152
+ function usage() {
3153
+ return [
3154
+ "verify-vh — standalone, read-only, OFFLINE verifier for verifyhash artifacts",
3155
+ "",
3156
+ "Usage:",
3157
+ " verify-vh demo (zero-config quickstart)",
3158
+ " verify-vh demo <dir> (write a keepable signed packet you can verify yourself)",
3159
+ " verify-vh <artifact> [--vendor <0xaddr>] [--dir <d>] [--revocations <file-or-dir> [--as-of <ISO>]] [--json]",
3160
+ " verify-vh <artifact> <artifact> ... [--vendor <0xaddr>] [--dir <d>] [--revocations <file-or-dir>] [--json] (batch)",
3161
+ " verify-vh --manifest <file> [--vendor <0xaddr>] [--dir <d>] [--revocations <file-or-dir>] [--json] (batch)",
3162
+ " verify-vh <receipt> --anchored-artifact <sealed-file> [--json] (anchored-receipt binding check)",
3163
+ "",
3164
+ "DEMO: `verify-vh demo` runs a self-contained, genuinely-signed packet through the real verify path —",
3165
+ "NO flags, NO key, NO install state: it ACCEPTs the packet (naming the signer), then REJECTs a one-byte-",
3166
+ "tampered copy. The single command that takes a brand-new user from nothing to a verified packet.",
3167
+ "`verify-vh demo <dir>` goes one step further: it WRITES that same genuine signed packet + its files into",
3168
+ "<dir> (which you keep) and prints copy-paste commands so you verify, tamper, and re-verify it by hand.",
3169
+ "",
3170
+ "Auto-detects the artifact kind (evidence seal, reconciliation seal, dataset attestation, proof",
3171
+ "bundle — bare or signed — or an agent-session packet *.vhagent.json), RE-DERIVES the keccak root",
3172
+ "from the referenced bytes (siblings resolve next to the artifact, or under --dir <d>), recovers",
3173
+ "the signer of a signed artifact, and PINS it to --vendor <0xaddr> (or reports the recovered signer",
3174
+ "when no pin is given). An agent-session packet is SELF-CONTAINED: every event leaf + the ordered",
3175
+ "RFC-6962-style head are re-derived from the events in the packet (REDACTED payloads are checked by",
3176
+ "their hash commitments), and a REJECT names the first offending event seq.",
3177
+ "",
3178
+ "REVOCATIONS: --revocations <file-or-dir> [--as-of <ISO>] downgrades an otherwise-ACCEPTED signed",
3179
+ "artifact to REVOKED (exit 3) when its signing key was REVOKED at or before --as-of (default now). The",
3180
+ "file may be one signed revocation or a JSON array; a directory is read as a flat pool of revocation",
3181
+ "files. A revocation dated AFTER --as-of stays ACCEPTED with a later-revoked note; a forged/tampered/",
3182
+ "third-party revocation is IGNORED with a warning. This reaches the SAME downgrade the producer's",
3183
+ "`vh ... verify-signed --revocations` does, OFFLINE — no producer stack, no network, no key.",
3184
+ "",
3185
+ "ANCHORED RECEIPTS (T-70.4): a `vh-anchored-receipt@1` produced by `vh anchor-artifact` verifies",
3186
+ "here WITHOUT the producer stack: --anchored-artifact <sealed-file> re-derives the sealed artifact's",
3187
+ "digest through the SAME closed kind table (evidence seal, agent-session packet, journal tree head,",
3188
+ "TrustLedger seal, dataset/parcel attestation), validates the receipt strictly (a drifted trust note",
3189
+ "is a named bad-receipt), and confirms the receipt binds EXACTLY those bytes — ACCEPTED exit 0, or",
3190
+ "the specific named reject (digest-mismatch / kind-mismatch / how-mismatch / bad-receipt / the",
3191
+ "artifact's own named reject) exit 3. OFFLINE binding leg ONLY: the receipt's `chain` facts remain",
3192
+ "the anchorer's CLAIM — re-check them on chain with the producer cli (`vh verify-anchored --rpc`).",
3193
+ "",
3194
+ "BATCH/MANIFEST: pass several <artifact> args, or --manifest <file> (a newline list or JSON array of",
3195
+ "artifact paths, each line/object may carry its own --vendor/--dir). ALL must pass for exit 0; if ANY",
3196
+ "is rejected, exit is 3 and the report names which artifact failed and why. --json emits a stable",
3197
+ "aggregate { ok, total, passed, failed, results:[...] } whose entries are the single-artifact shape.",
3198
+ "Top-level --vendor/--dir are inherited as defaults a manifest entry may override; --revocations/--as-of",
3199
+ "apply to every entry.",
3200
+ "",
3201
+ "READ-ONLY: holds no key, writes nothing. Exit: 0 ok / 3 rejected|revoked / 2 usage / 1 IO.",
3202
+ "",
3203
+ ].join("\n");
3204
+ }
3205
+
3206
+ // ---------------------------------------------------------------------------
3207
+ // run(argv, io) — the testable entrypoint. Returns the EXIT-contract integer. Injectable stdout/stderr.
3208
+ // ---------------------------------------------------------------------------
3209
+
3210
+ function run(argv, io = {}) {
3211
+ const write = io.write || ((s) => process.stdout.write(s));
3212
+ const writeErr = io.writeErr || ((s) => process.stderr.write(s));
3213
+
3214
+ let opts;
3215
+ try {
3216
+ opts = parseArgs(argv);
3217
+ } catch (e) {
3218
+ writeErr(`error: ${e.message}\n`);
3219
+ return EXIT.USAGE;
3220
+ }
3221
+ if (opts.help) {
3222
+ write(usage());
3223
+ return EXIT.OK;
3224
+ }
3225
+ // DEMO: the zero-config quickstart (T-55.2). `verify-vh demo` — a SINGLE bare positional `demo`, with NO
3226
+ // other args at all (no flags, no second positional, no manifest) — runs the self-contained signed packet
3227
+ // through the real verify path. We require the LONE argument to be exactly `demo` so the quickstart contract
3228
+ // is unambiguous: `demo` with any extra token falls through to the normal path (where it is a clean error),
3229
+ // never a silently-flag-ignoring run. It is a pure SUPERSET of the existing contract: `demo` was never a
3230
+ // valid artifact path before (there is no file named `demo`, so a lone `demo` was a clean IO error), so
3231
+ // intercepting it here shifts no existing caller.
3232
+ if (argv.length === 1 && opts.artifact === "demo") {
3233
+ return runDemo(write, writeErr);
3234
+ }
3235
+ // DEMO SCAFFOLD: `verify-vh demo <dir>` — a pure SUPERSET (T-55.2 rework). When `demo` is followed by exactly
3236
+ // ONE more bare token (a target directory) and NO flags, write the same genuine signed packet + its files
3237
+ // into that dir the user KEEPS, and print copy-paste verify/tamper/restore commands. This is the actionable
3238
+ // on-ramp the bare demo (a throwaway temp dir, gone on exit) cannot give. We require EXACTLY two bare
3239
+ // positionals and no flags so the contract stays unambiguous; `demo <dir> --anything` falls through to the
3240
+ // normal path (where a file literally named `demo` is a clean IO error, byte-identically to before).
3241
+ if (
3242
+ argv.length === 2 &&
3243
+ argv[0] === "demo" &&
3244
+ opts._pos.length === 2 &&
3245
+ opts._pos[0] === "demo" &&
3246
+ !opts.json &&
3247
+ opts.manifest === undefined &&
3248
+ opts.vendor === undefined &&
3249
+ opts.dir === undefined
3250
+ ) {
3251
+ return runDemoEmit(opts._pos[1], write, writeErr);
3252
+ }
3253
+ // No artifact AND no manifest → the same usage error as before (the batch additions are a pure superset).
3254
+ if (opts.artifact === undefined && opts.manifest === undefined) {
3255
+ writeErr("error: verify-vh requires an <artifact>\n\n");
3256
+ writeErr(usage());
3257
+ return EXIT.USAGE;
3258
+ }
3259
+
3260
+ // ANCHORED-RECEIPT binding check (T-70.4): a dedicated two-file leg — parseArgs already guaranteed
3261
+ // exactly one <receipt> positional and no incompatible flag. READ-ONLY; exit 0/3/2/1 as everywhere.
3262
+ if (opts.anchoredArtifact !== undefined) {
3263
+ return runVerifyAnchoredOffline(opts, write, writeErr);
3264
+ }
3265
+
3266
+ // The recipient's current decision instant (the default --as-of). Injectable via io.nowISO so a test can
3267
+ // pin the clock; otherwise the wall clock. Threaded onto opts for the (optional) revocation evaluation.
3268
+ opts.nowISO = io.nowISO || new Date().toISOString();
3269
+
3270
+ // BATCH path: a --manifest file or more than one positional <artifact>. Aggregates per-artifact verdicts
3271
+ // under one CI exit code. The single-artifact path below is byte-for-byte the original behavior.
3272
+ if (opts.batch) {
3273
+ let out;
3274
+ try {
3275
+ out = verifyBatch(opts);
3276
+ } catch (e) {
3277
+ if (e instanceof UsageError) {
3278
+ writeErr(`error: ${e.message}\n`);
3279
+ return EXIT.USAGE;
3280
+ }
3281
+ if (e instanceof IOError) {
3282
+ writeErr(`error: ${e.message}\n`);
3283
+ return EXIT.IO;
3284
+ }
3285
+ writeErr(`error: ${e.message}\n`);
3286
+ return EXIT.IO;
3287
+ }
3288
+ if (opts.json) {
3289
+ write(JSON.stringify(out.aggregate, null, 2) + "\n");
3290
+ } else {
3291
+ write(renderBatchHuman(out.aggregate));
3292
+ }
3293
+ return out.code;
3294
+ }
3295
+
3296
+ let out;
3297
+ try {
3298
+ out = verifyArtifact(opts);
3299
+ } catch (e) {
3300
+ if (e instanceof UsageError) {
3301
+ writeErr(`error: ${e.message}\n`);
3302
+ return EXIT.USAGE;
3303
+ }
3304
+ if (e instanceof IOError) {
3305
+ writeErr(`error: ${e.message}\n`);
3306
+ return EXIT.IO;
3307
+ }
3308
+ // Any other error is an unexpected internal fault — surface it as an IO error (never a stack to a
3309
+ // counterparty), exit 1.
3310
+ writeErr(`error: ${e.message}\n`);
3311
+ return EXIT.IO;
3312
+ }
3313
+
3314
+ if (opts.json) {
3315
+ write(JSON.stringify(out.result, null, 2) + "\n");
3316
+ } else {
3317
+ write(renderHuman(out.result));
3318
+ }
3319
+ return out.code;
3320
+ }
3321
+
3322
+ // CLI shim: only run when invoked directly (so the module is importable in tests without side effects).
3323
+ if (require.main === module) {
3324
+ process.exit(run(process.argv.slice(2)));
3325
+ }
3326
+
3327
+ module.exports = {
3328
+ EXIT,
3329
+ KINDS,
3330
+ TRUST_NOTE,
3331
+ UsageError,
3332
+ IOError,
3333
+ parseArgs,
3334
+ parseManifest,
3335
+ verifyArtifact,
3336
+ verifyArtifactFromBytes,
3337
+ verifyBatch,
3338
+ buildBatchEntries,
3339
+ renderBatchHuman,
3340
+ verifyEvidenceSeal,
3341
+ verifyTrustSeal,
3342
+ verifyDatasetAttestation,
3343
+ verifyProofBundle,
3344
+ verifyAgentSeal,
3345
+ AGENT_TRUST_NOTE,
3346
+ // ANCHORED-RECEIPT surface (T-70.4) — wire-format constants + the pure binding verify, exported so
3347
+ // the parity test can pin them against the producer core (cli/core/anchor-binding.js) byte-for-byte.
3348
+ ANCHORED_RECEIPT_KIND,
3349
+ ANCHOR_TRUST_NOTE,
3350
+ ANCHOR_REASONS,
3351
+ ANCHOR_ARTIFACT_KINDS,
3352
+ ANCHOR_JOURNAL_TREE_HEAD_KIND,
3353
+ ANCHOR_JOURNAL_EMPTY_ROOT,
3354
+ ANCHOR_LOCAL_DEV_CHAIN_IDS,
3355
+ ANCHOR_PUBLIC_TESTNET_CHAIN_IDS,
3356
+ anchorClassifyChainId,
3357
+ anchorArtifactDigest,
3358
+ verifyAnchoredReceipt,
3359
+ runVerifyAnchoredOffline,
3360
+ renderHuman,
3361
+ revocation,
3362
+ usage,
3363
+ run,
3364
+ runDemo,
3365
+ runDemoEmit,
3366
+ DEMO_SIGNER,
3367
+ DEMO_FILES,
3368
+ DEMO_CONTAINER,
3369
+ DEMO_PACKET_NAME,
3370
+ DEMO_AGENT_PACKET_NAME,
3371
+ DEMO_AGENT_PACKET_TEXT,
3372
+ DEMO_AGENT_TAMPER_SEQ,
3373
+ DEMO_AGENT_TAMPER_FROM,
3374
+ DEMO_AGENT_TAMPER_TO,
3375
+ MAX_RELPATH_CHARS,
3376
+ };