verifyhash 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +883 -0
  3. package/cli/abi/ContributionRegistry.json +881 -0
  4. package/cli/agent.js +2173 -0
  5. package/cli/anchor-artifact.js +853 -0
  6. package/cli/anchor.js +400 -0
  7. package/cli/claim.js +881 -0
  8. package/cli/core/agent-commit.js +448 -0
  9. package/cli/core/agent-session.js +598 -0
  10. package/cli/core/anchor-binding.js +663 -0
  11. package/cli/core/attestation.js +580 -0
  12. package/cli/core/evidence-plans.js +495 -0
  13. package/cli/core/fixtures/evidence-plans/baseline.json +19 -0
  14. package/cli/core/fulfill-intake.js +1082 -0
  15. package/cli/core/go-live-preflight.js +481 -0
  16. package/cli/core/license.js +534 -0
  17. package/cli/core/manifest.js +243 -0
  18. package/cli/core/packetseal.js +591 -0
  19. package/cli/core/registryArtifact.js +49 -0
  20. package/cli/core/revocation.js +539 -0
  21. package/cli/core/rfc3161.js +389 -0
  22. package/cli/core/timestamp.js +482 -0
  23. package/cli/core/trust-asof.js +479 -0
  24. package/cli/dataset.js +2950 -0
  25. package/cli/evidence.js +2227 -0
  26. package/cli/fulfill-webhook-http.js +438 -0
  27. package/cli/git.js +220 -0
  28. package/cli/hash.js +550 -0
  29. package/cli/identity.js +1072 -0
  30. package/cli/journal-cli.js +1110 -0
  31. package/cli/journal-log.js +454 -0
  32. package/cli/journal.js +334 -0
  33. package/cli/lineage.js +447 -0
  34. package/cli/list.js +287 -0
  35. package/cli/parcel.js +1509 -0
  36. package/cli/proof.js +578 -0
  37. package/cli/prove.js +300 -0
  38. package/cli/receipt.js +631 -0
  39. package/cli/registry.js +331 -0
  40. package/cli/reputation.js +344 -0
  41. package/cli/revocation.js +495 -0
  42. package/cli/serve-verify-http.js +298 -0
  43. package/cli/serve-verify.js +333 -0
  44. package/cli/show.js +339 -0
  45. package/cli/verify.js +383 -0
  46. package/cli/vh.js +3927 -0
  47. package/docs/ADOPT.md +183 -0
  48. package/docs/ADOPTION.json +11 -0
  49. package/docs/AGENTTRACE.md +247 -0
  50. package/docs/ANCHORING.md +167 -0
  51. package/docs/AUDIT.md +55 -0
  52. package/docs/CONFORMANCE.md +107 -0
  53. package/docs/DATALEDGER.md +638 -0
  54. package/docs/DECIDE.md +47 -0
  55. package/docs/DECISIONS-PENDING.md +27 -0
  56. package/docs/DEPLOY-PUBLIC-SITE.md +301 -0
  57. package/docs/ENGINE-LEDGER.json +12 -0
  58. package/docs/EVIDENCE.md +519 -0
  59. package/docs/GO-LIVE.md +66 -0
  60. package/docs/IDENTITY.md +123 -0
  61. package/docs/INDEPENDENT-VERIFICATION.md +377 -0
  62. package/docs/INTEGRITY-JOURNAL.md +337 -0
  63. package/docs/KEY-LIFECYCLE.md +179 -0
  64. package/docs/LICENSING.md +46 -0
  65. package/docs/LINEAGE.md +307 -0
  66. package/docs/LOOP-AUDIT-2026-07-03.json +580 -0
  67. package/docs/LOOP-HARDENING-PLAN.md +44 -0
  68. package/docs/MERKLE-LEAVES.md +113 -0
  69. package/docs/METRICS.jsonl +31 -0
  70. package/docs/MORNING.md +204 -0
  71. package/docs/PILOT.md +444 -0
  72. package/docs/PROOFPARCEL.md +227 -0
  73. package/docs/PROOFS.md +262 -0
  74. package/docs/RECEIPTS.md +341 -0
  75. package/docs/REPUTATION.md +158 -0
  76. package/docs/SDK.md +301 -0
  77. package/docs/STRATEGY-ARCHIVE.md +5055 -0
  78. package/docs/SUPERVISOR-RUNBOOK.md +52 -0
  79. package/docs/TRUST-BOUNDARIES.md +335 -0
  80. package/docs/TRUSTLEDGER.md +1976 -0
  81. package/docs/USAGE-BUDGET.json +121 -0
  82. package/docs/VERIFY-SERVICE.md +168 -0
  83. package/index.js +160 -0
  84. package/package.json +41 -0
  85. package/trustledger/build-standalone.js +796 -0
  86. package/trustledger/cli.js +3179 -0
  87. package/trustledger/close.js +391 -0
  88. package/trustledger/corpus.js +159 -0
  89. package/trustledger/dist/BUILD-PROVENANCE.json +99 -0
  90. package/trustledger/dist/trustledger-standalone.html +6197 -0
  91. package/trustledger/dist/trustledger-standalone.html.sha256 +1 -0
  92. package/trustledger/door-core.js +442 -0
  93. package/trustledger/fixtures/bank.csv +7 -0
  94. package/trustledger/fixtures/bank.malformed.csv +3 -0
  95. package/trustledger/fixtures/bank.noalias.csv +5 -0
  96. package/trustledger/fixtures/bank.ofx +34 -0
  97. package/trustledger/fixtures/bank.real.csv +5 -0
  98. package/trustledger/fixtures/corpus/_shared/prior-close.json +22 -0
  99. package/trustledger/fixtures/corpus/bank-book-mismatch--benign-twin/inputs.json +14 -0
  100. package/trustledger/fixtures/corpus/bank-book-mismatch--benign-twin/meta.json +7 -0
  101. package/trustledger/fixtures/corpus/bank-book-mismatch--out-of-trust/inputs.json +14 -0
  102. package/trustledger/fixtures/corpus/bank-book-mismatch--out-of-trust/meta.json +7 -0
  103. package/trustledger/fixtures/corpus/continuity-break--benign-twin/inputs.json +15 -0
  104. package/trustledger/fixtures/corpus/continuity-break--benign-twin/meta.json +7 -0
  105. package/trustledger/fixtures/corpus/continuity-break--out-of-trust/inputs.json +15 -0
  106. package/trustledger/fixtures/corpus/continuity-break--out-of-trust/meta.json +7 -0
  107. package/trustledger/fixtures/corpus/negative-tenant-ledger--benign-twin/inputs.json +13 -0
  108. package/trustledger/fixtures/corpus/negative-tenant-ledger--benign-twin/meta.json +7 -0
  109. package/trustledger/fixtures/corpus/negative-tenant-ledger--out-of-trust/inputs.json +13 -0
  110. package/trustledger/fixtures/corpus/negative-tenant-ledger--out-of-trust/meta.json +7 -0
  111. package/trustledger/fixtures/corpus/owner-overdraw--benign-twin/inputs.json +15 -0
  112. package/trustledger/fixtures/corpus/owner-overdraw--benign-twin/meta.json +7 -0
  113. package/trustledger/fixtures/corpus/owner-overdraw--out-of-trust/inputs.json +15 -0
  114. package/trustledger/fixtures/corpus/owner-overdraw--out-of-trust/meta.json +7 -0
  115. package/trustledger/fixtures/corpus/security-deposit-segregation--benign-twin/inputs.json +16 -0
  116. package/trustledger/fixtures/corpus/security-deposit-segregation--benign-twin/meta.json +7 -0
  117. package/trustledger/fixtures/corpus/security-deposit-segregation--out-of-trust/inputs.json +13 -0
  118. package/trustledger/fixtures/corpus/security-deposit-segregation--out-of-trust/meta.json +7 -0
  119. package/trustledger/fixtures/corpus/subledger-out-of-balance--benign-twin/inputs.json +13 -0
  120. package/trustledger/fixtures/corpus/subledger-out-of-balance--benign-twin/meta.json +7 -0
  121. package/trustledger/fixtures/corpus/subledger-out-of-balance--out-of-trust/inputs.json +13 -0
  122. package/trustledger/fixtures/corpus/subledger-out-of-balance--out-of-trust/meta.json +7 -0
  123. package/trustledger/fixtures/e2e/bank.aliased.csv +4 -0
  124. package/trustledger/fixtures/e2e/bank.csv +4 -0
  125. package/trustledger/fixtures/e2e/bank.nsf.csv +4 -0
  126. package/trustledger/fixtures/e2e/quickbooks.csv +6 -0
  127. package/trustledger/fixtures/e2e/quickbooks.nsf.csv +8 -0
  128. package/trustledger/fixtures/e2e/rentroll.csv +6 -0
  129. package/trustledger/fixtures/e2e/rentroll.nsf.csv +8 -0
  130. package/trustledger/fixtures/e2e/rentroll.short.csv +5 -0
  131. package/trustledger/fixtures/plans/baseline.json +25 -0
  132. package/trustledger/fixtures/plans/price-binding.example.json +27 -0
  133. package/trustledger/fixtures/policy/ambiguous-deposit-example.json +12 -0
  134. package/trustledger/fixtures/policy/baseline.json +19 -0
  135. package/trustledger/fixtures/policy/ca-example.json +12 -0
  136. package/trustledger/fixtures/policy/negative-tenant-ledger-example.json +12 -0
  137. package/trustledger/fixtures/policy/owner-overdraw-example.json +12 -0
  138. package/trustledger/fixtures/quickbooks.csv +7 -0
  139. package/trustledger/fixtures/quickbooks.real.csv +5 -0
  140. package/trustledger/fixtures/rentroll.csv +6 -0
  141. package/trustledger/fixtures/rentroll.real.csv +4 -0
  142. package/trustledger/ingest.js +1163 -0
  143. package/trustledger/lib/policy-bundled-loader.js +44 -0
  144. package/trustledger/lib/sha256-vendored.js +227 -0
  145. package/trustledger/license.js +563 -0
  146. package/trustledger/match.js +551 -0
  147. package/trustledger/plans.js +551 -0
  148. package/trustledger/policy.js +398 -0
  149. package/trustledger/public/index.html +512 -0
  150. package/trustledger/reconcile.js +1486 -0
  151. package/trustledger/report.js +887 -0
  152. package/trustledger/seal.js +854 -0
  153. package/trustledger/server.js +391 -0
  154. package/trustledger/valueproof.js +350 -0
@@ -0,0 +1,2227 @@
1
+ "use strict";
2
+
3
+ // cli/evidence.js — the EVIDENCE PACKET command (T-30.3): a product-AGNOSTIC, license-gated,
4
+ // tamper-evident evidence packet built ENTIRELY on the extracted shared cores.
5
+ //
6
+ // THE PRODUCT (the SECOND vertical on the provenance core).
7
+ // `vh evidence seal <dir>` walks a directory and binds the WHOLE file set into ONE content-addressed
8
+ // `*.vhevidence.json` packet over the GENERIC `cli/core/packetseal.js` core. `vh evidence verify <p>`
9
+ // RE-DERIVES the root from the bytes referenced and localizes any tamper to the exact file (MATCH /
10
+ // CHANGED / MISSING / UNEXPECTED). It is product-agnostic: there is NO trust-reconcile vocabulary
11
+ // (no verdict / role / period header) — the seal commits ONLY to (relPath, content) pairs. The seal
12
+ // therefore reuses the seal core with NO header (the optional binding seam of packetseal stays unused).
13
+ //
14
+ // FREE vs PAID.
15
+ // The FREE tier — an UNSIGNED baseline seal + verify over a free SAMPLE size — stays open so a buyer
16
+ // can try before buying. The PAID surface is GATED behind a valid `--license <f> --vendor <addr>`,
17
+ // verified OFFLINE via `cli/core/license.js` against a NEW, distinct EVIDENCE-PRODUCT entitlement table
18
+ // (its OWN `kind`, NOT `trustledger-license` — a separate sellable product). The paid surface is:
19
+ // * `evidence_signed` — wrap the seal in a signed attestation (a vendor/operator vouches for it);
20
+ // * `evidence_unlimited`— seal MORE than the free SAMPLE_LIMIT files in one packet.
21
+ // The gate reuses the SAME verifyLicense / named-reject posture as the TrustLedger CLI.
22
+ //
23
+ // TRUST-BOUNDARIES (the one-liner the output LEADS with).
24
+ // The seal proves TAMPER-EVIDENCE + OFFLINE-RECOMPUTE, NOT a trusted timestamp: "sealed at T" still
25
+ // rides the human-owned signing/timestamp trust-root (STRATEGY.md P-3). The packet is an UNTRUSTED
26
+ // transport container — verify RE-DERIVES the root from the bytes you hold, never the packet's own
27
+ // stored hashes. A signed wrap proves WHO vouched, still not WHEN.
28
+ //
29
+ // PURE CORES + a THIN CLI. All hashing / root math / signing lives in the shared cores; this file is the
30
+ // product framing (the seal/license cfgs) plus the I/O-bearing CLI run functions.
31
+
32
+ const fs = require("fs");
33
+ const path = require("path");
34
+
35
+ const packetseal = require("./core/packetseal");
36
+ const coreLicense = require("./core/license");
37
+ const coreAttestation = require("./core/attestation");
38
+ const coreTrustAsOf = require("./core/trust-asof");
39
+ const { listFiles, hashBytes } = require("./hash");
40
+ // REUSE the SAME path-bound file-level diff core the dataset/verify family uses — `diffManifest` — so a
41
+ // rename surfaces as REMOVED+ADDED and a content edit as CHANGED (old→new), with NO new diff logic here.
42
+ const { diffManifest } = require("./receipt");
43
+
44
+ // Exit contract (shared with the rest of the family): 0 ok / 1 IO / 2 usage / 3 gate-fail (seal-build /
45
+ // verify REJECTED). Mirrors trustledger/cli.js's EXIT so every gate reads the same.
46
+ const EXIT = Object.freeze({ OK: 0, IO: 1, USAGE: 2, FAIL: 3 });
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // THE EVIDENCE SEAL product framing — handed to cli/core/packetseal.js. A GENERIC product `kind`
50
+ // (no trust-reconcile vocabulary), NO header (the seal binds ONLY the file set). The core does ALL the
51
+ // hashing / root / per-file localization; this just names the product.
52
+ // ---------------------------------------------------------------------------
53
+
54
+ const SEAL_KIND = "vh.evidence-seal";
55
+ const SEAL_SCHEMA_VERSION = 1;
56
+ const SUPPORTED_SEAL_SCHEMA_VERSIONS = Object.freeze([1]);
57
+
58
+ // The free SAMPLE size: how many files an UNLICENSED packet may seal. Sealing more requires the
59
+ // `evidence_unlimited` paid entitlement (try-before-you-buy: a small packet is free).
60
+ const SAMPLE_LIMIT = 25;
61
+
62
+ // The TRUST-BOUNDARIES one-liner the output LEADS with — stated ONCE so the human + JSON paths agree and
63
+ // the caveat can never drift. It is the load-bearing honesty of the artifact.
64
+ const EVIDENCE_TRUST_NOTE =
65
+ "This evidence seal is TAMPER-EVIDENT + OFFLINE-RECOMPUTABLE, NOT a trusted timestamp. Its Merkle " +
66
+ "`root` commits to the full set of (relPath, content) pairs in the directory: any edit, rename, add, " +
67
+ "or remove changes the root, and verify RE-DERIVES the root from the bytes you hold and LOCALIZES the " +
68
+ "change to the exact file (MATCH / CHANGED / MISSING / UNEXPECTED). It does NOT prove WHEN the sealing " +
69
+ 'happened ("sealed at T" rides the human-owned signing/timestamp trust-root, STRATEGY.md P-3) and it ' +
70
+ "is NOT a legal opinion. The packet is an UNTRUSTED transport container: verify never trusts the " +
71
+ "packet's own stored hashes.";
72
+
73
+ const SEAL_CFG = Object.freeze({
74
+ kind: SEAL_KIND,
75
+ schemaVersion: SEAL_SCHEMA_VERSION,
76
+ supportedSchemaVersions: SUPPORTED_SEAL_SCHEMA_VERSIONS,
77
+ note: EVIDENCE_TRUST_NOTE,
78
+ label: "evidence seal",
79
+ // NO header: a product-agnostic, file-only seal (the optional packetseal binding seam stays unused).
80
+ });
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // THE EVIDENCE LICENSE product framing — handed to cli/core/license.js. A NEW, DISTINCT product `kind`
84
+ // (`vh-evidence-license`), NOT `trustledger-license`: a separate sellable product with its OWN closed
85
+ // entitlement table. The license core does ALL the crypto via the shared attestation envelope.
86
+ // ---------------------------------------------------------------------------
87
+
88
+ const LICENSE_KIND = "vh-evidence-license";
89
+ const LICENSE_SCHEMA_VERSION = 1;
90
+ const SUPPORTED_LICENSE_SCHEMA_VERSIONS = Object.freeze([1]);
91
+
92
+ // THE CLOSED ENTITLEMENT TABLE for the EVIDENCE product. Disjoint from TrustLedger's. An unknown flag is
93
+ // a hard build error in the core (never silently honored).
94
+ const ENTITLEMENTS = Object.freeze({
95
+ evidence_signed:
96
+ "Wrap an evidence seal in a signed attestation (a vendor/operator vouches for the sealed packet).",
97
+ evidence_unlimited:
98
+ `Seal more than the free sample size (${SAMPLE_LIMIT} files) in one evidence packet.`,
99
+ });
100
+
101
+ const LICENSE_TRUST_NOTE =
102
+ "This verifyhash EVIDENCE license is a SIGNED entitlement token, verified OFFLINE by re-deriving the " +
103
+ "signer from these exact bytes and pinning it to the evidence-product vendor key. A valid verdict " +
104
+ "proves the vendor signed THESE entitlements for THIS customer within [issuedAt, expiresAt]; it is an " +
105
+ "UNTRUSTED transport container (verifyLicense never trusts the file's own claims), it is NOT a trusted " +
106
+ "timestamp (issuedAt/expiresAt are self-asserted and ride the vendor key custody, STRATEGY.md P-3), " +
107
+ "and it is NOT the legal subscription agreement (which governs). It gates the evidence product's PAID " +
108
+ "surface; it never replaces the contract.";
109
+
110
+ const SIGNED_LICENSE_KIND = "vh-evidence-license-signed";
111
+ const SIGNED_LICENSE_SCHEMA_VERSION = 1;
112
+ const SUPPORTED_SIGNED_LICENSE_SCHEMA_VERSIONS = Object.freeze([1]);
113
+
114
+ const SIGNED_LICENSE_TRUST_NOTE =
115
+ "This is a SIGNED verifyhash EVIDENCE license container: it WRAPS (never edits) the EXACT canonical " +
116
+ "license bytes in `attestation` and attaches a detached EIP-191 signature. verifyLicense RE-DERIVES " +
117
+ "the signer from those bytes and pins it to the vendor key — it never trusts the file's own claims. " +
118
+ "Every caveat of the embedded license applies. " +
119
+ LICENSE_TRUST_NOTE;
120
+
121
+ // A dedicated error type so callers/tests catch ONE evidence-license error.
122
+ class EvidenceLicenseError extends Error {
123
+ constructor(message) {
124
+ super(message);
125
+ this.name = "EvidenceLicenseError";
126
+ }
127
+ }
128
+
129
+ const LICENSE_CFG = Object.freeze({
130
+ // unsigned license payload framing
131
+ kind: LICENSE_KIND,
132
+ schemaVersion: LICENSE_SCHEMA_VERSION,
133
+ supportedSchemaVersions: SUPPORTED_LICENSE_SCHEMA_VERSIONS,
134
+ note: LICENSE_TRUST_NOTE,
135
+ entitlements: ENTITLEMENTS,
136
+ // signed-container framing
137
+ signedKind: SIGNED_LICENSE_KIND,
138
+ signedSchemaVersion: SIGNED_LICENSE_SCHEMA_VERSION,
139
+ supportedSignedSchemaVersions: SUPPORTED_SIGNED_LICENSE_SCHEMA_VERSIONS,
140
+ signedNote: SIGNED_LICENSE_TRUST_NOTE,
141
+ signedLabel: "signed verifyhash evidence license",
142
+ ErrorClass: EvidenceLicenseError,
143
+ });
144
+
145
+ // Thin license adapters bound to the evidence CFG (so callers/tests need no cfg).
146
+ function buildLicense(params, signer) {
147
+ return coreLicense.buildLicense(params, signer, LICENSE_CFG);
148
+ }
149
+ function readLicense(input) {
150
+ return coreLicense.readLicense(input, LICENSE_CFG);
151
+ }
152
+ function verifyLicense(container, opts) {
153
+ if (opts == null || typeof opts !== "object" || Array.isArray(opts)) {
154
+ throw new EvidenceLicenseError("verifyLicense requires an options object { now, vendorAddress }");
155
+ }
156
+ return coreLicense.verifyLicense(container, {
157
+ now: opts.now,
158
+ vendorAddress: opts.vendorAddress,
159
+ cfg: LICENSE_CFG,
160
+ });
161
+ }
162
+ function hasEntitlement(verdict, flag) {
163
+ return coreLicense.hasEntitlement(verdict, flag);
164
+ }
165
+ function serializeSignedLicense(container) {
166
+ return coreLicense.serializeSignedLicense(container, LICENSE_CFG);
167
+ }
168
+
169
+ // ---------------------------------------------------------------------------
170
+ // THE SEAL build / validate / verify — thin wrappers binding SEAL_CFG to the GENERIC packetseal core.
171
+ // ---------------------------------------------------------------------------
172
+
173
+ /** Build a bare evidence seal from a flat { relPath, bytes } entry list. PURE. */
174
+ function buildSeal(entries) {
175
+ return packetseal.buildSeal({ files: { entries } }, SEAL_CFG);
176
+ }
177
+
178
+ /** STRICT structural + root re-derivation validation. Throws PacketSealError on the first problem. */
179
+ function validateSeal(obj) {
180
+ return packetseal.validateSeal(obj, SEAL_CFG);
181
+ }
182
+
183
+ /** Serialize a validated seal to canonical, byte-deterministic bytes (newline-terminated). */
184
+ function serializeSeal(seal) {
185
+ validateSeal(seal);
186
+ const canonical = {
187
+ kind: seal.kind,
188
+ schemaVersion: seal.schemaVersion,
189
+ note: seal.note,
190
+ root: seal.root,
191
+ fileCount: seal.fileCount,
192
+ files: seal.files.map((e) => ({
193
+ relPath: e.relPath,
194
+ contentHash: e.contentHash,
195
+ leaf: e.leaf,
196
+ })),
197
+ };
198
+ return JSON.stringify(canonical) + "\n";
199
+ }
200
+
201
+ /** Parse + strictly validate a seal (JSON string or object). A parse error is a PacketSealError. */
202
+ function readSeal(input) {
203
+ let obj;
204
+ if (typeof input === "string") {
205
+ try {
206
+ obj = JSON.parse(input);
207
+ } catch (e) {
208
+ throw new packetseal.PacketSealError(`evidence seal is not valid JSON: ${e.message}`);
209
+ }
210
+ } else if (input != null && typeof input === "object" && !Array.isArray(input)) {
211
+ obj = input;
212
+ } else {
213
+ throw new packetseal.PacketSealError("readSeal requires a JSON string or a seal object");
214
+ }
215
+ validateSeal(obj);
216
+ return obj;
217
+ }
218
+
219
+ /** The AUTHORITATIVE, PURE verify — recompute per-file + root from the supplied { relPath, bytes } set. */
220
+ function verifySeal(seal, entries) {
221
+ return packetseal.verifySeal(seal, { entries }, SEAL_CFG);
222
+ }
223
+
224
+ // ---------------------------------------------------------------------------
225
+ // `diffEvidence({ packetA, packetB })` — PURE, OFFLINE, packet-to-packet change report.
226
+ //
227
+ // WHY THIS EXISTS
228
+ // `vh evidence verify` answers "do these bytes on disk still match this packet?". But a buyer (or a CI
229
+ // pipeline) often holds TWO sealed evidence packets — version A and version B of the SAME file set —
230
+ // and no directory at all, and wants to answer "what changed between A and B?" PURELY from the two
231
+ // portable artifacts: NO directory, NO bytes re-read, NO provider, NO key, NO network. This is the
232
+ // evidence-product mirror of `cli/dataset.js › runDatasetDiff` — it reuses the EXACT SAME diff core.
233
+ //
234
+ // HOW (no new diff/crypto logic — every primitive is reused VERBATIM)
235
+ // Each input may be EITHER a parsed seal object OR a packet STRING; BOTH are validated through the
236
+ // EXISTING strict `readSeal` FIRST (a corrupt/foreign/edited/wrong-`kind` packet is REJECTED before any
237
+ // diff — never half-accepted). Each packet's `files[]` ({ relPath, contentHash, leaf }) is then mapped
238
+ // into the `{ path, contentHash, leaf }` shape `cli/receipt.js › diffManifest` expects and diffed by
239
+ // REUSING that core verbatim. A is the BASELINE ("recorded"), B is the COMPARISON ("current"): so
240
+ // ADDED = in B not A, REMOVED = in A not B, CHANGED = same relPath with a different leaf (old→new
241
+ // contentHash). A rename surfaces as REMOVED(old path) + ADDED(new path) — the relPath is bound into
242
+ // the leaf — never as a single CHANGED.
243
+ //
244
+ // The diff compares what each packet CLAIMS; it re-derives NOTHING from bytes (there is no directory).
245
+ // To re-derive a root from bytes, run `vh evidence verify` against the live tree.
246
+ //
247
+ // AUTHORITATIVE VERDICT
248
+ // The returned `identical` is `diff.identical` — the CHANGE SET (no ADDED/REMOVED/CHANGED), computed
249
+ // from the per-file LEAVES — NOT root-string equality (mirrors `runDatasetDiff` exactly). So a packet
250
+ // with a hand-edited `root` whose leaves are unchanged still reports `identical:true`: a hand-edited
251
+ // `root` cannot flip the verdict. `rootA`/`rootB`/`rootsIdentical` remain DISPLAYED metadata only.
252
+
253
+ /**
254
+ * Diff two evidence packets, PURELY and OFFLINE. Accepts EITHER two parsed seal objects OR two packet
255
+ * strings (or a mix); validates BOTH through the EXISTING strict `readSeal` BEFORE any diff (a
256
+ * corrupt/foreign/edited/wrong-kind packet is REJECTED, never half-accepted), then reuses
257
+ * `cli/receipt.js › diffManifest` VERBATIM. Mutates NEITHER input. Order-independent and deterministic.
258
+ *
259
+ * @param {object} args
260
+ * @param {object|string} args.packetA the BASELINE packet (the "from") — a seal object or a packet string
261
+ * @param {object|string} args.packetB the COMPARISON packet (the "to") — a seal object or a packet string
262
+ * @returns {{
263
+ * rootA: string, rootB: string, rootsIdentical: boolean, identical: boolean,
264
+ * added: Array<{path:string,contentHash:string}>,
265
+ * removed: Array<{path:string,contentHash:string}>,
266
+ * changed: Array<{path:string,oldContentHash:string,newContentHash:string}>,
267
+ * unchanged: Array<{path:string,contentHash:string}>,
268
+ * counts: { added: number, removed: number, changed: number, unchanged: number }
269
+ * }}
270
+ */
271
+ function diffEvidence(args) {
272
+ if (args == null || typeof args !== "object" || Array.isArray(args)) {
273
+ throw new packetseal.PacketSealError("diffEvidence requires { packetA, packetB }");
274
+ }
275
+ return diffEvidenceSeals(args.packetA, args.packetB);
276
+ }
277
+
278
+ /**
279
+ * The `seal`-object (positional) overload of `diffEvidence`. Same contract: each of `packetA`/`packetB`
280
+ * may be a parsed seal object OR a packet string, both are validated through the strict `readSeal`
281
+ * first, and the change set is computed by reusing `diffManifest` verbatim with the AUTHORITATIVE,
282
+ * change-set-driven `identical` (NOT root-string equality). PURE; mutates neither input.
283
+ *
284
+ * @param {object|string} packetA the BASELINE packet (a seal object or a packet string)
285
+ * @param {object|string} packetB the COMPARISON packet (a seal object or a packet string)
286
+ * @returns {object} see {@link diffEvidence}
287
+ */
288
+ function diffEvidenceSeals(packetA, packetB) {
289
+ // STRICT reads FIRST: a corrupt/edited/foreign/wrong-kind packet is REJECTED here (readSeal throws a
290
+ // PacketSealError), never half-accepted, BEFORE any diff is attempted. readSeal accepts EITHER a parsed
291
+ // seal object OR a JSON string and validates structure + per-file leaf re-derivation. It returns the
292
+ // SAME object reference for an object input, so we never mutate the caller's input below (we only READ
293
+ // `.root`/`.files` and map into a fresh array). Both must be structurally sound to be diffed.
294
+ const a = readSeal(packetA);
295
+ const b = readSeal(packetB);
296
+
297
+ const rootA = a.root;
298
+ const rootB = b.root;
299
+ // The two roots, recorded in the packets, are DISPLAYED metadata only. readSeal/validateSeal re-derives
300
+ // every leaf == pathLeaf(relPath, contentHash) and the root over those leaves, so for a structurally
301
+ // valid packet the root DOES summarize its leaves — but we still do NOT let root-string equality decide
302
+ // the verdict (see `identical` below), so the policy is identical to `runDatasetDiff`: a hand-edited
303
+ // `root` that survives validation cannot flip the change-set verdict.
304
+ const rootsIdentical = rootA.toLowerCase() === rootB.toLowerCase();
305
+
306
+ // Map each packet's `files` (relPath→path) into the shape diffManifest expects, then REUSE the SAME
307
+ // diff core VERBATIM. A is the baseline ("recorded"), B is the comparison ("current"): so diffManifest's
308
+ // ADDED = in B not A, REMOVED = in A not B, CHANGED = same relPath, different leaf (carrying old→new
309
+ // contentHash). A rename is REMOVED(old path) + ADDED(new path) — the relPath is bound into the leaf.
310
+ const aManifest = a.files.map((f) => ({
311
+ path: f.relPath,
312
+ contentHash: f.contentHash,
313
+ leaf: f.leaf,
314
+ }));
315
+ const bManifest = b.files.map((f) => ({
316
+ path: f.relPath,
317
+ contentHash: f.contentHash,
318
+ leaf: f.leaf,
319
+ }));
320
+ const diff = diffManifest(aManifest, bManifest);
321
+
322
+ // AUTHORITATIVE verdict is the CHANGE SET, not root-string equality. diffManifest already returns
323
+ // `identical` (true iff there is no ADDED / REMOVED / CHANGED) from the per-file LEAVES — the same data
324
+ // the returned changeset is built from. Deriving the verdict from the changeset guarantees `identical`
325
+ // and the body can never disagree: a packet with a hand-edited `root` (whose leaves are unchanged) still
326
+ // reports `identical:true` with an empty changeset. rootA/rootB/rootsIdentical remain DISPLAYED metadata.
327
+ const identical = diff.identical;
328
+
329
+ const counts = {
330
+ added: diff.added.length,
331
+ removed: diff.removed.length,
332
+ changed: diff.changed.length,
333
+ unchanged: diff.unchanged.length,
334
+ };
335
+
336
+ return {
337
+ rootA,
338
+ rootB,
339
+ rootsIdentical,
340
+ identical,
341
+ added: diff.added,
342
+ removed: diff.removed,
343
+ changed: diff.changed,
344
+ unchanged: diff.unchanged,
345
+ counts,
346
+ };
347
+ }
348
+
349
+ // ---------------------------------------------------------------------------
350
+ // DRIFT POLICY (T-46.1 leverage): a CI-gateable verdict over the change set `diffEvidence` produces.
351
+ //
352
+ // WHY THIS EXISTS (the paying-customer leverage over a bare diff)
353
+ // A bare A->B change report answers "WHAT changed?". A buyer who pins evidence in a compliance / IP /
354
+ // chain-of-custody pipeline needs the next question answered automatically: "is this change ALLOWED?"
355
+ // — and a NON-ZERO exit when it is not, so CI fails the build / blocks the merge / alerts the reviewer.
356
+ // `evaluateDriftPolicy({ diff, policy })` turns the pure `diffEvidence` change set into a PASS/FAIL
357
+ // verdict against a small, explicit policy, with a per-change violation list a human (or a ticket) can
358
+ // read. It mirrors `cli/dataset.js › evaluatePolicy` (the SAME verdict/violation/rulesEvaluated shape,
359
+ // the SAME PASS/FAIL vocabulary), so the two policy gates read identically across the product family.
360
+ //
361
+ // IT INVENTS NO NEW DIFF/CRYPTO MATH. It consumes the EXACT object `diffEvidence` returns (added /
362
+ // removed / changed) — no second walk of the packets, no re-hashing — and only CLASSIFIES those
363
+ // already-computed changes against the policy. So the gate can never disagree with the diff it gates.
364
+ //
365
+ // THE RULES (every field OPTIONAL and combinable; a policy with NO rules trivially PASSes)
366
+ // - noAdded : true -> ANY ADDED file violates (the new packet may not introduce files).
367
+ // - noRemoved : true -> ANY REMOVED file violates (append-only / nothing may disappear — the
368
+ // load-bearing rule for an evidence chain-of-custody: a removal is suspicious).
369
+ // - noChanged : true -> ANY CHANGED file (edited content at the same relPath) violates.
370
+ // - allowChangePaths : [prefixes] -> a CHANGED file whose relPath is NOT under one of these POSIX path
371
+ // prefixes violates (e.g. only files under "src/" may be edited). A prefix
372
+ // match is segment-aware: "src" matches "src/x" and "src" but never "srcfoo".
373
+ // - frozenPaths : [prefixes] -> a file under one of these prefixes that is CHANGED *or* REMOVED
374
+ // violates (those paths are FROZEN — neither edited nor deleted). ADDING a new
375
+ // file under a frozen prefix is allowed (freezing protects what already exists).
376
+ // A rename is REMOVED(old)+ADDED(new) in the change set, so it is gated as a remove + an add — never as
377
+ // a silent edit (consistent with the whole family: the relPath is bound into the leaf).
378
+ //
379
+ // PURE: no I/O, no provider, no key, no network. Deterministic + order-independent: violations are sorted
380
+ // (relPath, then rule), so two runs over the same diff+policy are byte-identical. Mutates NEITHER input.
381
+ // ---------------------------------------------------------------------------
382
+
383
+ const DRIFT_POLICY_KIND = "vh.evidence-drift-policy";
384
+ const DRIFT_POLICY_SCHEMA_VERSION = 1;
385
+ const SUPPORTED_DRIFT_POLICY_SCHEMA_VERSIONS = Object.freeze([1]);
386
+
387
+ // Stable, documented rule identifiers a violation reports in its `rule` field — a consumer can gate on
388
+ // these EXACT strings (mirrors cli/dataset.js › POLICY_RULE).
389
+ const DRIFT_RULE = Object.freeze({
390
+ NO_ADDED: "noAdded",
391
+ NO_REMOVED: "noRemoved",
392
+ NO_CHANGED: "noChanged",
393
+ ALLOW_CHANGE_PATHS: "allowChangePaths",
394
+ FROZEN_PATHS: "frozenPaths",
395
+ });
396
+
397
+ // The boolean rules (each present-and-`true` enables a constraint) and the path-list rules (each, when a
398
+ // non-empty array, constrains by POSIX path prefix). Kept as data so validation, the rule count, and the
399
+ // evaluator never drift in which fields they recognize.
400
+ const DRIFT_BOOL_RULES = Object.freeze([
401
+ DRIFT_RULE.NO_ADDED,
402
+ DRIFT_RULE.NO_REMOVED,
403
+ DRIFT_RULE.NO_CHANGED,
404
+ ]);
405
+ const DRIFT_LIST_RULES = Object.freeze([DRIFT_RULE.ALLOW_CHANGE_PATHS, DRIFT_RULE.FROZEN_PATHS]);
406
+
407
+ // Possible verdicts (same vocabulary as the dataset policy gate, so the family reads identically).
408
+ const DRIFT_VERDICT = Object.freeze({ PASS: "PASS", FAIL: "FAIL" });
409
+
410
+ // The TRUST one-liner the drift gate LEADS with — stated ONCE so human + JSON agree. A drift PASS is a
411
+ // statement about the CHANGE SET BETWEEN TWO PACKETS, computed from what each packet CLAIMS; it does NOT
412
+ // re-derive content from bytes and is NOT a trusted timestamp or a legal opinion.
413
+ const DRIFT_TRUST_NOTE =
414
+ "A drift-policy verdict gates the CHANGE SET between two evidence packets (what each packet CLAIMS) — " +
415
+ "it does NOT re-derive content from a directory, is NOT a trusted timestamp, and is NOT a legal " +
416
+ "opinion. Run `vh evidence verify <packet> --dir <d>` to re-derive a root from bytes. " +
417
+ EVIDENCE_TRUST_NOTE;
418
+
419
+ /**
420
+ * Strictly validate a parsed drift-policy object. Throws an Error describing the FIRST problem; never
421
+ * mutates and never fills defaults (mirrors cli/dataset.js › validatePolicy). A wrong kind/schemaVersion,
422
+ * a non-boolean boolean rule, or a non-array / empty-string-entry path list hard-errors here so a
423
+ * corrupt/foreign policy is rejected, never half-accepted. Every rule is OPTIONAL and combinable; a
424
+ * policy with NO rules is valid (and trivially PASSes).
425
+ * @param {any} obj
426
+ * @returns {object} the same object, if valid
427
+ */
428
+ function validateDriftPolicy(obj) {
429
+ if (obj == null || typeof obj !== "object" || Array.isArray(obj)) {
430
+ throw new packetseal.PacketSealError("evidence drift policy must be a JSON object");
431
+ }
432
+ if (obj.kind !== DRIFT_POLICY_KIND) {
433
+ throw new packetseal.PacketSealError(
434
+ `not a verifyhash evidence drift policy (kind: ${JSON.stringify(obj.kind)}; expected ${JSON.stringify(
435
+ DRIFT_POLICY_KIND
436
+ )})`
437
+ );
438
+ }
439
+ if (!SUPPORTED_DRIFT_POLICY_SCHEMA_VERSIONS.includes(obj.schemaVersion)) {
440
+ throw new packetseal.PacketSealError(
441
+ `unsupported evidence drift policy schemaVersion: ${JSON.stringify(obj.schemaVersion)} ` +
442
+ `(this build understands ${JSON.stringify(SUPPORTED_DRIFT_POLICY_SCHEMA_VERSIONS)})`
443
+ );
444
+ }
445
+ // Boolean rules: each, WHEN PRESENT, must be a STRICT boolean (reject a truthy string/number that would
446
+ // silently enable the rule).
447
+ for (const f of DRIFT_BOOL_RULES) {
448
+ if (obj[f] !== undefined && typeof obj[f] !== "boolean") {
449
+ throw new packetseal.PacketSealError(
450
+ `evidence drift policy ${f} must be a boolean when present, got: ${String(obj[f])}`
451
+ );
452
+ }
453
+ }
454
+ // Path-list rules: each, WHEN PRESENT, must be an array of non-empty strings. Reject a non-array or an
455
+ // empty/non-string entry rather than silently coercing.
456
+ for (const f of DRIFT_LIST_RULES) {
457
+ if (obj[f] === undefined) continue;
458
+ if (!Array.isArray(obj[f])) {
459
+ throw new packetseal.PacketSealError(
460
+ `evidence drift policy ${f} must be an array of path prefixes when present, got: ${String(obj[f])}`
461
+ );
462
+ }
463
+ obj[f].forEach((v, i) => {
464
+ if (typeof v !== "string" || v.length === 0) {
465
+ throw new packetseal.PacketSealError(
466
+ `evidence drift policy ${f}[${i}] must be a non-empty string, got: ${String(v)}`
467
+ );
468
+ }
469
+ });
470
+ }
471
+ return obj;
472
+ }
473
+
474
+ /**
475
+ * Count the rules a validated drift policy actually carries — so the verdict can report `rulesEvaluated`
476
+ * and a no-rules policy is announced clearly. A boolean rule counts only when exactly `true`; a path-list
477
+ * rule counts only when present AND non-empty (an empty `frozenPaths: []` carries no constraint).
478
+ * @param {object} policy a validated drift policy object
479
+ * @returns {number}
480
+ */
481
+ function _countDriftRules(policy) {
482
+ let n = 0;
483
+ for (const f of DRIFT_BOOL_RULES) if (policy[f] === true) n++;
484
+ for (const f of DRIFT_LIST_RULES) if (Array.isArray(policy[f]) && policy[f].length > 0) n++;
485
+ return n;
486
+ }
487
+
488
+ /**
489
+ * Does `relPath` fall under POSIX path `prefix`? SEGMENT-AWARE so a prefix never matches a sibling whose
490
+ * name merely starts with it: "src" matches "src" and "src/x" but NOT "srcfoo". A bare prefix equal to the
491
+ * whole path matches (the file IS that path). Inputs are the relPaths a seal already normalizes to POSIX
492
+ * forward slashes, so no separator juggling is needed.
493
+ * @param {string} relPath
494
+ * @param {string} prefix
495
+ * @returns {boolean}
496
+ */
497
+ function _underPrefix(relPath, prefix) {
498
+ // Normalize a trailing slash on the prefix away ("src/" and "src" mean the same subtree).
499
+ const p = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
500
+ return relPath === p || relPath.startsWith(p + "/");
501
+ }
502
+
503
+ /**
504
+ * Evaluate the change set `diffEvidence` produced against a drift policy, in a PURE, deterministic
505
+ * function (no I/O, no provider, no key, no network). Consumes the EXACT object `diffEvidence`/
506
+ * `diffEvidenceSeals` returns — it does NOT re-diff or re-hash — and classifies each already-computed
507
+ * ADDED/REMOVED/CHANGED entry against the policy's rules. Returns a verdict: PASS (no change violates any
508
+ * rule) or FAIL with, per violating change, the relPath + which rule it broke + the change KIND that
509
+ * triggered it. A single file can violate more than one rule (each is its own violation entry).
510
+ * Violations are sorted by relPath then rule, so two runs over the same inputs are byte-identical.
511
+ *
512
+ * Mutates NEITHER input (it only reads `diff.added/removed/changed` and the policy's rule fields).
513
+ *
514
+ * @param {object} args
515
+ * @param {object} args.diff the object `diffEvidence` returns (added/removed/changed/...)
516
+ * @param {object} args.policy a validated drift policy object (from `validateDriftPolicy`/`readDriftPolicy`)
517
+ * @returns {{
518
+ * verdict: "PASS"|"FAIL",
519
+ * rulesEvaluated: number,
520
+ * addedCount: number, removedCount: number, changedCount: number,
521
+ * violations: { relPath: string, rule: string, change: "ADDED"|"REMOVED"|"CHANGED" }[],
522
+ * }}
523
+ */
524
+ function evaluateDriftPolicy(args) {
525
+ if (args == null || typeof args !== "object" || Array.isArray(args)) {
526
+ throw new packetseal.PacketSealError("evaluateDriftPolicy requires { diff, policy }");
527
+ }
528
+ const { diff, policy } = args;
529
+ if (diff == null || typeof diff !== "object") {
530
+ throw new packetseal.PacketSealError("evaluateDriftPolicy requires a diff (from diffEvidence)");
531
+ }
532
+ validateDriftPolicy(policy); // a foreign/corrupt policy is rejected here, never half-evaluated
533
+
534
+ const added = Array.isArray(diff.added) ? diff.added : [];
535
+ const removed = Array.isArray(diff.removed) ? diff.removed : [];
536
+ const changed = Array.isArray(diff.changed) ? diff.changed : [];
537
+
538
+ const noAdded = policy.noAdded === true;
539
+ const noRemoved = policy.noRemoved === true;
540
+ const noChanged = policy.noChanged === true;
541
+ const allowChangePaths =
542
+ Array.isArray(policy.allowChangePaths) && policy.allowChangePaths.length > 0
543
+ ? policy.allowChangePaths
544
+ : null;
545
+ const frozenPaths =
546
+ Array.isArray(policy.frozenPaths) && policy.frozenPaths.length > 0 ? policy.frozenPaths : null;
547
+
548
+ const violations = [];
549
+
550
+ // ADDED files: only `noAdded` constrains them (a new file is allowed under a frozen prefix — freezing
551
+ // protects what already EXISTS, it does not forbid growth).
552
+ for (const a of added) {
553
+ if (noAdded) {
554
+ violations.push({ relPath: a.path, rule: DRIFT_RULE.NO_ADDED, change: "ADDED" });
555
+ }
556
+ }
557
+
558
+ // REMOVED files: `noRemoved` forbids any removal; `frozenPaths` forbids removing a file under a frozen
559
+ // prefix (a frozen path may be neither edited nor deleted).
560
+ for (const r of removed) {
561
+ if (noRemoved) {
562
+ violations.push({ relPath: r.path, rule: DRIFT_RULE.NO_REMOVED, change: "REMOVED" });
563
+ }
564
+ if (frozenPaths && frozenPaths.some((p) => _underPrefix(r.path, p))) {
565
+ violations.push({ relPath: r.path, rule: DRIFT_RULE.FROZEN_PATHS, change: "REMOVED" });
566
+ }
567
+ }
568
+
569
+ // CHANGED files: `noChanged` forbids any edit; `allowChangePaths`, when set, forbids editing a file NOT
570
+ // under one of the allowed prefixes; `frozenPaths` forbids editing a file under a frozen prefix.
571
+ for (const c of changed) {
572
+ if (noChanged) {
573
+ violations.push({ relPath: c.path, rule: DRIFT_RULE.NO_CHANGED, change: "CHANGED" });
574
+ }
575
+ if (allowChangePaths && !allowChangePaths.some((p) => _underPrefix(c.path, p))) {
576
+ violations.push({ relPath: c.path, rule: DRIFT_RULE.ALLOW_CHANGE_PATHS, change: "CHANGED" });
577
+ }
578
+ if (frozenPaths && frozenPaths.some((p) => _underPrefix(c.path, p))) {
579
+ violations.push({ relPath: c.path, rule: DRIFT_RULE.FROZEN_PATHS, change: "CHANGED" });
580
+ }
581
+ }
582
+
583
+ // Deterministic order: by relPath, then by rule (a stable total order, so two runs are byte-identical).
584
+ violations.sort((x, y) => {
585
+ if (x.relPath !== y.relPath) return x.relPath < y.relPath ? -1 : 1;
586
+ return x.rule < y.rule ? -1 : x.rule > y.rule ? 1 : 0;
587
+ });
588
+
589
+ return {
590
+ verdict: violations.length === 0 ? DRIFT_VERDICT.PASS : DRIFT_VERDICT.FAIL,
591
+ rulesEvaluated: _countDriftRules(policy),
592
+ addedCount: added.length,
593
+ removedCount: removed.length,
594
+ changedCount: changed.length,
595
+ violations,
596
+ };
597
+ }
598
+
599
+ /**
600
+ * Read, parse, and STRICTLY validate the drift policy at `policyPath`. Throws on a missing file, invalid
601
+ * JSON, or ANY schema deviation (a malformed/foreign policy is rejected, never half-accepted) — mirrors
602
+ * cli/dataset.js › readPolicy.
603
+ * @param {string} policyPath
604
+ * @returns {object} the validated drift policy object
605
+ */
606
+ function readDriftPolicy(policyPath) {
607
+ if (!policyPath || typeof policyPath !== "string") {
608
+ throw new packetseal.PacketSealError("readDriftPolicy requires a policy file path");
609
+ }
610
+ let raw;
611
+ try {
612
+ raw = fs.readFileSync(policyPath, "utf8");
613
+ } catch (e) {
614
+ throw new packetseal.PacketSealError(
615
+ `cannot read evidence drift policy at ${policyPath}: ${e.message}`
616
+ );
617
+ }
618
+ let obj;
619
+ try {
620
+ obj = JSON.parse(raw);
621
+ } catch (e) {
622
+ throw new packetseal.PacketSealError(
623
+ `evidence drift policy at ${policyPath} is not valid JSON: ${e.message}`
624
+ );
625
+ }
626
+ return validateDriftPolicy(obj);
627
+ }
628
+
629
+ // ---------------------------------------------------------------------------
630
+ // SIGNED-attestation WRAP (the PAID `evidence_signed` surface). The seal's CANONICAL bytes become the
631
+ // attestation payload — the SAME shared signing path the rest of the family uses (no new scheme).
632
+ // ---------------------------------------------------------------------------
633
+
634
+ const SIGNED_SEAL_KIND = "vh.evidence-seal-signed";
635
+ const SIGNED_SEAL_SCHEMA_VERSION = 1;
636
+ const SUPPORTED_SIGNED_SEAL_SCHEMA_VERSIONS = Object.freeze([1]);
637
+
638
+ const SIGNED_SEAL_TRUST_NOTE =
639
+ "This is a SIGNED evidence-seal container: it WRAPS (never edits) the EXACT canonical seal bytes in " +
640
+ "`attestation` and attaches a detached EIP-191 signature. It asserts the holder of the `signer` key " +
641
+ "vouched for THIS sealed packet (the embedded root) at signing time. It does NOT prove a timestamp " +
642
+ '(no "sealed since T" — still the human trust-root P-3) and is NOT a legal opinion. Every caveat of ' +
643
+ "the embedded seal applies. " +
644
+ EVIDENCE_TRUST_NOTE;
645
+
646
+ const SIGNED_SEAL_CFG = Object.freeze({
647
+ kind: SIGNED_SEAL_KIND,
648
+ schemaVersion: SIGNED_SEAL_SCHEMA_VERSION,
649
+ supportedSchemaVersions: SUPPORTED_SIGNED_SEAL_SCHEMA_VERSIONS,
650
+ note: SIGNED_SEAL_TRUST_NOTE,
651
+ label: "signed evidence seal",
652
+ validateUnsigned: validateSeal,
653
+ serializeUnsigned: serializeSeal,
654
+ });
655
+
656
+ /** Sign a validated seal with a caller-supplied ethers signer-like object and WRAP it. */
657
+ async function signSealWith(seal, signer) {
658
+ return coreAttestation.signAttestation({ attestation: seal, signer }, SIGNED_SEAL_CFG);
659
+ }
660
+
661
+ /** Strictly validate a parsed SIGNED-seal container. */
662
+ function validateSignedSeal(obj) {
663
+ return coreAttestation.validateSignedAttestation(obj, SIGNED_SEAL_CFG);
664
+ }
665
+
666
+ /** Verify a SIGNED-seal container OFFLINE (recover the signer; optionally pin/bind). */
667
+ function verifySignedSeal(params) {
668
+ return coreAttestation.verifySignedAttestation(params);
669
+ }
670
+
671
+ // The standing trust caveat the signed-verify path LEADS with — reuses EVIDENCE_TRUST_NOTE verbatim (so
672
+ // the caveats can NEVER drift) plus the signing-specific honesty: a valid signature proves WHO vouched,
673
+ // still NOT a timestamp (P-3) and NOT a legal opinion. Mirrors cli/dataset.js › VERIFY_ATTEST_TRUST_NOTE.
674
+ const VERIFY_SIGNED_SEAL_TRUST_NOTE =
675
+ "A valid signature proves the HOLDER OF `signer`'s key vouched for THIS evidence seal (the embedded " +
676
+ "root + the full set of (relPath, content) pairs). It does NOT by itself prove a trustworthy " +
677
+ 'TIMESTAMP: "sealed/vouched since a date T" still needs the human-owned signing/timestamp trust-root ' +
678
+ "(needs-human, P-3). It is NOT a legal opinion. " +
679
+ EVIDENCE_TRUST_NOTE;
680
+
681
+ /**
682
+ * Verify (purely, OFFLINE) a SIGNED evidence-seal container — the STRICT, PURE signed-verify path that
683
+ * MIRRORS `cli/dataset.js › verifySignedAttestation` EXACTLY. It recovers the signer from the embedded
684
+ * canonical seal bytes + signature and confirms it equals the container's CLAIMED `signer` (Check 1 —
685
+ * ALWAYS run); OPTIONALLY pins it to an EXPECTED signer (`expectedSigner` / the CLI `--signer` flag —
686
+ * Check 2, run ONLY when present); and OPTIONALLY confirms the signature binds a holder's OWN directory
687
+ * (`dir` / the CLI `--dir` flag) by recomputing the canonical UNSIGNED seal bytes from that directory via
688
+ * the EXISTING build path (`serializeSeal(buildSeal(loadDirEntries(dir)))`) and requiring them
689
+ * byte-identical to the embedded payload. The verdict is ACCEPTED only when EVERY requested check passes;
690
+ * a forged/mismatched/tampered signature is a clean REJECTED — NEVER a silent pass.
691
+ *
692
+ * It is OFFLINE / key-free / network-free: it recovers a PUBLIC address from a signature, holds no private
693
+ * key, and contacts nothing. It writes NOTHING and mutates NEITHER the container NOR the directory (the
694
+ * `--dir` read is the ONLY I/O, and only when binding is requested). Throws only on an unrecoverable
695
+ * signature when the scheme is unknown (defense-in-depth — validateSignedSeal already rejects one) or when
696
+ * the supplied `--dir` cannot be read; a recovered address that simply doesn't match is a clean REJECTED.
697
+ *
698
+ * The returned shape is the SIBLING-PARITY verdict shape (byte-for-byte the fields `verifySignedAttestation`
699
+ * returns, including the `manifestBindsAttestation`/`manifestChecked` field names so a future indexer/UI can
700
+ * depend on ONE stable verdict shape across the product family).
701
+ *
702
+ * @param {object} params
703
+ * @param {object} params.container a validated signed-seal container (from validateSignedSeal/readPacket)
704
+ * @param {string} [params.expectedSigner] OPTIONAL expected signer 0x-address (--signer); Check 2 runs when present
705
+ * @param {string} [params.dir] OPTIONAL directory to bind the signature to (--dir); binding runs when present
706
+ * @returns {{
707
+ * verdict: "ACCEPTED"|"REJECTED",
708
+ * accepted: boolean,
709
+ * recoveredSigner: string,
710
+ * claimedSigner: string,
711
+ * scheme: string,
712
+ * checks: {
713
+ * signatureMatchesSigner: boolean,
714
+ * signerMatchesExpected: boolean|null,
715
+ * manifestBindsAttestation: boolean|null,
716
+ * },
717
+ * expectedSigner: string|null,
718
+ * manifestChecked: boolean,
719
+ * failedChecks: string[],
720
+ * }}
721
+ */
722
+ function verifySignedSealAttestation(params) {
723
+ if (!params || typeof params !== "object" || Array.isArray(params)) {
724
+ throw new Error("verifySignedSealAttestation requires { container, [expectedSigner], [dir] }");
725
+ }
726
+ const { container, expectedSigner, dir } = params;
727
+
728
+ // The ONLY evidence-specific step: the OPTIONAL --dir binding check recomputes the canonical UNSIGNED
729
+ // seal bytes from the holder's OWN directory via the EXISTING build path (the SAME bytes `vh evidence
730
+ // seal` embeds), then hands them to the GENERIC core as `expectedCanonical`. The core does the signer
731
+ // recovery (Check 1, always), the OPTIONAL expected-signer pin (Check 2), and the byte-identity binding
732
+ // comparison — all product-agnostic. We pass `container` straight through (no copy; the container is only
733
+ // READ), so this never mutates the caller's input. The returned shape (incl. the field names) is
734
+ // byte-for-byte what the dataset sibling returns.
735
+ let expectedCanonical;
736
+ if (dir !== undefined && dir !== null) {
737
+ // Recompute the canonical seal bytes from the live directory — the SAME (relPath, content) walk + seal
738
+ // build the seal path uses. A directory the holder cannot read is a genuine error (re-thrown), never a
739
+ // silent "binding skipped" — the caller asked to bind to bytes that must exist.
740
+ const dirAbs = path.resolve(dir);
741
+ const entries = loadDirEntries(dirAbs);
742
+ expectedCanonical = serializeSeal(buildSeal(entries));
743
+ }
744
+ // Route through the existing `verifySignedSeal` thin wrapper (which calls coreAttestation.
745
+ // verifySignedAttestation) so this path stays the single, shared verify core — exactly mirroring how the
746
+ // dataset sibling funnels through coreAttestation.verifySignedAttestation.
747
+ return verifySignedSeal({ container, expectedSigner, expectedCanonical });
748
+ }
749
+
750
+ // ---------------------------------------------------------------------------
751
+ // I/O HELPERS — the only filesystem-touching code. Walk a directory into the flat { relPath, bytes }
752
+ // entry list the seal core consumes, REUSING cli/hash.js's listFiles (the SAME path-bound enumeration
753
+ // `vh hash <dir>` / `vh dataset build` use — no new walk).
754
+ // ---------------------------------------------------------------------------
755
+
756
+ /**
757
+ * Load a directory into a sorted [{ relPath, bytes }] list. relPath is POSIX-normalized + relative to
758
+ * `dirAbs` (the SAME convention the manifest core records), so the seal travels with the directory. PURE
759
+ * except for the file reads.
760
+ */
761
+ function loadDirEntries(dirAbs) {
762
+ const files = listFiles(dirAbs); // recursive; skips sockets/fifos/symlinks (no stable hash)
763
+ const entries = files.map((abs) => {
764
+ const rel = path.relative(dirAbs, abs).split(path.sep).join("/");
765
+ return { relPath: rel, bytes: fs.readFileSync(abs) };
766
+ });
767
+ entries.sort((a, b) => (a.relPath < b.relPath ? -1 : a.relPath > b.relPath ? 1 : 0));
768
+ return entries;
769
+ }
770
+
771
+ // ---------------------------------------------------------------------------
772
+ // `vh evidence seal <dir> [--out <p>] [--license <f> --vendor <addr>]`
773
+ //
774
+ // Walks <dir>, builds the *.vhevidence.json seal, and either prints it (default; writes NOTHING) or
775
+ // writes it to --out. NEVER writes to cwd without --out. The PAID surface (signed wrap, or sealing more
776
+ // than the free SAMPLE_LIMIT) is GATED behind a valid --license/--vendor verified OFFLINE. The output
777
+ // LEADS with the TRUST-BOUNDARIES one-liner. Exit: 0 ok / 3 seal-build-error / 2 usage / 1 IO.
778
+ // ---------------------------------------------------------------------------
779
+
780
+ function parseSealArgs(argv) {
781
+ const opts = {
782
+ dir: undefined,
783
+ out: undefined,
784
+ license: undefined,
785
+ vendor: undefined,
786
+ sign: false,
787
+ keyEnv: undefined,
788
+ keyFile: undefined,
789
+ json: false,
790
+ _positionals: [],
791
+ };
792
+ for (let i = 0; i < argv.length; i++) {
793
+ const a = argv[i];
794
+ const need = (flag) => {
795
+ const v = argv[++i];
796
+ if (v === undefined) {
797
+ const e = new Error(`${flag} requires a value`);
798
+ e.usage = true;
799
+ throw e;
800
+ }
801
+ return v;
802
+ };
803
+ switch (a) {
804
+ case "--out":
805
+ opts.out = need("--out");
806
+ break;
807
+ case "--license":
808
+ opts.license = need("--license");
809
+ break;
810
+ case "--vendor":
811
+ opts.vendor = need("--vendor");
812
+ break;
813
+ case "--sign":
814
+ opts.sign = true;
815
+ break;
816
+ case "--key-env":
817
+ opts.keyEnv = need("--key-env");
818
+ break;
819
+ case "--key-file":
820
+ opts.keyFile = need("--key-file");
821
+ break;
822
+ case "--json":
823
+ opts.json = true;
824
+ break;
825
+ default:
826
+ if (a && a.startsWith("--")) {
827
+ const e = new Error(`unknown flag: ${a}`);
828
+ e.usage = true;
829
+ throw e;
830
+ }
831
+ opts._positionals.push(a);
832
+ }
833
+ }
834
+ if (opts._positionals.length > 1) {
835
+ const e = new Error(
836
+ `unexpected extra argument: ${opts._positionals[1]} (evidence seal takes exactly one <dir>)`
837
+ );
838
+ e.usage = true;
839
+ throw e;
840
+ }
841
+ opts.dir = opts._positionals[0];
842
+ return opts;
843
+ }
844
+
845
+ // The license GATE for the paid evidence surfaces. Returns { ok, code?, verdict? }: a clean { ok:true }
846
+ // when NO paid surface is requested (FREE tier, no license needed), else REQUIRES a VALID, vendor-pinned
847
+ // license carrying the matching entitlement and reports the precise verifyLicense reason on reject. The
848
+ // reject NEVER silently downgrades to a free run. `now` dates the window check.
849
+ function gatePaid(opts, requested, now, writeErr) {
850
+ if (requested.length === 0) {
851
+ return { ok: true, verdict: null }; // FREE tier
852
+ }
853
+ const featureList = requested.map((r) => r.label).join(" and ");
854
+
855
+ const hasLicense = opts.license != null;
856
+ const hasVendor = opts.vendor != null;
857
+ if (!hasLicense && !hasVendor) {
858
+ writeErr(
859
+ `error: ${featureList} ${requested.length > 1 ? "are" : "is"} a PAID surface and ` +
860
+ "requires a license; pass --license <file> --vendor <0xaddr>. " +
861
+ `The FREE tier — an unsigned baseline seal of up to ${SAMPLE_LIMIT} files + verify — needs no license.\n`
862
+ );
863
+ return { ok: false, code: EXIT.USAGE };
864
+ }
865
+ if (hasLicense !== hasVendor) {
866
+ writeErr(
867
+ "error: --license and --vendor must be supplied together (a license file is verified by " +
868
+ "pinning it to the vendor key); pass BOTH --license <file> --vendor <0xaddr>\n"
869
+ );
870
+ return { ok: false, code: EXIT.USAGE };
871
+ }
872
+
873
+ // Read the license OFFLINE (an unreadable/garbled file is a usage error; there is no key in a license).
874
+ let container;
875
+ try {
876
+ const text = fs.readFileSync(path.resolve(opts.license), "utf8");
877
+ container = readLicense(text);
878
+ } catch (e) {
879
+ writeErr(`error: cannot read --license file ${opts.license}: ${e.message}\n`);
880
+ return { ok: false, code: EXIT.USAGE };
881
+ }
882
+
883
+ // Verify OFFLINE against the pinned vendor. A malformed --vendor is thrown by verifyLicense.
884
+ let verdict;
885
+ try {
886
+ verdict = verifyLicense(container, { now, vendorAddress: opts.vendor });
887
+ } catch (e) {
888
+ writeErr(`error: ${e.message}\n`);
889
+ return { ok: false, code: EXIT.USAGE };
890
+ }
891
+ if (!verdict.valid) {
892
+ writeErr(
893
+ `error: ${featureList} requires a VALID license, but the supplied license is ` +
894
+ `${verdict.reason} (recovered ${verdict.recoveredSigner || "(unrecoverable)"}, ` +
895
+ `pinned to ${verdict.vendorAddress}).\n`
896
+ );
897
+ return { ok: false, code: EXIT.FAIL };
898
+ }
899
+
900
+ // The license is valid — require it to actually CARRY each requested entitlement.
901
+ for (const r of requested) {
902
+ if (!hasEntitlement(verdict, r.entitlement)) {
903
+ writeErr(
904
+ `error: the supplied license is valid but does NOT include the "${r.entitlement}" ` +
905
+ `entitlement needed for ${r.label}; it grants only ${JSON.stringify(verdict.entitlements)}.\n`
906
+ );
907
+ return { ok: false, code: EXIT.FAIL };
908
+ }
909
+ }
910
+ return { ok: true, verdict };
911
+ }
912
+
913
+ async function runEvidenceSeal(opts, io = {}) {
914
+ const write = io.write || ((s) => process.stdout.write(s));
915
+ const writeErr = io.writeErr || ((s) => process.stderr.write(s));
916
+ const now = io.now || new Date();
917
+
918
+ if (!opts.dir) {
919
+ writeErr("error: `vh evidence seal` requires a <dir>\n");
920
+ return EXIT.USAGE;
921
+ }
922
+
923
+ // Walk the directory (the only read I/O). A missing/unreadable dir or a non-directory is an IO error.
924
+ const dirAbs = path.resolve(opts.dir);
925
+ let stat;
926
+ try {
927
+ stat = fs.statSync(dirAbs);
928
+ } catch (e) {
929
+ writeErr(`error: cannot read directory ${opts.dir}: ${e.message}\n`);
930
+ return EXIT.IO;
931
+ }
932
+ if (!stat.isDirectory()) {
933
+ writeErr(`error: ${opts.dir} is not a directory\n`);
934
+ return EXIT.IO;
935
+ }
936
+ let entries;
937
+ try {
938
+ entries = loadDirEntries(dirAbs);
939
+ } catch (e) {
940
+ writeErr(`error: cannot read directory ${opts.dir}: ${e.message}\n`);
941
+ return EXIT.IO;
942
+ }
943
+ if (entries.length === 0) {
944
+ writeErr(`error: ${opts.dir} contains no files to seal\n`);
945
+ return EXIT.FAIL;
946
+ }
947
+
948
+ // Decide which paid surfaces this invocation requests. Sealing more than the free sample requires
949
+ // `evidence_unlimited`; --sign requires `evidence_signed`. Both are gated OFFLINE before any work.
950
+ const requested = [];
951
+ if (opts.sign) {
952
+ requested.push({ entitlement: "evidence_signed", label: "the signed-attestation wrap (--sign)" });
953
+ }
954
+ if (entries.length > SAMPLE_LIMIT) {
955
+ requested.push({
956
+ entitlement: "evidence_unlimited",
957
+ label: `sealing more than the free sample size (${SAMPLE_LIMIT} files; this dir has ${entries.length})`,
958
+ });
959
+ }
960
+ const gate = gatePaid(opts, requested, now, writeErr);
961
+ if (!gate.ok) return gate.code;
962
+
963
+ // Build the bare seal over the GENERIC core. A build error (e.g. a duplicate path) is a 3, never a crash.
964
+ let seal;
965
+ try {
966
+ seal = buildSeal(entries);
967
+ } catch (e) {
968
+ writeErr(`error: cannot build evidence seal: ${e.message}\n`);
969
+ return EXIT.FAIL;
970
+ }
971
+
972
+ // Optionally WRAP in a signed attestation (the paid `evidence_signed` surface, already gated above).
973
+ // The key is read, used, and discarded inside loadSigningWallet — NEVER persisted or logged.
974
+ let artifactStr;
975
+ let signedBy = null;
976
+ if (opts.sign) {
977
+ let wallet;
978
+ try {
979
+ ({ wallet } = coreAttestation.loadSigningWallet({ keyEnv: opts.keyEnv, keyFile: opts.keyFile }));
980
+ } catch (e) {
981
+ writeErr(`error: ${e.message}\n`);
982
+ return EXIT.USAGE;
983
+ }
984
+ let container;
985
+ try {
986
+ container = await signSealWith(seal, wallet);
987
+ } catch (e) {
988
+ writeErr(`error: cannot sign evidence seal: ${e.message}\n`);
989
+ return EXIT.FAIL;
990
+ }
991
+ signedBy = coreAttestation.recoverSigner(container);
992
+ artifactStr = coreAttestation.serializeSignedAttestation(container, SIGNED_SEAL_CFG);
993
+ } else {
994
+ artifactStr = serializeSeal(seal);
995
+ }
996
+
997
+ // Write to --out (caller-chosen path; NEVER cwd) or print to stdout (writes nothing).
998
+ let outAbs = null;
999
+ if (opts.out) {
1000
+ outAbs = path.resolve(opts.out);
1001
+ try {
1002
+ fs.writeFileSync(outAbs, artifactStr);
1003
+ } catch (e) {
1004
+ writeErr(`error: cannot write --out file ${opts.out}: ${e.message}\n`);
1005
+ return EXIT.IO;
1006
+ }
1007
+ }
1008
+
1009
+ if (opts.json) {
1010
+ write(
1011
+ JSON.stringify(
1012
+ {
1013
+ ok: true,
1014
+ note: EVIDENCE_TRUST_NOTE,
1015
+ kind: signedBy ? SIGNED_SEAL_KIND : SEAL_KIND,
1016
+ root: seal.root,
1017
+ fileCount: seal.fileCount,
1018
+ signed: !!signedBy,
1019
+ signer: signedBy,
1020
+ out: outAbs,
1021
+ // With NO --out the artifact rides in `artifact` so --json never drops it (parity with the family).
1022
+ artifact: outAbs ? null : artifactStr,
1023
+ },
1024
+ null,
1025
+ 2
1026
+ ) + "\n"
1027
+ );
1028
+ } else {
1029
+ write(EVIDENCE_TRUST_NOTE + "\n\n");
1030
+ write(
1031
+ `sealed ${seal.fileCount} file${seal.fileCount === 1 ? "" : "s"} ` +
1032
+ `into ${signedBy ? "a SIGNED evidence packet" : "an evidence packet"} — root ${seal.root}\n`
1033
+ );
1034
+ if (signedBy) write(` signed by: ${signedBy}\n`);
1035
+ if (outAbs) {
1036
+ write(` written: ${outAbs}\n`);
1037
+ } else {
1038
+ // Default: print the seal bytes so a buyer can eyeball/redirect them — still writes nothing.
1039
+ write(artifactStr);
1040
+ }
1041
+ }
1042
+ return EXIT.OK;
1043
+ }
1044
+
1045
+ // ---------------------------------------------------------------------------
1046
+ // `vh evidence verify <p>` — read-only, NO key. RE-DERIVES the root from the bytes referenced and reports
1047
+ // OK / which file CHANGED/MISSING/UNEXPECTED. Files resolve relative to --dir (if given) else the packet
1048
+ // file's own directory (the packet stores relPaths relative to where its <dir> was sealed). Exit: 0 OK /
1049
+ // 3 REJECTED / 2 usage / 1 IO. Exactly the offline-recompute posture of `vh verify-seal`/`verify-proof`.
1050
+ // ---------------------------------------------------------------------------
1051
+
1052
+ function parseVerifyArgs(argv) {
1053
+ const opts = { packet: undefined, dir: undefined, json: false, _positionals: [] };
1054
+ for (let i = 0; i < argv.length; i++) {
1055
+ const a = argv[i];
1056
+ switch (a) {
1057
+ case "--dir": {
1058
+ const v = argv[++i];
1059
+ if (v === undefined) {
1060
+ const e = new Error("--dir requires a value");
1061
+ e.usage = true;
1062
+ throw e;
1063
+ }
1064
+ opts.dir = v;
1065
+ break;
1066
+ }
1067
+ case "--json":
1068
+ opts.json = true;
1069
+ break;
1070
+ default:
1071
+ if (a && a.startsWith("--")) {
1072
+ const e = new Error(`unknown flag: ${a}`);
1073
+ e.usage = true;
1074
+ throw e;
1075
+ }
1076
+ opts._positionals.push(a);
1077
+ }
1078
+ }
1079
+ if (opts._positionals.length > 1) {
1080
+ const e = new Error(
1081
+ `unexpected extra argument: ${opts._positionals[1]} (evidence verify takes exactly one <packet>)`
1082
+ );
1083
+ e.usage = true;
1084
+ throw e;
1085
+ }
1086
+ opts.packet = opts._positionals[0];
1087
+ return opts;
1088
+ }
1089
+
1090
+ // Render the human verify report. PURE.
1091
+ function renderVerify(result, ctx) {
1092
+ const L = [];
1093
+ L.push(EVIDENCE_TRUST_NOTE);
1094
+ L.push("");
1095
+ L.push(`# vh evidence verify — ${ctx.packet}`);
1096
+ L.push(`sealed root: ${result.sealedRoot}`);
1097
+ L.push(`recomputed root: ${result.recomputedRoot || "(none)"}`);
1098
+ L.push(`root matches: ${result.rootMatches ? "yes" : "NO"}`);
1099
+ L.push(
1100
+ `files: ${result.counts.matched} matched, ${result.counts.changed} changed, ` +
1101
+ `${result.counts.missing} missing, ${result.counts.unexpected} unexpected`
1102
+ );
1103
+ // SIGNATURE section — only for a SIGNED packet. `verify` re-derives the content root; it does NOT pin the
1104
+ // signer (that is `verify-signed --signer`). But it MUST NOT report a CLAIMED signer as if trusted: it
1105
+ // recovers the signer from the bytes + signature and either REJECTS a forged signature or labels a
1106
+ // genuine one UNVERIFIED-for-pinning, pointing at `verify-signed`. (T-47.2 — close the silent claim.)
1107
+ const sig = ctx.sig;
1108
+ if (sig) {
1109
+ L.push("");
1110
+ if (sig.signatureMatchesSigner) {
1111
+ L.push(`signature: UNVERIFIED — claimed signer ${sig.claimedSigner} is GENUINE (the signature`);
1112
+ L.push(" recovers to it), but this command does NOT pin the signer to anyone you trust.");
1113
+ L.push(` Run \`vh evidence verify-signed ${ctx.packet} --signer <0xaddr>\` to PIN the signer`);
1114
+ L.push(" (and --dir to bind the signature to YOUR bytes).");
1115
+ } else {
1116
+ L.push(`signature: FORGED — REJECTED. The container CLAIMS signer ${sig.claimedSigner} but the`);
1117
+ L.push(` signature actually recovers to ${sig.recoveredSigner}. The \`signer\` label is`);
1118
+ L.push(" UNBACKED. Run `vh evidence verify-signed` for the full per-check verdict.");
1119
+ }
1120
+ }
1121
+ L.push("");
1122
+ if (result.accepted && !(sig && !sig.signatureMatchesSigner)) {
1123
+ L.push("OK — every sealed file re-derives byte-for-byte and the root matches.");
1124
+ if (sig) {
1125
+ L.push(" (The content matches; the signature is GENUINE but UNVERIFIED-for-pinning — see above.)");
1126
+ }
1127
+ } else {
1128
+ L.push("REJECTED — the files do NOT match the packet:");
1129
+ for (const c of result.changed) {
1130
+ L.push(` CHANGED ${c.relPath}: sealed ${c.expectedContentHash} != on-disk ${c.actualContentHash}`);
1131
+ }
1132
+ for (const m of result.missing) {
1133
+ L.push(` MISSING ${m.relPath}: sealed but not found on disk`);
1134
+ }
1135
+ for (const u of result.unexpected) {
1136
+ L.push(` UNEXPECTED ${u.relPath}: on disk but not named in the packet`);
1137
+ }
1138
+ if (
1139
+ !result.rootMatches &&
1140
+ result.changed.length === 0 &&
1141
+ result.missing.length === 0 &&
1142
+ result.unexpected.length === 0
1143
+ ) {
1144
+ L.push(" ROOT the recomputed root does not equal the sealed root");
1145
+ }
1146
+ if (sig && !sig.signatureMatchesSigner) {
1147
+ L.push(" SIGNATURE the signature is FORGED (recovers to a different address than claimed)");
1148
+ }
1149
+ }
1150
+ L.push("");
1151
+ return L.join("\n");
1152
+ }
1153
+
1154
+ // Read a packet that may be a BARE seal OR a signed-seal container. Returns { seal, signed, container }.
1155
+ // For a signed container it returns the validated CONTAINER (so `verify` can run the signature check —
1156
+ // `validateSignedSeal` proves the bytes are CANONICAL but NOT that the signature recovers to the claimed
1157
+ // `signer`, so the recovery must happen at the call site, never here). It does NOT return a `signer` field:
1158
+ // the CLAIMED signer is not trustworthy until the signature is recovered (T-47.2 — close the silent claim).
1159
+ function readPacket(text) {
1160
+ let obj;
1161
+ try {
1162
+ obj = JSON.parse(text);
1163
+ } catch (e) {
1164
+ throw new packetseal.PacketSealError(`evidence packet is not valid JSON: ${e.message}`);
1165
+ }
1166
+ if (obj && obj.kind === SIGNED_SEAL_KIND) {
1167
+ validateSignedSeal(obj); // strict; rejects a tampered/foreign signed container (but NOT a forged sig)
1168
+ const seal = readSeal(obj.attestation); // the embedded canonical seal bytes
1169
+ return { seal, signed: true, container: obj };
1170
+ }
1171
+ return { seal: readSeal(obj), signed: false, container: null };
1172
+ }
1173
+
1174
+ function runEvidenceVerify(opts, io = {}) {
1175
+ const write = io.write || ((s) => process.stdout.write(s));
1176
+ const writeErr = io.writeErr || ((s) => process.stderr.write(s));
1177
+
1178
+ if (!opts.packet) {
1179
+ writeErr("error: `vh evidence verify` requires a <packet>\n");
1180
+ return EXIT.USAGE;
1181
+ }
1182
+
1183
+ // Load + STRICT-validate the packet BEFORE any referenced file is read — a malformed/missing packet
1184
+ // hard-errors (exit 1), never half-accepted nor treated as "everything changed".
1185
+ const packetPath = path.resolve(opts.packet);
1186
+ let text;
1187
+ try {
1188
+ text = fs.readFileSync(packetPath, "utf8");
1189
+ } catch (e) {
1190
+ writeErr(`error: cannot read evidence packet ${opts.packet}: ${e.message}\n`);
1191
+ return EXIT.IO;
1192
+ }
1193
+ let parsed;
1194
+ try {
1195
+ parsed = readPacket(text);
1196
+ } catch (e) {
1197
+ writeErr(`error: invalid evidence packet ${opts.packet}: ${e.message}\n`);
1198
+ return EXIT.IO;
1199
+ }
1200
+ const seal = parsed.seal;
1201
+
1202
+ // Resolve referenced files relative to --dir (if given) else the packet file's own directory. A file
1203
+ // the packet NAMES but that is absent must NOT abort — it is a MISSING finding verify localizes.
1204
+ const baseDir = opts.dir != null ? path.resolve(opts.dir) : path.dirname(packetPath);
1205
+ const entries = [];
1206
+ for (const f of seal.files) {
1207
+ const abs = path.resolve(baseDir, f.relPath);
1208
+ let bytes;
1209
+ try {
1210
+ bytes = fs.readFileSync(abs);
1211
+ } catch (_) {
1212
+ continue; // absent -> verifySeal reports MISSING
1213
+ }
1214
+ entries.push({ relPath: f.relPath, bytes });
1215
+ }
1216
+
1217
+ let result;
1218
+ try {
1219
+ result = verifySeal(seal, entries);
1220
+ } catch (e) {
1221
+ writeErr(`error: ${e.message}\n`);
1222
+ return EXIT.IO;
1223
+ }
1224
+
1225
+ // CLOSE THE SILENT CLAIM (T-47.2). For a SIGNED packet, `validateSignedSeal` proved the bytes are
1226
+ // canonical but NOT that the signature recovers to the CLAIMED `signer`. So we recover the signer here
1227
+ // (Check 1 of the verify-signed verdict, ALWAYS run, key-free/offline) and HONESTLY report it:
1228
+ // * a FORGED signature (recovers to a DIFFERENT address than claimed) is a clean REJECTED — never a
1229
+ // silent pass that reports the claimed signer as if trusted;
1230
+ // * a GENUINE signature is labelled UNVERIFIED-for-pinning (the signer is real but NOT pinned to anyone
1231
+ // the caller trusts) and points at `vh evidence verify-signed` for the full pin/bind verdict.
1232
+ // `verify` never PINS the signer (no --signer here) — pinning + binding is the `verify-signed` command.
1233
+ let sig = null;
1234
+ if (parsed.signed) {
1235
+ const sv = verifySignedSeal({ container: parsed.container }); // recovers signer; no pin, no binding
1236
+ sig = {
1237
+ signed: true,
1238
+ signatureMatchesSigner: sv.checks.signatureMatchesSigner,
1239
+ recoveredSigner: sv.recoveredSigner,
1240
+ claimedSigner: sv.claimedSigner,
1241
+ scheme: sv.scheme,
1242
+ };
1243
+ }
1244
+
1245
+ // A forged signature flips the overall verdict to REJECTED even when the content matches: the packet's
1246
+ // own `signer` label is unbacked, so the artifact as a whole must NOT report OK. Content failures still
1247
+ // reject as before; the two are independent and either alone is sufficient to REJECT.
1248
+ const accepted = result.accepted && !(sig && !sig.signatureMatchesSigner);
1249
+ const code = accepted ? EXIT.OK : EXIT.FAIL;
1250
+ if (opts.json) {
1251
+ write(
1252
+ JSON.stringify(
1253
+ {
1254
+ ...result,
1255
+ // Overall accepted/verdict accounts for BOTH content re-derivation AND (for a signed packet) the
1256
+ // signature-recovers-to-claimed-signer check. `contentVerdict`/`contentAccepted` preserve the
1257
+ // pure seal-content result a machine reader may still want separately.
1258
+ accepted,
1259
+ verdict: accepted ? "ACCEPTED" : "REJECTED",
1260
+ contentAccepted: result.accepted,
1261
+ contentVerdict: result.verdict,
1262
+ packet: opts.packet,
1263
+ dir: baseDir,
1264
+ signed: parsed.signed,
1265
+ // The recovered + claimed signer + whether the signature is GENUINE; null for an unsigned packet.
1266
+ // We NEVER expose a bare `signer` that conflates "claimed" with "trusted" (T-47.2).
1267
+ signature: sig
1268
+ ? {
1269
+ signatureMatchesSigner: sig.signatureMatchesSigner,
1270
+ recoveredSigner: sig.recoveredSigner,
1271
+ claimedSigner: sig.claimedSigner,
1272
+ scheme: sig.scheme,
1273
+ // The signer is GENUINE-but-UNVERIFIED-for-pinning here; verify-signed pins/binds it.
1274
+ pinned: false,
1275
+ hint: "run `vh evidence verify-signed <packet> --signer <addr> [--dir <d>]` to pin + bind",
1276
+ }
1277
+ : null,
1278
+ note: EVIDENCE_TRUST_NOTE,
1279
+ },
1280
+ null,
1281
+ 2
1282
+ ) + "\n"
1283
+ );
1284
+ } else {
1285
+ write(renderVerify(result, { packet: opts.packet, sig }));
1286
+ }
1287
+ return code;
1288
+ }
1289
+
1290
+ // ---------------------------------------------------------------------------
1291
+ // `vh evidence verify-signed <signed> [--dir <d>] [--signer <addr>] [--json]` — the OFFLINE, key-free,
1292
+ // network-free signed-verify CLI over the PURE `verifySignedSealAttestation` core (T-47.1). It is the
1293
+ // command that ACTUALLY CHECKS a signed packet's signature (the closing of the silent claim `vh evidence
1294
+ // verify` leaves open): it recovers the signer from the embedded canonical bytes + signature (Check 1,
1295
+ // ALWAYS), OPTIONALLY pins it to an expected `--signer` (Check 2), and OPTIONALLY binds it to the holder's
1296
+ // OWN `--dir` bytes (Check 3). Leads with the trust caveat; prints per-check PASS/FAIL/skip. The verdict is
1297
+ // ACCEPTED only when EVERY REQUESTED check passes; a forged/mismatched/tampered/wrong-key signature is a
1298
+ // clean REJECTED — NEVER a silent pass. Writes NOTHING (the --dir read is the only I/O). Exit: 0 ACCEPTED /
1299
+ // 3 REJECTED / 2 usage / 1 IO (mirrors `vh dataset verify-attest`).
1300
+ // ---------------------------------------------------------------------------
1301
+
1302
+ function parseVerifySignedArgs(argv) {
1303
+ const opts = {
1304
+ signed: undefined,
1305
+ dir: undefined,
1306
+ signer: undefined,
1307
+ revocations: undefined,
1308
+ asOf: undefined,
1309
+ json: false,
1310
+ _positionals: [],
1311
+ };
1312
+ for (let i = 0; i < argv.length; i++) {
1313
+ const a = argv[i];
1314
+ const need = (flag) => {
1315
+ const v = argv[++i];
1316
+ if (v === undefined) {
1317
+ const e = new Error(`${flag} requires a value`);
1318
+ e.usage = true;
1319
+ throw e;
1320
+ }
1321
+ return v;
1322
+ };
1323
+ switch (a) {
1324
+ case "--dir":
1325
+ opts.dir = need("--dir");
1326
+ break;
1327
+ case "--signer":
1328
+ opts.signer = need("--signer");
1329
+ break;
1330
+ case "--revocations":
1331
+ opts.revocations = need("--revocations");
1332
+ break;
1333
+ case "--as-of":
1334
+ opts.asOf = need("--as-of");
1335
+ break;
1336
+ case "--json":
1337
+ opts.json = true;
1338
+ break;
1339
+ default:
1340
+ if (a && a.startsWith("--")) {
1341
+ const e = new Error(`unknown flag: ${a}`);
1342
+ e.usage = true;
1343
+ throw e;
1344
+ }
1345
+ opts._positionals.push(a);
1346
+ }
1347
+ }
1348
+ if (opts._positionals.length > 1) {
1349
+ const e = new Error(
1350
+ `unexpected extra argument: ${opts._positionals[1]} (evidence verify-signed takes exactly one <signed>)`
1351
+ );
1352
+ e.usage = true;
1353
+ throw e;
1354
+ }
1355
+ opts.signed = opts._positionals[0];
1356
+ return opts;
1357
+ }
1358
+
1359
+ // Render the human verify-signed report. PURE. LEADS with the signing trust caveat (the SAME standing note
1360
+ // the dataset sibling leads with — reuses EVIDENCE_TRUST_NOTE verbatim so the caveats never drift), then the
1361
+ // verdict, the recovered/claimed/expected signer, and each requested check with PASS/FAIL (or [skip] when an
1362
+ // optional check was not requested). A REJECTED verdict NAMES which check(s) failed.
1363
+ function renderVerifySigned(r, ctx) {
1364
+ const L = [];
1365
+ // TRUST caveat FIRST: a valid signature proves WHO vouched, NOT a timestamp (P-3), NOT a legal opinion.
1366
+ L.push("TRUST: " + VERIFY_SIGNED_SEAL_TRUST_NOTE);
1367
+ L.push("");
1368
+ L.push(`# vh evidence verify-signed — ${ctx.signed}`);
1369
+ L.push(`verify-signed: ${r.verdict}`);
1370
+ L.push(`scheme: ${r.scheme}`);
1371
+ L.push(`recovered signer: ${r.recoveredSigner} (from the embedded canonical seal bytes + signature)`);
1372
+ L.push(`claimed signer: ${r.claimedSigner} (the container's \`signer\` field)`);
1373
+ // Check 1 (ALWAYS): the signature recovers to the claimed signer.
1374
+ L.push(
1375
+ ` [${r.checks.signatureMatchesSigner ? "PASS" : "FAIL"}] signature recovers to the claimed signer`
1376
+ );
1377
+ // Check 2 (only under --signer): the recovered signer equals the expected signer.
1378
+ if (r.checks.signerMatchesExpected === null) {
1379
+ L.push(" [skip] expected-signer pin: not requested (pass --signer <0xaddr> to pin the signer)");
1380
+ } else {
1381
+ L.push(
1382
+ ` [${r.checks.signerMatchesExpected ? "PASS" : "FAIL"}] recovered signer matches the expected ` +
1383
+ `signer (${r.expectedSigner})`
1384
+ );
1385
+ }
1386
+ // Check 3 (only under --dir): the signature binds the holder's OWN directory bytes.
1387
+ if (r.checks.manifestBindsAttestation === null) {
1388
+ L.push(
1389
+ " [skip] directory binding: not requested (pass --dir <d> to bind the signature to YOUR bytes)"
1390
+ );
1391
+ } else {
1392
+ L.push(
1393
+ ` [${r.checks.manifestBindsAttestation ? "PASS" : "FAIL"}] the signature binds YOUR directory ` +
1394
+ "(its canonical seal bytes are byte-identical to the signed payload)"
1395
+ );
1396
+ }
1397
+ if (r.accepted) {
1398
+ L.push("ACCEPTED: every requested check passed.");
1399
+ } else {
1400
+ L.push(`REJECTED: failed check(s): ${r.failedChecks.join(", ")}.`);
1401
+ if (r.failedChecks.includes("signatureMatchesSigner")) {
1402
+ L.push(
1403
+ " forged-signature: the signature does NOT recover to the claimed `signer` — the signer label is"
1404
+ );
1405
+ L.push(" UNBACKED (a forged/tampered/wrong-key signature), NOT a packet you can trust.");
1406
+ }
1407
+ if (r.failedChecks.includes("manifestBindsAttestation")) {
1408
+ L.push(
1409
+ " binding-mismatch: the signed payload does NOT match YOUR directory — the signature vouches for a"
1410
+ );
1411
+ L.push(" DIFFERENT file set than the one you hold.");
1412
+ }
1413
+ }
1414
+ L.push("");
1415
+ return L.join("\n");
1416
+ }
1417
+
1418
+ // Shared up-front shape validation for the OPTIONAL recipient-side trust-decision flags (--revocations /
1419
+ // --as-of, T-51.2). Returns null when fine, else a usage-error message. A malformed --as-of is a usage error
1420
+ // (never a runtime throw mid-verify); --as-of without --revocations is a usage error (it would silently do
1421
+ // nothing). Mirrors the trust-asof core's canonical-instant grammar.
1422
+ function validateAsOfFlags(opts) {
1423
+ if (opts.asOf !== undefined && !opts.revocations) {
1424
+ return "--as-of requires --revocations (it pins the instant the revocation decision is made AS OF)";
1425
+ }
1426
+ if (opts.asOf !== undefined) {
1427
+ const re = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/;
1428
+ const ms = Date.parse(opts.asOf);
1429
+ if (
1430
+ typeof opts.asOf !== "string" ||
1431
+ !re.test(opts.asOf) ||
1432
+ Number.isNaN(ms) ||
1433
+ new Date(ms).toISOString() !== opts.asOf
1434
+ ) {
1435
+ return `invalid --as-of: ${opts.asOf} (expected a canonical ISO-8601 UTC instant, e.g. 2026-06-01T00:00:00.000Z)`;
1436
+ }
1437
+ }
1438
+ return null;
1439
+ }
1440
+
1441
+ function runEvidenceVerifySigned(opts, io = {}) {
1442
+ const write = io.write || ((s) => process.stdout.write(s));
1443
+ const writeErr = io.writeErr || ((s) => process.stderr.write(s));
1444
+
1445
+ if (!opts.signed) {
1446
+ writeErr("error: `vh evidence verify-signed` requires a <signed> (signed evidence packet path)\n");
1447
+ return EXIT.USAGE;
1448
+ }
1449
+
1450
+ // Validate the --signer address SHAPE up front (when given) so a malformed expected signer is a usage
1451
+ // error (2), never a runtime throw mid-verify. PURELY OFFLINE — no network here either.
1452
+ if (opts.signer !== undefined && opts.signer !== null) {
1453
+ let isAddress;
1454
+ try {
1455
+ ({ isAddress } = require("ethers"));
1456
+ } catch (_) {
1457
+ isAddress = null;
1458
+ }
1459
+ if (isAddress && !isAddress(opts.signer)) {
1460
+ writeErr(
1461
+ `error: invalid --signer address: ${opts.signer} (expected a 20-byte 0x-hex address)\n`
1462
+ );
1463
+ return EXIT.USAGE;
1464
+ }
1465
+ }
1466
+
1467
+ // Validate the OPTIONAL trust-decision flags (--revocations/--as-of, T-51.2) SHAPE up front so a malformed
1468
+ // --as-of (or --as-of without --revocations) is a usage error (2), never a runtime throw mid-verify.
1469
+ {
1470
+ const asOfErr = validateAsOfFlags(opts);
1471
+ if (asOfErr) {
1472
+ writeErr(`error: ${asOfErr}\n`);
1473
+ return EXIT.USAGE;
1474
+ }
1475
+ }
1476
+
1477
+ // Read + STRICT-validate the signed container BEFORE any recovery — a malformed/edited/foreign container
1478
+ // (or a BARE unsigned seal handed here) hard-errors (exit 1), never half-accepted. A forged signature is
1479
+ // NOT a parse error: validateSignedSeal proves the bytes are canonical; the recovery (the verdict) runs
1480
+ // below in the PURE core.
1481
+ let container;
1482
+ try {
1483
+ const text = fs.readFileSync(path.resolve(opts.signed), "utf8");
1484
+ let obj;
1485
+ try {
1486
+ obj = JSON.parse(text);
1487
+ } catch (e) {
1488
+ throw new packetseal.PacketSealError(`signed evidence packet is not valid JSON: ${e.message}`);
1489
+ }
1490
+ if (!obj || obj.kind !== SIGNED_SEAL_KIND) {
1491
+ throw new packetseal.PacketSealError(
1492
+ `not a signed evidence packet (kind ${JSON.stringify(obj && obj.kind)}; expected ` +
1493
+ `${JSON.stringify(SIGNED_SEAL_KIND)}). \`verify-signed\` checks a SIGNED packet; for a bare seal ` +
1494
+ "use `vh evidence verify`."
1495
+ );
1496
+ }
1497
+ container = validateSignedSeal(obj); // strict; rejects a tampered/foreign signed container
1498
+ } catch (e) {
1499
+ writeErr(`error: cannot read signed evidence packet ${opts.signed}: ${e.message}\n`);
1500
+ return EXIT.IO;
1501
+ }
1502
+
1503
+ // Run the PURE, OFFLINE verify. The ONLY I/O is the optional --dir read (inside the core), and only when
1504
+ // binding is requested. An unreadable --dir is a genuine IO error (1), never a silently-skipped binding.
1505
+ let result;
1506
+ try {
1507
+ result = verifySignedSealAttestation({
1508
+ container,
1509
+ expectedSigner: opts.signer,
1510
+ dir: opts.dir,
1511
+ });
1512
+ } catch (e) {
1513
+ writeErr(`error: ${e.message}\n`);
1514
+ return EXIT.IO;
1515
+ }
1516
+
1517
+ // OPTIONAL recipient-side TRUST-DECISION-AS-OF (EPIC-51 / T-51.2). Runs ONLY under --revocations — with no
1518
+ // flag the result is byte-identical to the pre-EPIC baseline. A key revoked-before-as-of downgrades an
1519
+ // otherwise-ACCEPTED packet to REVOKED (exit 3); a later-dated revocation is informational; a forged one is
1520
+ // ignored with a warning. OFFLINE / key-free on the read side. The revocations file is the ONLY new I/O.
1521
+ let defaulted = false;
1522
+ if (opts.revocations) {
1523
+ try {
1524
+ const applied = coreTrustAsOf.loadAndApply({
1525
+ result,
1526
+ revocationsPath: opts.revocations,
1527
+ asOf: opts.asOf,
1528
+ nowISO: io.nowISO || new Date().toISOString(),
1529
+ readFile: (p) => fs.readFileSync(path.resolve(p), "utf8"),
1530
+ });
1531
+ result = applied.result;
1532
+ defaulted = applied.defaulted;
1533
+ } catch (e) {
1534
+ writeErr(`error: cannot evaluate --revocations ${opts.revocations}: ${e.message}\n`);
1535
+ return EXIT.IO;
1536
+ }
1537
+ }
1538
+
1539
+ if (opts.json) {
1540
+ write(
1541
+ JSON.stringify(
1542
+ {
1543
+ ...result,
1544
+ signed: opts.signed,
1545
+ dir: opts.dir != null ? path.resolve(opts.dir) : null,
1546
+ note: VERIFY_SIGNED_SEAL_TRUST_NOTE,
1547
+ },
1548
+ null,
1549
+ 2
1550
+ ) + "\n"
1551
+ );
1552
+ } else {
1553
+ let out = renderVerifySigned(result, { signed: opts.signed });
1554
+ if (result.trustAsOf) {
1555
+ out += coreTrustAsOf.renderTrustAsOf(result.trustAsOf, { defaulted }).join("\n") + "\n";
1556
+ }
1557
+ write(out);
1558
+ }
1559
+
1560
+ // Exit non-zero on REJECTED/REVOKED so a buyer's CI can gate (mirrors the family's 0 ACCEPTED / 3 not-OK).
1561
+ return result.accepted ? EXIT.OK : EXIT.FAIL;
1562
+ }
1563
+
1564
+ // ---------------------------------------------------------------------------
1565
+ // `vh evidence diff <packetA> <packetB> [--json]` — read-only, FREE, key-free, OFFLINE change report
1566
+ // between TWO already-sealed evidence packets. The CLI surface over the PURE `diffEvidenceSeals` core
1567
+ // (T-46.1). It re-derives NOTHING from bytes (there is no directory) — it compares what each packet
1568
+ // CLAIMS — and writes NOTHING (a diff produces no sealed artifact, so it needs NO license and never
1569
+ // gates). A is the BASELINE ("recorded"), B is the COMPARISON ("current"): ADDED = in B not A,
1570
+ // REMOVED = in A not B, CHANGED = same relPath/different content (old→new); a rename surfaces as
1571
+ // REMOVED+ADDED. The verdict (and exit code + headline) is the CHANGE SET (`identical`), NOT root-string
1572
+ // equality. Exit: 0 IDENTICAL / 3 DIFFERENT / 2 usage / 1 IO (mirrors `vh dataset diff`).
1573
+ // ---------------------------------------------------------------------------
1574
+
1575
+ function parseDiffArgs(argv) {
1576
+ const opts = {
1577
+ packetA: undefined,
1578
+ packetB: undefined,
1579
+ policy: undefined,
1580
+ json: false,
1581
+ _positionals: [],
1582
+ };
1583
+ for (let i = 0; i < argv.length; i++) {
1584
+ const a = argv[i];
1585
+ switch (a) {
1586
+ case "--json":
1587
+ opts.json = true;
1588
+ break;
1589
+ case "--policy": {
1590
+ const v = argv[++i];
1591
+ if (v === undefined || (typeof v === "string" && v.startsWith("--"))) {
1592
+ const e = new Error("--policy requires a <file> argument");
1593
+ e.usage = true;
1594
+ throw e;
1595
+ }
1596
+ opts.policy = v;
1597
+ break;
1598
+ }
1599
+ default:
1600
+ if (a && a.startsWith("--")) {
1601
+ const e = new Error(`unknown flag: ${a}`);
1602
+ e.usage = true;
1603
+ throw e;
1604
+ }
1605
+ opts._positionals.push(a);
1606
+ }
1607
+ }
1608
+ if (opts._positionals.length > 2) {
1609
+ const e = new Error(
1610
+ `unexpected extra argument: ${opts._positionals[2]} (evidence diff takes exactly two <packet>s)`
1611
+ );
1612
+ e.usage = true;
1613
+ throw e;
1614
+ }
1615
+ opts.packetA = opts._positionals[0];
1616
+ opts.packetB = opts._positionals[1];
1617
+ return opts;
1618
+ }
1619
+
1620
+ // Render the human diff report. PURE. LEADS with the CLAIMS-not-content TRUST line (a diff compares what
1621
+ // each packet CLAIMS — it does NOT re-derive content), prints a deterministic IDENTICAL/DIFFERENT
1622
+ // headline, the per-file ADDED/REMOVED/CHANGED block, and a count line driven by the change set. The
1623
+ // headline is driven by `result.identical` — the CHANGE SET, not root-string equality — so it can never
1624
+ // contradict the per-file body or the exit code. The two recorded roots are DISPLAYED metadata only.
1625
+ function renderDiff(result, ctx) {
1626
+ const L = [];
1627
+ // TRUST FIRST: a diff compares what each packet CLAIMS; it does not re-derive content (no directory).
1628
+ L.push(
1629
+ "TRUST: this compares what each evidence packet CLAIMS — it does NOT re-derive content (there is " +
1630
+ "no directory). " +
1631
+ EVIDENCE_TRUST_NOTE
1632
+ );
1633
+ L.push(" (run `vh evidence verify <packet> --dir <d>` against the live tree to re-derive a root from bytes).");
1634
+ L.push("");
1635
+ L.push(`# vh evidence diff — ${ctx.packetA} -> ${ctx.packetB}`);
1636
+ L.push(`packet A root: ${result.rootA}`);
1637
+ L.push(`packet B root: ${result.rootB}`);
1638
+ if (result.identical) {
1639
+ L.push(
1640
+ "files: IDENTICAL — the two packets commit to the SAME set of (relPath, content) pairs " +
1641
+ "(no ADDED / REMOVED / CHANGED)."
1642
+ );
1643
+ L.push(`+0 / -0 / ~0 / ${result.counts.unchanged} unchanged`);
1644
+ // In the evidence product readSeal RE-DERIVES the root over the leaves, so a structurally-valid pair
1645
+ // can NEVER reach here with mismatched roots but identical leaves — a tampered root is rejected
1646
+ // outright before the diff. The roots therefore always agree with the change set on this path; we
1647
+ // surface no "hand-edited root" note (unlike the dataset diff) because that state is unreachable.
1648
+ for (const line of _renderDriftSection(ctx.drift)) L.push(line);
1649
+ L.push("");
1650
+ return L.join("\n");
1651
+ }
1652
+ L.push(
1653
+ "files: DIFFERENT — the packets commit to different (relPath, content) sets. Per-file changes (A->B). " +
1654
+ "A rename surfaces as REMOVED(old path) + ADDED(new path) — the path is bound into the leaf — " +
1655
+ "NOT as two unrelated edits."
1656
+ );
1657
+ L.push(
1658
+ `+${result.counts.added} / -${result.counts.removed} / ~${result.counts.changed} / ` +
1659
+ `${result.counts.unchanged} unchanged`
1660
+ );
1661
+ for (const c of result.changed) {
1662
+ L.push(` CHANGED ${c.path}`);
1663
+ L.push(` old: ${c.oldContentHash}`);
1664
+ L.push(` new: ${c.newContentHash}`);
1665
+ }
1666
+ for (const a of result.added) {
1667
+ L.push(` ADDED ${a.path} (${a.contentHash}) in B, not in A`);
1668
+ }
1669
+ for (const rm of result.removed) {
1670
+ L.push(` REMOVED ${rm.path} (${rm.contentHash}) in A, not in B`);
1671
+ }
1672
+ for (const line of _renderDriftSection(ctx.drift)) L.push(line);
1673
+ L.push("");
1674
+ return L.join("\n");
1675
+ }
1676
+
1677
+ // Render the OPTIONAL drift-policy section (printed only when `--policy` was given). LEADS with the
1678
+ // SAME UNTRUSTED-change-set caveat the gate carries, states the PASS/FAIL verdict + rules evaluated, and
1679
+ // lists each violation (relPath, the rule it broke, and which change KIND triggered it). The verdict can
1680
+ // never disagree with the diff above: it is computed from the SAME change set (evaluateDriftPolicy reads
1681
+ // `diff.added/removed/changed` directly). Returns [] when no policy was evaluated.
1682
+ function _renderDriftSection(drift) {
1683
+ if (!drift) return [];
1684
+ const L = ["", "## drift policy"];
1685
+ L.push(
1686
+ " TRUST: a drift verdict gates the CHANGE SET above (what each packet CLAIMS) — it does NOT " +
1687
+ "re-derive content."
1688
+ );
1689
+ L.push(` verdict: ${drift.verdict} (rules evaluated: ${drift.rulesEvaluated})`);
1690
+ if (drift.rulesEvaluated === 0) {
1691
+ L.push(" This policy declares NO rules, so it trivially PASSes — any change satisfies it.");
1692
+ return L;
1693
+ }
1694
+ if (drift.verdict === DRIFT_VERDICT.PASS) {
1695
+ L.push(" PASS — every change between A and B is permitted by this policy.");
1696
+ return L;
1697
+ }
1698
+ L.push(` FAIL — ${drift.violations.length} disallowed change(s):`);
1699
+ for (const v of drift.violations) {
1700
+ L.push(` ${v.change.padEnd(7)} ${v.relPath} [${v.rule}]`);
1701
+ }
1702
+ return L;
1703
+ }
1704
+
1705
+ function runEvidenceDiff(opts, io = {}) {
1706
+ const write = io.write || ((s) => process.stdout.write(s));
1707
+ const writeErr = io.writeErr || ((s) => process.stderr.write(s));
1708
+
1709
+ if (!opts.packetA || !opts.packetB) {
1710
+ writeErr("error: `vh evidence diff` requires exactly two packet paths <packetA> <packetB>\n");
1711
+ return EXIT.USAGE;
1712
+ }
1713
+
1714
+ // Read BOTH packet files (the only I/O — a diff writes NOTHING). A missing/unreadable file is an IO
1715
+ // error (exit 1). We pass the raw bytes through the strict diff core, which re-validates structure +
1716
+ // root re-derivation and REJECTS a corrupt/foreign/wrong-kind/hand-edited packet before any diff.
1717
+ let textA;
1718
+ try {
1719
+ textA = fs.readFileSync(path.resolve(opts.packetA), "utf8");
1720
+ } catch (e) {
1721
+ writeErr(`error: cannot read evidence packet ${opts.packetA}: ${e.message}\n`);
1722
+ return EXIT.IO;
1723
+ }
1724
+ let textB;
1725
+ try {
1726
+ textB = fs.readFileSync(path.resolve(opts.packetB), "utf8");
1727
+ } catch (e) {
1728
+ writeErr(`error: cannot read evidence packet ${opts.packetB}: ${e.message}\n`);
1729
+ return EXIT.IO;
1730
+ }
1731
+
1732
+ let result;
1733
+ try {
1734
+ result = diffEvidenceSeals(textA, textB);
1735
+ } catch (e) {
1736
+ // A corrupt/foreign/wrong-kind/hand-edited packet (PacketSealError from readSeal) is a runtime/IO
1737
+ // error (exit 1), never a half-accepted diff — exactly like `vh dataset diff`'s corrupt-manifest path.
1738
+ writeErr(`error: ${e.message}\n`);
1739
+ return EXIT.IO;
1740
+ }
1741
+
1742
+ // OPTIONAL drift gate: when `--policy <f>` was given, read it strictly (a corrupt/foreign policy is an
1743
+ // IO error, never half-accepted) and evaluate the SAME change set against it. The policy verdict is
1744
+ // computed from `result` directly (no re-diff), so it can never disagree with the printed/JSON diff.
1745
+ let drift = null;
1746
+ if (opts.policy) {
1747
+ let policy;
1748
+ try {
1749
+ policy = readDriftPolicy(path.resolve(opts.policy));
1750
+ } catch (e) {
1751
+ writeErr(`error: ${e.message}\n`);
1752
+ return EXIT.IO;
1753
+ }
1754
+ drift = evaluateDriftPolicy({ diff: result, policy });
1755
+ }
1756
+
1757
+ if (opts.json) {
1758
+ write(
1759
+ JSON.stringify(
1760
+ {
1761
+ identical: result.identical,
1762
+ rootA: result.rootA,
1763
+ rootB: result.rootB,
1764
+ rootsIdentical: result.rootsIdentical,
1765
+ added: result.added,
1766
+ removed: result.removed,
1767
+ changed: result.changed,
1768
+ unchanged: result.unchanged,
1769
+ counts: result.counts,
1770
+ packetA: opts.packetA,
1771
+ packetB: opts.packetB,
1772
+ // The drift verdict (only when --policy was given) rides alongside the change set, so a CI
1773
+ // consumer reads the verdict, the rule count, and the exact violations from the SAME object.
1774
+ drift: drift
1775
+ ? {
1776
+ verdict: drift.verdict,
1777
+ rulesEvaluated: drift.rulesEvaluated,
1778
+ violations: drift.violations,
1779
+ }
1780
+ : null,
1781
+ note: DRIFT_TRUST_NOTE,
1782
+ },
1783
+ null,
1784
+ 2
1785
+ ) + "\n"
1786
+ );
1787
+ } else {
1788
+ write(renderDiff(result, { packetA: opts.packetA, packetB: opts.packetB, drift }));
1789
+ }
1790
+
1791
+ // EXIT CODE. Without --policy: exit non-zero when the packets DIFFER (mirrors the family's
1792
+ // MISMATCH/DIFFERENT). WITH --policy: the gate is the POLICY verdict — a buyer who passes a drift policy
1793
+ // is asking "is this change ALLOWED?", so a DIFFERENT-but-PERMITTED change is a PASS (exit 0) and a
1794
+ // disallowed change is a FAIL (exit 3). Either way the verdict is derived from the SAME change set, so
1795
+ // the exit code can never disagree with the printed/JSON body.
1796
+ if (drift) {
1797
+ return drift.verdict === DRIFT_VERDICT.PASS ? EXIT.OK : EXIT.FAIL;
1798
+ }
1799
+ return result.identical ? EXIT.OK : EXIT.FAIL;
1800
+ }
1801
+
1802
+ // ---------------------------------------------------------------------------
1803
+ // `vh evidence license fulfill --plan <id> --customer <name> [--paid-through <ISO>]
1804
+ // [--catalog <file>] (--key-env <VAR> | --key-file <path>) [--issued <ISO>]
1805
+ // [--license-id <id>] [--out <file>] [--json]` (T-48.2).
1806
+ //
1807
+ // The self-serve EVIDENCE fulfillment seam — the evidence-vertical MIRROR of
1808
+ // `vh trust license fulfill`. Given the planId a customer bought (+ their name, and
1809
+ // when the period is paid through), it resolves the plan against the bundled-or-
1810
+ // `--catalog` VALIDATED evidence plan catalog, copies that plan's entitlements
1811
+ // VERBATIM (never hand-typed here, so a typo can never mis-entitle a sale), derives
1812
+ // the [issuedAt, expiresAt] window, and mints the SAME signed `*.vhevidence-license.json`
1813
+ // the existing `verifyLicense` gate already accepts — so an evidence sale is ONE
1814
+ // command per billing webhook, not a human hand-crafting a license at a terminal.
1815
+ //
1816
+ // The catalog is the BUNDLED DRAFT by default (the seller's reviewed price-list,
1817
+ // shipped as a skeleton the human prices — the loop sets NO price), or an explicit
1818
+ // `--catalog <file>`. The key is read the EXACT read-used-discarded way `vh evidence
1819
+ // seal --sign` reads it (coreAttestation.loadSigningWallet: EXACTLY ONE of
1820
+ // --key-env/--key-file; the loop NEVER holds the key, NEVER echoes it). Exit mirrors
1821
+ // the family: 0 ok / 3 gate-fail / 2 usage / 1 IO.
1822
+ // ---------------------------------------------------------------------------
1823
+
1824
+ // The bundled DRAFT evidence plan catalog `fulfill` resolves a plan against when no
1825
+ // --catalog is given. Read from THIS package's own fixtures dir — never a caller
1826
+ // path — so the default resolution is deterministic and self-contained.
1827
+ const BUNDLED_EVIDENCE_CATALOG = path.join(
1828
+ __dirname,
1829
+ "core",
1830
+ "fixtures",
1831
+ "evidence-plans",
1832
+ "baseline.json"
1833
+ );
1834
+
1835
+ // Real "now" as a canonical ISO-8601 UTC instant — the fulfill default clock,
1836
+ // isolated + injectable (io.nowISO) so the command stays deterministic under test.
1837
+ function nowISO() {
1838
+ return new Date().toISOString();
1839
+ }
1840
+
1841
+ // Parse `license fulfill` argv. EXACTLY-ONE-of key sources is enforced downstream by
1842
+ // loadSigningWallet (so neither/both error key-free); the parser only collects flags.
1843
+ function parseLicenseFulfillArgs(argv) {
1844
+ const opts = {
1845
+ plan: undefined, // a planId in the catalog
1846
+ customer: undefined,
1847
+ paidThrough: undefined, // OPTIONAL ISO instant; default = issuedAt + plan term
1848
+ issued: undefined, // OPTIONAL ISO instant; default "now" supplied by the command
1849
+ licenseId: undefined, // OPTIONAL; defaulted deterministically by fulfillEvidenceOrder
1850
+ catalog: undefined, // OPTIONAL path to a plan catalog JSON; default = bundled baseline
1851
+ keyEnv: undefined,
1852
+ keyFile: undefined,
1853
+ out: undefined,
1854
+ json: false,
1855
+ };
1856
+ for (let i = 0; i < argv.length; i++) {
1857
+ const a = argv[i];
1858
+ const need = () => {
1859
+ const v = argv[++i];
1860
+ if (v === undefined || String(v).startsWith("--")) {
1861
+ const e = new Error(`${a} requires a value`);
1862
+ e.usage = true;
1863
+ throw e;
1864
+ }
1865
+ return v;
1866
+ };
1867
+ switch (a) {
1868
+ case "--plan": opts.plan = need(); break;
1869
+ case "--customer": opts.customer = need(); break;
1870
+ case "--paid-through": opts.paidThrough = need(); break;
1871
+ case "--issued": opts.issued = need(); break;
1872
+ case "--license-id": opts.licenseId = need(); break;
1873
+ case "--catalog": opts.catalog = need(); break;
1874
+ case "--key-env": opts.keyEnv = need(); break;
1875
+ case "--key-file": opts.keyFile = need(); break;
1876
+ case "--out": opts.out = need(); break;
1877
+ case "--json": opts.json = true; break;
1878
+ default: {
1879
+ const e = new Error(`unknown flag: ${a}`);
1880
+ e.usage = true;
1881
+ throw e;
1882
+ }
1883
+ }
1884
+ }
1885
+ return opts;
1886
+ }
1887
+
1888
+ async function runEvidenceLicenseFulfill(opts, io = {}) {
1889
+ const write = io.write || ((s) => process.stdout.write(s));
1890
+ const writeErr = io.writeErr || ((s) => process.stderr.write(s));
1891
+
1892
+ // Required order fields (the key sources are validated by loadSigningWallet; the
1893
+ // plan is resolved against the catalog by fulfillEvidenceOrder).
1894
+ for (const [flag, val] of [
1895
+ ["--plan", opts.plan],
1896
+ ["--customer", opts.customer],
1897
+ ]) {
1898
+ if (val == null) {
1899
+ writeErr(`error: \`vh evidence license fulfill\` requires ${flag}\n`);
1900
+ return EXIT.USAGE;
1901
+ }
1902
+ }
1903
+
1904
+ // The order -> license-params catalog core. Required lazily (NOT at module load) to
1905
+ // avoid the require cycle: cli/core/evidence-plans.js requires THIS module's
1906
+ // LICENSE_CFG at its own module-eval time.
1907
+ const evidencePlans = require("./core/evidence-plans");
1908
+
1909
+ // Load + strictly validate the plan catalog (bundled DRAFT baseline by default). A
1910
+ // malformed/unreadable catalog is a usage error (a bad data file, not an IO crash).
1911
+ const catalogPath =
1912
+ opts.catalog != null ? path.resolve(opts.catalog) : BUNDLED_EVIDENCE_CATALOG;
1913
+ let catalog;
1914
+ try {
1915
+ const text = fs.readFileSync(catalogPath, "utf8");
1916
+ catalog = evidencePlans.validateEvidencePlanCatalog(JSON.parse(text));
1917
+ } catch (e) {
1918
+ writeErr(`error: cannot load evidence plan catalog ${catalogPath}: ${e.message}\n`);
1919
+ return EXIT.USAGE;
1920
+ }
1921
+
1922
+ // Resolve the HUMAN-supplied key into an in-process Wallet FIRST, BEFORE building
1923
+ // anything — neither/both sources, a missing env var, an unreadable file, or a
1924
+ // malformed/zero key hard-errors here with a KEY-FREE message (the SAME core +
1925
+ // posture as `vh evidence seal --sign`). The loop never holds a key.
1926
+ let wallet;
1927
+ try {
1928
+ ({ wallet } = coreAttestation.loadSigningWallet({
1929
+ keyEnv: opts.keyEnv,
1930
+ keyFile: opts.keyFile,
1931
+ }));
1932
+ } catch (e) {
1933
+ writeErr(`error: ${e.message}\n`);
1934
+ return EXIT.USAGE;
1935
+ }
1936
+
1937
+ // issuedAt defaults to the injectable clock (a real ISO instant at runtime; a pinned
1938
+ // one in tests). The order -> license-params mapping is PURE + deterministic.
1939
+ const issuedAt = opts.issued != null ? opts.issued : (io.nowISO || nowISO)();
1940
+ let params;
1941
+ try {
1942
+ params = evidencePlans.fulfillEvidenceOrder(
1943
+ {
1944
+ plan: opts.plan,
1945
+ customer: opts.customer,
1946
+ issuedAt,
1947
+ paidThrough: opts.paidThrough != null ? opts.paidThrough : undefined,
1948
+ licenseId: opts.licenseId != null && opts.licenseId !== "" ? opts.licenseId : undefined,
1949
+ },
1950
+ catalog
1951
+ );
1952
+ } catch (e) {
1953
+ // An unknown plan / paidThrough<=issuedAt / malformed date is a usage error —
1954
+ // NEVER echo the key (a mapping error carries only the bad order field).
1955
+ writeErr(`error: ${e.message}\n`);
1956
+ return EXIT.USAGE;
1957
+ }
1958
+
1959
+ // Sign the derived params into the SAME signed container `vh evidence seal --sign`'s
1960
+ // gate accepts — the existing verifyLicense gate accepts it byte-for-byte. No key
1961
+ // handling here; the key lives only inside `wallet`.
1962
+ let container;
1963
+ try {
1964
+ container = await buildLicense(params, wallet);
1965
+ } catch (e) {
1966
+ writeErr(`error: ${e.message}\n`);
1967
+ return EXIT.USAGE;
1968
+ }
1969
+
1970
+ const canonical = serializeSignedLicense(container);
1971
+ // The PUBLIC vendor address — recovered from the signature, never the key.
1972
+ const vendor = coreAttestation.recoverSigner(container);
1973
+ const payload = JSON.parse(container.attestation);
1974
+
1975
+ let outAbs = null;
1976
+ if (opts.out) {
1977
+ outAbs = path.resolve(opts.out);
1978
+ try {
1979
+ fs.writeFileSync(outAbs, canonical);
1980
+ } catch (e) {
1981
+ writeErr(`error: cannot write --out license file ${opts.out}: ${e.message}\n`);
1982
+ return EXIT.IO;
1983
+ }
1984
+ }
1985
+
1986
+ if (opts.json) {
1987
+ // ONLY public fields: vendor ADDRESS, the license summary, the path — NEVER the
1988
+ // key. With no --out the canonical bytes ride in `container` (artifact parity).
1989
+ write(
1990
+ JSON.stringify(
1991
+ {
1992
+ fulfilled: true,
1993
+ vendor,
1994
+ licenseId: payload.licenseId,
1995
+ customer: payload.customer,
1996
+ plan: payload.plan,
1997
+ entitlements: payload.entitlements,
1998
+ issuedAt: payload.issuedAt,
1999
+ expiresAt: payload.expiresAt,
2000
+ out: outAbs,
2001
+ container: outAbs ? null : canonical,
2002
+ },
2003
+ null,
2004
+ 2
2005
+ ) + "\n"
2006
+ );
2007
+ } else {
2008
+ write(`fulfilled evidence license for plan ${payload.plan} by vendor ${vendor}\n`);
2009
+ write(` licenseId: ${payload.licenseId}\n`);
2010
+ write(` customer: ${payload.customer}\n`);
2011
+ write(` plan: ${payload.plan}\n`);
2012
+ write(` entitlements: ${payload.entitlements.join(", ")}\n`);
2013
+ write(` issuedAt: ${payload.issuedAt}\n`);
2014
+ write(` expiresAt: ${payload.expiresAt}\n`);
2015
+ if (outAbs) {
2016
+ write(` written: ${outAbs}\n`);
2017
+ } else {
2018
+ // No --out: emit the canonical signed bytes after the human header.
2019
+ write(canonical);
2020
+ }
2021
+ }
2022
+ return EXIT.OK;
2023
+ }
2024
+
2025
+ // `vh evidence license <fulfill> ...` dispatcher. Mirrors `vh trust license` — a thin
2026
+ // sub-dispatch so a future `issue`/`verify` can slot in without touching cmdEvidence.
2027
+ async function cmdEvidenceLicense(argv, io = {}) {
2028
+ const writeErr = io.writeErr || ((s) => process.stderr.write(s));
2029
+ const [sub, ...rest] = argv;
2030
+ if (sub === "fulfill") {
2031
+ let opts;
2032
+ try {
2033
+ opts = parseLicenseFulfillArgs(rest);
2034
+ } catch (e) {
2035
+ writeErr(`error: ${e.message}\n`);
2036
+ return EXIT.USAGE;
2037
+ }
2038
+ return runEvidenceLicenseFulfill(opts, io);
2039
+ }
2040
+ if (sub === undefined || sub === "-h" || sub === "--help" || sub === "help") {
2041
+ const usageStr =
2042
+ "vh evidence license — mint the signed evidence license the paid surfaces accept\n\n" +
2043
+ "Usage:\n" +
2044
+ " vh evidence license fulfill --plan <id> --customer <name> [--paid-through <ISO>]\n" +
2045
+ " [--catalog <file>] (--key-env <VAR> | --key-file <path>) [--issued <ISO>]\n" +
2046
+ " [--license-id <id>] [--out <file>] [--json]\n\n" +
2047
+ "fulfill resolves <id> in the bundled DRAFT evidence plan catalog (or --catalog), copies\n" +
2048
+ "that plan's entitlements VERBATIM, derives the [issuedAt, expiresAt] window (--paid-through\n" +
2049
+ "wins, else issuedAt + the plan's term), and mints the signed *.vhevidence-license.json the\n" +
2050
+ "existing `verifyLicense` gate accepts (it UNLOCKS `vh evidence seal --sign`). The key is read\n" +
2051
+ "read-used-discarded (EXACTLY ONE of --key-env/--key-file); the loop sets NO price.\n" +
2052
+ "Exit: 0 ok / 3 gate-fail / 2 usage / 1 IO.\n";
2053
+ io.write ? io.write(usageStr) : process.stdout.write(usageStr);
2054
+ return sub === undefined ? EXIT.USAGE : EXIT.OK;
2055
+ }
2056
+ writeErr(`error: unknown evidence license subcommand: ${sub} (expected: fulfill)\n`);
2057
+ return EXIT.USAGE;
2058
+ }
2059
+
2060
+ // ---------------------------------------------------------------------------
2061
+ // CLI dispatch: `vh evidence <seal|verify|verify-signed|diff|license> ...`.
2062
+ // ---------------------------------------------------------------------------
2063
+
2064
+ async function cmdEvidence(argv, io = {}) {
2065
+ const writeErr = io.writeErr || ((s) => process.stderr.write(s));
2066
+ const [sub, ...rest] = argv;
2067
+ if (sub === "seal") {
2068
+ let opts;
2069
+ try {
2070
+ opts = parseSealArgs(rest);
2071
+ } catch (e) {
2072
+ writeErr(`error: ${e.message}\n`);
2073
+ return EXIT.USAGE;
2074
+ }
2075
+ return runEvidenceSeal(opts, io);
2076
+ }
2077
+ if (sub === "verify") {
2078
+ let opts;
2079
+ try {
2080
+ opts = parseVerifyArgs(rest);
2081
+ } catch (e) {
2082
+ writeErr(`error: ${e.message}\n`);
2083
+ return EXIT.USAGE;
2084
+ }
2085
+ return runEvidenceVerify(opts, io);
2086
+ }
2087
+ if (sub === "verify-signed") {
2088
+ let opts;
2089
+ try {
2090
+ opts = parseVerifySignedArgs(rest);
2091
+ } catch (e) {
2092
+ writeErr(`error: ${e.message}\n`);
2093
+ return EXIT.USAGE;
2094
+ }
2095
+ return runEvidenceVerifySigned(opts, io);
2096
+ }
2097
+ if (sub === "diff") {
2098
+ let opts;
2099
+ try {
2100
+ opts = parseDiffArgs(rest);
2101
+ } catch (e) {
2102
+ writeErr(`error: ${e.message}\n`);
2103
+ return EXIT.USAGE;
2104
+ }
2105
+ return runEvidenceDiff(opts, io);
2106
+ }
2107
+ if (sub === "license") {
2108
+ return cmdEvidenceLicense(rest, io);
2109
+ }
2110
+ if (sub === "go-live-preflight") {
2111
+ // Lazily required to avoid a require cycle (go-live-preflight requires THIS module's exports).
2112
+ return require("./core/go-live-preflight").cmdGoLivePreflight(rest, io);
2113
+ }
2114
+ if (sub === undefined || sub === "-h" || sub === "--help" || sub === "help") {
2115
+ io.write
2116
+ ? io.write(evidenceUsage())
2117
+ : process.stdout.write(evidenceUsage());
2118
+ return sub === undefined ? EXIT.USAGE : EXIT.OK;
2119
+ }
2120
+ writeErr(
2121
+ `error: unknown evidence subcommand: ${sub} (expected: seal, verify, verify-signed, diff, license, go-live-preflight)\n`
2122
+ );
2123
+ return EXIT.USAGE;
2124
+ }
2125
+
2126
+ function evidenceUsage() {
2127
+ return [
2128
+ "vh evidence — product-agnostic, license-gated, tamper-evident evidence packets",
2129
+ "",
2130
+ "Usage:",
2131
+ " vh evidence seal <dir> [--out <p>] [--license <f> --vendor <0xaddr>] [--sign] [--json]",
2132
+ " vh evidence verify <p> [--dir <d>] [--json]",
2133
+ " vh evidence verify-signed <signed> [--dir <d>] [--signer <0xaddr>] [--revocations <f> --as-of <ISO>] [--json]",
2134
+ " vh evidence diff <packetA> <packetB> [--policy <f>] [--json]",
2135
+ " vh evidence license fulfill --plan <id> --customer <name> [--paid-through <ISO>] [--catalog <f>] (--key-env <VAR>|--key-file <p>) [--issued <ISO>] [--license-id <id>] [--out <f>] [--json]",
2136
+ " vh evidence go-live-preflight --binding <f> [--catalog <f>] [--secret-env <VAR>] (--key-env <VAR>|--key-file <p>) [--json]",
2137
+ "",
2138
+ "The seal proves TAMPER-EVIDENCE + OFFLINE-RECOMPUTE, NOT a trusted timestamp (\"sealed at T\" rides P-3).",
2139
+ "FREE: an unsigned baseline seal of up to " + SAMPLE_LIMIT + " files + verify + verify-signed + diff (try before buying).",
2140
+ "PAID (require --license + --vendor): --sign (signed-attestation wrap) and sealing > " + SAMPLE_LIMIT + " files.",
2141
+ "verify-signed is OFFLINE/key-free/network-free: it RECOVERS the signer + (--signer) pins it + (--dir) binds the bytes",
2142
+ " + (--revocations) checks the signer was not REVOKED as of --as-of (default now).",
2143
+ " A forged/tampered/wrong-key signature, or a key revoked-before-as-of, is a clean REJECTED/REVOKED — never a silent pass.",
2144
+ " Exit 0 ACCEPTED / 3 REJECTED|REVOKED / 2 usage / 1 IO.",
2145
+ "verify on a SIGNED packet no longer trusts the claimed signer: it REJECTS a forged signature OR labels a genuine one",
2146
+ " UNVERIFIED-for-pinning and points at `verify-signed`.",
2147
+ "diff is read-only/FREE/key-free/OFFLINE: it compares what TWO packets CLAIM and writes nothing.",
2148
+ " With --policy <f> it GATES the change set (noAdded/noRemoved/noChanged/allowChangePaths/frozenPaths):",
2149
+ " exit is then the policy verdict — a DIFFERENT-but-PERMITTED change PASSes (0), a disallowed change FAILs (3).",
2150
+ "Exit: diff 0 IDENTICAL (or policy PASS) / 3 DIFFERENT (or policy FAIL) / 2 usage / 1 IO.",
2151
+ "license fulfill MINTS the signed evidence license the paid surfaces accept: it resolves <id> in the bundled DRAFT catalog",
2152
+ " (or --catalog), copies that plan's entitlements VERBATIM, derives the window (--paid-through wins else the plan's term),",
2153
+ " and signs with a HUMAN-provisioned key (EXACTLY ONE of --key-env/--key-file, read-used-discarded; the loop sets NO price).",
2154
+ " The minted license UNLOCKS `vh evidence seal --sign`. Exit: 0 ok / 3 gate-fail / 2 usage / 1 IO.",
2155
+ "go-live-preflight VALIDATES the operator's OWN --binding + --catalog + vendor key end-to-end OFFLINE so a config typo",
2156
+ " cannot silently cause 'customer PAID, no license delivered': for every price it RESOLVES the plan (an unmapped/duplicate/",
2157
+ " typo'd price is NAMED, never a silent default), MINTS a signed license, and confirms it PASSES the paid `vh evidence seal",
2158
+ " --sign` gate (a plan lacking `evidence_signed` is caught, never PASS). With --secret-env it exercises your REAL webhook",
2159
+ " secret (fail-closed). No network, no deploy; a throwaway workspace is removed on exit. Exit: 0 all-deliver / 2 config / 3 a price would not deliver.",
2160
+ "",
2161
+ ].join("\n");
2162
+ }
2163
+
2164
+ module.exports = {
2165
+ EXIT,
2166
+ SAMPLE_LIMIT,
2167
+ // seal product
2168
+ SEAL_KIND,
2169
+ SEAL_SCHEMA_VERSION,
2170
+ EVIDENCE_TRUST_NOTE,
2171
+ SEAL_CFG,
2172
+ buildSeal,
2173
+ validateSeal,
2174
+ serializeSeal,
2175
+ readSeal,
2176
+ verifySeal,
2177
+ diffEvidence,
2178
+ diffEvidenceSeals,
2179
+ // drift policy (the CI-gateable verdict over the change set)
2180
+ DRIFT_POLICY_KIND,
2181
+ DRIFT_POLICY_SCHEMA_VERSION,
2182
+ DRIFT_RULE,
2183
+ DRIFT_VERDICT,
2184
+ DRIFT_TRUST_NOTE,
2185
+ validateDriftPolicy,
2186
+ readDriftPolicy,
2187
+ evaluateDriftPolicy,
2188
+ loadDirEntries,
2189
+ // signed wrap
2190
+ SIGNED_SEAL_KIND,
2191
+ SIGNED_SEAL_CFG,
2192
+ signSealWith,
2193
+ validateSignedSeal,
2194
+ verifySignedSeal,
2195
+ verifySignedSealAttestation,
2196
+ VERIFY_SIGNED_SEAL_TRUST_NOTE,
2197
+ // license product
2198
+ LICENSE_KIND,
2199
+ LICENSE_CFG,
2200
+ ENTITLEMENTS,
2201
+ EvidenceLicenseError,
2202
+ buildLicense,
2203
+ readLicense,
2204
+ verifyLicense,
2205
+ hasEntitlement,
2206
+ serializeSignedLicense,
2207
+ // license fulfillment
2208
+ BUNDLED_EVIDENCE_CATALOG,
2209
+ nowISO,
2210
+ parseLicenseFulfillArgs,
2211
+ runEvidenceLicenseFulfill,
2212
+ cmdEvidenceLicense,
2213
+ // CLI
2214
+ parseSealArgs,
2215
+ parseVerifyArgs,
2216
+ parseVerifySignedArgs,
2217
+ parseDiffArgs,
2218
+ runEvidenceSeal,
2219
+ runEvidenceVerify,
2220
+ runEvidenceVerifySigned,
2221
+ runEvidenceDiff,
2222
+ renderVerify,
2223
+ renderVerifySigned,
2224
+ renderDiff,
2225
+ cmdEvidence,
2226
+ evidenceUsage,
2227
+ };