Skip to content

Commit 2601945

Browse files
committed
Document evidence bundle verification boundaries
1 parent e7e5382 commit 2601945

3 files changed

Lines changed: 205 additions & 28 deletions

File tree

scripts/verifyEvidenceBundle.js

Lines changed: 139 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,13 @@
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

1217
import 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+
*/
2232
function 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+
*/
2741
function 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+
*/
4160
function 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+
*/
4876
function 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+
*/
69108
function 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+
*/
102154
function 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+
*/
139204
function 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+
*/
176252
function 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+
*/
213300
function 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+
*/
227326
function 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+
*/
279392
function 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+
*/
342465
function 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

Comments
 (0)