55 * Usage: npm run vsc -- verify-bundle <bundle-folder>
66 * or: node scripts/verifyEvidenceBundle.js <bundle-folder>
77 *
8- * Verifies an exported VSC evidence bundle is complete, internally consistent,
9- * and unchanged according to its manifest and checksums.
8+ * Read-only verification of an exported VSC evidence bundle.
9+ * Checks that the bundle is complete, internally consistent, and that every
10+ * file matches the checksum binding recorded at export time.
11+ *
12+ * This script never writes to the source bundle — manifest integrity is
13+ * evaluated by reading, not by recomputing and overwriting. Fail-closed
14+ * behavior: any unresolvable inconsistency exits non-zero.
1015 */
1116
1217import fs from "fs" ;
@@ -19,11 +24,20 @@ const VSC_VERSION = "v1.16";
1924
2025// ── Helpers ────────────────────────────────────────────────────────────────────
2126
27+ /**
28+ * Computes the SHA-256 digest of a file as hex.
29+ * Used to re-derive the hash at verification time; the result is compared
30+ * against the checksum binding recorded in checksums.sha256 at export time.
31+ */
2232function sha256File ( filePath ) {
2333 const data = fs . readFileSync ( filePath ) ;
2434 return crypto . createHash ( "sha256" ) . update ( data ) . digest ( "hex" ) ;
2535}
2636
37+ /**
38+ * Parses a JSON file, returning null on any failure.
39+ * Callers treat null as a structural error — fail-closed behavior.
40+ */
2741function readJson ( filePath ) {
2842 try {
2943 return JSON . parse ( fs . readFileSync ( filePath , "utf8" ) ) ;
@@ -38,13 +52,27 @@ function printResult(label, passed, details = "") {
3852 console . log ( ` ${ icon } ${ label } : ${ status } ${ details ? " " + details : "" } ` ) ;
3953}
4054
55+ /**
56+ * Terminates with a non-zero exit code.
57+ * Called when a verification step detects an unrecoverable inconsistency.
58+ * Ensures fail-closed behavior: the caller can never silently continue.
59+ */
4160function fail ( message , exitCode = 1 ) {
4261 console . error ( `\n✗ ${ message } ` ) ;
4362 process . exit ( exitCode ) ;
4463}
4564
4665// ── Checksum Parser ────────────────────────────────────────────────────────────
4766
67+ /**
68+ * Parses a checksums.sha256 file into an array of { hash, filePath } entries.
69+ *
70+ * The checksum file is the primary checksum binding artifact: it records the
71+ * expected SHA-256 digest of every file at the moment the bundle was exported.
72+ * Verifying against it is what makes the bundle tamper-evident.
73+ *
74+ * Accepts both single-space and double-space separators (shasum compatibility).
75+ */
4876function parseChecksums ( checksumsPath ) {
4977 const content = fs . readFileSync ( checksumsPath , "utf8" ) ;
5078 const lines = content . split ( "\n" ) . filter ( line => line . trim ( ) ) ;
@@ -66,6 +94,17 @@ function parseChecksums(checksumsPath) {
6694
6795// ── Verification Functions ─────────────────────────────────────────────────────
6896
97+ /**
98+ * Verifies that all structurally required files are present in the bundle.
99+ *
100+ * The required-file list is the minimal evidence boundary: a bundle missing
101+ * any of these files cannot be fully verified and must be rejected.
102+ * JSON event bundles carry additional required files beyond the generic set.
103+ *
104+ * @param {string } bundleDir - Absolute path to the bundle directory.
105+ * @param {boolean } isJsonEventBundle - Whether to enforce JSON event file requirements.
106+ * @returns {{ passed: boolean, missing: string[] } }
107+ */
69108function verifyRequiredFiles ( bundleDir , isJsonEventBundle ) {
70109 const requiredGeneric = [
71110 "README.md" ,
@@ -99,6 +138,19 @@ function verifyRequiredFiles(bundleDir, isJsonEventBundle) {
99138 return { passed : missing . length === 0 , missing } ;
100139}
101140
141+ /**
142+ * Core checksum binding verification.
143+ *
144+ * Re-hashes every file listed in checksums.sha256 and compares the result
145+ * against the digest recorded at export time. A mismatch means the file was
146+ * modified after the bundle was sealed — the evidence boundary is broken.
147+ *
148+ * This step must pass before any token-level checks are trusted, because
149+ * the token files themselves are covered by the checksum binding.
150+ *
151+ * @param {string } bundleDir - Absolute path to the bundle directory.
152+ * @returns {{ passed: boolean, verified: number, total: number, errors: string[] } }
153+ */
102154function verifyChecksums ( bundleDir ) {
103155 const checksumsPath = path . join ( bundleDir , "checksums.sha256" ) ;
104156 if ( ! fs . existsSync ( checksumsPath ) ) {
@@ -120,6 +172,7 @@ function verifyChecksums(bundleDir) {
120172 continue ;
121173 }
122174
175+ // Re-derive hash and compare against the bound digest — not against the manifest.
123176 const actualHash = sha256File ( filePath ) . toLowerCase ( ) ;
124177 if ( actualHash !== entry . hash ) {
125178 errors . push ( `Checksum mismatch: ${ entry . filePath } ` ) ;
@@ -136,6 +189,18 @@ function verifyChecksums(bundleDir) {
136189 } ;
137190}
138191
192+ /**
193+ * Verifies manifest integrity by cross-checking its chain references against
194+ * the chain token embedded in the bundle.
195+ *
196+ * The manifest is a human-readable index of the bundle's contents and claims.
197+ * Its token IDs must agree with the authoritative chain-token.json — any
198+ * divergence means the manifest was edited independently after sealing.
199+ *
200+ * @param {string } bundleDir - Absolute path to the bundle directory.
201+ * @param {object } chainToken - Already-parsed chain token object.
202+ * @returns {{ passed: boolean, errors: string[], warnings: string[], manifest: object|null } }
203+ */
139204function verifyManifest ( bundleDir , chainToken ) {
140205 const manifestPath = path . join ( bundleDir , "manifest.json" ) ;
141206 if ( ! fs . existsSync ( manifestPath ) ) {
@@ -150,7 +215,8 @@ function verifyManifest(bundleDir, chainToken) {
150215 const errors = [ ] ;
151216 const warnings = [ ] ;
152217
153- // Verify chain references match
218+ // Cross-check manifest chain references against the authoritative chain token.
219+ // Mismatches mean either the manifest or the token was replaced post-export.
154220 if ( manifest . chain ) {
155221 const chainBaseId = chainToken . baseTokenId || chainToken . base ?. id ;
156222 const chainLatestId = chainToken . latestTokenId || chainToken . latest ?. id ;
@@ -173,6 +239,16 @@ function verifyManifest(bundleDir, chainToken) {
173239 return { passed : errors . length === 0 , errors, warnings, manifest } ;
174240}
175241
242+ /**
243+ * Verifies the chain token — the root evidence artifact of the bundle.
244+ *
245+ * The chain token encodes the full delta sequence (base → latest) and is the
246+ * anchor against which all other bundle contents are cross-checked. If the
247+ * chain token is missing or malformed, no downstream verification is meaningful.
248+ *
249+ * @param {string } bundleDir - Absolute path to the bundle directory.
250+ * @returns {{ passed: boolean, errors: string[], chainToken: object|null, baseTokenId: string, latestTokenId: string } }
251+ */
176252function verifyChainToken ( bundleDir ) {
177253 const chainPath = path . join ( bundleDir , "chain-token.json" ) ;
178254 if ( ! fs . existsSync ( chainPath ) ) {
@@ -186,7 +262,8 @@ function verifyChainToken(bundleDir) {
186262
187263 const errors = [ ] ;
188264
189- // Validate it's a chain token
265+ // A valid chain token must declare DELTA_CHAIN mode and carry a steps array.
266+ // Without steps, delta token count cannot be cross-checked.
190267 if ( chainToken . mode !== "DELTA_CHAIN" && ! chainToken . steps ) {
191268 errors . push ( "Not a valid chain token (missing mode or steps)" ) ;
192269 }
@@ -210,6 +287,16 @@ function verifyChainToken(bundleDir) {
210287 } ;
211288}
212289
290+ /**
291+ * Verifies the base token is present and parseable.
292+ *
293+ * The base token records the full initial state snapshot. It is required for
294+ * deterministic reconstruction: without it, no delta sequence can be replayed
295+ * back to the original starting point.
296+ *
297+ * @param {string } bundleDir - Absolute path to the bundle directory.
298+ * @returns {{ passed: boolean, errors: string[], baseToken: object|null } }
299+ */
213300function verifyBaseToken ( bundleDir ) {
214301 const basePath = path . join ( bundleDir , "base-token.json" ) ;
215302 if ( ! fs . existsSync ( basePath ) ) {
@@ -224,6 +311,18 @@ function verifyBaseToken(bundleDir) {
224311 return { passed : true , errors : [ ] , baseToken } ;
225312}
226313
314+ /**
315+ * Verifies that every delta token referenced by the chain token is present
316+ * and parseable in the bundle's delta-tokens directory.
317+ *
318+ * Delta tokens are the incremental steps that, together with the base token,
319+ * enable deterministic reconstruction of every intermediate state. A gap in
320+ * the sequence breaks the chain and must be reported.
321+ *
322+ * @param {string } bundleDir - Absolute path to the bundle directory.
323+ * @param {object } chainToken - Already-parsed chain token (provides the expected step list).
324+ * @returns {{ passed: boolean, errors: string[], count: number, expected: number } }
325+ */
227326function verifyDeltaTokens ( bundleDir , chainToken ) {
228327 const deltaDir = path . join ( bundleDir , "delta-tokens" ) ;
229328 if ( ! fs . existsSync ( deltaDir ) ) {
@@ -244,13 +343,13 @@ function verifyDeltaTokens(bundleDir, chainToken) {
244343 const fromId = step . fromTokenId || step . from ;
245344 const toId = step . toTokenId || step . to ;
246345
247- // Look for delta file by index pattern
346+ // Primary filename uses zero-padded index (e.g. delta-001.json).
248347 const indexStr = String ( i + 1 ) . padStart ( 3 , "0" ) ;
249348 const deltaFileName = `delta-${ indexStr } .json` ;
250349 const deltaPath = path . join ( deltaDir , deltaFileName ) ;
251350
252351 if ( ! fs . existsSync ( deltaPath ) ) {
253- // Try without zero-padding for older bundles
352+ // Fall back to unpadded index for bundles exported before zero-padding was introduced.
254353 const altName = `delta-${ i + 1 } .json` ;
255354 const altPath = path . join ( deltaDir , altName ) ;
256355 if ( ! fs . existsSync ( altPath ) ) {
@@ -276,12 +375,25 @@ function verifyDeltaTokens(bundleDir, chainToken) {
276375 } ;
277376}
278377
378+ /**
379+ * Verifies JSON event metadata files when present in the bundle.
380+ *
381+ * JSON event bundles (v1.15+) carry additional evidence artifacts beyond
382+ * the generic chain proof: an event schema, a session summary, and benchmark
383+ * results. These establish the behavioral evidence boundary for AI-style
384+ * structured event logs.
385+ *
386+ * This check is skipped (N/A) for generic evidence bundles — their absence
387+ * is not an error, but their presence and parseability is required when detected.
388+ *
389+ * @param {string } bundleDir - Absolute path to the bundle directory.
390+ * @returns {{ passed: boolean, errors: string[], warnings: string[], isJsonEventBundle: boolean, metadata: object } }
391+ */
279392function verifyJsonEventMetadata ( bundleDir ) {
280393 const errors = [ ] ;
281394 const warnings = [ ] ;
282395 let metadata = { } ;
283396
284- // Check event-schema.json
285397 const schemaPath = path . join ( bundleDir , "event-schema.json" ) ;
286398 if ( fs . existsSync ( schemaPath ) ) {
287399 const schema = readJson ( schemaPath ) ;
@@ -294,7 +406,6 @@ function verifyJsonEventMetadata(bundleDir) {
294406 warnings . push ( "event-schema.json not found (optional for generic bundles)" ) ;
295407 }
296408
297- // Check event-summary.json
298409 const summaryPath = path . join ( bundleDir , "event-summary.json" ) ;
299410 if ( fs . existsSync ( summaryPath ) ) {
300411 const summary = readJson ( summaryPath ) ;
@@ -308,15 +419,14 @@ function verifyJsonEventMetadata(bundleDir) {
308419 warnings . push ( "event-summary.json not found (optional for generic bundles)" ) ;
309420 }
310421
311- // Check json-benchmark-summary.json
312422 const benchPath = path . join ( bundleDir , "json-benchmark-summary.json" ) ;
313423 if ( fs . existsSync ( benchPath ) ) {
314424 const bench = readJson ( benchPath ) ;
315425 if ( ! bench ) {
316426 errors . push ( "Failed to parse json-benchmark-summary.json" ) ;
317427 } else {
318428 metadata . hasBenchmarkSummary = true ;
319- // Check for expected session ID in known v1.15 bundle
429+ // The v1.15 canonical session ID anchors this bundle to a known benchmark run.
320430 if ( bench . event_model ?. session_id === "2F9047C9F1C1A3FF" ) {
321431 metadata . expectedSession = true ;
322432 }
@@ -325,7 +435,8 @@ function verifyJsonEventMetadata(bundleDir) {
325435 warnings . push ( "json-benchmark-summary.json not found (optional for generic bundles)" ) ;
326436 }
327437
328- // If no JSON event files exist at all, this is N/A
438+ // Bundle is classified as a JSON event bundle if any of its event-specific
439+ // artifacts are present — presence alone triggers the stricter check set.
329440 const isJsonEventBundle = metadata . hasSchema || metadata . hasSummary || metadata . hasBenchmarkSummary ;
330441
331442 return {
@@ -339,6 +450,18 @@ function verifyJsonEventMetadata(bundleDir) {
339450
340451// ── Main Verification ──────────────────────────────────────────────────────────
341452
453+ /**
454+ * Orchestrates the full read-only verification of an evidence bundle.
455+ *
456+ * Verification order matters: required-file and checksum checks run first
457+ * so that structural completeness is confirmed before token-level semantics
458+ * are evaluated. Fail-closed: any failing step exits non-zero immediately
459+ * or is aggregated into the final FAIL result.
460+ *
461+ * This function never writes to the bundle directory.
462+ *
463+ * @param {string } bundlePath - Relative or absolute path to the bundle directory.
464+ */
342465function verifyEvidenceBundle ( bundlePath ) {
343466 console . log ( "╔════════════════════════════════════════════════════════════╗" ) ;
344467 console . log ( "║ VSC v1.16 — Evidence Bundle Verification ║" ) ;
@@ -361,7 +484,9 @@ function verifyEvidenceBundle(bundlePath) {
361484
362485 console . log ( `\nBundle path: ${ resolvedBundlePath } ` ) ;
363486
364- // Detect bundle type early for required file checks
487+ // Detect bundle type before required-file validation so the correct
488+ // file set is enforced. Detection is based on presence of JSON event
489+ // artifacts — no manifest field is trusted for this classification.
365490 const isJsonEventBundle =
366491 fs . existsSync ( path . join ( resolvedBundlePath , "event-schema.json" ) ) ||
367492 fs . existsSync ( path . join ( resolvedBundlePath , "json-benchmark-summary.json" ) ) ;
@@ -473,6 +598,8 @@ function verifyEvidenceBundle(bundlePath) {
473598 console . log ( "║ VERIFICATION SUMMARY ║" ) ;
474599 console . log ( "╚════════════════════════════════════════════════════════════╝" ) ;
475600
601+ // JSON event metadata is only a blocking check for JSON event bundles.
602+ // For generic bundles its absence is expected and must not count as a failure.
476603 const results = {
477604 manifest : requiredResult . passed ,
478605 checksums : checksumResult . passed ,
0 commit comments