diff --git a/DOCS.md b/DOCS.md
index 3dd09873..e1d3e140 100644
--- a/DOCS.md
+++ b/DOCS.md
@@ -335,6 +335,8 @@ vg evidence export [--out
] [--regime ]
If a command cannot serialize or write a JSON or JSONL file — permission denied, a missing parent directory, a path that is a directory, a full disk, or a value that cannot be serialized — that is an operational error. The command exits `1`. The message names the file. Check the path and permissions, or choose another `--out` or `--bundle`.
+`vg evidence verify` reads the bundle before it reports `verified`, `unverified`, or `failed`. A missing bundle exits `3`. A bundle whose envelope or `result.json` is truncated, is not valid JSON, or is not an evidence document this version can read exits `1`. The message names the envelope or the result and tells you to restore the bundle or re-create it with `vg evidence exposure --bundle`. It does not print the file's contents or a stack trace. A readable bundle that was signed and then edited still reports `failed`.
+
No language model touches any figure in the evidence path, and every determination carries an evidence-not-compliance disclaimer. Vibgrate Evidence produces evidence to support your obligations under a regime; it does not determine compliance and is not legal advice.
---
diff --git a/README.md b/README.md
index 2e2bb26f..b4f7b872 100644
--- a/README.md
+++ b/README.md
@@ -507,6 +507,8 @@ vg evidence release acme-gateway 3.2.1 --image ghcr.io/acme/gateway:3.2.1 --ship
| `unverified` | Cryptographically intact and unmodified, but the signer is not pinned — real, and not yet trusted by you |
| `failed` | Bad signature, or a `result.json` that no longer matches what was signed |
+A truncated, invalid, or unreadable bundle is not one of those states. `vg evidence verify` exits 1 and tells you to restore the bundle or re-create it with `vg evidence exposure --bundle`. The message does not include the file's contents.
+
Exit codes make it a CI gate: **0** no exposure · **2** exposure found · **3** undetermined, needs manual review · **1** operational error.
Evidence state lives in `.vibgrate/evidence/`. The Ed25519 signing key is minted on first use at `.vibgrate/attest-key.pem` (mode `0600`, with a `.pub` beside it) unless you point at your own with `VG_ATTEST_KEY` — back it up, and never commit it.
diff --git a/src/reporting/commands/evidence/bundle.ts b/src/reporting/commands/evidence/bundle.ts
index 8b7500c6..cdbdacc4 100644
--- a/src/reporting/commands/evidence/bundle.ts
+++ b/src/reporting/commands/evidence/bundle.ts
@@ -161,6 +161,220 @@ export function verifyEvidenceEnvelope(
};
}
+const RESULT_SCHEMA = 'evidence-1';
+const RECREATE = 'Restore the bundle, or re-create it with `vg evidence exposure --bundle`.';
+
+/**
+ * An evidence bundle on disk cannot be loaded.
+ *
+ * The message is the operator-facing error: what failed, and how to restore
+ * the bundle. It never includes file contents, parser excerpts, environment
+ * values, or any other bytes from the artifact (those can carry credentials).
+ * `code` is {@link ExitCode.ERROR} so `vg evidence verify` exits non-zero.
+ * This is a read failure, distinct from a signature or digest mismatch on a
+ * bundle that loaded.
+ */
+export class EvidenceBundleLoadError extends CliError {
+ readonly isEvidenceBundleLoadError = true;
+ readonly kind: 'corrupt' | 'schema';
+
+ constructor(message: string, kind: 'corrupt' | 'schema') {
+ super(message, ExitCode.ERROR);
+ this.name = 'EvidenceBundleLoadError';
+ this.kind = kind;
+ }
+}
+
+export interface LoadedEvidenceBundle {
+ envelopePath: string;
+ envelope: DsseEnvelope;
+ result?: ExposureResult;
+ /** Present when result.json loaded and timestamp.tsr is on disk. */
+ timestampToken?: Buffer;
+}
+
+function corruptJsonMessage(which: 'envelope' | 'result'): string {
+ const noun = which === 'envelope' ? 'evidence envelope' : 'evidence result';
+ return `The ${noun} is truncated or not valid JSON. ${RECREATE}`;
+}
+
+function unreadableMessage(): string {
+ return `The evidence bundle could not be read. ${RECREATE}`;
+}
+
+function shapeMessage(which: 'envelope' | 'result'): string {
+ const noun = which === 'envelope' ? 'evidence envelope' : 'evidence result';
+ return `The ${noun} is not a readable evidence bundle. ${RECREATE}`;
+}
+
+function schemaMessage(which: 'envelope' | 'result', version: unknown): string {
+ const echoed = which === 'result' ? echoableResultSchema(version) : echoableAttestation(version);
+ const noun = which === 'result' ? 'evidence result' : 'evidence envelope';
+ const got = echoed ? `schema \`${echoed}\`` : 'a schema this version of vg cannot read';
+ const reads = which === 'result' ? `result schema \`${RESULT_SCHEMA}\`` : `predicate \`${EVIDENCE_PREDICATE_TYPE}\``;
+ return `The ${noun} uses ${got} (this version reads ${reads}). ${RECREATE}`;
+}
+
+function isPlainObject(value: unknown): value is Record {
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
+}
+
+/** A result schema token we are willing to echo. Anything else stays out of the message. */
+function echoableResultSchema(value: unknown): string | null {
+ if (typeof value !== 'string' || value.length > 32) return null;
+ return /^evidence-\d{1,4}$/.test(value) ? value : null;
+}
+
+/**
+ * An attestation token we are willing to echo: a DSSE payload type or a
+ * Vibgrate predicate URL. Arbitrary strings stay out of the message.
+ */
+function echoableAttestation(value: unknown): string | null {
+ if (typeof value !== 'string' || value.length > 96) return null;
+ if (/^application\/vnd\.[a-z0-9.+-]{1,40}$/.test(value)) return value;
+ if (/^https:\/\/vibgrate\.com\/attestation\/[a-z0-9-]{1,48}\/v\d{1,4}$/.test(value)) return value;
+ if (/^https:\/\/in-toto\.io\/Statement\/v\d{1,4}$/.test(value)) return value;
+ return null;
+}
+
+function stripBom(text: string): string {
+ return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
+}
+
+function errnoCode(err: unknown): string {
+ if (typeof err === 'object' && err !== null && 'code' in err) {
+ const code = (err as { code?: unknown }).code;
+ if (typeof code === 'string') return code;
+ }
+ return '';
+}
+
+function readBundleText(file: string): string {
+ try {
+ return fs.readFileSync(file, 'utf8');
+ } catch (err) {
+ if (errnoCode(err) === 'ENOENT') {
+ throw new CliError(`no evidence.intoto.jsonl at ${file}`, ExitCode.NOT_FOUND);
+ }
+ throw new EvidenceBundleLoadError(unreadableMessage(), 'corrupt');
+ }
+}
+
+function readBundleBytes(file: string): Buffer {
+ try {
+ return fs.readFileSync(file);
+ } catch {
+ throw new EvidenceBundleLoadError(unreadableMessage(), 'corrupt');
+ }
+}
+
+function parseJsonOrCorrupt(text: string, which: 'envelope' | 'result'): unknown {
+ const body = stripBom(text).trim();
+ if (!body) throw new EvidenceBundleLoadError(corruptJsonMessage(which), 'corrupt');
+ try {
+ return JSON.parse(body);
+ } catch {
+ throw new EvidenceBundleLoadError(corruptJsonMessage(which), 'corrupt');
+ }
+}
+
+/** First non-empty JSONL record. The envelope file is one JSON object per line. */
+function firstJsonlRecord(text: string): string {
+ const line = stripBom(text).split('\n').find((entry) => entry.trim().length > 0);
+ return line?.trim() ?? '';
+}
+
+const PREDICATE_STRINGS = ['evidenceId', 'regime', 'advisoryId', 'overallStatus', 'resultDigest'] as const;
+
+function assertEvidenceEnvelope(value: unknown): DsseEnvelope {
+ if (!isPlainObject(value) || typeof value.payload !== 'string' || !Array.isArray(value.signatures)) {
+ throw new EvidenceBundleLoadError(shapeMessage('envelope'), 'corrupt');
+ }
+ if (value.payloadType !== DSSE_PAYLOAD_TYPE) {
+ throw new EvidenceBundleLoadError(schemaMessage('envelope', value.payloadType), 'schema');
+ }
+ let statement: unknown;
+ try {
+ statement = JSON.parse(Buffer.from(value.payload, 'base64').toString('utf8'));
+ } catch {
+ throw new EvidenceBundleLoadError(corruptJsonMessage('envelope'), 'corrupt');
+ }
+ if (!isPlainObject(statement)) {
+ throw new EvidenceBundleLoadError(shapeMessage('envelope'), 'corrupt');
+ }
+ if (statement._type !== IN_TOTO_STATEMENT_TYPE) {
+ throw new EvidenceBundleLoadError(schemaMessage('envelope', statement._type), 'schema');
+ }
+ if (statement.predicateType !== EVIDENCE_PREDICATE_TYPE) {
+ throw new EvidenceBundleLoadError(schemaMessage('envelope', statement.predicateType), 'schema');
+ }
+ const predicate = statement.predicate;
+ if (!isPlainObject(predicate) || !PREDICATE_STRINGS.every((key) => typeof predicate[key] === 'string')) {
+ throw new EvidenceBundleLoadError(shapeMessage('envelope'), 'corrupt');
+ }
+ return value as unknown as DsseEnvelope;
+}
+
+function assertEvidenceResult(value: unknown): ExposureResult {
+ if (!isPlainObject(value)) throw new EvidenceBundleLoadError(shapeMessage('result'), 'corrupt');
+ if (value.schemaVersion !== RESULT_SCHEMA) {
+ throw new EvidenceBundleLoadError(schemaMessage('result', value.schemaVersion), 'schema');
+ }
+ const advisory = value.advisory;
+ const meta = value.meta;
+ const readable =
+ typeof value.overallStatus === 'string' &&
+ isPlainObject(advisory) &&
+ typeof advisory.id === 'string' &&
+ Array.isArray(value.products) &&
+ isPlainObject(meta) &&
+ typeof meta.evidenceId === 'string';
+ if (!readable) throw new EvidenceBundleLoadError(shapeMessage('result'), 'corrupt');
+ return value as unknown as ExposureResult;
+}
+
+function resolveEnvelopePath(bundlePath: string): string {
+ const abs = path.resolve(bundlePath);
+ let st: fs.Stats;
+ try {
+ st = fs.statSync(abs);
+ } catch (err) {
+ if (errnoCode(err) === 'ENOENT' || errnoCode(err) === 'ENOTDIR') {
+ throw new CliError(`no evidence.intoto.jsonl at ${bundlePath}`, ExitCode.NOT_FOUND);
+ }
+ throw new EvidenceBundleLoadError(unreadableMessage(), 'corrupt');
+ }
+ const envPath = st.isDirectory() ? path.join(abs, 'evidence.intoto.jsonl') : abs;
+ if (!fs.existsSync(envPath)) {
+ throw new CliError(`no evidence.intoto.jsonl at ${bundlePath}`, ExitCode.NOT_FOUND);
+ }
+ return envPath;
+}
+
+/**
+ * Load an evidence bundle for offline verification.
+ *
+ * Throws {@link EvidenceBundleLoadError} when the envelope or `result.json`
+ * is truncated, not JSON, or not a schema this version of vg can read.
+ * A missing bundle is a not-found {@link CliError}. The message names the
+ * failure and how to restore the bundle; it does not include the document.
+ */
+export function loadEvidenceBundle(bundlePath: string): LoadedEvidenceBundle {
+ const envelopePath = resolveEnvelopePath(bundlePath);
+ const envelope = assertEvidenceEnvelope(
+ parseJsonOrCorrupt(firstJsonlRecord(readBundleText(envelopePath)), 'envelope'),
+ );
+ const resultPath = path.join(path.dirname(envelopePath), 'result.json');
+ let result: ExposureResult | undefined;
+ if (fs.existsSync(resultPath)) {
+ result = assertEvidenceResult(parseJsonOrCorrupt(readBundleText(resultPath), 'result'));
+ }
+ let timestampToken: Buffer | undefined;
+ const tsrPath = path.join(path.dirname(envelopePath), 'timestamp.tsr');
+ if (result && fs.existsSync(tsrPath)) timestampToken = readBundleBytes(tsrPath);
+ return { envelopePath, envelope, result, timestampToken };
+}
+
const DEFAULT_KEY = 'attest-key.pem';
/**
@@ -282,6 +496,8 @@ function verifyDoc(input: WriteBundleInput): string {
'- `unverified` — cryptographically intact but the signer is not pinned, or the',
' bundle is unsigned. We never fabricate a pass.',
'- `failed` — the signature is bad or `result.json` no longer matches its digest.',
+ '- A truncated, invalid, or unreadable bundle is an error, not `failed`.',
+ ' Restore the original files, or re-create the bundle.',
'',
input.timestampToken
? `Timestamp: an RFC 3161 token is included as \`timestamp.tsr\` (trusted time\n${input.result.meta.timestamp.value}). Verify it fully with your TSA's CA:\n\`\`\`\nopenssl ts -verify -in timestamp.tsr -data result.json -CAfile \n\`\`\`\n\`vg evidence verify\` confirms the token's imprint binds to result.json and\nsurfaces the trusted time; it does not re-verify the TSA signature chain.`
diff --git a/src/reporting/commands/evidence/index.ts b/src/reporting/commands/evidence/index.ts
index 1c3b54ad..a247412e 100644
--- a/src/reporting/commands/evidence/index.ts
+++ b/src/reporting/commands/evidence/index.ts
@@ -21,7 +21,7 @@ import { resolveAdvisory } from './advisory.js';
import { buildRelease } from './release.js';
import { buildPushPayload, describePushResult, type PushAttestation, type PushResponse } from './push-payload.js';
import { buildPack } from './pack.js';
-import { buildEvidenceStatement, signEvidenceStatement, verifyEvidenceEnvelope, resolveSigningKey, writeBundle } from './bundle.js';
+import { buildEvidenceStatement, signEvidenceStatement, verifyEvidenceEnvelope, resolveSigningKey, writeBundle, loadEvidenceBundle } from './bundle.js';
import { synthesizeAdvisory, undeterminedFields, recordDrill, hasRecentDrill } from './drill.js';
import { formatExposure, formatReadiness, formatRegimeList } from './format.js';
import { resolveDsn } from '../../credentials.js';
@@ -385,28 +385,30 @@ const drillCmd = new Command('drill')
}
});
+function readPinnedPublicKey(file: string): string {
+ try {
+ return fs.readFileSync(path.resolve(file), 'utf8');
+ } catch {
+ throw new CliError(`could not read the public key at ${file}. Pass --pub with a PEM file.`, ExitCode.USAGE_ERROR);
+ }
+}
+
// ── verify ──
const verifyCmd = new Command('verify')
.description('Verify an evidence bundle offline (no Vibgrate needed)')
.argument('', 'Path to an evidence bundle directory or evidence.intoto.jsonl')
.option('--pub ', 'Public key PEM to pin the signer (establish trust)')
.action((bundlePath: string, opts) => {
- const abs = path.resolve(bundlePath);
- const envPath = fs.statSync(abs).isDirectory() ? path.join(abs, 'evidence.intoto.jsonl') : abs;
- if (!fs.existsSync(envPath)) throw new CliError(`no evidence.intoto.jsonl at ${bundlePath}`, ExitCode.NOT_FOUND);
- const envelope = JSON.parse(fs.readFileSync(envPath, 'utf8').trim().split('\n')[0]) as DsseEnvelope;
- const resultPath = path.join(path.dirname(envPath), 'result.json');
- const result = fs.existsSync(resultPath) ? (JSON.parse(fs.readFileSync(resultPath, 'utf8')) as ExposureResult) : undefined;
- const publicKeyPem = opts.pub ? fs.readFileSync(path.resolve(opts.pub as string), 'utf8') : undefined;
- const v = verifyEvidenceEnvelope(envelope, { publicKeyPem, result });
+ const loaded = loadEvidenceBundle(bundlePath);
+ const publicKeyPem = opts.pub ? readPinnedPublicKey(opts.pub as string) : undefined;
+ const v = verifyEvidenceEnvelope(loaded.envelope, { publicKeyPem, result: loaded.result });
const color = v.status === 'verified' ? chalk.green : v.status === 'failed' ? chalk.red : chalk.yellow;
console.log(' ' + color(v.status.toUpperCase()) + ` ${v.reason}`);
if (v.evidenceId) console.log(' ' + chalk.dim(`evidence ${v.evidenceId} · regime ${v.regime} · advisory ${v.advisoryId} · ${v.overallStatus}`));
// RFC 3161 timestamp, when the bundle carries one.
- const tsrPath = path.join(path.dirname(envPath), 'timestamp.tsr');
- if (fs.existsSync(tsrPath) && result) {
- const t = verifyTimestamp(fs.readFileSync(tsrPath), exposureSubjectDigest(result));
+ if (loaded.timestampToken && loaded.result) {
+ const t = verifyTimestamp(loaded.timestampToken, exposureSubjectDigest(loaded.result));
const tcolor = t.imprintMatches ? chalk.green : chalk.red;
console.log(' ' + tcolor('TIMESTAMP') + ` ${t.reason}`);
}
diff --git a/test/evidence-verify-load.test.ts b/test/evidence-verify-load.test.ts
new file mode 100644
index 00000000..759b595a
--- /dev/null
+++ b/test/evidence-verify-load.test.ts
@@ -0,0 +1,286 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import * as crypto from 'node:crypto';
+import * as fs from 'node:fs';
+import * as os from 'node:os';
+import * as path from 'node:path';
+import { main } from '../src/cli.js';
+import { DSSE_PAYLOAD_TYPE } from '../src/engine/attest.js';
+import {
+ EvidenceBundleLoadError,
+ buildEvidenceStatement,
+ loadEvidenceBundle,
+ signEvidenceStatement,
+ verifyEvidenceEnvelope,
+} from '../src/reporting/commands/evidence/bundle.js';
+import type { ExposureResult } from '../src/reporting/commands/evidence/types.js';
+import { CliError, ExitCode } from '../src/util/exit.js';
+
+/**
+ * A truncated, invalid, or schema-broken evidence bundle must fail as an
+ * operator error: what went wrong, how to restore it, non-zero exit, and
+ * none of the file's bytes.
+ */
+
+// Distinct file bytes. A token-shaped value here trips the secret scan.
+const SECRET = 'placeholder-file-contents-must-not-leak';
+const RESTORE = 'Restore the bundle, or re-create it with `vg evidence exposure --bundle`';
+const EVIDENCE_PREDICATE = 'https://vibgrate.com/attestation/regulatory-evidence/v1';
+
+const dirs: string[] = [];
+
+afterEach(() => {
+ vi.restoreAllMocks();
+ process.exitCode = undefined;
+ delete process.env.VG_EVIDENCE_VERIFY_TEST;
+ for (const dir of dirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true });
+});
+
+function tempDir(): string {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vg-evidence-verify-'));
+ dirs.push(dir);
+ return dir;
+}
+
+function resultFixture(): ExposureResult {
+ return {
+ schemaVersion: 'evidence-1',
+ regime: 'cra',
+ advisory: { id: 'CVE-2026-1000', sourceProvenance: 'local', kevListed: false },
+ overallStatus: 'not-affected',
+ products: [],
+ meta: {
+ evidenceId: 'ev_fixture',
+ dataPackVersion: 'none',
+ kernelVersion: 'test',
+ timestamp: { source: 'local-clock', value: '2026-01-01T00:00:00.000Z' },
+ },
+ };
+}
+
+function signedBundle(dir: string, result: ExposureResult = resultFixture()): { pubPem: string } {
+ const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
+ const pubPem = publicKey.export({ type: 'spki', format: 'pem' }).toString();
+ const envelope = signEvidenceStatement(buildEvidenceStatement(result, '1.0.0'), privateKey);
+ fs.mkdirSync(dir, { recursive: true });
+ fs.writeFileSync(path.join(dir, 'evidence.intoto.jsonl'), `${JSON.stringify(envelope)}\n`);
+ fs.writeFileSync(path.join(dir, 'result.json'), `${JSON.stringify(result, null, 2)}\n`);
+ return { pubPem };
+}
+
+function writeEnvelope(dir: string, body: string, resultBody?: string): void {
+ fs.mkdirSync(dir, { recursive: true });
+ fs.writeFileSync(path.join(dir, 'evidence.intoto.jsonl'), body);
+ if (resultBody !== undefined) fs.writeFileSync(path.join(dir, 'result.json'), resultBody);
+}
+
+function assertSafe(err: EvidenceBundleLoadError): void {
+ expect(err).toBeInstanceOf(EvidenceBundleLoadError);
+ expect(err).toBeInstanceOf(CliError);
+ expect(err.name).toBe('EvidenceBundleLoadError');
+ expect(err.code).toBe(ExitCode.ERROR);
+ expect(err.code).not.toBe(0);
+ expect(err.message).toContain(RESTORE);
+ expect(err.message).not.toContain(SECRET);
+ expect(err.message).not.toContain('ghp_');
+ expect(err.message).not.toContain('Unexpected token');
+ expect(err.message).not.toContain('Unexpected end');
+ expect(err.message).not.toContain('SyntaxError');
+ expect(err.message).not.toMatch(/\n/);
+ expect(process.env.VG_EVIDENCE_VERIFY_TEST).toBe(SECRET);
+ expect(err.message).not.toContain('VG_EVIDENCE_VERIFY_TEST');
+}
+
+function catchLoad(bundlePath: string): EvidenceBundleLoadError {
+ process.env.VG_EVIDENCE_VERIFY_TEST = SECRET;
+ let caught: unknown;
+ try {
+ loadEvidenceBundle(bundlePath);
+ } catch (err) {
+ caught = err;
+ }
+ expect(caught).toBeInstanceOf(EvidenceBundleLoadError);
+ const err = caught as EvidenceBundleLoadError;
+ assertSafe(err);
+ return err;
+}
+
+function envelopeShell(statement: unknown): string {
+ return `${JSON.stringify({
+ payloadType: DSSE_PAYLOAD_TYPE,
+ payload: Buffer.from(JSON.stringify(statement), 'utf8').toString('base64'),
+ signatures: [],
+ })}\n`;
+}
+
+describe('loadEvidenceBundle', () => {
+ it('loads a signed bundle and verifies it when the signer is pinned', () => {
+ const dir = tempDir();
+ const { pubPem } = signedBundle(dir);
+ const loaded = loadEvidenceBundle(dir);
+ expect(loaded.result?.schemaVersion).toBe('evidence-1');
+ expect(verifyEvidenceEnvelope(loaded.envelope, { publicKeyPem: pubPem, result: loaded.result }).status).toBe('verified');
+ });
+
+ it('still reports failed when a readable result no longer matches the signed digest', () => {
+ const dir = tempDir();
+ const result = resultFixture();
+ const { pubPem } = signedBundle(dir, result);
+ const tampered: ExposureResult = { ...result, overallStatus: 'affected' };
+ fs.writeFileSync(path.join(dir, 'result.json'), `${JSON.stringify(tampered, null, 2)}\n`);
+ const loaded = loadEvidenceBundle(dir);
+ expect(verifyEvidenceEnvelope(loaded.envelope, { publicKeyPem: pubPem, result: loaded.result }).status).toBe('failed');
+ });
+
+ it('rejects a truncated envelope without echoing file contents', () => {
+ const dir = tempDir();
+ signedBundle(dir);
+ const file = path.join(dir, 'evidence.intoto.jsonl');
+ const full = fs.readFileSync(file, 'utf8');
+ fs.writeFileSync(file, `${full.slice(0, Math.max(8, Math.floor(full.length / 2)))}${SECRET}`);
+ const err = catchLoad(dir);
+ expect(err.kind).toBe('corrupt');
+ expect(err.message).toMatch(/evidence envelope is truncated or not valid JSON/);
+ });
+
+ it('rejects invalid JSON without echoing file contents', () => {
+ const dir = tempDir();
+ writeEnvelope(dir, `not-json ${SECRET}\n`);
+ const err = catchLoad(path.join(dir, 'evidence.intoto.jsonl'));
+ expect(err.kind).toBe('corrupt');
+ expect(err.message).toMatch(/evidence envelope is truncated or not valid JSON/);
+ });
+
+ it('rejects a schema this vg cannot read without echoing the document', () => {
+ const dir = tempDir();
+ writeEnvelope(dir, envelopeShell({
+ _type: 'https://in-toto.io/Statement/v1',
+ predicateType: 'https://vibgrate.com/attestation/code-graph/v1',
+ predicate: { note: SECRET },
+ leaked: SECRET,
+ }));
+ const err = catchLoad(dir);
+ expect(err.kind).toBe('schema');
+ expect(err.message).toContain('https://vibgrate.com/attestation/code-graph/v1');
+ expect(err.message).toContain(EVIDENCE_PREDICATE);
+ });
+
+ it('does not echo a predicate type that is not an attestation token', () => {
+ const dir = tempDir();
+ writeEnvelope(dir, envelopeShell({
+ _type: 'https://in-toto.io/Statement/v1',
+ predicateType: SECRET,
+ predicate: { note: SECRET },
+ }));
+ const err = catchLoad(dir);
+ expect(err.kind).toBe('schema');
+ expect(err.message).toMatch(/cannot read/);
+ expect(err.message).not.toContain('schema `');
+ });
+
+ it('rejects a truncated result.json beside a readable envelope', () => {
+ const dir = tempDir();
+ signedBundle(dir);
+ const resultPath = path.join(dir, 'result.json');
+ const full = fs.readFileSync(resultPath, 'utf8');
+ fs.writeFileSync(resultPath, `${full.slice(0, Math.floor(full.length / 2))}${SECRET}`);
+ const err = catchLoad(dir);
+ expect(err.kind).toBe('corrupt');
+ expect(err.message).toMatch(/evidence result is truncated or not valid JSON/);
+ });
+
+ it('rejects a result schema this vg cannot read', () => {
+ const dir = tempDir();
+ signedBundle(dir);
+ fs.writeFileSync(path.join(dir, 'result.json'), `${JSON.stringify({
+ schemaVersion: 'evidence-0',
+ note: SECRET,
+ overallStatus: 'affected',
+ })}\n`);
+ const err = catchLoad(dir);
+ expect(err.kind).toBe('schema');
+ expect(err.message).toContain('schema `evidence-0`');
+ expect(err.message).toContain('evidence-1');
+ });
+
+ it('reports a missing bundle as not found, not a load failure', () => {
+ const dir = tempDir();
+ const missing = path.join(dir, 'absent');
+ expect(() => loadEvidenceBundle(missing)).toThrow(CliError);
+ try {
+ loadEvidenceBundle(missing);
+ } catch (err) {
+ expect(err).toBeInstanceOf(CliError);
+ expect(err).not.toBeInstanceOf(EvidenceBundleLoadError);
+ expect((err as CliError).code).toBe(ExitCode.NOT_FOUND);
+ expect((err as CliError).message).toMatch(/no evidence\.intoto\.jsonl/);
+ expect((err as CliError).message).not.toContain('ENOENT');
+ }
+ });
+});
+
+describe('vg evidence verify', () => {
+ async function runVerify(args: string[]): Promise<{ exits: number[]; stderr: string; stdout: string }> {
+ const exits: number[] = [];
+ const errSpy = vi.spyOn(process.stderr, 'write');
+ const logSpy = vi.spyOn(console, 'log');
+ vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
+ exits.push(code ?? -1);
+ return undefined as never;
+ }) as typeof process.exit);
+ const strip = (value: string): string => value.replace(/\u001b\[[0-9;]*m/g, '');
+
+ await main(['node', 'vg', 'evidence', 'verify', ...args]);
+ await new Promise((resolve) => setImmediate(resolve));
+ const stderr = strip(errSpy.mock.calls.map((call) => String(call[0])).join(''));
+ const stdout = strip(logSpy.mock.calls.map((call) => call.map((part) => String(part)).join(' ')).join('\n'));
+ return { exits, stderr, stdout };
+ }
+
+ it('exits non-zero from the CLI entry on a truncated bundle, without a stack', async () => {
+ const dir = tempDir();
+ writeEnvelope(dir, `{"payload":"${SECRET}"`);
+ const { exits, stderr } = await runVerify([dir]);
+ expect(exits).toEqual([ExitCode.ERROR]);
+ expect(stderr).toContain('error:');
+ expect(stderr).toContain(RESTORE);
+ expect(stderr).toContain('truncated or not valid JSON');
+ expect(stderr).not.toContain(SECRET);
+ expect(stderr).not.toContain('Unexpected token');
+ expect(stderr).not.toContain('SyntaxError');
+ expect(stderr).not.toContain('re-run with --json');
+ expect(stderr).not.toMatch(/\n\s+at /);
+ });
+
+ it('exits non-zero on invalid JSON and on a schema mismatch', async () => {
+ const dir = tempDir();
+ writeEnvelope(dir, `not-json ${SECRET}\n`);
+ const invalid = await runVerify([path.join(dir, 'evidence.intoto.jsonl')]);
+ expect(invalid.exits).toEqual([ExitCode.ERROR]);
+ expect(invalid.stderr).toContain('truncated or not valid JSON');
+ expect(invalid.stderr).not.toContain(SECRET);
+ expect(invalid.stderr).not.toMatch(/\n\s+at /);
+
+ const schemaDir = tempDir();
+ writeEnvelope(schemaDir, envelopeShell({
+ _type: 'https://in-toto.io/Statement/v9',
+ leaked: SECRET,
+ }));
+ const schema = await runVerify([schemaDir]);
+ expect(schema.exits).toEqual([ExitCode.ERROR]);
+ expect(schema.stderr).toContain('schema `https://in-toto.io/Statement/v9`');
+ expect(schema.stderr).toContain(RESTORE);
+ expect(schema.stderr).not.toContain(SECRET);
+ expect(schema.stderr).not.toContain('SyntaxError');
+ expect(schema.stderr).not.toMatch(/\n\s+at /);
+ });
+
+ it('still prints an honest unverified state for a readable bundle', async () => {
+ const dir = tempDir();
+ signedBundle(dir);
+ const { exits, stdout, stderr } = await runVerify([dir]);
+ expect(exits).toEqual([]);
+ expect(stdout).toMatch(/UNVERIFIED/);
+ expect(stderr).not.toContain(SECRET);
+ expect(process.exitCode).toBe(ExitCode.GATE_FAILED);
+ });
+});