verifyhash 0.1.0 → 0.1.1

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 (63) 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/PUBLISH-VERIFY-VH.md +45 -0
  6. package/examples/README.md +185 -0
  7. package/examples/policy.lenient.json +5 -0
  8. package/examples/policy.strict.json +6 -0
  9. package/examples/run.js +366 -0
  10. package/examples/sample-dataset/README.txt +10 -0
  11. package/examples/sample-dataset/corpus/cc-by-poem.txt +8 -0
  12. package/examples/sample-dataset/corpus/mit-notes.txt +4 -0
  13. package/examples/sample-dataset/data/unlabeled.txt +5 -0
  14. package/examples/sample-dataset/vendored/gpl-snippet.txt +5 -0
  15. package/examples/sample-dataset.hints.json +7 -0
  16. package/examples/sample-parcel/data/manifest-of-contents.txt +7 -0
  17. package/examples/sample-parcel/data/records.csv +4 -0
  18. package/examples/sample-parcel/delivery-note.txt +9 -0
  19. package/package.json +25 -3
  20. package/verifier/README.md +555 -0
  21. package/verifier/action/README.md +87 -0
  22. package/verifier/action/action.yml +146 -0
  23. package/verifier/build-standalone-html.js +1287 -0
  24. package/verifier/build-standalone.js +989 -0
  25. package/verifier/ci/journal.generic.sh +96 -0
  26. package/verifier/ci/journal.github-actions.yml +99 -0
  27. package/verifier/ci/reproduce-vh.generic.sh +59 -0
  28. package/verifier/ci/reproduce-vh.github-actions.yml +49 -0
  29. package/verifier/ci/verify-service.generic.sh +96 -0
  30. package/verifier/ci/verify-service.github-actions.yml +88 -0
  31. package/verifier/ci/verify-vh.generic.sh +75 -0
  32. package/verifier/ci/verify-vh.github-actions.yml +56 -0
  33. package/verifier/dist/BUILD-PROVENANCE.json +210 -0
  34. package/verifier/dist/seal-vh-standalone.js +876 -0
  35. package/verifier/dist/seal-vh-standalone.js.sha256 +1 -0
  36. package/verifier/dist/verify-vh-standalone.html +3373 -0
  37. package/verifier/dist/verify-vh-standalone.html.sha256 +1 -0
  38. package/verifier/dist/verify-vh-standalone.js +4121 -0
  39. package/verifier/dist/verify-vh-standalone.js.sha256 +1 -0
  40. package/verifier/lib/canonical.js +141 -0
  41. package/verifier/lib/keccak.js +30 -0
  42. package/verifier/lib/keccak256-vendored.js +206 -0
  43. package/verifier/lib/merkle.js +145 -0
  44. package/verifier/lib/revocation-core.js +606 -0
  45. package/verifier/lib/revocation.js +200 -0
  46. package/verifier/lib/seal-cli.js +374 -0
  47. package/verifier/lib/seal-evidence.js +237 -0
  48. package/verifier/lib/secp256k1-recover.js +249 -0
  49. package/verifier/package.json +39 -0
  50. package/verifier/verify-vh.js +2374 -0
  51. package/docs/ADOPTION.json +0 -11
  52. package/docs/AUDIT.md +0 -55
  53. package/docs/DECIDE.md +0 -47
  54. package/docs/DECISIONS-PENDING.md +0 -27
  55. package/docs/DEPLOY-PUBLIC-SITE.md +0 -301
  56. package/docs/ENGINE-LEDGER.json +0 -12
  57. package/docs/LOOP-AUDIT-2026-07-03.json +0 -580
  58. package/docs/LOOP-HARDENING-PLAN.md +0 -44
  59. package/docs/METRICS.jsonl +0 -31
  60. package/docs/MORNING.md +0 -204
  61. package/docs/STRATEGY-ARCHIVE.md +0 -5055
  62. package/docs/SUPERVISOR-RUNBOOK.md +0 -52
  63. package/docs/USAGE-BUDGET.json +0 -121
@@ -0,0 +1,2374 @@
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
+
43
+ const merkle = require("./lib/merkle");
44
+ const canonical = require("./lib/canonical");
45
+ const { recoverPersonalSignAddress } = require("./lib/secp256k1-recover");
46
+ const revocation = require("./lib/revocation");
47
+
48
+ // ============================ BEGIN VERIFY-VH PURE ENGINE (T-66.1) ============================
49
+ // EVERYTHING between this marker and the matching END marker is the PURE verify engine: it performs NO
50
+ // I/O of its own and never touches fs / os / path / process / child_process — every byte it verifies
51
+ // arrives through the injected `readEntry` seam (or as an argument). Its only outside references are the
52
+ // four module bindings above, all of which resolve to PURE modules for the functions used here:
53
+ // `merkle`, `canonical`, `recoverPersonalSignAddress`, and the PURE decision half of `revocation`
54
+ // (./lib/revocation-core.js re-exports — never the fs-backed readRevocationsFromPath/loadAndApply).
55
+ // test/verifier.browser-core.test.js enforces all of this mechanically; the markers also make the block
56
+ // mechanically extractable (vm / browser bundling, EPIC-66).
57
+
58
+ // CI-gateable exit contract, mirroring the producer family (vh verify-seal / vh evidence verify):
59
+ // 0 ok / 3 rejected / 2 usage / 1 IO. Stable; a future CI/indexer keys on these.
60
+ const EXIT = Object.freeze({ OK: 0, IO: 1, USAGE: 2, REJECTED: 3 });
61
+
62
+ // A usage error the CLI maps to exit 2 (vs an IO error -> 1, vs a clean REJECTED verdict -> 3).
63
+ class UsageError extends Error {}
64
+ class IOError extends Error {}
65
+
66
+ // The on-disk `kind` discriminators of every artifact family this verifier understands. Bare and signed
67
+ // variants are listed so auto-detect routes correctly. Disjoint, versioned strings — a foreign/random
68
+ // JSON file falls through to a clear "unrecognized artifact" usage error rather than a misread.
69
+ const KINDS = Object.freeze({
70
+ EVIDENCE_SEAL: "vh.evidence-seal",
71
+ EVIDENCE_SEAL_SIGNED: "vh.evidence-seal-signed",
72
+ TRUST_SEAL: "trustledger.reconcile-seal",
73
+ TRUST_SEAL_SIGNED: "trustledger.reconcile-seal-signed",
74
+ DATASET_ATTESTATION: "verifyhash.dataset-attestation",
75
+ DATASET_ATTESTATION_SIGNED: "verifyhash.dataset-attestation-signed",
76
+ DATASET_ATTESTATION_TIMESTAMPED: "verifyhash.dataset-attestation-timestamped",
77
+ PROOF: "verifyhash.merkle-proof",
78
+ AGENT_PACKET: "vh.agent-session-packet",
79
+ });
80
+
81
+ const TRUST_NOTE =
82
+ "verify-vh is an INDEPENDENT, read-only, OFFLINE verifier. It RE-DERIVES the keccak root from the " +
83
+ "bytes you hold and recovers the signer with no producer stack. It proves TAMPER-EVIDENCE + WHO " +
84
+ "vouched — NOT a trusted timestamp and NOT a legal opinion.";
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Address normalization + recovery helpers. The verifier compares addresses as LOWERCASE 0x-hex (the
88
+ // canonical byte-deterministic form the producer records); a caller may paste an EIP-55-checksummed
89
+ // --vendor and we lowercase it (a checksum mismatch is not our concern — we compare 20 raw bytes).
90
+ // ---------------------------------------------------------------------------
91
+
92
+ const ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
93
+
94
+ function normalizeAddress(addr, label) {
95
+ if (typeof addr !== "string" || !ADDRESS_RE.test(addr)) {
96
+ throw new UsageError(`${label} must be a 0x-prefixed 20-byte hex address, got: ${String(addr)}`);
97
+ }
98
+ return addr.toLowerCase();
99
+ }
100
+
101
+ // Recover the EIP-191 signer over the embedded canonical bytes. A tampered/corrupt signature can be
102
+ // UNRECOVERABLE (no valid curve point) — that throws, which the caller turns into a `bad_signature`
103
+ // REJECTED verdict, never a crash. Returns lowercase 0x-hex, or null if recovery failed.
104
+ function tryRecover(message, signature) {
105
+ try {
106
+ return recoverPersonalSignAddress(message, signature);
107
+ } catch (_) {
108
+ return null;
109
+ }
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Signed-container decoding. A signed artifact carries the embedded UNSIGNED payload as the EXACT
114
+ // canonical bytes (a STRING) in `attestation`, plus a { scheme, signer, signature } block. The signed
115
+ // MESSAGE is that embedded string verbatim, so signer recovery runs over `container.attestation`.
116
+ // ---------------------------------------------------------------------------
117
+
118
+ function decodeSigned(container) {
119
+ const sig = container && container.signature;
120
+ if (sig == null || typeof sig !== "object" || Array.isArray(sig)) {
121
+ throw new IOError("signed artifact is missing a { scheme, signer, signature } signature block");
122
+ }
123
+ if (sig.scheme !== "eip191-personal-sign") {
124
+ throw new IOError(
125
+ `unsupported signature scheme: ${JSON.stringify(sig.scheme)} ` +
126
+ "(this verifier understands eip191-personal-sign)"
127
+ );
128
+ }
129
+ if (typeof container.attestation !== "string") {
130
+ throw new IOError("signed artifact must embed the canonical UNSIGNED bytes as a string `attestation`");
131
+ }
132
+ if (typeof sig.signature !== "string" || !/^0x[0-9a-fA-F]{130}$/.test(sig.signature)) {
133
+ throw new IOError("signed artifact signature must be a 65-byte (r||s||v) 0x-hex string");
134
+ }
135
+ if (typeof sig.signer !== "string" || !ADDRESS_RE.test(sig.signer)) {
136
+ throw new IOError("signed artifact signer must be a 0x-prefixed 20-byte hex address");
137
+ }
138
+ let embedded;
139
+ try {
140
+ embedded = JSON.parse(container.attestation);
141
+ } catch (e) {
142
+ throw new IOError(`embedded attestation is not valid JSON: ${e.message}`);
143
+ }
144
+ return { embedded, message: container.attestation, claimedSigner: sig.signer.toLowerCase(), signature: sig.signature };
145
+ }
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // Per-file re-derivation, shared by every seal kind AND by both file sources. Given the sealed
149
+ // { relPath, contentHash } entries and a `readEntry` source, fetch each referenced file's bytes through
150
+ // the source, recompute its contentHash, and localize the outcome to MATCH / CHANGED / MISSING /
151
+ // ESCAPED; a file present under a sealed relPath that is NOT in the seal cannot occur here (we only read
152
+ // sealed relPaths) — UNEXPECTED is reported only for seals where the producer enumerates a directory
153
+ // (evidence seal verify re-walks the dir). For artifact verification we follow the producer's read
154
+ // model: read exactly the relPaths the artifact names from the source.
155
+ //
156
+ // SECURITY — CONFINEMENT LIVES IN THE SOURCE. `relPath` values come straight from the attacker-controlled
157
+ // artifact JSON (the threat model is attacker-controls-the-input, victim-runs-on-their-own-machine: a
158
+ // malicious producer hands a counterparty a "verify me" artifact, hoping its relPaths probe the
159
+ // counterparty's filesystem). Each source therefore CONFINES every read BEFORE touching its backing
160
+ // store and answers `{ status: "escaped" }` for a hostile relPath (absolute, a `..` traversal component,
161
+ // or — for the disk source — a resolved/realpath escape of baseDir). An escaped entry is recorded ONLY by
162
+ // relPath (the attacker's string) — we NEVER hash it and NEVER emit an actualContentHash for it, so the
163
+ // verdict can never become a content-confirmation / hash-disclosure oracle over a file outside the
164
+ // source. A `path_escape` entry is a hard REJECTED verdict.
165
+ // ---------------------------------------------------------------------------
166
+
167
+ function classifyFilesWith(sealedEntries, readEntry) {
168
+ const changed = [];
169
+ const missing = [];
170
+ const matched = [];
171
+ const escaped = []; // { relPath } only — NEVER a hash; a confinement reject, read nothing
172
+ const flat = []; // { relPath, contentHash } actually-present, for the root re-derivation
173
+
174
+ for (const e of sealedEntries) {
175
+ const relPath = e.relPath;
176
+ const r = readEntry(relPath);
177
+ if (r.status === "escaped") {
178
+ escaped.push({ relPath: String(relPath) });
179
+ continue;
180
+ }
181
+ if (r.status === "missing") {
182
+ missing.push({ relPath });
183
+ continue;
184
+ }
185
+ const actual = merkle.hashBytes(r.bytes);
186
+ flat.push({ relPath, contentHash: actual });
187
+ if (actual.toLowerCase() === String(e.contentHash).toLowerCase()) {
188
+ matched.push({ relPath, contentHash: actual });
189
+ } else {
190
+ changed.push({ relPath, expectedContentHash: e.contentHash, actualContentHash: actual });
191
+ }
192
+ }
193
+ return { matched, changed, missing, escaped, flat };
194
+ }
195
+
196
+ // ---------------------------------------------------------------------------
197
+ // Verify an EVIDENCE seal (bare or the embedded seal of a signed container). The seal lists `files`
198
+ // [{ relPath, contentHash, leaf }] + `root`. We re-derive the root from the bytes the source holds and
199
+ // localize any tamper. NO header (evidence seals bind only the file set). UNEXPECTED files (present
200
+ // under a sealed-sibling tree but not named) are NOT scanned here — the artifact names exactly what it
201
+ // commits to; the producer's `vh evidence verify` re-walks the dir, but the standalone verifier verifies
202
+ // what the artifact REFERENCES (read-only, no directory walk). NOTE an "extra" file is still caught
203
+ // structurally: the sealed root commits to the FULL file set, so a seal doctored to omit an entry can
204
+ // never keep its root (root_mismatch), and a signed seal edited that way breaks its signature.
205
+ // ---------------------------------------------------------------------------
206
+
207
+ function verifyEvidenceSealWith(seal, readEntry) {
208
+ if (!Array.isArray(seal.files) || seal.files.length === 0) {
209
+ throw new IOError("evidence seal `files` must be a non-empty array");
210
+ }
211
+ if (typeof seal.root !== "string" || !merkle.HEX32_RE.test(seal.root)) {
212
+ throw new IOError("evidence seal `root` must be a 0x-prefixed 32-byte hex string");
213
+ }
214
+ const { matched, changed, missing, escaped, flat } = classifyFilesWith(seal.files, readEntry);
215
+
216
+ // The AUTHORITATIVE root is re-derived from the bytes actually held — never the seal's stored root.
217
+ // A partial/changed set yields a different root; rootMatches goes false.
218
+ let recomputedRoot = null;
219
+ if (flat.length > 0) {
220
+ try {
221
+ recomputedRoot = merkle.rootFromFlat(flat);
222
+ } catch (_) {
223
+ recomputedRoot = null;
224
+ }
225
+ }
226
+ const rootMatches =
227
+ missing.length === 0 &&
228
+ changed.length === 0 &&
229
+ escaped.length === 0 &&
230
+ recomputedRoot != null &&
231
+ recomputedRoot.toLowerCase() === seal.root.toLowerCase();
232
+
233
+ return {
234
+ matched,
235
+ changed,
236
+ missing,
237
+ escaped,
238
+ unexpected: [],
239
+ sealedRoot: seal.root,
240
+ recomputedRoot,
241
+ rootMatches,
242
+ filesOk: changed.length === 0 && missing.length === 0 && escaped.length === 0 && rootMatches,
243
+ };
244
+ }
245
+
246
+ // ---------------------------------------------------------------------------
247
+ // Verify a TRUST (reconciliation) seal (bare or embedded). The seal lists `inputs` (role+relPath+
248
+ // contentHash+leaf) and `outputs` (relPath+contentHash+leaf), plus a `verdict` + `root`. The root commits
249
+ // to all inputs + outputs PLUS a synthetic verdict/role HEADER leaf. We re-derive the root from the held
250
+ // bytes AND the header content recomputed from the seal's OWN verdict + input role bindings — so a
251
+ // verdict/role edit (which lives in the seal, not a file) still changes the recomputed root. Inputs are
252
+ // sealed by basename and resolve through the source (the portable handoff ships sources next to the seal).
253
+ // ---------------------------------------------------------------------------
254
+
255
+ function verifyTrustSealWith(seal, readEntry) {
256
+ if (!Array.isArray(seal.inputs) || seal.inputs.length === 0) {
257
+ throw new IOError("trust seal `inputs` must be a non-empty array");
258
+ }
259
+ if (!Array.isArray(seal.outputs) || seal.outputs.length === 0) {
260
+ throw new IOError("trust seal `outputs` must be a non-empty array");
261
+ }
262
+ if (typeof seal.root !== "string" || !merkle.HEX32_RE.test(seal.root)) {
263
+ throw new IOError("trust seal `root` must be a 0x-prefixed 32-byte hex string");
264
+ }
265
+ if (seal.verdict == null || typeof seal.verdict !== "object") {
266
+ throw new IOError("trust seal is missing its `verdict` block");
267
+ }
268
+
269
+ const sealedEntries = [
270
+ ...seal.inputs.map((e) => ({ relPath: e.relPath, contentHash: e.contentHash, role: e.role })),
271
+ ...seal.outputs.map((e) => ({ relPath: e.relPath, contentHash: e.contentHash, role: null })),
272
+ ];
273
+ const { matched, changed, missing, escaped, flat } = classifyFilesWith(sealedEntries, readEntry);
274
+
275
+ // Re-derive the root: the held file leaves PLUS the verdict/role HEADER leaf (content recomputed
276
+ // from the seal's own verdict + input role bindings). The header is folded in as one more (relPath,
277
+ // content) pair under the reserved header relPath — exactly the producer's binding.
278
+ let recomputedRoot = null;
279
+ // Only attempt the root re-derivation when no file is MISSING or ESCAPED (a partial set can never
280
+ // re-derive the sealed root anyway, and the header binds the FULL committed structure).
281
+ if (missing.length === 0 && escaped.length === 0 && flat.length === seal.inputs.length + seal.outputs.length) {
282
+ try {
283
+ const headerBytes = canonical.trustSealHeaderBytes(
284
+ seal.verdict,
285
+ seal.inputs.map((e) => ({ role: e.role, relPath: e.relPath }))
286
+ );
287
+ const committed = [
288
+ ...flat,
289
+ { relPath: canonical.TRUST_SEAL_HEADER_RELPATH, contentHash: merkle.hashBytes(headerBytes) },
290
+ ];
291
+ recomputedRoot = merkle.rootFromFlat(committed);
292
+ } catch (_) {
293
+ recomputedRoot = null;
294
+ }
295
+ }
296
+ const rootMatches =
297
+ escaped.length === 0 &&
298
+ recomputedRoot != null &&
299
+ recomputedRoot.toLowerCase() === seal.root.toLowerCase();
300
+
301
+ return {
302
+ matched,
303
+ changed,
304
+ missing,
305
+ escaped,
306
+ unexpected: [],
307
+ sealedRoot: seal.root,
308
+ recomputedRoot,
309
+ rootMatches,
310
+ filesOk: changed.length === 0 && missing.length === 0 && escaped.length === 0 && rootMatches,
311
+ };
312
+ }
313
+
314
+ // ---------------------------------------------------------------------------
315
+ // Verify a DATASET attestation (bare/signed/timestamped). A dataset attestation commits to the dataset
316
+ // IDENTITY (root, fileCount, manifestDigest) — it does NOT carry the per-file list, so there are no
317
+ // sibling bytes to re-derive a Merkle root from without the original manifest. The independent verifier
318
+ // therefore confirms the embedded identity is well-formed + (for signed) recovers/pins the signer; the
319
+ // `root` is the dataset's, carried as-is. (`vh dataset verify <dir> --manifest` is the path that
320
+ // re-derives a root from a live tree; the attestation alone has no tree to re-walk.)
321
+ // ---------------------------------------------------------------------------
322
+
323
+ function verifyDatasetAttestation(att) {
324
+ for (const f of ["root", "manifestDigest"]) {
325
+ if (typeof att[f] !== "string" || !merkle.HEX32_RE.test(att[f])) {
326
+ throw new IOError(`dataset attestation ${f} must be a 0x-prefixed 32-byte hex string`);
327
+ }
328
+ }
329
+ if (!Number.isInteger(att.fileCount) || att.fileCount < 1) {
330
+ throw new IOError("dataset attestation fileCount must be a positive integer");
331
+ }
332
+ return {
333
+ matched: [],
334
+ changed: [],
335
+ missing: [],
336
+ escaped: [],
337
+ unexpected: [],
338
+ sealedRoot: att.root,
339
+ recomputedRoot: null,
340
+ rootMatches: null, // no sibling bytes to re-derive a root from (identity-only artifact)
341
+ filesOk: true, // structural identity is sound; the binding is via the signature for signed variants
342
+ identityOnly: true,
343
+ };
344
+ }
345
+
346
+ // ---------------------------------------------------------------------------
347
+ // Verify a PROOF bundle. A proof artifact carries { root, leaf, contentHash, relPath, proof[] }. We
348
+ // RE-DERIVE the leaf from relPath + contentHash, then fold leafHash(leaf) up through the proof siblings
349
+ // with nodeHash and confirm it reproduces `root` — byte-identically to the on-chain verifyLeaf, but
350
+ // fully OFFLINE. (The on-chain "is this root anchored" check is out of scope for the offline verifier.)
351
+ // ---------------------------------------------------------------------------
352
+
353
+ function verifyProofBundle(art) {
354
+ for (const f of ["root", "leaf", "contentHash"]) {
355
+ if (typeof art[f] !== "string" || !merkle.HEX32_RE.test(art[f])) {
356
+ throw new IOError(`proof artifact ${f} must be a 0x-prefixed 32-byte hex string`);
357
+ }
358
+ }
359
+ if (typeof art.relPath !== "string" || art.relPath.length === 0) {
360
+ throw new IOError("proof artifact relPath must be a non-empty string");
361
+ }
362
+ if (!Array.isArray(art.proof)) {
363
+ throw new IOError("proof artifact `proof` must be an array of 0x 32-byte hex siblings");
364
+ }
365
+ const derivedLeaf = merkle.pathLeaf(art.relPath, art.contentHash);
366
+ const leafMatches = derivedLeaf.toLowerCase() === art.leaf.toLowerCase();
367
+ let computed = merkle.leafHash(art.leaf);
368
+ for (const sib of art.proof) {
369
+ computed = merkle.nodeHash(computed, sib);
370
+ }
371
+ const foldsToRoot = computed.toLowerCase() === art.root.toLowerCase();
372
+ return {
373
+ matched: leafMatches && foldsToRoot ? [{ relPath: art.relPath, contentHash: art.contentHash }] : [],
374
+ changed:
375
+ leafMatches && foldsToRoot ? [] : [{ relPath: art.relPath, expectedContentHash: art.root, actualContentHash: computed }],
376
+ missing: [],
377
+ escaped: [],
378
+ unexpected: [],
379
+ sealedRoot: art.root,
380
+ recomputedRoot: computed,
381
+ rootMatches: leafMatches && foldsToRoot,
382
+ filesOk: leafMatches && foldsToRoot,
383
+ proof: { derivedLeaf, leafMatches, foldsToRoot },
384
+ };
385
+ }
386
+
387
+ // ---------------------------------------------------------------------------
388
+ // Verify an AGENT-SESSION packet (T-68.3 — the AgentTrace funnel leg, FREE surface only).
389
+ //
390
+ // A `*.vhagent.json` packet is SELF-CONTAINED: it carries its ordered event list (full and/or
391
+ // REDACTED), a per-event leaf expectation list, and an RFC-6962-style ordered Merkle head
392
+ // { size, root } — there are NO sibling files to read, so `readEntry` is never consulted. This block
393
+ // RE-DERIVES every event leaf and the root from the events the packet holds, exactly as the producer's
394
+ // `vh agent verify` does, but from an INDEPENDENT implementation surface: everything below is written
395
+ // against the verifier's OWN dependency-free keccak (merkle.hashBytes) — it imports NOTHING from cli/.
396
+ //
397
+ // THE CONVENTION (must match cli/core/agent-session.js + cli/journal-log.js VERBATIM):
398
+ // * payloadHash = keccak256(utf8(payload)) (the payload COMMITMENT)
399
+ // * event leaf = keccak256(utf8(JSON.stringify([
400
+ // LEAF_DOMAIN, seq, ts, actor, type, payloadHash, canonicalMetaJson|null ])))
401
+ // — the payload participates ONLY via its commitment, so a FULL event and its REDACTED twin
402
+ // (payload dropped, commitment carried, `redacted: true`) derive the IDENTICAL leaf: redaction
403
+ // changes neither the leaves nor the root (it can WITHHOLD, never silently ALTER).
404
+ // * the ordered tree (RFC 6962, position-bound, NO sorting — the OPPOSITE of the evidence tree):
405
+ // leaf node = keccak256(0x00 || leaf) interior = keccak256(0x01 || left || right)
406
+ // MTH(D[0:n]) = interior(MTH(D[0:k]), MTH(D[k:n])), k = largest power of two < n
407
+ // empty log root = keccak256(utf8("vh.journal-log/v1:empty-root"))
408
+ // * a SIGNED packet carries `headAttestation`: a detached EIP-191 personal-sign over the EXACT
409
+ // canonical head-payload bytes (the embedded `attestation` string). The signature wraps the HEAD,
410
+ // so ONE signature stays valid for every redacted copy of the same sealed session.
411
+ //
412
+ // VERDICTS: event-level tamper (a payload that no longer matches its carried commitment — including a
413
+ // REDACTED event whose commitment was forged — or a leaf that no longer matches its expectation) is a
414
+ // REJECT NAMING THE SEQ; a tampered head is `root_mismatch`; a forged signature is `bad_signature`; a
415
+ // sound signature by the wrong signer under a --vendor pin is `wrong_issuer`; a --vendor pin on an
416
+ // UNSIGNED packet is `unsigned_cannot_pin_vendor` (a stripped signature never passes a pinned verify).
417
+ // The recompute is AUTHORITATIVE: the packet is an untrusted container and its stored hashes are only
418
+ // EXPECTATIONS checked against.
419
+ // ---------------------------------------------------------------------------
420
+
421
+ // The producer's in-band trust note, REQUIRED verbatim (the packetseal discipline: the caveat may not
422
+ // drift; a packet whose note was edited is structurally invalid, exactly as `vh agent verify` treats it).
423
+ const AGENT_TRUST_NOTE =
424
+ "This agent-session packet is TAMPER-EVIDENT + OFFLINE-RECOMPUTABLE, NOT a trusted timestamp and " +
425
+ "NOT a claim the agent behaved well. Its ordered Merkle `head` {size, root} (RFC-6962-style, " +
426
+ "position-bound) commits to every event: verify RE-DERIVES each event leaf — recomputing the " +
427
+ "payload hash commitment for a FULL event, checking the carried commitment for a REDACTED one — " +
428
+ "and the root from the events you hold, and a REJECT names the first offending event seq. " +
429
+ "Redaction WITHHOLDS a payload behind its hash commitment without changing any leaf or the root: " +
430
+ "it can hide, never silently alter. Event `ts` fields are SELF-ASSERTED metadata (recorded, never " +
431
+ 'verified against any clock); "sealed at time T" rides the human-owned signing/timestamp ' +
432
+ "trust-root (STRATEGY.md P-3). Garbage-in is out of scope: the head proves the LOG is intact and " +
433
+ "append-only, not that the log faithfully records what the agent actually did. The packet is an " +
434
+ "UNTRUSTED transport container: verify never trusts the packet's own stored hashes.";
435
+
436
+ const AGENT_SIGNED_HEAD_TRUST_NOTE =
437
+ "This is a SIGNED agent-session HEAD attestation: it WRAPS (never edits) the EXACT canonical head " +
438
+ "bytes in `attestation` and attaches a detached EIP-191 signature. It asserts the holder of the " +
439
+ "`signer` key vouched for THIS session head {size, root} at signing time. Because event leaves " +
440
+ "are redaction-safe, the SAME signature stays valid for every redacted copy of the sealed session " +
441
+ "(redaction changes neither leaves nor root). It does NOT prove a timestamp (no \"sealed since " +
442
+ "T\" — still the human trust-root P-3) and is NOT a legal opinion. Every caveat of the packet " +
443
+ "applies. " +
444
+ AGENT_TRUST_NOTE;
445
+
446
+ const AGENT_HEAD_KIND = "vh.agent-head";
447
+ const AGENT_SIGNED_HEAD_KIND = "vh.agent-head-signed";
448
+ const AGENT_PACKET_SCHEMA_VERSIONS = Object.freeze([1]);
449
+ const AGENT_EVENT_TYPES = Object.freeze(["prompt", "completion", "tool_call", "tool_result", "note"]);
450
+ const AGENT_EVENT_FIELDS = Object.freeze([
451
+ "seq",
452
+ "ts",
453
+ "actor",
454
+ "type",
455
+ "payload",
456
+ "payloadHash",
457
+ "redacted",
458
+ "meta",
459
+ ]);
460
+ const AGENT_LEAF_DOMAIN = "vh.agent-session/v1:event-leaf";
461
+ const AGENT_EMPTY_ROOT_DOMAIN = "vh.journal-log/v1:empty-root";
462
+ const AGENT_META_MAX_DEPTH = 32;
463
+ const AGENT_META_MAX_NODES = 100000;
464
+
465
+ // Canonical-case wire shapes (the producer emits lowercase-only hex; mixed case is a foreign artifact).
466
+ const AGENT_HEX32_LC_RE = /^0x[0-9a-f]{64}$/;
467
+ const AGENT_ADDRESS_LC_RE = /^0x[0-9a-f]{40}$/;
468
+ const AGENT_SIG_LC_RE = /^0x[0-9a-f]{130}$/;
469
+
470
+ // STRICT UTF-8 encoder that MIRRORS the producer's ethers `toUtf8Bytes` byte-for-byte (verified over
471
+ // the whole 0x0000..0xFFFF code-unit space + surrogate edge cases). ethers' default error mode THROWS
472
+ // only on a lone HIGH surrogate (an unfinished pair, no code point) — so this returns null there — but
473
+ // it ENCODES a lone LOW surrogate as its literal 3-byte sequence (U+DC00 -> ed b0 80), NOT an error;
474
+ // so a lone low surrogate falls straight through to the c<0x10000 branch below (matching the producer,
475
+ // whose commitment over such a payload is well-defined). Pure JS; no TextEncoder (which would silently
476
+ // substitute U+FFFD and DIVERGE from the producer). null => the event's commitment is undefined here
477
+ // exactly as it is for the producer, so both sides reject in lockstep (fail-closed, never a mismatch).
478
+ function agentUtf8Bytes(str) {
479
+ const out = [];
480
+ for (let i = 0; i < str.length; i++) {
481
+ let c = str.charCodeAt(i);
482
+ if (c >= 0xd800 && c <= 0xdbff) {
483
+ const lo = i + 1 < str.length ? str.charCodeAt(i + 1) : -1;
484
+ if (lo < 0xdc00 || lo > 0xdfff) return null; // lone HIGH surrogate (ethers THROWS; no code point)
485
+ c = (c - 0xd800) * 0x400 + (lo - 0xdc00) + 0x10000;
486
+ i++;
487
+ }
488
+ // A lone LOW surrogate (0xdc00..0xdfff) is NOT special-cased: ethers encodes it as its 3-byte form
489
+ // via the c<0x10000 branch, so we do too — deleting the old lone-low `return null` that FALSELY
490
+ // rejected genuine packets carrying truncated-UTF-16 / arbitrary-tool-result bytes.
491
+ if (c < 0x80) out.push(c);
492
+ else if (c < 0x800) out.push(0xc0 | (c >> 6), 0x80 | (c & 63));
493
+ else if (c < 0x10000) out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63));
494
+ else out.push(0xf0 | (c >> 18), 0x80 | ((c >> 12) & 63), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63));
495
+ }
496
+ return new Uint8Array(out);
497
+ }
498
+
499
+ // 0x-hex -> bytes, and a tiny concat — the only byte plumbing the ordered tree needs.
500
+ function agentHexToBytes(hex) {
501
+ const s = hex.slice(2);
502
+ const out = new Uint8Array(s.length / 2);
503
+ for (let i = 0; i < out.length; i++) out[i] = parseInt(s.slice(i * 2, i * 2 + 2), 16);
504
+ return out;
505
+ }
506
+ function agentConcatBytes(list) {
507
+ let total = 0;
508
+ for (const b of list) total += b.length;
509
+ const out = new Uint8Array(total);
510
+ let off = 0;
511
+ for (const b of list) {
512
+ out.set(b, off);
513
+ off += b.length;
514
+ }
515
+ return out;
516
+ }
517
+
518
+ // RFC-6962 domain-separated hashing over the verifier's OWN keccak (merkle.hashBytes — the same
519
+ // independent primitive every other artifact family here is re-derived with). Children fold in TREE
520
+ // ORDER (never sorted): position IS meaning in an ordered session log.
521
+ function agentLeafNodeHash(leafHex) {
522
+ return merkle.hashBytes(agentConcatBytes([Uint8Array.of(0x00), agentHexToBytes(leafHex)]));
523
+ }
524
+ function agentInteriorHash(leftHex, rightHex) {
525
+ return merkle.hashBytes(
526
+ agentConcatBytes([Uint8Array.of(0x01), agentHexToBytes(leftHex), agentHexToBytes(rightHex)])
527
+ );
528
+ }
529
+
530
+ // MTH (RFC 6962 §2.1) over the ORDERED leaf values; the empty log has a domain-separated constant root.
531
+ function agentTreeRoot(leaves) {
532
+ if (leaves.length === 0) return merkle.hashBytes(agentUtf8Bytes(AGENT_EMPTY_ROOT_DOMAIN));
533
+ function mth(lo, hi) {
534
+ const n = hi - lo;
535
+ if (n === 1) return agentLeafNodeHash(leaves[lo]);
536
+ let k = 1;
537
+ while (k * 2 < n) k *= 2;
538
+ return agentInteriorHash(mth(lo, lo + k), mth(lo + k, hi));
539
+ }
540
+ return mth(0, leaves.length);
541
+ }
542
+
543
+ // A "plain" JSON-shaped object (prototype Object.prototype or null) — the same strictness the producer
544
+ // applies, so what is hashed is exactly what could be written to disk and read back.
545
+ function agentIsPlainObject(v) {
546
+ if (v === null || typeof v !== "object" || Array.isArray(v)) return false;
547
+ const proto = Object.getPrototypeOf(v);
548
+ return proto === Object.prototype || proto === null;
549
+ }
550
+
551
+ // Canonical JSON for `meta`: keys SORTED, only JSON-representable values, depth capped, and a TOTAL
552
+ // work budget so a shared-reference DAG can never hang the verifier. Returns the canonical text or
553
+ // null (reject) — byte-identical to the producer's canonicalization for every accepted value.
554
+ function agentCanonicalJson(value, depth, budget) {
555
+ if (depth > AGENT_META_MAX_DEPTH) return null;
556
+ if (++budget.n > AGENT_META_MAX_NODES) return null;
557
+ if (value === null) return "null";
558
+ const t = typeof value;
559
+ if (t === "boolean") return value ? "true" : "false";
560
+ if (t === "number") return Number.isFinite(value) ? JSON.stringify(value) : null;
561
+ if (t === "string") return JSON.stringify(value);
562
+ if (Array.isArray(value)) {
563
+ const parts = [];
564
+ for (const item of value) {
565
+ const p = agentCanonicalJson(item, depth + 1, budget);
566
+ if (p === null) return null;
567
+ parts.push(p);
568
+ }
569
+ return "[" + parts.join(",") + "]";
570
+ }
571
+ if (agentIsPlainObject(value)) {
572
+ const keys = Object.keys(value).sort();
573
+ const parts = [];
574
+ for (const k of keys) {
575
+ const p = agentCanonicalJson(value[k], depth + 1, budget);
576
+ if (p === null) return null;
577
+ parts.push(JSON.stringify(k) + ":" + p);
578
+ }
579
+ return "{" + parts.join(",") + "}";
580
+ }
581
+ return null;
582
+ }
583
+
584
+ // The payload COMMITMENT: keccak256 over the payload's UTF-8 bytes. null on a non-string or a string
585
+ // with no UTF-8 encoding (a lone HIGH surrogate — where ethers throws) — TOTAL, mirrors the producer
586
+ // exactly (a lone LOW surrogate IS encodable, so it commits rather than rejecting).
587
+ function agentPayloadHash(payload) {
588
+ if (typeof payload !== "string") return null;
589
+ const bytes = agentUtf8Bytes(payload);
590
+ return bytes === null ? null : merkle.hashBytes(bytes);
591
+ }
592
+
593
+ // STRICT validation of one canonical event — an INDEPENDENT re-implementation of the producer's rules
594
+ // (closed field set; exactly the FULL or REDACTED shape; a carried commitment on a full event must
595
+ // equal the recomputed one). Never throws; every failure is a named { ok:false, reason, field? } (the
596
+ // commitment-mismatch reject also carries carried/recomputed so the caller can localize the change).
597
+ function agentValidateEvent(event) {
598
+ try {
599
+ if (!agentIsPlainObject(event)) return { ok: false, reason: "EVENT_NOT_OBJECT" };
600
+ for (const k of Object.keys(event)) {
601
+ if (!AGENT_EVENT_FIELDS.includes(k)) return { ok: false, reason: "EVENT_UNKNOWN_FIELD", field: k };
602
+ }
603
+ if (!Number.isSafeInteger(event.seq) || event.seq < 0) {
604
+ return { ok: false, reason: "EVENT_BAD_SEQ", field: "seq" };
605
+ }
606
+ if (typeof event.ts !== "string") return { ok: false, reason: "EVENT_BAD_TS", field: "ts" };
607
+ if (typeof event.actor !== "string" || event.actor.length === 0) {
608
+ return { ok: false, reason: "EVENT_BAD_ACTOR", field: "actor" };
609
+ }
610
+ if (!AGENT_EVENT_TYPES.includes(event.type)) return { ok: false, reason: "EVENT_BAD_TYPE", field: "type" };
611
+ const hasPayload = "payload" in event;
612
+ const hasHash = "payloadHash" in event;
613
+ if (hasPayload && typeof event.payload !== "string") {
614
+ return { ok: false, reason: "EVENT_BAD_PAYLOAD", field: "payload" };
615
+ }
616
+ if (hasHash && !(typeof event.payloadHash === "string" && merkle.HEX32_RE.test(event.payloadHash))) {
617
+ return { ok: false, reason: "EVENT_BAD_PAYLOAD_HASH", field: "payloadHash" };
618
+ }
619
+ if ("redacted" in event && typeof event.redacted !== "boolean") {
620
+ return { ok: false, reason: "EVENT_BAD_REDACTED_FLAG", field: "redacted" };
621
+ }
622
+ if (!hasPayload && !hasHash) return { ok: false, reason: "EVENT_MISSING_PAYLOAD", field: "payload" };
623
+ if (event.redacted === true && hasPayload) {
624
+ return { ok: false, reason: "EVENT_REDACTED_WITH_PAYLOAD", field: "redacted" };
625
+ }
626
+ if (event.redacted === true && !hasHash) {
627
+ return { ok: false, reason: "EVENT_BAD_PAYLOAD_HASH", field: "payloadHash" };
628
+ }
629
+ if (!hasPayload && event.redacted !== true) {
630
+ return { ok: false, reason: "EVENT_UNFLAGGED_REDACTION", field: "redacted" };
631
+ }
632
+ let commitment;
633
+ if (hasPayload) {
634
+ commitment = agentPayloadHash(event.payload);
635
+ if (commitment === null) return { ok: false, reason: "EVENT_BAD_PAYLOAD", field: "payload" };
636
+ if (hasHash && commitment !== event.payloadHash.toLowerCase()) {
637
+ return {
638
+ ok: false,
639
+ reason: "EVENT_PAYLOAD_HASH_MISMATCH",
640
+ field: "payloadHash",
641
+ carried: event.payloadHash.toLowerCase(),
642
+ recomputed: commitment,
643
+ };
644
+ }
645
+ } else {
646
+ commitment = event.payloadHash.toLowerCase();
647
+ }
648
+ let metaJson = null;
649
+ if ("meta" in event) {
650
+ metaJson = agentCanonicalJson(event.meta, 0, { n: 0 });
651
+ if (metaJson === null) return { ok: false, reason: "EVENT_BAD_META", field: "meta" };
652
+ }
653
+ return { ok: true, redacted: !hasPayload, payloadHash: commitment, metaJson };
654
+ } catch (_) {
655
+ return { ok: false, reason: "HOSTILE_INPUT" };
656
+ }
657
+ }
658
+
659
+ // The redaction-safe LEAF VALUE of one validated event: the fixed-position JSON array preimage with
660
+ // the payload represented ONLY by its commitment (so a full event and its redacted twin derive the
661
+ // identical leaf). Returns null only for an encoding fault (kept total).
662
+ function agentEventLeaf(event, validated) {
663
+ const encoded = JSON.stringify([
664
+ AGENT_LEAF_DOMAIN,
665
+ event.seq,
666
+ event.ts,
667
+ event.actor,
668
+ event.type,
669
+ validated.payloadHash,
670
+ validated.metaJson,
671
+ ]);
672
+ const bytes = agentUtf8Bytes(encoded);
673
+ return bytes === null ? null : merkle.hashBytes(bytes);
674
+ }
675
+
676
+ // The shared { size, root } head shape. Throws IOError (a malformed/foreign artifact, exit 1 — the same
677
+ // class `vh agent verify` gives a structurally invalid packet).
678
+ function validateAgentHeadShape(head, label) {
679
+ if (head == null || typeof head !== "object" || Array.isArray(head)) {
680
+ throw new IOError(`${label} \`head\` must be a { size, root } object`);
681
+ }
682
+ for (const k of Object.keys(head)) {
683
+ if (k !== "size" && k !== "root") {
684
+ throw new IOError(`${label} head has unknown field: ${JSON.stringify(k)}`);
685
+ }
686
+ }
687
+ if (!Number.isSafeInteger(head.size) || head.size < 0) {
688
+ throw new IOError(`${label} head.size must be a non-negative integer, got: ${String(head.size)}`);
689
+ }
690
+ if (typeof head.root !== "string" || !AGENT_HEX32_LC_RE.test(head.root)) {
691
+ throw new IOError(
692
+ `${label} head.root must be a LOWERCASE 0x-bytes32 hex string, got: ${String(head.root)}`
693
+ );
694
+ }
695
+ }
696
+
697
+ // STRICT structural validation of the OPTIONAL signed-head container: the exact canonical embedded
698
+ // bytes, a known scheme, lowercase signer/signature, and an embedded head payload in canonical form.
699
+ // Returns { embeddedHead } for the binding check. Throws IOError on any structural defect.
700
+ function validateAgentSignedHead(container) {
701
+ const label = "agent-session packet headAttestation";
702
+ if (container == null || typeof container !== "object" || Array.isArray(container)) {
703
+ throw new IOError(`${label} must be a JSON object`);
704
+ }
705
+ const KNOWN = ["kind", "schemaVersion", "note", "attestation", "signature"];
706
+ for (const k of Object.keys(container)) {
707
+ if (!KNOWN.includes(k)) throw new IOError(`${label} has unknown field: ${JSON.stringify(k)}`);
708
+ }
709
+ if (container.kind !== AGENT_SIGNED_HEAD_KIND) {
710
+ throw new IOError(
711
+ `${label} kind must be ${JSON.stringify(AGENT_SIGNED_HEAD_KIND)}, got: ${JSON.stringify(container.kind)}`
712
+ );
713
+ }
714
+ if (container.schemaVersion !== 1) {
715
+ throw new IOError(`${label} has unsupported schemaVersion: ${JSON.stringify(container.schemaVersion)}`);
716
+ }
717
+ if (container.note !== AGENT_SIGNED_HEAD_TRUST_NOTE) {
718
+ throw new IOError(`${label} note must be the standing signed-head trust note (caveat must not drift)`);
719
+ }
720
+ if (typeof container.attestation !== "string") {
721
+ throw new IOError(`${label} must embed the canonical UNSIGNED head bytes as a string \`attestation\``);
722
+ }
723
+ let embedded;
724
+ try {
725
+ embedded = JSON.parse(container.attestation);
726
+ } catch (e) {
727
+ throw new IOError(`${label} embedded attestation is not valid JSON: ${e.message}`);
728
+ }
729
+ if (
730
+ embedded == null ||
731
+ typeof embedded !== "object" ||
732
+ Array.isArray(embedded) ||
733
+ embedded.kind !== AGENT_HEAD_KIND ||
734
+ embedded.schemaVersion !== 1 ||
735
+ embedded.note !== AGENT_TRUST_NOTE
736
+ ) {
737
+ throw new IOError(`${label} embedded payload is not a canonical ${JSON.stringify(AGENT_HEAD_KIND)} payload`);
738
+ }
739
+ validateAgentHeadShape(embedded.head, `${label} embedded payload`);
740
+ // The embedded string must be the EXACT canonical serialization (the byte-unambiguous signed message);
741
+ // an insignificant-whitespace/reordered variant is a foreign artifact.
742
+ const canonicalText =
743
+ JSON.stringify({
744
+ kind: embedded.kind,
745
+ schemaVersion: embedded.schemaVersion,
746
+ note: embedded.note,
747
+ head: { size: embedded.head.size, root: embedded.head.root },
748
+ }) + "\n";
749
+ if (container.attestation !== canonicalText) {
750
+ throw new IOError(`${label} embedded attestation is not in canonical form (the signed-over bytes are ambiguous)`);
751
+ }
752
+ const sig = container.signature;
753
+ if (sig == null || typeof sig !== "object" || Array.isArray(sig)) {
754
+ throw new IOError(`${label} signature must be a { scheme, signer, signature } object`);
755
+ }
756
+ if (sig.scheme !== "eip191-personal-sign") {
757
+ throw new IOError(
758
+ `${label} has unsupported signature scheme: ${JSON.stringify(sig.scheme)} (this verifier understands eip191-personal-sign)`
759
+ );
760
+ }
761
+ if (typeof sig.signer !== "string" || !AGENT_ADDRESS_LC_RE.test(sig.signer)) {
762
+ throw new IOError(`${label} signer must be a LOWERCASE 0x-prefixed 20-byte hex address`);
763
+ }
764
+ if (typeof sig.signature !== "string" || !AGENT_SIG_LC_RE.test(sig.signature)) {
765
+ throw new IOError(`${label} signature must be a 65-byte (r||s||v) LOWERCASE 0x-hex string`);
766
+ }
767
+ return { embeddedHead: { size: embedded.head.size, root: embedded.head.root } };
768
+ }
769
+
770
+ // STRICT structural validation of a parsed packet (SHAPE only — the per-event/leaf/root RECOMPUTE is
771
+ // verifyAgentSeal's job, so event-level tamper stays a NAMED verdict naming the seq, never a throw).
772
+ // Mirrors the producer's validatePacketShape defect-for-defect. Throws IOError.
773
+ function validateAgentPacketStructure(obj) {
774
+ const label = "agent-session packet";
775
+ const KNOWN = ["kind", "schemaVersion", "note", "head", "counts", "events", "leaves", "headAttestation"];
776
+ for (const k of Object.keys(obj)) {
777
+ if (!KNOWN.includes(k)) throw new IOError(`${label} has unknown field: ${JSON.stringify(k)}`);
778
+ }
779
+ if (!AGENT_PACKET_SCHEMA_VERSIONS.includes(obj.schemaVersion)) {
780
+ throw new IOError(
781
+ `unsupported ${label} schemaVersion: ${JSON.stringify(obj.schemaVersion)} ` +
782
+ `(this verifier understands ${JSON.stringify(AGENT_PACKET_SCHEMA_VERSIONS)})`
783
+ );
784
+ }
785
+ if (obj.note !== AGENT_TRUST_NOTE) {
786
+ throw new IOError(`${label} \`note\` must be the standing trust note (caveat must not drift)`);
787
+ }
788
+ validateAgentHeadShape(obj.head, label);
789
+ if (obj.counts == null || typeof obj.counts !== "object" || Array.isArray(obj.counts)) {
790
+ throw new IOError(`${label} \`counts\` must be a { events, full, redacted } object`);
791
+ }
792
+ for (const k of Object.keys(obj.counts)) {
793
+ if (!["events", "full", "redacted"].includes(k)) {
794
+ throw new IOError(`${label} counts has unknown field: ${JSON.stringify(k)}`);
795
+ }
796
+ }
797
+ for (const k of ["events", "full", "redacted"]) {
798
+ if (!Number.isSafeInteger(obj.counts[k]) || obj.counts[k] < 0) {
799
+ throw new IOError(`${label} counts.${k} must be a non-negative integer, got: ${String(obj.counts[k])}`);
800
+ }
801
+ }
802
+ if (!Array.isArray(obj.events)) throw new IOError(`${label} \`events\` must be an array`);
803
+ if (!Array.isArray(obj.leaves) || obj.leaves.length !== obj.events.length) {
804
+ throw new IOError(`${label} \`leaves\` must be an array with EXACTLY one leaf expectation per event`);
805
+ }
806
+ obj.leaves.forEach((l, i) => {
807
+ if (typeof l !== "string" || !AGENT_HEX32_LC_RE.test(l)) {
808
+ throw new IOError(`${label} leaves[${i}] must be a LOWERCASE 0x-bytes32 hex string, got: ${String(l)}`);
809
+ }
810
+ });
811
+ if (obj.head.size !== obj.events.length) {
812
+ throw new IOError(
813
+ `${label} head.size (${obj.head.size}) does not match the events length (${obj.events.length})`
814
+ );
815
+ }
816
+ if (obj.counts.events !== obj.events.length || obj.counts.full + obj.counts.redacted !== obj.counts.events) {
817
+ throw new IOError(
818
+ `${label} \`counts\` is internally inconsistent (events must equal the events length; full + redacted must equal events)`
819
+ );
820
+ }
821
+ let signedHead = null;
822
+ if (obj.headAttestation !== undefined) signedHead = validateAgentSignedHead(obj.headAttestation);
823
+ return { packet: obj, signedHead };
824
+ }
825
+
826
+ // The AUTHORITATIVE per-event/leaf/root/counts RECOMPUTE over a shape-validated packet. Returns the
827
+ // engine's standard fileResult shape (matched/changed/... + roots) PLUS an `agent` sub-verdict block
828
+ // and a `reasonKind` in the verifier's reason vocabulary. Event faults are localized to the FIRST
829
+ // offending seq, exactly as the producer's verify names it. Never throws.
830
+ function verifyAgentSeal(packet) {
831
+ const matched = [];
832
+ const changed = [];
833
+ const withheld = [];
834
+ const agent = {
835
+ head: { size: packet.head.size, root: packet.head.root },
836
+ recomputedHead: null,
837
+ counts: null,
838
+ withheld: null,
839
+ seq: null,
840
+ reason: null,
841
+ };
842
+ const base = {
843
+ matched,
844
+ changed,
845
+ missing: [],
846
+ escaped: [],
847
+ unexpected: [],
848
+ sealedRoot: packet.head.root,
849
+ recomputedRoot: null,
850
+ rootMatches: null,
851
+ filesOk: false,
852
+ reasonKind: null,
853
+ agent,
854
+ };
855
+ const events = packet.events;
856
+ const leaves = [];
857
+ for (let i = 0; i < events.length; i++) {
858
+ const v = agentValidateEvent(events[i]);
859
+ if (!v.ok) {
860
+ agent.seq = i;
861
+ agent.reason = v.reason;
862
+ if (v.field !== undefined) agent.field = v.field;
863
+ if (v.reason === "EVENT_PAYLOAD_HASH_MISMATCH") {
864
+ // The payload no longer matches its carried commitment: a CONTENT change localized to its seq
865
+ // (this is also how a REDACTED event's FORGED commitment surfaces once its leaf is checked).
866
+ changed.push({ relPath: `events[${i}]`, expectedContentHash: v.carried, actualContentHash: v.recomputed });
867
+ base.reasonKind = "CHANGED";
868
+ } else {
869
+ base.reasonKind = "event_invalid";
870
+ }
871
+ return base;
872
+ }
873
+ if (events[i].seq !== i) {
874
+ agent.seq = i;
875
+ agent.reason = "SESSION_SEQ_NOT_CONTIGUOUS";
876
+ base.reasonKind = "event_invalid";
877
+ return base;
878
+ }
879
+ const leaf = agentEventLeaf(events[i], v);
880
+ if (leaf === null || leaf !== packet.leaves[i]) {
881
+ // A bound-field edit (ts/actor/type/meta) or a forged redacted commitment: the re-derived leaf no
882
+ // longer matches the packet's own expectation — named by seq, recompute authoritative.
883
+ agent.seq = i;
884
+ agent.reason = "EVENT_LEAF_MISMATCH";
885
+ changed.push({ relPath: `events[${i}]`, expectedContentHash: packet.leaves[i], actualContentHash: leaf });
886
+ base.reasonKind = "CHANGED";
887
+ return base;
888
+ }
889
+ leaves.push(leaf);
890
+ matched.push({ relPath: `events[${i}]`, contentHash: leaf });
891
+ if (v.redacted) withheld.push(i);
892
+ }
893
+ const recomputedRoot = agentTreeRoot(leaves);
894
+ base.recomputedRoot = recomputedRoot;
895
+ agent.recomputedHead = { size: leaves.length, root: recomputedRoot };
896
+ base.rootMatches = leaves.length === packet.head.size && recomputedRoot === packet.head.root;
897
+ if (!base.rootMatches) {
898
+ agent.reason = "HEAD_MISMATCH";
899
+ base.reasonKind = "root_mismatch";
900
+ return base;
901
+ }
902
+ const full = events.length - withheld.length;
903
+ agent.counts = { events: events.length, full, redacted: withheld.length };
904
+ agent.withheld = withheld;
905
+ if (packet.counts.full !== full || packet.counts.redacted !== withheld.length) {
906
+ agent.reason = "COUNTS_MISMATCH";
907
+ base.reasonKind = "counts_mismatch";
908
+ return base;
909
+ }
910
+ base.filesOk = true;
911
+ return base;
912
+ }
913
+
914
+ // The artifact-level orchestrator for KINDS.AGENT_PACKET — both entrypoints (disk + bytes) route here
915
+ // through verifyParsedArtifact, so the two paths' verdicts are one code path (deep-equal by
916
+ // construction). Precedence mirrors the producer's `vh agent verify`: event/leaf/head/counts faults
917
+ // (naming the seq) dominate; then head binding, signature genuineness, and the vendor pin.
918
+ function verifyAgentPacketArtifact({ artifact, obj, pinned }) {
919
+ const { signedHead } = validateAgentPacketStructure(obj); // throws IOError on a malformed/foreign packet
920
+ const fileResult = verifyAgentSeal(obj);
921
+ const agent = fileResult.agent;
922
+
923
+ const signed = obj.headAttestation !== undefined;
924
+ let recoveredSigner = null;
925
+ let claimedSigner = null;
926
+ let signatureOk = null;
927
+ let signerMatchesVendor = null;
928
+ let headBound = null;
929
+ if (signed) {
930
+ claimedSigner = obj.headAttestation.signature.signer; // lowercase, structurally enforced
931
+ recoveredSigner = tryRecover(obj.headAttestation.attestation, obj.headAttestation.signature.signature);
932
+ signatureOk = recoveredSigner != null && recoveredSigner === claimedSigner;
933
+ if (agent.recomputedHead != null) {
934
+ // The signature must vouch for THIS session's RECOMPUTED head — a signature pasted from a
935
+ // different session recovers fine but binds a different { size, root }.
936
+ headBound =
937
+ signedHead.embeddedHead.size === agent.recomputedHead.size &&
938
+ signedHead.embeddedHead.root === agent.recomputedHead.root;
939
+ }
940
+ if (signatureOk && pinned != null) signerMatchesVendor = recoveredSigner === pinned;
941
+ }
942
+
943
+ let accepted = true;
944
+ let reason = "OK";
945
+ if (!fileResult.filesOk) {
946
+ accepted = false;
947
+ reason = fileResult.reasonKind;
948
+ } else if (signed && headBound === false) {
949
+ accepted = false;
950
+ reason = "head_not_bound";
951
+ agent.reason = "HEAD_NOT_BOUND";
952
+ } else if (signed && !signatureOk) {
953
+ accepted = false;
954
+ reason = "bad_signature";
955
+ agent.reason = "SIGNATURE_FORGED";
956
+ } else if (signed && pinned != null && signerMatchesVendor !== true) {
957
+ accepted = false;
958
+ reason = "wrong_issuer";
959
+ agent.reason = "WRONG_VENDOR";
960
+ } else if (!signed && pinned != null) {
961
+ // Fail-closed pin: a stripped signature can never pass a pinned verify.
962
+ accepted = false;
963
+ reason = "unsigned_cannot_pin_vendor";
964
+ agent.reason = "NOT_SIGNED";
965
+ }
966
+
967
+ const result = {
968
+ artifact,
969
+ kind: KINDS.AGENT_PACKET,
970
+ payloadKind: KINDS.AGENT_PACKET,
971
+ signed,
972
+ verdict: accepted ? "OK" : "REJECTED",
973
+ reason,
974
+ accepted,
975
+ recoveredSigner,
976
+ claimedSigner,
977
+ pinnedVendor: pinned,
978
+ signatureOk,
979
+ signerMatchesVendor,
980
+ sealedRoot: fileResult.sealedRoot,
981
+ recomputedRoot: fileResult.recomputedRoot,
982
+ rootMatches: fileResult.rootMatches,
983
+ counts: {
984
+ matched: fileResult.matched.length,
985
+ changed: fileResult.changed.length,
986
+ missing: 0,
987
+ escaped: 0,
988
+ unexpected: 0,
989
+ },
990
+ matched: fileResult.matched,
991
+ changed: fileResult.changed,
992
+ missing: [],
993
+ escaped: [],
994
+ unexpected: [],
995
+ agent,
996
+ note: TRUST_NOTE,
997
+ };
998
+ return { result, code: accepted ? EXIT.OK : EXIT.REJECTED };
999
+ }
1000
+
1001
+ // ---------------------------------------------------------------------------
1002
+ // The core verify orchestration over an ALREADY-PARSED artifact object + an injected file source. This
1003
+ // is the ONE engine BOTH entrypoints drive — `verifyArtifact` (disk: the CLI contract, byte-identical to
1004
+ // before this seam existed) and `verifyArtifactFromBytes` (in-memory map). It auto-detects the artifact
1005
+ // kind, decodes a signed container (recovering + pinning the signer), re-derives the root from
1006
+ // referenced bytes, and assembles a deterministic verdict. PURE: every read goes through `readEntry`.
1007
+ // Returns { result, code } — code is the EXIT-contract integer.
1008
+ // ---------------------------------------------------------------------------
1009
+
1010
+ function verifyParsedArtifact({ artifact, obj, vendor, readEntry }) {
1011
+ const kind = obj.kind;
1012
+ const pinned = vendor != null ? normalizeAddress(vendor, "--vendor") : null;
1013
+
1014
+ // AGENT-SESSION packet (T-68.3): SELF-CONTAINED — no sibling bytes, its own leaf/root convention and
1015
+ // its own in-packet signed head. Routed to the dedicated orchestrator above (`readEntry` unused).
1016
+ if (kind === KINDS.AGENT_PACKET) {
1017
+ return verifyAgentPacketArtifact({ artifact, obj, pinned });
1018
+ }
1019
+
1020
+ // Detect signed vs bare and the underlying payload kind. A signed container wraps the embedded payload.
1021
+ let signed = false;
1022
+ let recoveredSigner = null;
1023
+ let claimedSigner = null;
1024
+ let signatureOk = null; // null = no signature on this artifact
1025
+ let payload = obj; // the (possibly embedded) thing whose root we re-derive
1026
+ let payloadKind = kind;
1027
+
1028
+ if (
1029
+ kind === KINDS.EVIDENCE_SEAL_SIGNED ||
1030
+ kind === KINDS.TRUST_SEAL_SIGNED ||
1031
+ kind === KINDS.DATASET_ATTESTATION_SIGNED ||
1032
+ kind === KINDS.DATASET_ATTESTATION_TIMESTAMPED
1033
+ ) {
1034
+ signed = true;
1035
+ const dec = decodeSigned(obj);
1036
+ payload = dec.embedded;
1037
+ payloadKind = dec.embedded.kind;
1038
+ claimedSigner = dec.claimedSigner;
1039
+ recoveredSigner = tryRecover(dec.message, dec.signature);
1040
+ // signatureOk: the signature recovers AND matches the CLAIMED signer recorded in the container.
1041
+ signatureOk = recoveredSigner != null && recoveredSigner === claimedSigner;
1042
+ } else if (!Object.values(KINDS).includes(kind)) {
1043
+ throw new UsageError(
1044
+ `unrecognized artifact kind: ${JSON.stringify(kind)} ` +
1045
+ "(verify-vh understands evidence seals, reconciliation seals, dataset attestations, and proof bundles)"
1046
+ );
1047
+ }
1048
+
1049
+ // Re-derive the root from the referenced bytes per the (underlying) kind.
1050
+ let fileResult;
1051
+ if (payloadKind === KINDS.EVIDENCE_SEAL) {
1052
+ fileResult = verifyEvidenceSealWith(payload, readEntry);
1053
+ } else if (payloadKind === KINDS.TRUST_SEAL) {
1054
+ fileResult = verifyTrustSealWith(payload, readEntry);
1055
+ } else if (payloadKind === KINDS.DATASET_ATTESTATION) {
1056
+ fileResult = verifyDatasetAttestation(payload);
1057
+ } else if (payloadKind === KINDS.PROOF) {
1058
+ fileResult = verifyProofBundle(payload);
1059
+ } else {
1060
+ throw new UsageError(
1061
+ `unrecognized embedded artifact kind: ${JSON.stringify(payloadKind)}`
1062
+ );
1063
+ }
1064
+
1065
+ // --- Decide the verdict + the deterministic reason. ---
1066
+ // Precedence: a structural file tamper (CHANGED/MISSING/root mismatch) is a clean REJECTED. For a
1067
+ // SIGNED artifact, a broken signature is `bad_signature`; a recovered signer that does not equal the
1068
+ // pinned --vendor is `wrong_issuer`. Both are clean REJECTED verdicts (exit 3), never a crash.
1069
+ let reason = "OK";
1070
+ let accepted = true;
1071
+
1072
+ const escaped = fileResult.escaped || [];
1073
+ if (!fileResult.filesOk) {
1074
+ accepted = false;
1075
+ // path_escape DOMINATES: an artifact that tries to read outside its source is malicious by
1076
+ // construction (the threat model is a hostile producer probing the counterparty's filesystem), so it
1077
+ // is reported FIRST — never as a benign CHANGED/MISSING, and never with a leaked out-of-tree content
1078
+ // hash.
1079
+ if (escaped.length > 0) reason = "path_escape";
1080
+ else if (fileResult.changed.length > 0) reason = "CHANGED";
1081
+ else if (fileResult.missing.length > 0) reason = "MISSING";
1082
+ else if (fileResult.unexpected.length > 0) reason = "UNEXPECTED";
1083
+ else reason = "root_mismatch";
1084
+ }
1085
+
1086
+ // Signature checks (only for signed artifacts). A bad signature dominates the "issuer" check (you
1087
+ // cannot trust an issuer you cannot recover).
1088
+ let signerMatchesVendor = null;
1089
+ if (signed) {
1090
+ if (!signatureOk) {
1091
+ accepted = false;
1092
+ // bad_signature is the dominant reason ONLY if files were otherwise OK; if a file also changed we
1093
+ // still surface bad_signature because the signature is the trust root of a signed artifact.
1094
+ reason = "bad_signature";
1095
+ } else if (pinned != null) {
1096
+ signerMatchesVendor = recoveredSigner === pinned;
1097
+ if (!signerMatchesVendor) {
1098
+ accepted = false;
1099
+ // wrong_issuer only when the signature itself is sound but the signer is not the pinned vendor.
1100
+ if (fileResult.filesOk) reason = "wrong_issuer";
1101
+ else if (reason === "OK") reason = "wrong_issuer";
1102
+ }
1103
+ }
1104
+ } else if (pinned != null) {
1105
+ // A --vendor pin on an UNSIGNED artifact cannot be satisfied (there is no signer to recover); this is
1106
+ // a clean REJECTED wrong_issuer-style verdict so a CI gate expecting a signed-by-vendor artifact fails.
1107
+ accepted = false;
1108
+ reason = "unsigned_cannot_pin_vendor";
1109
+ }
1110
+
1111
+ const verdict = accepted ? "OK" : "REJECTED";
1112
+ const code = accepted ? EXIT.OK : EXIT.REJECTED;
1113
+
1114
+ const result = {
1115
+ artifact,
1116
+ kind,
1117
+ payloadKind,
1118
+ signed,
1119
+ verdict,
1120
+ reason,
1121
+ accepted,
1122
+ recoveredSigner,
1123
+ claimedSigner,
1124
+ pinnedVendor: pinned,
1125
+ signatureOk,
1126
+ signerMatchesVendor,
1127
+ sealedRoot: fileResult.sealedRoot,
1128
+ recomputedRoot: fileResult.recomputedRoot,
1129
+ rootMatches: fileResult.rootMatches,
1130
+ counts: {
1131
+ matched: fileResult.matched.length,
1132
+ changed: fileResult.changed.length,
1133
+ missing: fileResult.missing.length,
1134
+ escaped: escaped.length,
1135
+ unexpected: fileResult.unexpected.length,
1136
+ },
1137
+ matched: fileResult.matched,
1138
+ changed: fileResult.changed,
1139
+ missing: fileResult.missing,
1140
+ escaped,
1141
+ unexpected: fileResult.unexpected,
1142
+ note: TRUST_NOTE,
1143
+ };
1144
+ if (fileResult.identityOnly) result.identityOnly = true;
1145
+ if (fileResult.proof) result.proof = fileResult.proof;
1146
+
1147
+ return { result, code };
1148
+ }
1149
+
1150
+ // ---------------------------------------------------------------------------
1151
+ // The PURE revocation fold for the bytes path. Semantically identical to revocation.loadAndApply (the
1152
+ // disk integration) once the entries are in hand: resolve the as-of instant (defaulting to nowISO),
1153
+ // normalize the caller-supplied revocations input (a JSON string, a container object, or an array of
1154
+ // either), fold the decision onto the result, and recompute the exit code. Uses ONLY the pure decision
1155
+ // functions (./lib/revocation-core.js via the revocation re-exports) — never the fs-backed reader.
1156
+ // ---------------------------------------------------------------------------
1157
+
1158
+ function applyRevocationsDecision(result, revocationsInput, asOf, nowISO) {
1159
+ const resolved = revocation.resolveAsOf(asOf, nowISO);
1160
+ const entries = revocation.normalizeRevocationsInput(revocationsInput);
1161
+ const downgraded = revocation.applyToVerifyResult({ result, revocations: entries, asOf: resolved.asOf });
1162
+ downgraded.trustAsOfDefaulted = resolved.defaulted;
1163
+ return { result: downgraded, code: downgraded.accepted ? EXIT.OK : EXIT.REJECTED };
1164
+ }
1165
+
1166
+ // ---------------------------------------------------------------------------
1167
+ // THE IN-MEMORY FILE SOURCE + BYTES ENTRYPOINT (T-66.1).
1168
+ //
1169
+ // `verifyArtifactFromBytes({ artifactText, files, vendor, revocationsText, asOf, nowISO, artifactName })`
1170
+ // drives the EXACT engine above over caller-supplied bytes:
1171
+ // * `artifactText` — the artifact JSON as a STRING (what a browser read out of a dropped file);
1172
+ // * `files` — a plain `{ relPath: Uint8Array|Buffer }` map of the packet's referenced bytes;
1173
+ // * `vendor` — optional 0x-address pin (same semantics as `--vendor`);
1174
+ // * `revocationsText` — optional revocations input (JSON text / container / array; same semantics as
1175
+ // the CONTENT of a `--revocations` file), with optional `asOf` (canonical ISO instant) + `nowISO`;
1176
+ // * `artifactName` — optional label used verbatim as `result.artifact` (defaults below).
1177
+ //
1178
+ // CONTRACT — NEVER THROWS. Hostile input (non-JSON artifact text, an oversized / absolute / `..` map
1179
+ // key, a non-bytes map value, a malformed vendor or asOf) is NAMED-rejected: the return value is
1180
+ // { ok, code, result, error }
1181
+ // where a computed verdict carries `result` (the SAME structured shape `verifyArtifact` returns — the
1182
+ // two are DEEP-EQUAL on identical inputs) + `error: null`, and an input problem carries `result: null` +
1183
+ // `error: { name: "UsageError"|"IOError", code, message }` with the exact defect named. The verdict
1184
+ // classes (missing / extra / content-mismatch / wrong-vendor / tampered-signature / path_escape /
1185
+ // revoked) derive from the MAP exactly as the disk path derives them from the directory.
1186
+ // ---------------------------------------------------------------------------
1187
+
1188
+ // The largest relPath key the in-memory map accepts. Sealed relPaths are short; a multi-kilobyte "key"
1189
+ // is hostile input (an attempted resource-exhaustion / log-flooding vector), rejected by NAME up front.
1190
+ const MAX_RELPATH_CHARS = 4096;
1191
+
1192
+ // PURE string-level confinement for an in-memory relPath — the map-source mirror of the disk source's
1193
+ // string checks (absolute anywhere, or any `..` traversal component, is hostile). Windows-style drive
1194
+ // and UNC prefixes are treated as absolute here too: an in-memory map NEVER has a legitimate absolute
1195
+ // key, whatever platform authored the artifact.
1196
+ function isTraversalOrAbsoluteRelPath(relPath) {
1197
+ if (typeof relPath !== "string" || relPath.length === 0) return true;
1198
+ if (relPath.charAt(0) === "/" || relPath.charAt(0) === "\\") return true;
1199
+ if (/^[A-Za-z]:[\\/]/.test(relPath)) return true;
1200
+ if (relPath.split(/[\\/]/).includes("..")) return true;
1201
+ return false;
1202
+ }
1203
+
1204
+ // Validate the caller's `{ relPath: bytes }` map SHAPE up front so a hostile map is NAMED-rejected
1205
+ // before any verification work (and before any key is dereferenced). Throws UsageError; the entrypoint
1206
+ // converts that into the structured `{ error }` return — never an uncaught throw.
1207
+ function validateFilesMap(files) {
1208
+ if (files == null || typeof files !== "object" || Array.isArray(files)) {
1209
+ throw new UsageError(
1210
+ "verifyArtifactFromBytes requires `files` as a plain { relPath: Uint8Array|Buffer } object map"
1211
+ );
1212
+ }
1213
+ for (const key of Object.keys(files)) {
1214
+ if (key.length === 0) {
1215
+ throw new UsageError("files map contains an empty relPath key");
1216
+ }
1217
+ if (key.length > MAX_RELPATH_CHARS) {
1218
+ throw new UsageError(
1219
+ `files map key exceeds ${MAX_RELPATH_CHARS} characters (oversized relPath, starts: ` +
1220
+ `${JSON.stringify(key.slice(0, 64))})`
1221
+ );
1222
+ }
1223
+ if (isTraversalOrAbsoluteRelPath(key)) {
1224
+ throw new UsageError(
1225
+ `files map key is not a confined relative path: ${JSON.stringify(key.slice(0, 256))}`
1226
+ );
1227
+ }
1228
+ const v = files[key];
1229
+ if (!(v instanceof Uint8Array)) {
1230
+ throw new UsageError(
1231
+ `files map value for ${JSON.stringify(key.slice(0, 256))} must be a Uint8Array/Buffer of the file's bytes`
1232
+ );
1233
+ }
1234
+ }
1235
+ }
1236
+
1237
+ // The in-memory `readEntry` source over an (already-validated) map: a hostile relPath from the ARTIFACT
1238
+ // is `escaped` (the same string-level rules as the disk source — so absolute/`..` seal entries produce
1239
+ // the identical path_escape verdict), an absent key is `missing`, and a present key answers its bytes.
1240
+ // Lookups use an own-property check so `__proto__`/`constructor` style keys can never smuggle
1241
+ // prototype-chain values in as file bytes.
1242
+ function makeMapReadEntry(files) {
1243
+ return function readEntry(relPath) {
1244
+ if (isTraversalOrAbsoluteRelPath(relPath)) return { status: "escaped" };
1245
+ if (!Object.prototype.hasOwnProperty.call(files, relPath)) return { status: "missing" };
1246
+ return { status: "ok", bytes: files[relPath] };
1247
+ };
1248
+ }
1249
+
1250
+ function verifyArtifactFromBytes(params) {
1251
+ try {
1252
+ if (params == null || typeof params !== "object" || Array.isArray(params)) {
1253
+ throw new UsageError(
1254
+ "verifyArtifactFromBytes requires a params object: " +
1255
+ "{ artifactText, files, vendor?, revocationsText?, asOf?, nowISO?, artifactName? }"
1256
+ );
1257
+ }
1258
+ const { artifactText, files, vendor, revocationsText, asOf, nowISO, artifactName } = params;
1259
+ if (typeof artifactText !== "string") {
1260
+ throw new UsageError("verifyArtifactFromBytes requires `artifactText` (the artifact JSON as a string)");
1261
+ }
1262
+ validateFilesMap(files);
1263
+
1264
+ // Mirror the CLI's flag-shape gate (parseArgs): asOf only means something alongside revocations, and
1265
+ // must be a canonical ISO-8601 UTC instant — a malformed one is a NAMED usage rejection up front,
1266
+ // never a mid-verify throw.
1267
+ if (asOf !== undefined && asOf !== null && (revocationsText === undefined || revocationsText === null)) {
1268
+ throw new UsageError(
1269
+ "asOf requires revocationsText (it pins the instant the revocation decision is made AS OF)"
1270
+ );
1271
+ }
1272
+ if (asOf !== undefined && asOf !== null) {
1273
+ const ms = Date.parse(asOf);
1274
+ if (
1275
+ typeof asOf !== "string" ||
1276
+ !revocation.ISO_INSTANT_RE.test(asOf) ||
1277
+ Number.isNaN(ms) ||
1278
+ new Date(ms).toISOString() !== asOf
1279
+ ) {
1280
+ throw new UsageError(
1281
+ `invalid asOf: ${String(asOf)} (expected a canonical ISO-8601 UTC instant, e.g. 2026-06-01T00:00:00.000Z)`
1282
+ );
1283
+ }
1284
+ }
1285
+
1286
+ const label = artifactName != null ? String(artifactName) : "(in-memory artifact)";
1287
+ let obj;
1288
+ try {
1289
+ obj = JSON.parse(artifactText);
1290
+ } catch (e) {
1291
+ throw new IOError(`artifact ${label} is not valid JSON: ${e.message}`);
1292
+ }
1293
+ if (obj == null || typeof obj !== "object" || Array.isArray(obj)) {
1294
+ throw new IOError(`artifact ${label} must be a JSON object`);
1295
+ }
1296
+
1297
+ const { result, code } = verifyParsedArtifact({
1298
+ artifact: label,
1299
+ obj,
1300
+ vendor,
1301
+ readEntry: makeMapReadEntry(files),
1302
+ });
1303
+
1304
+ // OPTIONAL recipient-side TRUST-DECISION-AS-OF, from caller-supplied revocations INPUT (never a
1305
+ // filesystem read). Same downgrade math as the disk path's revocation.loadAndApply, so the two
1306
+ // paths' results stay deep-equal on identical inputs.
1307
+ if (revocationsText !== undefined && revocationsText !== null) {
1308
+ let applied;
1309
+ try {
1310
+ applied = applyRevocationsDecision(result, revocationsText, asOf, nowISO || new Date().toISOString());
1311
+ } catch (e) {
1312
+ // A non-JSON / wrong-shape revocations input is the bytes-path analogue of an unreadable
1313
+ // --revocations file: a NAMED IO-class rejection, never a silently-skipped downgrade.
1314
+ throw new IOError(`cannot evaluate revocations: ${e.message}`);
1315
+ }
1316
+ return { ok: applied.result.accepted, code: applied.code, result: applied.result, error: null };
1317
+ }
1318
+
1319
+ return { ok: result.accepted, code, result, error: null };
1320
+ } catch (e) {
1321
+ const isUsage = e instanceof UsageError;
1322
+ const code = isUsage ? EXIT.USAGE : EXIT.IO;
1323
+ return {
1324
+ ok: false,
1325
+ code,
1326
+ result: null,
1327
+ error: {
1328
+ name: isUsage ? "UsageError" : "IOError",
1329
+ code,
1330
+ message: String(e && e.message ? e.message : e),
1331
+ },
1332
+ };
1333
+ }
1334
+ }
1335
+
1336
+ // ============================= END VERIFY-VH PURE ENGINE (T-66.1) =============================
1337
+
1338
+ // ---------------------------------------------------------------------------
1339
+ // Argument parsing.
1340
+ // SINGLE-ARTIFACT (the original, byte-for-byte unchanged contract):
1341
+ // verify-vh <artifact> [--vendor <0xaddr>] [--dir <d>] [--json]
1342
+ // BATCH/MANIFEST (T-33.1 — one invocation gates EVERY release artifact, one CI exit code):
1343
+ // verify-vh <artifact> <artifact> ... [--vendor <0xaddr>] [--dir <d>] [--json]
1344
+ // verify-vh --manifest <file> [--vendor <0xaddr>] [--dir <d>] [--json]
1345
+ // Batch mode is a pure SUPERSET: it engages ONLY when more than one positional <artifact> is given OR
1346
+ // `--manifest <file>` is supplied. A lone positional with no --manifest takes the identical single path,
1347
+ // so existing callers/tests never shift. A top-level `--vendor`/`--dir` is a DEFAULT each entry inherits
1348
+ // unless the entry (a manifest line) overrides it with its own per-entry `--vendor`/`--dir`.
1349
+ // ---------------------------------------------------------------------------
1350
+
1351
+ function parseArgs(argv) {
1352
+ const opts = {
1353
+ artifact: undefined,
1354
+ vendor: undefined,
1355
+ dir: undefined,
1356
+ json: false,
1357
+ help: false,
1358
+ manifest: undefined,
1359
+ revocations: undefined,
1360
+ asOf: undefined,
1361
+ _pos: [],
1362
+ };
1363
+ for (let i = 0; i < argv.length; i++) {
1364
+ const a = argv[i];
1365
+ const need = (flag) => {
1366
+ const v = argv[++i];
1367
+ if (v === undefined) throw new UsageError(`${flag} requires a value`);
1368
+ return v;
1369
+ };
1370
+ switch (a) {
1371
+ case "--vendor":
1372
+ opts.vendor = need("--vendor");
1373
+ break;
1374
+ case "--dir":
1375
+ opts.dir = need("--dir");
1376
+ break;
1377
+ case "--manifest":
1378
+ opts.manifest = need("--manifest");
1379
+ break;
1380
+ case "--revocations":
1381
+ opts.revocations = need("--revocations");
1382
+ break;
1383
+ case "--as-of":
1384
+ opts.asOf = need("--as-of");
1385
+ break;
1386
+ case "--json":
1387
+ opts.json = true;
1388
+ break;
1389
+ case "-h":
1390
+ case "--help":
1391
+ case "help":
1392
+ opts.help = true;
1393
+ break;
1394
+ default:
1395
+ if (a && a.startsWith("--")) throw new UsageError(`unknown flag: ${a}`);
1396
+ opts._pos.push(a);
1397
+ }
1398
+ }
1399
+ // batch === any path that aggregates MULTIPLE per-artifact verdicts under ONE exit code:
1400
+ // either a --manifest file, or more than one repeated positional <artifact>.
1401
+ opts.batch = opts.manifest !== undefined || opts._pos.length > 1;
1402
+ if (opts.manifest !== undefined && opts._pos.length > 0) {
1403
+ throw new UsageError(
1404
+ `--manifest <file> lists the artifacts; do not also pass positional <artifact> args (got: ${opts._pos[0]})`
1405
+ );
1406
+ }
1407
+ // Validate the OPTIONAL recipient-side trust-decision flags (--revocations / --as-of, T-51.4) SHAPE up
1408
+ // front so a malformed --as-of (or --as-of without --revocations) is a usage error (2), never a runtime
1409
+ // throw mid-verify. Mirrors `vh evidence verify-signed`'s validateAsOfFlags so the two stacks reject the
1410
+ // same inputs the same way.
1411
+ if (opts.asOf !== undefined && !opts.revocations) {
1412
+ throw new UsageError(
1413
+ "--as-of requires --revocations (it pins the instant the revocation decision is made AS OF)"
1414
+ );
1415
+ }
1416
+ if (opts.asOf !== undefined) {
1417
+ const ms = Date.parse(opts.asOf);
1418
+ if (
1419
+ typeof opts.asOf !== "string" ||
1420
+ !revocation.ISO_INSTANT_RE.test(opts.asOf) ||
1421
+ Number.isNaN(ms) ||
1422
+ new Date(ms).toISOString() !== opts.asOf
1423
+ ) {
1424
+ throw new UsageError(
1425
+ `invalid --as-of: ${opts.asOf} (expected a canonical ISO-8601 UTC instant, e.g. 2026-06-01T00:00:00.000Z)`
1426
+ );
1427
+ }
1428
+ }
1429
+ // Preserve the SINGLE-artifact contract verbatim: exactly one positional and no --manifest.
1430
+ opts.artifact = opts._pos[0];
1431
+ return opts;
1432
+ }
1433
+
1434
+ // ---------------------------------------------------------------------------
1435
+ // Manifest parsing. A manifest is a newline list OR a JSON array of artifact entries; each entry names an
1436
+ // artifact path and may carry a per-entry `--vendor`/`--dir` that overrides the top-level defaults.
1437
+ //
1438
+ // NEWLINE form — one entry per line, shell-style tokens. Blank lines and `#` comments are skipped:
1439
+ // releases/a.vhevidence.json
1440
+ // releases/b.vhseal --vendor 0xabc... --dir ./out
1441
+ // JSON form — an array of strings and/or objects:
1442
+ // ["a.vhevidence.json", {"artifact":"b.vhseal","vendor":"0xabc...","dir":"./out"}]
1443
+ //
1444
+ // Paths in the manifest resolve relative to the MANIFEST FILE's own directory (a release ships its
1445
+ // manifest next to its artifacts), unless the path is given a per-entry `--dir` for its SIBLINGS — note
1446
+ // `dir` localizes where an artifact's SIBLING files are read, exactly as the single-artifact `--dir` does;
1447
+ // the artifact path itself resolves against the manifest dir. The manifest is parsed in-process; NO new
1448
+ // crypto and NO network — it is a list, nothing more.
1449
+ // ---------------------------------------------------------------------------
1450
+
1451
+ // Minimal whitespace tokenizer for a newline-form manifest line. No quoting support is needed (artifact
1452
+ // paths and 0x addresses contain no spaces); a token is any run of non-whitespace.
1453
+ function tokenizeManifestLine(line) {
1454
+ return line.split(/\s+/).filter((t) => t.length > 0);
1455
+ }
1456
+
1457
+ function parseManifestLine(line, lineNo) {
1458
+ const toks = tokenizeManifestLine(line);
1459
+ const entry = { artifact: undefined, vendor: undefined, dir: undefined };
1460
+ for (let i = 0; i < toks.length; i++) {
1461
+ const t = toks[i];
1462
+ const need = (flag) => {
1463
+ const v = toks[++i];
1464
+ if (v === undefined) throw new UsageError(`manifest line ${lineNo}: ${flag} requires a value`);
1465
+ return v;
1466
+ };
1467
+ if (t === "--vendor") entry.vendor = need("--vendor");
1468
+ else if (t === "--dir") entry.dir = need("--dir");
1469
+ else if (t.startsWith("--")) throw new UsageError(`manifest line ${lineNo}: unknown flag: ${t}`);
1470
+ else if (entry.artifact === undefined) entry.artifact = t;
1471
+ else throw new UsageError(`manifest line ${lineNo}: unexpected extra token: ${t}`);
1472
+ }
1473
+ if (entry.artifact === undefined) {
1474
+ throw new UsageError(`manifest line ${lineNo}: no artifact path`);
1475
+ }
1476
+ return entry;
1477
+ }
1478
+
1479
+ function parseManifest(text, manifestPath) {
1480
+ const trimmed = text.replace(/^/, "").trim();
1481
+ const entries = [];
1482
+ if (trimmed.startsWith("[")) {
1483
+ // JSON array form.
1484
+ let arr;
1485
+ try {
1486
+ arr = JSON.parse(trimmed);
1487
+ } catch (e) {
1488
+ throw new IOError(`manifest ${manifestPath} is not valid JSON: ${e.message}`);
1489
+ }
1490
+ if (!Array.isArray(arr)) throw new IOError(`manifest ${manifestPath} JSON must be an array of entries`);
1491
+ arr.forEach((raw, idx) => {
1492
+ if (typeof raw === "string") {
1493
+ entries.push({ artifact: raw, vendor: undefined, dir: undefined });
1494
+ } else if (raw && typeof raw === "object" && !Array.isArray(raw)) {
1495
+ if (typeof raw.artifact !== "string" || raw.artifact.length === 0) {
1496
+ throw new IOError(`manifest ${manifestPath} entry ${idx}: "artifact" must be a non-empty string`);
1497
+ }
1498
+ entries.push({
1499
+ artifact: raw.artifact,
1500
+ vendor: raw.vendor != null ? String(raw.vendor) : undefined,
1501
+ dir: raw.dir != null ? String(raw.dir) : undefined,
1502
+ });
1503
+ } else {
1504
+ throw new IOError(`manifest ${manifestPath} entry ${idx} must be a string or { artifact, vendor?, dir? }`);
1505
+ }
1506
+ });
1507
+ } else {
1508
+ // Newline form: one entry per non-blank, non-comment line.
1509
+ const lines = trimmed.split(/\r?\n/);
1510
+ for (let i = 0; i < lines.length; i++) {
1511
+ const line = lines[i];
1512
+ const bare = line.trim();
1513
+ if (bare.length === 0 || bare.startsWith("#")) continue;
1514
+ entries.push(parseManifestLine(line, i + 1));
1515
+ }
1516
+ }
1517
+ if (entries.length === 0) {
1518
+ throw new UsageError(`manifest ${manifestPath} lists no artifacts`);
1519
+ }
1520
+ return entries;
1521
+ }
1522
+
1523
+ // ---------------------------------------------------------------------------
1524
+ // THE DISK FILE SOURCE — the CLI's `readEntry` implementation, carrying the FULL path-confinement
1525
+ // discipline the disk path always had (byte-identical classification):
1526
+ // (1) string-level confinement, BEFORE any filesystem access: an ABSOLUTE relPath, or any relPath with
1527
+ // a `..` path COMPONENT, is REJECTED unread;
1528
+ // (2) resolved-path confinement: a resolved path that ESCAPES baseDir (string-wise, against the
1529
+ // realpath of baseDir) is REJECTED;
1530
+ // (3) post-open symlink confinement: after opening a present file we realpath it and re-assert
1531
+ // containment, defeating a sibling that is a SYMLINK pointing out of baseDir (fs.readFileSync
1532
+ // follows symlinks regardless of the string check) — the just-read bytes are DROPPED, never hashed.
1533
+ // ---------------------------------------------------------------------------
1534
+
1535
+ // True when a resolved absolute path escapes the (already realpath'd) base directory. A path equal to the
1536
+ // base or under it does not escape; anything that path.relative()'s to "" / ".." / an absolute drive is out.
1537
+ function escapesBase(baseReal, abs) {
1538
+ const rel = path.relative(baseReal, abs);
1539
+ return rel === ".." || rel.startsWith(".." + path.sep) || path.isAbsolute(rel);
1540
+ }
1541
+
1542
+ function makeDiskReadEntry(baseDir) {
1543
+ // Anchor confinement on the REALPATH of baseDir so a symlinked baseDir itself (e.g. /tmp -> /private/tmp
1544
+ // on macOS) does not spuriously trip the containment check on otherwise-legitimate siblings.
1545
+ let baseReal;
1546
+ try {
1547
+ baseReal = fs.realpathSync(baseDir);
1548
+ } catch (_) {
1549
+ baseReal = path.resolve(baseDir);
1550
+ }
1551
+
1552
+ return function readEntry(relPath) {
1553
+ // (1) String-level confinement, BEFORE any filesystem access.
1554
+ if (
1555
+ typeof relPath !== "string" ||
1556
+ relPath.length === 0 ||
1557
+ path.isAbsolute(relPath) ||
1558
+ relPath.split(/[\\/]/).includes("..")
1559
+ ) {
1560
+ return { status: "escaped" };
1561
+ }
1562
+
1563
+ // (2) Resolved-path confinement: the resolved absolute path must stay under baseReal.
1564
+ const abs = path.resolve(baseDir, relPath);
1565
+ if (escapesBase(baseReal, abs)) {
1566
+ return { status: "escaped" };
1567
+ }
1568
+
1569
+ let bytes;
1570
+ try {
1571
+ bytes = fs.readFileSync(abs);
1572
+ } catch (_) {
1573
+ return { status: "missing" };
1574
+ }
1575
+
1576
+ // (3) Post-open symlink confinement.
1577
+ let real;
1578
+ try {
1579
+ real = fs.realpathSync(abs);
1580
+ } catch (_) {
1581
+ real = abs;
1582
+ }
1583
+ if (escapesBase(baseReal, real)) {
1584
+ return { status: "escaped" };
1585
+ }
1586
+
1587
+ return { status: "ok", bytes };
1588
+ };
1589
+ }
1590
+
1591
+ // The original disk-shaped helpers, kept with their exact signatures + behavior (thin wrappers over the
1592
+ // engine with a disk source). `relResolver` was always accepted-and-unused on classifyFiles; retained so
1593
+ // the signature does not shift.
1594
+ function classifyFiles(sealedEntries, baseDir, relResolver) { // eslint-disable-line no-unused-vars
1595
+ return classifyFilesWith(sealedEntries, makeDiskReadEntry(baseDir));
1596
+ }
1597
+
1598
+ function verifyEvidenceSeal(seal, baseDir) {
1599
+ return verifyEvidenceSealWith(seal, makeDiskReadEntry(baseDir));
1600
+ }
1601
+
1602
+ function verifyTrustSeal(seal, baseDir) {
1603
+ return verifyTrustSealWith(seal, makeDiskReadEntry(baseDir));
1604
+ }
1605
+
1606
+ // ---------------------------------------------------------------------------
1607
+ // The DISK verify entrypoint — the original CLI contract, byte-identical: reads + JSON-parses the
1608
+ // artifact, then drives the SAME pure engine with the disk file source. Returns { result, code }.
1609
+ // ---------------------------------------------------------------------------
1610
+
1611
+ function verifyArtifact(opts) {
1612
+ if (!opts.artifact) throw new UsageError("verify-vh requires an <artifact>");
1613
+
1614
+ const artifactPath = path.resolve(opts.artifact);
1615
+ let text;
1616
+ try {
1617
+ text = fs.readFileSync(artifactPath, "utf8");
1618
+ } catch (e) {
1619
+ throw new IOError(`cannot read artifact ${opts.artifact}: ${e.message}`);
1620
+ }
1621
+ let obj;
1622
+ try {
1623
+ obj = JSON.parse(text);
1624
+ } catch (e) {
1625
+ throw new IOError(`artifact ${opts.artifact} is not valid JSON: ${e.message}`);
1626
+ }
1627
+ if (obj == null || typeof obj !== "object" || Array.isArray(obj)) {
1628
+ throw new IOError(`artifact ${opts.artifact} must be a JSON object`);
1629
+ }
1630
+
1631
+ // The base directory siblings resolve against: --dir override else the artifact's own directory.
1632
+ const baseDir = opts.dir != null ? path.resolve(opts.dir) : path.dirname(artifactPath);
1633
+
1634
+ const { result, code } = verifyParsedArtifact({
1635
+ artifact: opts.artifact,
1636
+ obj,
1637
+ vendor: opts.vendor,
1638
+ readEntry: makeDiskReadEntry(baseDir),
1639
+ });
1640
+
1641
+ // OPTIONAL recipient-side TRUST-DECISION-AS-OF (EPIC-51 / T-51.4). Runs ONLY under --revocations — with no
1642
+ // flag the result + code are byte-identical to the pre-T-51.4 baseline (regression-pinned). A signer
1643
+ // revoked-before-as-of downgrades an otherwise-ACCEPTED artifact to REVOKED (exit 3); a later-dated
1644
+ // revocation is informational; a forged/tampered/third-party one is ignored with a warning. OFFLINE /
1645
+ // key-free on the read side; the revocations file/dir is the ONLY new I/O. This reaches the SAME downgrade
1646
+ // `vh ... verify-signed --revocations` does, byte-for-byte on identical inputs.
1647
+ if (opts.revocations) {
1648
+ let applied;
1649
+ try {
1650
+ applied = revocation.loadAndApply({
1651
+ result,
1652
+ revocationsPath: opts.revocations,
1653
+ asOf: opts.asOf,
1654
+ nowISO: opts.nowISO || new Date().toISOString(),
1655
+ });
1656
+ } catch (e) {
1657
+ // A malformed --as-of is caught at parse time; here the only failures are an unreadable path or a
1658
+ // non-JSON single revocations file — a genuine IO error (exit 1), surfaced (never a stack), never a
1659
+ // silently-skipped downgrade.
1660
+ throw new IOError(`cannot evaluate --revocations ${opts.revocations}: ${e.message}`);
1661
+ }
1662
+ // A REVOKED decision flips an otherwise-ACCEPTED verdict to REVOKED (exit 3); an already-REJECTED verdict
1663
+ // is left rejected (the trust-as-of never upgrades). The trustAsOf block + defaulted flag ride along for
1664
+ // the renderer.
1665
+ const downgraded = applied.result;
1666
+ downgraded.trustAsOfDefaulted = applied.defaulted;
1667
+ const newCode = downgraded.accepted ? EXIT.OK : EXIT.REJECTED;
1668
+ return { result: downgraded, code: newCode };
1669
+ }
1670
+
1671
+ return { result, code };
1672
+ }
1673
+
1674
+ // ---------------------------------------------------------------------------
1675
+ // BATCH / MANIFEST orchestration (T-33.1). One invocation gates EVERY artifact a release produces and
1676
+ // returns ONE CI exit code. Each entry is verified READ-ONLY through the SAME `verifyArtifact` core (NO
1677
+ // new crypto, NO new artifact kind, path-escape/no-network guarantees preserved per entry); the per-entry
1678
+ // `--json` body is the IDENTICAL single-artifact shape, so there is no divergence to drift.
1679
+ //
1680
+ // AGGREGATE EXIT CONTRACT:
1681
+ // * exit 0 (OK) — and only if — EVERY artifact verifies (each accepted).
1682
+ // * exit 3 (REJECTED) — if ANY artifact is rejected (CHANGED/MISSING/bad_signature/wrong_issuer/…);
1683
+ // the report names WHICH artifact failed and why.
1684
+ // * exit 2 (USAGE) — a malformed flag / per-entry --vendor (raised before any verify runs).
1685
+ // * exit 1 (IO) — an artifact (or the manifest itself) is unreadable / not the expected shape.
1686
+ // Usage/IO are evaluated PER ENTRY and SHORT-CIRCUIT the whole run with the matching code, exactly as the
1687
+ // single-artifact path does — a release gate must not "pass" while one of its artifacts could not even be
1688
+ // read or parsed. The IO/USAGE code wins over a REJECTED tally (you cannot certify a batch you could not
1689
+ // fully evaluate).
1690
+ // ---------------------------------------------------------------------------
1691
+
1692
+ function buildBatchEntries(opts) {
1693
+ // Returns [{ artifact, vendor, dir }] with top-level --vendor/--dir applied as DEFAULTS each entry may
1694
+ // override. Artifact paths from a manifest resolve against the manifest file's own directory.
1695
+ if (opts.manifest !== undefined) {
1696
+ const manifestPath = path.resolve(opts.manifest);
1697
+ let text;
1698
+ try {
1699
+ text = fs.readFileSync(manifestPath, "utf8");
1700
+ } catch (e) {
1701
+ throw new IOError(`cannot read manifest ${opts.manifest}: ${e.message}`);
1702
+ }
1703
+ const manifestDir = path.dirname(manifestPath);
1704
+ return parseManifest(text, opts.manifest).map((e) => ({
1705
+ // The artifact path resolves relative to the manifest's directory (a release ships them together).
1706
+ artifact: path.resolve(manifestDir, e.artifact),
1707
+ // Per-entry --vendor/--dir override the top-level defaults; a --dir resolves against the manifest dir.
1708
+ vendor: e.vendor != null ? e.vendor : opts.vendor,
1709
+ dir: e.dir != null ? path.resolve(manifestDir, e.dir) : opts.dir,
1710
+ }));
1711
+ }
1712
+ // Repeated positional <artifact> args: each inherits the (single) top-level --vendor/--dir.
1713
+ return opts._pos.map((a) => ({ artifact: a, vendor: opts.vendor, dir: opts.dir }));
1714
+ }
1715
+
1716
+ function verifyBatch(opts) {
1717
+ const entries = buildBatchEntries(opts);
1718
+ const results = [];
1719
+ for (const e of entries) {
1720
+ // Verify each entry through the SAME core. A USAGE/IO problem with any single entry short-circuits the
1721
+ // whole batch with that code (the gate cannot certify a release it could not fully evaluate). The
1722
+ // top-level --revocations/--as-of (T-51.4) apply to EVERY entry as a default, so one revocations
1723
+ // file/dir gates a whole release's signed artifacts under one as-of instant.
1724
+ const { result } = verifyArtifact({
1725
+ artifact: e.artifact,
1726
+ vendor: e.vendor,
1727
+ dir: e.dir,
1728
+ revocations: opts.revocations,
1729
+ asOf: opts.asOf,
1730
+ nowISO: opts.nowISO,
1731
+ });
1732
+ results.push(result);
1733
+ }
1734
+ const total = results.length;
1735
+ const passed = results.filter((r) => r.accepted).length;
1736
+ const failed = total - passed;
1737
+ const ok = failed === 0;
1738
+ const aggregate = { ok, total, passed, failed, results };
1739
+ return { aggregate, code: ok ? EXIT.OK : EXIT.REJECTED };
1740
+ }
1741
+
1742
+ // ---------------------------------------------------------------------------
1743
+ // Human + JSON rendering.
1744
+ // ---------------------------------------------------------------------------
1745
+
1746
+ function renderHuman(r) {
1747
+ const L = [];
1748
+ L.push(TRUST_NOTE);
1749
+ L.push("");
1750
+ L.push(`# verify-vh — ${r.artifact}`);
1751
+ L.push(`kind: ${r.kind}`);
1752
+ if (r.payloadKind !== r.kind) L.push(`embedded kind: ${r.payloadKind}`);
1753
+ L.push(`signed: ${r.signed ? "yes" : "no"}`);
1754
+ if (r.signed) {
1755
+ L.push(`recovered signer:${r.recoveredSigner ? " " + r.recoveredSigner : " (unrecoverable)"}`);
1756
+ L.push(`claimed signer: ${r.claimedSigner}`);
1757
+ if (r.pinnedVendor != null) {
1758
+ L.push(`pinned --vendor: ${r.pinnedVendor}`);
1759
+ L.push(`signer matches vendor: ${r.signerMatchesVendor ? "yes" : "NO"}`);
1760
+ } else {
1761
+ L.push("(no --vendor pin: the recovered signer above is reported, not pinned)");
1762
+ }
1763
+ } else if (r.recoveredSigner == null && r.pinnedVendor != null) {
1764
+ L.push("note: --vendor was supplied but this artifact is UNSIGNED (no signer to pin)");
1765
+ }
1766
+ if (r.sealedRoot != null) L.push(`sealed root: ${r.sealedRoot}`);
1767
+ if (r.recomputedRoot != null) L.push(`recomputed root: ${r.recomputedRoot}`);
1768
+ if (r.rootMatches != null) L.push(`root matches: ${r.rootMatches ? "yes" : "NO"}`);
1769
+ if (r.identityOnly) {
1770
+ L.push("(identity-only artifact: it commits to a dataset root/digest, not a re-walkable file set)");
1771
+ }
1772
+ L.push(
1773
+ `files: ${r.counts.matched} matched, ${r.counts.changed} changed, ` +
1774
+ `${r.counts.missing} missing, ${r.counts.escaped || 0} rejected, ${r.counts.unexpected} unexpected`
1775
+ );
1776
+ // AGENT-SESSION packet block (T-68.3) — present ONLY for r.agent results, so every other kind's
1777
+ // output stays byte-identical.
1778
+ if (r.agent) {
1779
+ L.push(`declared head: { size: ${r.agent.head.size}, root: ${r.agent.head.root} }`);
1780
+ if (r.agent.counts) {
1781
+ L.push(
1782
+ `events: ${r.agent.counts.events} (${r.agent.counts.full} full, ${r.agent.counts.redacted} redacted)`
1783
+ );
1784
+ L.push(
1785
+ `withheld seqs: ${r.agent.withheld.length === 0 ? "(none — every payload disclosed)" : r.agent.withheld.join(", ")}`
1786
+ );
1787
+ }
1788
+ }
1789
+ // OPTIONAL recipient-side TRUST-DECISION-AS-OF block (T-51.4) — printed ONLY when --revocations was
1790
+ // supplied (r.trustAsOf is attached then). With no flag this block is absent, so the output is byte-
1791
+ // identical to the pre-T-51.4 baseline. The block reads the SAME way the producer's verify-signed does.
1792
+ if (r.trustAsOf) {
1793
+ L.push("");
1794
+ for (const line of revocation.renderTrustAsOf(r.trustAsOf, { defaulted: r.trustAsOfDefaulted })) {
1795
+ L.push(line);
1796
+ }
1797
+ }
1798
+ L.push("");
1799
+ if (r.accepted) {
1800
+ L.push("OK — the artifact verifies.");
1801
+ } else if (r.reason === "key_revoked_as_of") {
1802
+ // The signature + bytes checked out, but the signing key was revoked AT OR BEFORE the as-of instant — a
1803
+ // distinct REVOKED verdict (exit 3), matching the producer's verify-signed downgrade.
1804
+ const g = r.trustAsOf && r.trustAsOf.governing;
1805
+ L.push("REVOKED (key_revoked_as_of):");
1806
+ if (g) {
1807
+ L.push(
1808
+ ` key_revoked_as_of: the signing key (${g.vendorAddress}) was REVOKED as of ${g.revokedAt} ` +
1809
+ `(reason: ${g.reason})${g.supersededBy ? `, superseded by ${g.supersededBy}` : ""} — at or before ` +
1810
+ `the as-of instant. The bytes + signature check out, but the key was no longer trustworthy then.`
1811
+ );
1812
+ }
1813
+ } else {
1814
+ L.push(`REJECTED (${r.reason}):`);
1815
+ for (const c of r.changed) {
1816
+ L.push(` CHANGED ${c.relPath}: sealed ${c.expectedContentHash} != on-disk ${c.actualContentHash}`);
1817
+ }
1818
+ for (const m of r.missing) {
1819
+ L.push(` MISSING ${m.relPath}: referenced but not found on disk`);
1820
+ }
1821
+ for (const x of r.escaped || []) {
1822
+ // SECURITY: print the attacker's relPath string ONLY — never a content hash of the out-of-tree target.
1823
+ L.push(` REJECTED ${x.relPath}: path escapes the artifact directory (refused to read; no hash computed)`);
1824
+ }
1825
+ for (const u of r.unexpected) {
1826
+ L.push(` UNEXPECTED ${u.relPath}: on disk but not referenced`);
1827
+ }
1828
+ if (r.reason === "bad_signature") {
1829
+ L.push(" bad_signature: the signature does not recover to the claimed signer (tampered or forged).");
1830
+ }
1831
+ if (r.reason === "wrong_issuer") {
1832
+ L.push(
1833
+ ` wrong_issuer: recovered ${r.recoveredSigner} but you pinned --vendor ${r.pinnedVendor}.`
1834
+ );
1835
+ }
1836
+ if (r.reason === "unsigned_cannot_pin_vendor") {
1837
+ L.push(" --vendor was pinned but the artifact carries no signature to recover a signer from.");
1838
+ }
1839
+ if (r.reason === "root_mismatch") {
1840
+ L.push(" root_mismatch: the recomputed root does not equal the sealed root.");
1841
+ }
1842
+ if (r.reason === "path_escape") {
1843
+ L.push(
1844
+ " path_escape: the artifact references a file OUTSIDE its own directory (absolute path, `..` " +
1845
+ "traversal, or an out-of-tree symlink). A genuine artifact never does this; refused to read it."
1846
+ );
1847
+ }
1848
+ // AGENT-SESSION packet reject details (T-68.3): name the first offending event seq + the named fault.
1849
+ if (r.agent) {
1850
+ if (r.agent.seq !== null && r.agent.seq !== undefined) {
1851
+ L.push(` first offending event seq: ${r.agent.seq}${r.agent.reason ? ` (${r.agent.reason})` : ""}`);
1852
+ }
1853
+ if (r.reason === "event_invalid") {
1854
+ L.push(
1855
+ ` event_invalid: an event failed strict canonical validation` +
1856
+ `${r.agent.field ? ` (field: ${r.agent.field})` : ""} — the packet cannot be trusted.`
1857
+ );
1858
+ }
1859
+ if (r.reason === "counts_mismatch") {
1860
+ L.push(" counts_mismatch: the packet's declared full/redacted counts do not match a recount.");
1861
+ }
1862
+ if (r.reason === "head_not_bound") {
1863
+ L.push(
1864
+ " head_not_bound: the headAttestation signs a DIFFERENT { size, root } than this packet's " +
1865
+ "events derive — the signature belongs to another session."
1866
+ );
1867
+ }
1868
+ }
1869
+ }
1870
+ L.push("");
1871
+ return L.join("\n");
1872
+ }
1873
+
1874
+ // Human rendering of a batch aggregate: a per-artifact PASS/FAIL line (FAIL names the reason), then the
1875
+ // one-line roll-up + the final verdict. The trust note is printed ONCE at the top.
1876
+ function renderBatchHuman(agg) {
1877
+ const L = [];
1878
+ L.push(TRUST_NOTE);
1879
+ L.push("");
1880
+ L.push(`# verify-vh — BATCH (${agg.total} artifact${agg.total === 1 ? "" : "s"})`);
1881
+ for (const r of agg.results) {
1882
+ if (r.accepted) {
1883
+ L.push(` PASS ${r.artifact}`);
1884
+ } else {
1885
+ L.push(` FAIL ${r.artifact} (${r.reason})`);
1886
+ // Localize the first failing detail so a CI log names exactly what moved, per artifact.
1887
+ for (const c of r.changed) {
1888
+ L.push(` CHANGED ${c.relPath}: sealed ${c.expectedContentHash} != on-disk ${c.actualContentHash}`);
1889
+ }
1890
+ for (const m of r.missing) {
1891
+ L.push(` MISSING ${m.relPath}`);
1892
+ }
1893
+ for (const x of r.escaped || []) {
1894
+ L.push(` REJECTED ${x.relPath}: path escapes the artifact directory (no hash computed)`);
1895
+ }
1896
+ }
1897
+ }
1898
+ L.push("");
1899
+ L.push(`total: ${agg.total}, passed: ${agg.passed}, failed: ${agg.failed}`);
1900
+ L.push(agg.ok ? "OK — every artifact verifies." : `REJECTED — ${agg.failed} artifact(s) failed.`);
1901
+ L.push("");
1902
+ return L.join("\n");
1903
+ }
1904
+
1905
+ // ---------------------------------------------------------------------------
1906
+ // `demo` — the ZERO-CONFIG, zero-flag, zero-key-knowledge quickstart (T-55.2).
1907
+ //
1908
+ // WHY THIS EXISTS
1909
+ // A cold prospect should be able to go from NOTHING to a VERIFIED packet in one command — `verify-vh demo`
1910
+ // (or `npx … demo`) — with NO flags, NO `--vendor` to paste, and NO key knowledge. The whole sales promise
1911
+ // ("don't trust us — verify it yourself, offline") is unfalsifiable until they have RUN the tool once and
1912
+ // watched it ACCEPT a genuine packet, name the signer, then REJECT a one-byte-tampered copy. `demo` IS that
1913
+ // first run: it ships a tiny, self-contained, GENUINELY-SIGNED evidence packet baked into this file, plays
1914
+ // it through the EXACT same `verifyArtifact` core every real verify uses, and prints the honest verdict.
1915
+ //
1916
+ // HOW IT STAYS HONEST (no special-case verify path)
1917
+ // The fixture below is a REAL `vh.evidence-seal-signed` container: a keccak Merkle seal over two referenced
1918
+ // files, signed with a FIXED, well-known TEST-ONLY key (NEVER a real key, NEVER real funds — its address is
1919
+ // the standard hardhat account #1, published precisely so no one mistakes it for a production signer). The
1920
+ // signature was produced once with the family's real EIP-191 personal-sign path; the demo RECOVERS it with
1921
+ // the SAME vendored secp256k1 recovery a real verify uses, so the signer address printed is genuinely
1922
+ // recovered from the bytes — not echoed. `demo` materializes the packet + its two files into a throwaway
1923
+ // temp dir, runs the real `verifyArtifact` twice (genuine -> ACCEPT pinned to the recovered signer; a
1924
+ // one-byte-tampered copy -> REJECT/CHANGED), then deletes the temp dir. It writes NOTHING under cwd.
1925
+ // ---------------------------------------------------------------------------
1926
+
1927
+ // The fixed TEST-ONLY signer (hardhat account #1). Published so it can NEVER be confused with a real key.
1928
+ const DEMO_SIGNER = "0x70997970c51812dc3a010c7d01b50e0d17dc79c8";
1929
+
1930
+ // The two referenced files the demo seal commits to, by relPath -> exact UTF-8 content.
1931
+ const DEMO_FILES = Object.freeze({
1932
+ "model-card.md": "# Demo model card\nThis file is sealed by the verify-vh demo.\n",
1933
+ "weights.txt": "0.10 0.20 0.30\n",
1934
+ });
1935
+
1936
+ // The GENUINELY-SIGNED evidence container. `attestation` is the EXACT bytes the signature is over (the same
1937
+ // plain serialization the producer's evidence path emits for the embedded seal); the signature is a real
1938
+ // 65-byte EIP-191 personal-sign over those bytes by DEMO_SIGNER. Re-derived from DEMO_FILES (a build-time
1939
+ // check would re-seal the same bytes), so the root binds the real file content above.
1940
+ const DEMO_CONTAINER = Object.freeze({
1941
+ kind: "vh.evidence-seal-signed",
1942
+ attestation:
1943
+ '{"kind":"vh.evidence-seal","files":[{"relPath":"model-card.md","contentHash":"0x1aeca0ad922f53e9c30186234c5d1a62ffda62a828988bdd266fa93240675db0","leaf":"0xbbb3052a7359188aed3f114e15b721cf5d707a8bdf09109d1d51ec5765b3c58c"},{"relPath":"weights.txt","contentHash":"0x7716d380e062d1daf7ca58897b55f6b58900ed4fd1eda79445956c5c3d336cdf","leaf":"0x34ce488c6fb49a32d356a2553196dc817a439c13a03ce9a2a2ff2710fcf9eea2"}],"root":"0x621a5eb924a9887f88d4b05ccdf19834cdae2f4ed2399921acc7b8a45d48da9b"}',
1944
+ signature: {
1945
+ scheme: "eip191-personal-sign",
1946
+ signer: DEMO_SIGNER,
1947
+ signature:
1948
+ "0x1aabba1530df192e87498bbf1a26f63a7e30d84d72c14bf5d08b2d872df9810b672efcf26f30ec6a38a00ffc158be53633daeff9e99f344b6c1a2e99522d61a01b",
1949
+ },
1950
+ });
1951
+
1952
+ // The packet filename the demo materializes (shared by the throwaway-temp round-trip and the `demo <dir>`
1953
+ // keepable scaffold) so the "NEXT" command the demo prints names the file it actually wrote.
1954
+ const DEMO_PACKET_NAME = "demo-packet.vhevidence.json";
1955
+
1956
+ // ---------------------------------------------------------------------------
1957
+ // The DEMO AGENT-SESSION packet (T-68.3): a small, GENUINE `vh.agent-session-packet` produced by the
1958
+ // REAL `vh agent seal` + `vh agent redact` path (never re-authored by hand) — a 4-event session
1959
+ // (prompt -> tool_call -> tool_result -> completion) whose tool_call payload (seq 1) is REDACTED
1960
+ // behind its hash commitment, so the fixture demonstrates the load-bearing property: a redacted
1961
+ // packet STILL VERIFIES (identical leaves + root). UNSIGNED — the whole agent verify surface is the
1962
+ // FREE funnel leg. The standalone HTML page inlines these constants verbatim (next to DEMO_FILES /
1963
+ // DEMO_CONTAINER above) for its built-in agent demo: click -> ACCEPT; tamper ONE byte of a payload in
1964
+ // the page -> REJECT naming event seq DEMO_AGENT_TAMPER_SEQ. The TAMPER_FROM/TO pair is a one-byte
1965
+ // substring edit that occurs EXACTLY once in the packet text (pinned by test/verifier.agent.test.js).
1966
+ // ---------------------------------------------------------------------------
1967
+ const DEMO_AGENT_PACKET_NAME = "demo-session.vhagent.json";
1968
+ 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";
1969
+ const DEMO_AGENT_TAMPER_SEQ = 0;
1970
+ const DEMO_AGENT_TAMPER_FROM = "\"payload\":\"Summarize the vendor contract";
1971
+ const DEMO_AGENT_TAMPER_TO = "\"payload\":\"SUMMARIZE the vendor contract";
1972
+
1973
+ // Materialize the demo packet + its referenced files into `dir`. Returns the packet path.
1974
+ function writeDemoFixture(dir) {
1975
+ for (const [rel, content] of Object.entries(DEMO_FILES)) {
1976
+ fs.writeFileSync(path.join(dir, rel), content);
1977
+ }
1978
+ const packetPath = path.join(dir, DEMO_PACKET_NAME);
1979
+ fs.writeFileSync(packetPath, JSON.stringify(DEMO_CONTAINER, null, 2));
1980
+ return packetPath;
1981
+ }
1982
+
1983
+ // Run the zero-config demo: seal -> ACCEPT (pinned to the recovered signer) -> tamper -> REJECT. Uses the
1984
+ // REAL verifyArtifact core for BOTH runs (no bespoke verify path), so the verdicts are exactly what a real
1985
+ // counterparty would see. Returns the EXIT-contract code (0 only when the whole demo behaved as designed).
1986
+ function runDemo(write, writeErr) {
1987
+ // A throwaway temp dir so the demo needs no input and writes NOTHING under cwd. Cleaned in finally.
1988
+ let tmp;
1989
+ try {
1990
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), "verify-vh-demo-"));
1991
+ } catch (e) {
1992
+ writeErr(`error: demo could not create a temp working dir: ${e.message}\n`);
1993
+ return EXIT.IO;
1994
+ }
1995
+ try {
1996
+ const packetPath = writeDemoFixture(tmp);
1997
+
1998
+ const L = [];
1999
+ L.push(TRUST_NOTE);
2000
+ L.push("");
2001
+ L.push("# verify-vh demo — a self-contained, GENUINELY-SIGNED packet, verified OFFLINE with zero config.");
2002
+ L.push("# (No flags, no key to paste: the demo ships a real signed seal + its files and checks them for you.)");
2003
+ L.push(`# Working dir (throwaway, deleted on exit): ${tmp}`);
2004
+ L.push("");
2005
+
2006
+ // (1) GENUINE packet: recover the signer first, then PIN it (so the demo proves both recovery AND the
2007
+ // vendor-pin path) — exactly what a real counterparty does once they learn the producer's address.
2008
+ const recovered = tryRecover(DEMO_CONTAINER.attestation, DEMO_CONTAINER.signature.signature);
2009
+ if (recovered !== DEMO_SIGNER) {
2010
+ writeErr(
2011
+ `error: demo fixture is corrupt — embedded signature recovered ${String(recovered)} != ${DEMO_SIGNER}\n`
2012
+ );
2013
+ return EXIT.IO;
2014
+ }
2015
+ L.push("STEP 1 — verify the genuine packet (signer recovered from the bytes, then pinned):");
2016
+ const good = verifyArtifact({ artifact: packetPath, vendor: recovered, dir: tmp });
2017
+ if (!good.result.accepted || good.code !== EXIT.OK) {
2018
+ // Should never happen for the shipped fixture; treat as an internal fault, not a silent pass.
2019
+ writeErr(`error: demo genuine packet did NOT verify (reason: ${good.result.reason})\n`);
2020
+ write(renderHuman(good.result));
2021
+ return EXIT.IO;
2022
+ }
2023
+ L.push(` ACCEPT — the artifact verifies. signer: ${good.result.recoveredSigner}`);
2024
+ L.push(` sealed root: ${good.result.sealedRoot}`);
2025
+ L.push(` recomputed root: ${good.result.recomputedRoot} (re-derived from the bytes on disk)`);
2026
+ L.push(` files: ${good.result.counts.matched} matched, 0 changed, 0 missing.`);
2027
+ L.push("");
2028
+
2029
+ // (2) TAMPER one byte of a referenced file, re-verify the SAME packet -> a clean REJECT naming the file.
2030
+ const victim = path.join(tmp, "model-card.md");
2031
+ fs.writeFileSync(victim, DEMO_FILES["model-card.md"] + "X"); // one extra byte
2032
+ L.push("STEP 2 — tamper ONE byte of a referenced file, then re-verify the SAME packet:");
2033
+ const bad = verifyArtifact({ artifact: packetPath, vendor: recovered, dir: tmp });
2034
+ if (bad.result.accepted || bad.code !== EXIT.REJECTED) {
2035
+ writeErr(`error: demo tampered packet was NOT rejected (reason: ${bad.result.reason})\n`);
2036
+ return EXIT.IO;
2037
+ }
2038
+ L.push(` REJECT (${bad.result.reason}) — the tampered copy is caught:`);
2039
+ for (const c of bad.result.changed) {
2040
+ L.push(` CHANGED ${c.relPath}: sealed ${c.expectedContentHash} != on-disk ${c.actualContentHash}`);
2041
+ }
2042
+ L.push("");
2043
+
2044
+ L.push("That is the whole promise: a genuine packet is ACCEPTED and its signer named, while a one-byte");
2045
+ L.push("change is REJECTED — re-derived from the bytes you hold, offline, with no producer stack.");
2046
+ L.push("");
2047
+ // The bare demo is a closed loop in a temp dir — gone the instant it exits. Hand the user the ONE command
2048
+ // that turns "I watched a demo" into "I have a real packet on disk I can poke at": `demo <dir>` writes the
2049
+ // same genuine packet somewhere they KEEP, with copy-paste verify/tamper/restore commands. That is the
2050
+ // working on-ramp from the canned proof to verifying their OWN bytes (where the paid `--sign` pull begins).
2051
+ // NOTE: we name the command literally (NOT process.argv[1]) so the bare-demo output is byte-identical
2052
+ // whether run in-process, as `node verify-vh.js`, or from the standalone bundle — the demo's own
2053
+ // determinism is a tested invariant (the standalone must byte-match the in-tree demo).
2054
+ L.push("TRY IT YOURSELF: keep a copy you can tamper with by hand —");
2055
+ L.push(" node verify-vh.js demo ./vh-demo # writes the same signed packet + files into ./vh-demo,");
2056
+ L.push(" # then prints the exact verify / tamper / restore commands.");
2057
+ L.push("");
2058
+ L.push("NEXT: run it on a REAL packet you were handed:");
2059
+ L.push(" node verify-vh.js <packet> --vendor 0xPRODUCER_ADDRESS (exit 0 = verifies; 3 = REJECTED)");
2060
+ L.push("");
2061
+ write(L.join("\n"));
2062
+ return EXIT.OK;
2063
+ } catch (e) {
2064
+ writeErr(`error: demo failed unexpectedly: ${e.message}\n`);
2065
+ return EXIT.IO;
2066
+ } finally {
2067
+ try {
2068
+ fs.rmSync(tmp, { recursive: true, force: true });
2069
+ } catch (_) {
2070
+ /* best-effort cleanup; the OS reaps temp dirs anyway */
2071
+ }
2072
+ }
2073
+ }
2074
+
2075
+ // ---------------------------------------------------------------------------
2076
+ // `demo <dir>` — the KEEPABLE scaffold (T-55.2 rework). The bare `demo` proves the round-trip in a throwaway
2077
+ // temp dir and is GONE the instant it exits — a closed loop the prospect can WATCH but cannot TOUCH. That is
2078
+ // the funnel dead-end the review panel flagged: the demo's own "NEXT: run it on a REAL packet" is unactionable
2079
+ // because a brand-new user HAS no packet yet. `demo <dir>` closes that gap: it MATERIALIZES the same genuine
2080
+ // signed packet + its two referenced files into a directory the user names and KEEPS, then prints the exact,
2081
+ // copy-pasteable REAL commands to (a) verify it with the real (non-canned) verify path, (b) tamper one byte
2082
+ // and watch the real REJECT, and (c) restore and re-ACCEPT. The prospect's FIRST hands-on artifact is now one
2083
+ // they hold on disk and can poke at with the production code path — the working on-ramp from "watched a demo"
2084
+ // to "verified my own bytes", which is where the free→paid pull (sign YOUR OWN files: `vh evidence seal
2085
+ // --sign` / the `evidence_unlimited` upgrade) actually begins.
2086
+ //
2087
+ // It is a PURE SUPERSET of the flagless quickstart: it engages ONLY when a single <dir> token follows `demo`
2088
+ // (`verify-vh demo` with no token stays the byte-identical throwaway round-trip above). It WRITES — by design,
2089
+ // into the dir the user explicitly named — so it is never reached by the bare flagless path the "writes
2090
+ // nothing under cwd" contract pins. The packet it writes is byte-identical to the round-trip's, signed by the
2091
+ // same fixed TEST-ONLY key (hardhat #1 — never a real key / real funds).
2092
+ // ---------------------------------------------------------------------------
2093
+
2094
+ function runDemoEmit(targetDir, write, writeErr) {
2095
+ // Confirm the shipped fixture is internally sound BEFORE writing anything (recover the signer from the
2096
+ // embedded bytes, exactly as a real verify does) — a corrupt fixture is an internal fault, not a scaffold.
2097
+ const recovered = tryRecover(DEMO_CONTAINER.attestation, DEMO_CONTAINER.signature.signature);
2098
+ if (recovered !== DEMO_SIGNER) {
2099
+ writeErr(
2100
+ `error: demo fixture is corrupt — embedded signature recovered ${String(recovered)} != ${DEMO_SIGNER}\n`
2101
+ );
2102
+ return EXIT.IO;
2103
+ }
2104
+
2105
+ const dir = path.resolve(targetDir);
2106
+ // mkdir -p the target. We create the user-named dir if absent; an existing dir is fine (we only add files).
2107
+ try {
2108
+ fs.mkdirSync(dir, { recursive: true });
2109
+ } catch (e) {
2110
+ writeErr(`error: demo could not create ${targetDir}: ${e.message}\n`);
2111
+ return EXIT.IO;
2112
+ }
2113
+
2114
+ let packetPath;
2115
+ try {
2116
+ packetPath = writeDemoFixture(dir);
2117
+ } catch (e) {
2118
+ writeErr(`error: demo could not write the scaffold into ${targetDir}: ${e.message}\n`);
2119
+ return EXIT.IO;
2120
+ }
2121
+
2122
+ // Verify the just-written packet through the REAL core (no canned path), so the scaffold is proven good on
2123
+ // disk before we tell the user to trust it — and so the ACCEPT line the user will reproduce is the truth.
2124
+ const good = verifyArtifact({ artifact: packetPath, vendor: recovered, dir });
2125
+ if (!good.result.accepted || good.code !== EXIT.OK) {
2126
+ writeErr(`error: demo scaffold did NOT verify after writing (reason: ${good.result.reason})\n`);
2127
+ return EXIT.IO;
2128
+ }
2129
+
2130
+ // The command name as the user invoked us (verify-vh.js in-tree, verify-vh-standalone.js as the bundle), so
2131
+ // the copy-paste commands below name the EXACT file they ran — not a guessed path.
2132
+ // Name the command the user actually ran (verify-vh.js in-tree, verify-vh-standalone.js as the bundle) so the
2133
+ // copy-paste lines below name the EXACT file they invoked. If argv[1] is not one of our scripts (e.g. running
2134
+ // in-process under a test harness), fall back to the canonical name rather than printing the harness binary.
2135
+ const argv1 = path.basename(process.argv[1] || "");
2136
+ const self = /verify-vh/.test(argv1) ? argv1 : "verify-vh.js";
2137
+ // Print a path that is copy-pasteable from the user's CURRENT shell: the relative path when the target sits
2138
+ // at/under cwd (the common `demo ./vh-demo` case -> a tidy `vh-demo/...`), else the absolute path (a `../../`
2139
+ // chain to a far-off dir is unreadable and brittle — the absolute path always resolves).
2140
+ const rel = (p) => {
2141
+ const r = path.relative(process.cwd(), p);
2142
+ return r && !r.startsWith("..") && !path.isAbsolute(r) ? r : p;
2143
+ };
2144
+ const pkt = rel(packetPath);
2145
+ const card = rel(path.join(dir, "model-card.md"));
2146
+
2147
+ const L = [];
2148
+ L.push(TRUST_NOTE);
2149
+ L.push("");
2150
+ L.push(`# verify-vh demo — wrote a real, KEEPABLE signed packet you can verify yourself, hands-on.`);
2151
+ L.push(`# Signed by a fixed TEST-ONLY key (hardhat #1 — never a real key / real funds).`);
2152
+ L.push("");
2153
+ L.push(`Wrote into ${dir}:`);
2154
+ L.push(` ${DEMO_PACKET_NAME} (a genuinely-signed evidence packet)`);
2155
+ for (const r of Object.keys(DEMO_FILES)) L.push(` ${r}`);
2156
+ L.push(` signer (recovered from the bytes): ${recovered}`);
2157
+ L.push("");
2158
+ L.push("It already VERIFIES — run it yourself (the real verify path, no canned demo):");
2159
+ L.push(` node ${self} ${pkt} --vendor ${recovered}`);
2160
+ L.push(" # exit 0 = ACCEPT (root re-derived from YOUR bytes on disk; signer pinned).");
2161
+ L.push("");
2162
+ L.push("Now PROVE tamper-evidence with your own hands — change one byte, then re-verify:");
2163
+ L.push(` printf 'X' >> ${card}`);
2164
+ L.push(` node ${self} ${pkt} --vendor ${recovered} # exit 3 = REJECT (CHANGED ${path.basename(card)})`);
2165
+ L.push("");
2166
+ L.push("Restore it and watch it ACCEPT again (the change was the ONLY reason it rejected):");
2167
+ L.push(` node ${self} ${pkt} --vendor ${recovered} # after restoring the byte`);
2168
+ L.push("");
2169
+ L.push("NEXT — verify a packet someone handed YOU (same command, their address):");
2170
+ L.push(` node ${self} <their-packet> --vendor 0xTHEIR_ADDRESS`);
2171
+ L.push("");
2172
+ L.push("Want to SIGN your OWN files so a counterparty can pin YOU? That is the paid producer side:");
2173
+ L.push(" vh evidence seal <your-folder> --sign (an EIP-191 signer-pin; the `evidence_unlimited`");
2174
+ L.push(" upgrade lifts the free 25-file cap) — see verifier/README.md §0a.");
2175
+ L.push("");
2176
+ write(L.join("\n"));
2177
+ return EXIT.OK;
2178
+ }
2179
+
2180
+ function usage() {
2181
+ return [
2182
+ "verify-vh — standalone, read-only, OFFLINE verifier for verifyhash artifacts",
2183
+ "",
2184
+ "Usage:",
2185
+ " verify-vh demo (zero-config quickstart)",
2186
+ " verify-vh demo <dir> (write a keepable signed packet you can verify yourself)",
2187
+ " verify-vh <artifact> [--vendor <0xaddr>] [--dir <d>] [--revocations <file-or-dir> [--as-of <ISO>]] [--json]",
2188
+ " verify-vh <artifact> <artifact> ... [--vendor <0xaddr>] [--dir <d>] [--revocations <file-or-dir>] [--json] (batch)",
2189
+ " verify-vh --manifest <file> [--vendor <0xaddr>] [--dir <d>] [--revocations <file-or-dir>] [--json] (batch)",
2190
+ "",
2191
+ "DEMO: `verify-vh demo` runs a self-contained, genuinely-signed packet through the real verify path —",
2192
+ "NO flags, NO key, NO install state: it ACCEPTs the packet (naming the signer), then REJECTs a one-byte-",
2193
+ "tampered copy. The single command that takes a brand-new user from nothing to a verified packet.",
2194
+ "`verify-vh demo <dir>` goes one step further: it WRITES that same genuine signed packet + its files into",
2195
+ "<dir> (which you keep) and prints copy-paste commands so you verify, tamper, and re-verify it by hand.",
2196
+ "",
2197
+ "Auto-detects the artifact kind (evidence seal, reconciliation seal, dataset attestation, proof",
2198
+ "bundle — bare or signed — or an agent-session packet *.vhagent.json), RE-DERIVES the keccak root",
2199
+ "from the referenced bytes (siblings resolve next to the artifact, or under --dir <d>), recovers",
2200
+ "the signer of a signed artifact, and PINS it to --vendor <0xaddr> (or reports the recovered signer",
2201
+ "when no pin is given). An agent-session packet is SELF-CONTAINED: every event leaf + the ordered",
2202
+ "RFC-6962-style head are re-derived from the events in the packet (REDACTED payloads are checked by",
2203
+ "their hash commitments), and a REJECT names the first offending event seq.",
2204
+ "",
2205
+ "REVOCATIONS: --revocations <file-or-dir> [--as-of <ISO>] downgrades an otherwise-ACCEPTED signed",
2206
+ "artifact to REVOKED (exit 3) when its signing key was REVOKED at or before --as-of (default now). The",
2207
+ "file may be one signed revocation or a JSON array; a directory is read as a flat pool of revocation",
2208
+ "files. A revocation dated AFTER --as-of stays ACCEPTED with a later-revoked note; a forged/tampered/",
2209
+ "third-party revocation is IGNORED with a warning. This reaches the SAME downgrade the producer's",
2210
+ "`vh ... verify-signed --revocations` does, OFFLINE — no producer stack, no network, no key.",
2211
+ "",
2212
+ "BATCH/MANIFEST: pass several <artifact> args, or --manifest <file> (a newline list or JSON array of",
2213
+ "artifact paths, each line/object may carry its own --vendor/--dir). ALL must pass for exit 0; if ANY",
2214
+ "is rejected, exit is 3 and the report names which artifact failed and why. --json emits a stable",
2215
+ "aggregate { ok, total, passed, failed, results:[...] } whose entries are the single-artifact shape.",
2216
+ "Top-level --vendor/--dir are inherited as defaults a manifest entry may override; --revocations/--as-of",
2217
+ "apply to every entry.",
2218
+ "",
2219
+ "READ-ONLY: holds no key, writes nothing. Exit: 0 ok / 3 rejected|revoked / 2 usage / 1 IO.",
2220
+ "",
2221
+ ].join("\n");
2222
+ }
2223
+
2224
+ // ---------------------------------------------------------------------------
2225
+ // run(argv, io) — the testable entrypoint. Returns the EXIT-contract integer. Injectable stdout/stderr.
2226
+ // ---------------------------------------------------------------------------
2227
+
2228
+ function run(argv, io = {}) {
2229
+ const write = io.write || ((s) => process.stdout.write(s));
2230
+ const writeErr = io.writeErr || ((s) => process.stderr.write(s));
2231
+
2232
+ let opts;
2233
+ try {
2234
+ opts = parseArgs(argv);
2235
+ } catch (e) {
2236
+ writeErr(`error: ${e.message}\n`);
2237
+ return EXIT.USAGE;
2238
+ }
2239
+ if (opts.help) {
2240
+ write(usage());
2241
+ return EXIT.OK;
2242
+ }
2243
+ // DEMO: the zero-config quickstart (T-55.2). `verify-vh demo` — a SINGLE bare positional `demo`, with NO
2244
+ // other args at all (no flags, no second positional, no manifest) — runs the self-contained signed packet
2245
+ // through the real verify path. We require the LONE argument to be exactly `demo` so the quickstart contract
2246
+ // is unambiguous: `demo` with any extra token falls through to the normal path (where it is a clean error),
2247
+ // never a silently-flag-ignoring run. It is a pure SUPERSET of the existing contract: `demo` was never a
2248
+ // valid artifact path before (there is no file named `demo`, so a lone `demo` was a clean IO error), so
2249
+ // intercepting it here shifts no existing caller.
2250
+ if (argv.length === 1 && opts.artifact === "demo") {
2251
+ return runDemo(write, writeErr);
2252
+ }
2253
+ // DEMO SCAFFOLD: `verify-vh demo <dir>` — a pure SUPERSET (T-55.2 rework). When `demo` is followed by exactly
2254
+ // ONE more bare token (a target directory) and NO flags, write the same genuine signed packet + its files
2255
+ // into that dir the user KEEPS, and print copy-paste verify/tamper/restore commands. This is the actionable
2256
+ // on-ramp the bare demo (a throwaway temp dir, gone on exit) cannot give. We require EXACTLY two bare
2257
+ // positionals and no flags so the contract stays unambiguous; `demo <dir> --anything` falls through to the
2258
+ // normal path (where a file literally named `demo` is a clean IO error, byte-identically to before).
2259
+ if (
2260
+ argv.length === 2 &&
2261
+ argv[0] === "demo" &&
2262
+ opts._pos.length === 2 &&
2263
+ opts._pos[0] === "demo" &&
2264
+ !opts.json &&
2265
+ opts.manifest === undefined &&
2266
+ opts.vendor === undefined &&
2267
+ opts.dir === undefined
2268
+ ) {
2269
+ return runDemoEmit(opts._pos[1], write, writeErr);
2270
+ }
2271
+ // No artifact AND no manifest → the same usage error as before (the batch additions are a pure superset).
2272
+ if (opts.artifact === undefined && opts.manifest === undefined) {
2273
+ writeErr("error: verify-vh requires an <artifact>\n\n");
2274
+ writeErr(usage());
2275
+ return EXIT.USAGE;
2276
+ }
2277
+
2278
+ // The recipient's current decision instant (the default --as-of). Injectable via io.nowISO so a test can
2279
+ // pin the clock; otherwise the wall clock. Threaded onto opts for the (optional) revocation evaluation.
2280
+ opts.nowISO = io.nowISO || new Date().toISOString();
2281
+
2282
+ // BATCH path: a --manifest file or more than one positional <artifact>. Aggregates per-artifact verdicts
2283
+ // under one CI exit code. The single-artifact path below is byte-for-byte the original behavior.
2284
+ if (opts.batch) {
2285
+ let out;
2286
+ try {
2287
+ out = verifyBatch(opts);
2288
+ } catch (e) {
2289
+ if (e instanceof UsageError) {
2290
+ writeErr(`error: ${e.message}\n`);
2291
+ return EXIT.USAGE;
2292
+ }
2293
+ if (e instanceof IOError) {
2294
+ writeErr(`error: ${e.message}\n`);
2295
+ return EXIT.IO;
2296
+ }
2297
+ writeErr(`error: ${e.message}\n`);
2298
+ return EXIT.IO;
2299
+ }
2300
+ if (opts.json) {
2301
+ write(JSON.stringify(out.aggregate, null, 2) + "\n");
2302
+ } else {
2303
+ write(renderBatchHuman(out.aggregate));
2304
+ }
2305
+ return out.code;
2306
+ }
2307
+
2308
+ let out;
2309
+ try {
2310
+ out = verifyArtifact(opts);
2311
+ } catch (e) {
2312
+ if (e instanceof UsageError) {
2313
+ writeErr(`error: ${e.message}\n`);
2314
+ return EXIT.USAGE;
2315
+ }
2316
+ if (e instanceof IOError) {
2317
+ writeErr(`error: ${e.message}\n`);
2318
+ return EXIT.IO;
2319
+ }
2320
+ // Any other error is an unexpected internal fault — surface it as an IO error (never a stack to a
2321
+ // counterparty), exit 1.
2322
+ writeErr(`error: ${e.message}\n`);
2323
+ return EXIT.IO;
2324
+ }
2325
+
2326
+ if (opts.json) {
2327
+ write(JSON.stringify(out.result, null, 2) + "\n");
2328
+ } else {
2329
+ write(renderHuman(out.result));
2330
+ }
2331
+ return out.code;
2332
+ }
2333
+
2334
+ // CLI shim: only run when invoked directly (so the module is importable in tests without side effects).
2335
+ if (require.main === module) {
2336
+ process.exit(run(process.argv.slice(2)));
2337
+ }
2338
+
2339
+ module.exports = {
2340
+ EXIT,
2341
+ KINDS,
2342
+ TRUST_NOTE,
2343
+ UsageError,
2344
+ IOError,
2345
+ parseArgs,
2346
+ parseManifest,
2347
+ verifyArtifact,
2348
+ verifyArtifactFromBytes,
2349
+ verifyBatch,
2350
+ buildBatchEntries,
2351
+ renderBatchHuman,
2352
+ verifyEvidenceSeal,
2353
+ verifyTrustSeal,
2354
+ verifyDatasetAttestation,
2355
+ verifyProofBundle,
2356
+ verifyAgentSeal,
2357
+ AGENT_TRUST_NOTE,
2358
+ renderHuman,
2359
+ revocation,
2360
+ usage,
2361
+ run,
2362
+ runDemo,
2363
+ runDemoEmit,
2364
+ DEMO_SIGNER,
2365
+ DEMO_FILES,
2366
+ DEMO_CONTAINER,
2367
+ DEMO_PACKET_NAME,
2368
+ DEMO_AGENT_PACKET_NAME,
2369
+ DEMO_AGENT_PACKET_TEXT,
2370
+ DEMO_AGENT_TAMPER_SEQ,
2371
+ DEMO_AGENT_TAMPER_FROM,
2372
+ DEMO_AGENT_TAMPER_TO,
2373
+ MAX_RELPATH_CHARS,
2374
+ };