From f4bddf1664a65c5a19bd70368163b3cd0d6778af Mon Sep 17 00:00:00 2001 From: Antonio Antenore Date: Sun, 19 Jul 2026 13:33:43 +0200 Subject: [PATCH] feat: verify complete execution passport artifact sets --- README.md | 14 +- docs/architecture.md | 6 + docs/contracts-and-conformance.md | 9 + .../execution-passport/v2/README.md | 20 +++ docs/threat-model.md | 1 + scripts/package-smoke.mjs | 28 ++- src/adapters/documents.ts | 13 +- src/cli-program.ts | 55 +++++- src/core/passport.ts | 158 ++++++++++++++++- test/execution-passport-cli.test.ts | 32 +++- test/execution-passport-verification.test.ts | 165 ++++++++++++++++++ test/stagefabric-evidence.test.ts | 28 ++- 12 files changed, 518 insertions(+), 11 deletions(-) create mode 100644 test/execution-passport-verification.test.ts diff --git a/README.md b/README.md index 8e1f2a5..12b7fbc 100644 --- a/README.md +++ b/README.md @@ -122,9 +122,20 @@ node dist/cli.js passport .tmp/demo/run.bundle.json \ --oasf-media-type "$OASF_MEDIA_TYPE" \ --execution-evidence .tmp/demo/stagefabric-binding.json \ --output .tmp/demo/execution-passport.json +node dist/cli.js verify-passport .tmp/demo/execution-passport.json \ + --bundle .tmp/demo/run.bundle.json \ + --report .tmp/demo/conformance-report.json \ + --oasf-record /path/to/validated-oasf-record.json \ + --execution-evidence-resource /path/to/stagefabric-evidence.json ``` -The output file contains exact RFC 8785 Statement bytes suitable for an external DSSE/Sigstore adapter. Repeat `--execution-evidence` only with strict v2 bindings whose `runIdDigest` matches the Passport run and whose authority is fixed to `observation-only`. +`passport` writes exact RFC 8785 Statement bytes suitable for an external +DSSE/Sigstore adapter. `verify-passport` is the consumer-side check: it rebuilds +the expected subject and predicate bindings from the supplied bundle, report, +and OASF record, then requires the exact bytes of every referenced execution +artifact. It proves internal consistency, not signer authenticity or OASF +semantics. Repeat `--execution-evidence` and +`--execution-evidence-resource` once per corresponding binding and artifact. `bind-stagefabric` accepts only the exact canonical JSON plus trailing LF emitted by the StageFabric evidence writer. Pretty-printed JSON, YAML, or a reserialized object is rejected because the resulting descriptor digest identifies the retrievable file bytes, not merely an equivalent object. @@ -168,6 +179,7 @@ Read [Architecture](docs/architecture.md), [Contracts and conformance](docs/cont - Run status and conformance status are separate: a failed run can still have an intact, conformant evidence record, but it is never explained as a completed outcome. - Reports bind the evaluator name, package version, semantic revision, selected rule set, and effective profile configuration. The evaluator digest identifies the declared implementation revision; it is not a code signature. - An unsigned Execution Passport proves deterministic integrity and cross-artifact binding only. Authenticity requires an externally verified DSSE envelope or equivalent trusted channel, and consumers must make that requirement explicit so removing a signature cannot silently downgrade policy. +- Consumer verification requires the complete supplied subject set and exact bytes for every execution-evidence resource; validating the Passport schema alone does not verify those bindings. - The OASF subject digest proves which opaque record was bound; it does not prove that the record is semantically valid OASF. Validate it before passport creation with the specification owner’s tooling. - Execution-placement evidence enters the predicate only as a strict, run-bound, observation-only descriptor with a normalized observation time and no payload or `content` field. A StageFabric descriptor hashes the exact canonical JSON file bytes, including its trailing LF. Descriptor metadata must come from a trusted producer and be redacted or pseudonymized when sensitive; its optional stable URI may be omitted. Full provider result objects are outside the Passport contract. - The StageFabric adapter follows its finalized `0.7.0-alpha.1` successful-run contract: trace events are either completed placements or pre-output retries with status `429`, `502`, `503`, or `504`. Terminal failures, arbitrary HTTP codes, missing retry codes, and codes attached to completed events fail closed. diff --git a/docs/architecture.md b/docs/architecture.md index a6016ea..fd48d67 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -80,6 +80,12 @@ Passport creation is a deterministic packaging step after conformance evaluation Optional execution-placement evidence is reduced to a `ResourceDescriptor` with no payload or `content` field. AgenticStrata admits only the descriptor, producer identity, fixed role, canonical run-identifier digest, normalized observation time, `observation-only` authority, and `content-free` disclosure marker. A StageFabric adapter validates the source artifact, its successful-run-only trace (`completed` or a pre-output retry with `429`, `502`, `503`, or `504`), timestamp grammar, producer seal, and exact canonical-JSON-plus-LF writer bytes before reduction. Descriptor metadata must be trusted and redacted or pseudonymized when sensitive, and its URI may be omitted. A placement observation never grants authority; a descriptor from another run or after the report is rejected. +The consumer path accepts the Passport together with all three subject documents +and the exact bytes of every execution-evidence resource. It reconstructs the +expected Statement, checks the complete bundle/report relationship again, and +requires a one-to-one digest match for external evidence. Schema validation by +itself is deliberately insufficient. + The core emits an unsigned Statement. A replaceable external adapter may wrap its exact RFC 8785 bytes in DSSE and use Sigstore or another trust system. Signature verification, signer identity, transparency-log policy, and trusted time remain consumer-owned controls. ## Safe semantic caching diff --git a/docs/contracts-and-conformance.md b/docs/contracts-and-conformance.md index 0e54554..371c3c7 100644 --- a/docs/contracts-and-conformance.md +++ b/docs/contracts-and-conformance.md @@ -54,6 +54,15 @@ OASF input is accepted only as an opaque I-JSON object and canonicalized for its The Passport is created after assessment and is not required by a conformance rule. Making it a prerequisite of the report it contains would create a circular dependency. +Consumer verification is a separate fail-closed operation. Given a v2 Passport, +the complete run bundle, report, opaque OASF record, and every referenced +execution-evidence file, `verifyExecutionPassport` reconstructs the expected +Statement and compares all subject and predicate bindings. Evidence files are +matched one-to-one by SHA-256 over their exact bytes; missing, duplicated, or +unreferenced files fail verification. The CLI exposes the same operation as +`verify-passport`. This verifies an unsigned artifact set, not producer identity, +OASF semantics, trusted time, or an external DSSE envelope. + ## Evidence, not file detection A conformance run consumes a `RunBundle`. It does not infer architecture from directory names, imports, or keywords. This avoids declaring success simply because a project contains a policy file or telemetry package. Conversely, a source scanner remains useful before runtime evidence exists; its result should be treated as a discovery signal rather than runtime proof. diff --git a/docs/spec/attestations/execution-passport/v2/README.md b/docs/spec/attestations/execution-passport/v2/README.md index 6e83a97..b382ed3 100644 --- a/docs/spec/attestations/execution-passport/v2/README.md +++ b/docs/spec/attestations/execution-passport/v2/README.md @@ -64,6 +64,26 @@ A conforming builder fails closed unless: These checks establish deterministic internal consistency. They do not authenticate a producer or prevent wholesale replacement by an actor able to construct another self-consistent set. +## Consumer verification + +A consumer must not treat schema validation as verification of the external +subjects. `verifyExecutionPassport` and the `verify-passport` CLI require the +Passport plus the complete RunBundle, ConformanceReport, opaque OASF record, and +the exact bytes of every execution-evidence resource. The verifier: + +1. validates the strict v2 Statement; +2. repeats every creation-time bundle, report, receipt, run, status, and time check; +3. reconstructs and compares the three ordered subject descriptors and predicate; +4. matches every external evidence descriptor to exactly one supplied file by + SHA-256 over its complete bytes; and +5. rejects missing, duplicated, or unreferenced evidence files. + +This is unsigned artifact-set verification. Producer-contract semantics are +validated by the adapter that creates a binding; OASF semantics remain with its +owner. Authenticity, signer identity, trusted time, revocation, and protection +against wholesale replacement still require an externally verified envelope or +trusted store. + ## OASF, DSSE, and signing boundaries OASF input is an opaque I-JSON object. Validate it with the owning ecosystem before Passport creation; its subject digest proves which canonical bytes were bound, not semantic OASF validity. diff --git a/docs/threat-model.md b/docs/threat-model.md index 7daa431..2ce5501 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -33,6 +33,7 @@ Model output, retrieved content, external messages, tool descriptions, and impor | A report is replayed under ambiguous evaluator semantics | Reports bind evaluator name, package version, semantic revision, rule IDs, and effective configuration by digest. | The declared evaluator digest is not a code signature; trusted distribution or executable attestation is required for stronger provenance. | | Imported agent definition becomes mutable | Reference plus digest; embedded redefinition is rejected. | Availability and signature verification belong to the artifact resolver. | | A Passport mixes a run with another report or manifest | Creation recomputes canonical bundle and manifest digests, verifies the complete report and evaluator seals, and rejects mismatched run identity or terminal state. | Hashes do not establish who produced otherwise self-consistent artifacts. | +| A consumer validates only the Passport schema while receiving different or missing subjects | `verify-passport` reconstructs the Statement from the supplied bundle, report, and OASF record, then requires one exact-byte SHA-256 match for every referenced execution artifact. | An unsigned, wholly replaced but self-consistent artifact set still requires an authenticated envelope or trusted channel. | | A Passport is interpreted as proof of success | The predicate carries separate explicit run and conformance statuses and permits failed or incomplete states. | Consumers must still enforce the statuses appropriate to their decision. | | Raw execution data leaks through placement evidence | The schema and CLI reject payload/content fields, inline or local URI schemes, credentialed HTTPS references, query strings, fragments, and oversized URIs. | Descriptor names, producer identifiers, media types, HTTPS paths, and URNs remain metadata channels; accept them only from trusted producers and redact, pseudonymize, or omit optional URIs. | | Valid placement evidence is replayed onto another run or treated as permission | V2 binds each descriptor to `SHA256(RFC8785(runId))`, fixes its authority to `observation-only`, and rejects mismatches before Passport creation. | A compromised producer can still create false but internally consistent observations; authenticate and qualify producers externally. | diff --git a/scripts/package-smoke.mjs b/scripts/package-smoke.mjs index 04234b1..daa5ffa 100644 --- a/scripts/package-smoke.mjs +++ b/scripts/package-smoke.mjs @@ -154,12 +154,38 @@ try { temporary ); run(executable, ["validate", passportOutput], temporary); + const verification = JSON.parse( + run( + executable, + [ + "verify-passport", + passportOutput, + "--bundle", + join(cliOutput, "run.bundle.json"), + "--report", + join(cliOutput, "conformance-report.json"), + "--oasf-record", + oasfRecord, + "--execution-evidence-resource", + stageFabricEvidence + ], + temporary + ) + ); + if ( + verification.valid !== true || + verification.verifiedSubjects !== 3 || + verification.verifiedExecutionEvidenceResources !== 1 + ) { + throw new Error("Installed CLI did not verify the complete Passport artifact set."); + } console.log( JSON.stringify({ cliVersion: version, cliDemo: "pass", cliStageFabricBinding: "pass", - cliPassport: "pass" + cliPassport: "pass", + cliPassportVerification: "pass" }) ); } finally { diff --git a/src/adapters/documents.ts b/src/adapters/documents.ts index c6fbdaa..b6baabf 100644 --- a/src/adapters/documents.ts +++ b/src/adapters/documents.ts @@ -8,18 +8,23 @@ import { canonicalize } from "../core/canonical.js"; export const MAX_DOCUMENT_BYTES = 16 * 1024 * 1024; -function readSource(path: string): string { +export function readResourceBytes(path: string): Uint8Array { const metadata = statSync(path); if (!metadata.isFile()) { - throw new Error("Contract input must be a regular file."); + throw new Error("Input must be a regular file."); } if (metadata.size > MAX_DOCUMENT_BYTES) { - throw new Error(`Contract input exceeds the ${MAX_DOCUMENT_BYTES}-byte limit.`); + throw new Error(`Input exceeds the ${MAX_DOCUMENT_BYTES}-byte limit.`); } const bytes = readFileSync(path); if (bytes.byteLength > MAX_DOCUMENT_BYTES) { - throw new Error(`Contract input exceeds the ${MAX_DOCUMENT_BYTES}-byte limit.`); + throw new Error(`Input exceeds the ${MAX_DOCUMENT_BYTES}-byte limit.`); } + return bytes; +} + +function readSource(path: string): string { + const bytes = readResourceBytes(path); try { return new TextDecoder("utf-8", { fatal: true }).decode(bytes); } catch { diff --git a/src/cli-program.ts b/src/cli-program.ts index d179f61..876e5b9 100644 --- a/src/cli-program.ts +++ b/src/cli-program.ts @@ -5,6 +5,7 @@ import { Command, InvalidArgumentError } from "commander"; import { readDocument, readJsonDocumentWithSource, + readResourceBytes, writeCanonicalJson, writeJson, writeText @@ -20,6 +21,7 @@ import type { ConformanceProfile, ConformanceReport, ExecutionEvidenceBinding, + ExecutionPassport, RunBundle, ValidationResult } from "./contracts/types.js"; @@ -28,7 +30,7 @@ import { reportIssues, runConformance } from "./conformance/engine.js"; import { lintManifest } from "./conformance/manifest.js"; import { canonicalize } from "./core/canonical.js"; import { explainRun } from "./core/explain.js"; -import { createExecutionPassport } from "./core/passport.js"; +import { createExecutionPassport, verifyExecutionPassport } from "./core/passport.js"; import { evidenceIndex, verifyTraceChain } from "./core/receipts.js"; import { runDemo } from "./demo/demo.js"; @@ -84,6 +86,16 @@ function requireExecutionEvidence(path: string): ExecutionEvidenceBinding { return document as ExecutionEvidenceBinding; } +function requirePassport(path: string): ExecutionPassport { + const document = readDocument(path); + const validation = validateAs("ExecutionPassport", document); + if (!validation.valid) { + printValidation(validation); + throw new Error("The input is not a valid Execution Passport v2."); + } + return document as ExecutionPassport; +} + interface PassportCommandOptions { report: string; oasfRecord: string; @@ -219,6 +231,47 @@ export function createCliProgram(): Command { } }); + program + .command("verify-passport") + .description( + "Verify an unsigned Passport against its complete subjects and execution-evidence files." + ) + .argument("") + .requiredOption("--bundle ", "RunBundle subject") + .requiredOption("--report ", "ConformanceReport subject") + .requiredOption("--oasf-record ", "opaque OASF record subject") + .option( + "--execution-evidence-resource ", + "complete evidence artifact referenced by the Passport; repeat for multiple resources", + collectPath, + [] + ) + .action( + ( + file: string, + options: { + bundle: string; + report: string; + oasfRecord: string; + executionEvidenceResource: string[]; + } + ) => { + const result = verifyExecutionPassport({ + passport: requirePassport(resolve(file)), + bundle: requireBundle(resolve(options.bundle)), + report: requireReport(resolve(options.report)), + oasfRecord: readDocument(resolve(options.oasfRecord)), + executionEvidenceResources: options.executionEvidenceResource.map((path) => + readResourceBytes(resolve(path)) + ) + }); + console.log(JSON.stringify(result, null, 2)); + if (!result.valid) { + process.exitCode = 2; + } + } + ); + program .command("replay") .description("Replay the hash-linked runtime receipt chain and detect tampering or missing evidence.") diff --git a/src/core/passport.ts b/src/core/passport.ts index a6c3a8a..ddcae7e 100644 --- a/src/core/passport.ts +++ b/src/core/passport.ts @@ -1,3 +1,5 @@ +import { createHash } from "node:crypto"; + import { validateAs } from "../contracts/registry.js"; import { API_VERSION, @@ -13,9 +15,10 @@ import { type ResourceDescriptor, type RunBundle, type RunBundleResourceDescriptor, + type ValidationIssue, type ValidationResult } from "../contracts/types.js"; -import { digestValue, verifySeal } from "./canonical.js"; +import { canonicalize, digestValue, verifySeal } from "./canonical.js"; import { evidenceIndex, verifyTraceChain } from "./receipts.js"; export interface ExecutionPassportSubjectUris { @@ -34,6 +37,19 @@ export interface CreateExecutionPassportInput { issuedAt?: string; } +export interface VerifyExecutionPassportInput { + passport: ExecutionPassport; + bundle: RunBundle; + report: ConformanceReport; + oasfRecord: unknown; + executionEvidenceResources?: readonly Uint8Array[]; +} + +export interface ExecutionPassportVerificationResult extends ValidationResult { + verifiedSubjects: number; + verifiedExecutionEvidenceResources: number; +} + function validationMessage(result: ValidationResult): string { return result.issues .map((issue) => `${issue.path} ${issue.code}: ${issue.message}`) @@ -308,3 +324,143 @@ export function createExecutionPassport( requireValid("Execution Passport", validateAs("ExecutionPassport", passport)); return passport; } + +function bytesDigest(value: Uint8Array): string { + return createHash("sha256").update(value).digest("hex"); +} + +function verificationFailure( + issues: ValidationIssue[], + verifiedSubjects = 0, + verifiedExecutionEvidenceResources = 0 +): ExecutionPassportVerificationResult { + return { + valid: false, + issues, + verifiedSubjects, + verifiedExecutionEvidenceResources + }; +} + +/** + * Verify one unsigned Passport against the complete artifacts supplied by its + * consumer. This establishes internal consistency and exact resource binding; + * signer identity and authenticity remain the responsibility of an external + * DSSE or equivalent trust-policy adapter. + */ +export function verifyExecutionPassport( + input: VerifyExecutionPassportInput +): ExecutionPassportVerificationResult { + const schema = validateAs("ExecutionPassport", input.passport); + if (!schema.valid) { + return verificationFailure(schema.issues); + } + + let expected: ExecutionPassport; + try { + expected = createExecutionPassport({ + bundle: input.bundle, + report: input.report, + oasfRecord: input.oasfRecord, + oasfMediaType: input.passport.subject[2].mediaType, + subjectUris: { + ...(input.passport.subject[0].uri === undefined + ? {} + : { runBundle: input.passport.subject[0].uri }), + ...(input.passport.subject[1].uri === undefined + ? {} + : { conformanceReport: input.passport.subject[1].uri }), + ...(input.passport.subject[2].uri === undefined + ? {} + : { oasfRecord: input.passport.subject[2].uri }) + }, + executionEvidence: input.passport.predicate.executionEvidence, + issuedAt: input.passport.predicate.issuedAt + }); + } catch (error: unknown) { + return verificationFailure([ + { + path: "/", + code: "artifact-set", + message: + error instanceof Error + ? `The supplied Passport artifacts are inconsistent: ${error.message}` + : "The supplied Passport artifacts are inconsistent." + } + ]); + } + + const issues: ValidationIssue[] = []; + let verifiedSubjects = 0; + for (const [index, subject] of input.passport.subject.entries()) { + if (canonicalize(subject) === canonicalize(expected.subject[index])) { + verifiedSubjects += 1; + } else { + issues.push({ + path: `/subject/${index}`, + code: "subject-binding", + message: "The subject descriptor does not match the supplied artifact." + }); + } + } + + if (canonicalize(input.passport.predicate) !== canonicalize(expected.predicate)) { + issues.push({ + path: "/predicate", + code: "predicate-binding", + message: "The Passport predicate does not match the supplied run and report." + }); + } + + const expectedResourceDigests = new Map( + input.passport.predicate.executionEvidence.map((binding, index) => [ + binding.resource.digest.sha256, + index + ]) + ); + const suppliedResourceDigests = (input.executionEvidenceResources ?? []).map(bytesDigest); + const suppliedCounts = new Map(); + for (const digest of suppliedResourceDigests) { + suppliedCounts.set(digest, (suppliedCounts.get(digest) ?? 0) + 1); + } + + let verifiedExecutionEvidenceResources = 0; + for (const [digest, index] of expectedResourceDigests) { + const count = suppliedCounts.get(digest) ?? 0; + if (count === 1) { + verifiedExecutionEvidenceResources += 1; + } else { + issues.push({ + path: `/predicate/executionEvidence/${index}/resource/digest/sha256`, + code: count === 0 ? "resource-missing" : "resource-duplicate", + message: + count === 0 + ? "No supplied execution-evidence artifact matches this digest." + : "More than one supplied execution-evidence artifact matches this digest." + }); + } + } + for (const [index, digest] of suppliedResourceDigests.entries()) { + if (!expectedResourceDigests.has(digest)) { + issues.push({ + path: `/executionEvidenceResources/${index}`, + code: "resource-unreferenced", + message: "The supplied execution-evidence artifact is not referenced by the Passport." + }); + } + } + + if (issues.length > 0) { + return verificationFailure( + issues, + verifiedSubjects, + verifiedExecutionEvidenceResources + ); + } + return { + valid: true, + issues: [], + verifiedSubjects, + verifiedExecutionEvidenceResources + }; +} diff --git a/test/execution-passport-cli.test.ts b/test/execution-passport-cli.test.ts index aa18b67..1e9f15e 100644 --- a/test/execution-passport-cli.test.ts +++ b/test/execution-passport-cli.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -33,8 +33,9 @@ function evidenceBinding(): ExecutionEvidenceBinding { } describe("passport CLI", () => { - it("writes exact canonical Statement bytes without embedding source documents or paths", async () => { + it("writes canonical Statement bytes and verifies the installed consumer artifact set", async () => { const directory = mkdtempSync(join(tmpdir(), "agentic-strata-passport-cli-")); + const logOutput = vi.spyOn(console, "log").mockImplementation(() => undefined); try { const bundle = runDemo().bundle; const report = runConformance(bundle, "enterprise", { @@ -44,11 +45,13 @@ describe("passport CLI", () => { const reportPath = join(directory, "report.json"); const oasfPath = join(directory, "external-oasf.json"); const evidencePath = join(directory, "execution-evidence-binding.json"); + const evidenceResourcePath = join(directory, "execution-placement-evidence.json"); const outputPath = join(directory, "passport.json"); writeJson(bundlePath, bundle); writeJson(reportPath, report); writeJson(oasfPath, { opaqueExternalRecord: "must-not-be-embedded" }); writeJson(evidencePath, evidenceBinding()); + writeFileSync(evidenceResourcePath, '{"contentFreeFixture":true}', "utf8"); await createCliProgram().parseAsync( [ @@ -77,7 +80,32 @@ describe("passport CLI", () => { expect(raw).not.toContain("must-not-be-embedded"); expect(raw).not.toContain(directory); expect(raw.endsWith("\n")).toBe(false); + + await createCliProgram().parseAsync( + [ + "verify-passport", + outputPath, + "--bundle", + bundlePath, + "--report", + reportPath, + "--oasf-record", + oasfPath, + "--execution-evidence-resource", + evidenceResourcePath + ], + { from: "user" } + ); + const verification = JSON.parse( + String(logOutput.mock.calls.at(-1)?.[0]) + ) as Record; + expect(verification).toMatchObject({ + valid: true, + verifiedSubjects: 3, + verifiedExecutionEvidenceResources: 1 + }); } finally { + logOutput.mockRestore(); rmSync(directory, { recursive: true, force: true }); } }); diff --git a/test/execution-passport-verification.test.ts b/test/execution-passport-verification.test.ts new file mode 100644 index 0000000..a46b92d --- /dev/null +++ b/test/execution-passport-verification.test.ts @@ -0,0 +1,165 @@ +import { createHash } from "node:crypto"; + +import { describe, expect, it } from "vitest"; + +import { + createExecutionPassport, + digestValue, + runConformance, + runDemo, + verifyExecutionPassport +} from "../src/index.js"; +import type { + ExecutionEvidenceBinding, + ExecutionPassport +} from "../src/index.js"; + +const GENERATED_AT = "2026-07-17T12:00:00.000Z"; +const ISSUED_AT = "2026-07-17T12:00:01.000Z"; + +function digestBytes(value: Uint8Array): string { + return createHash("sha256").update(value).digest("hex"); +} + +function fixture() { + const bundle = runDemo().bundle; + const report = runConformance(bundle, "enterprise", { generatedAt: GENERATED_AT }); + const oasfRecord = { externalRecord: "validated-by-owner" }; + const executionEvidenceResource = new TextEncoder().encode( + '{"kind":"opaque-placement-evidence","run":"run-change-demo-001"}\n' + ); + const executionEvidence: ExecutionEvidenceBinding = { + contractType: "ExecutionEvidenceBinding", + role: "execution-placement", + producer: "placement-provider", + runIdDigest: digestValue(bundle.execution.runId), + observedAt: "2026-07-17T11:59:59.000Z", + authority: "observation-only", + resource: { + name: "execution-placement-evidence", + digest: { sha256: digestBytes(executionEvidenceResource) }, + mediaType: "application/vnd.example.execution-placement+json" + }, + disclosure: "content-free" + }; + const passport = createExecutionPassport({ + bundle, + report, + oasfRecord, + oasfMediaType: "application/json", + executionEvidence: [executionEvidence], + issuedAt: ISSUED_AT + }); + return { bundle, report, oasfRecord, executionEvidenceResource, passport }; +} + +describe("Execution Passport consumer verification", () => { + it("verifies all subjects, predicate bindings, and exact evidence bytes", () => { + const input = fixture(); + + expect( + verifyExecutionPassport({ + ...input, + executionEvidenceResources: [input.executionEvidenceResource] + }) + ).toEqual({ + valid: true, + issues: [], + verifiedSubjects: 3, + verifiedExecutionEvidenceResources: 1 + }); + }); + + it("rejects a different subject even when the Passport remains schema-valid", () => { + const input = fixture(); + const result = verifyExecutionPassport({ + ...input, + oasfRecord: { externalRecord: "replaced" }, + executionEvidenceResources: [input.executionEvidenceResource] + }); + + expect(result.valid).toBe(false); + expect(result.verifiedSubjects).toBe(2); + expect(result.issues).toContainEqual({ + path: "/subject/2", + code: "subject-binding", + message: "The subject descriptor does not match the supplied artifact." + }); + }); + + it("rejects a predicate that overstates the supplied report", () => { + const input = fixture(); + const passport: ExecutionPassport = { + ...input.passport, + predicate: { ...input.passport.predicate, conformanceStatus: "fail" } + }; + const result = verifyExecutionPassport({ + ...input, + passport, + executionEvidenceResources: [input.executionEvidenceResource] + }); + + expect(result.valid).toBe(false); + expect(result.issues).toContainEqual({ + path: "/predicate", + code: "predicate-binding", + message: "The Passport predicate does not match the supplied run and report." + }); + }); + + it("requires a one-to-one match for every referenced evidence resource", () => { + const input = fixture(); + const missing = verifyExecutionPassport(input); + expect(missing.valid).toBe(false); + expect(missing.issues[0]?.code).toBe("resource-missing"); + + const changedResource = new TextEncoder().encode("changed\n"); + const changed = verifyExecutionPassport({ + ...input, + executionEvidenceResources: [changedResource] + }); + expect(changed.issues.map((issue) => issue.code)).toEqual([ + "resource-missing", + "resource-unreferenced" + ]); + + const duplicate = verifyExecutionPassport({ + ...input, + executionEvidenceResources: [ + input.executionEvidenceResource, + input.executionEvidenceResource + ] + }); + expect(duplicate.issues[0]?.code).toBe("resource-duplicate"); + }); + + it("rejects an internally inconsistent artifact set before trusting descriptors", () => { + const input = fixture(); + const bundle = structuredClone(input.bundle); + bundle.manifest.metadata.description = "A different supplied application manifest."; + const result = verifyExecutionPassport({ + ...input, + bundle, + executionEvidenceResources: [input.executionEvidenceResource] + }); + + expect(result).toMatchObject({ + valid: false, + verifiedSubjects: 0, + verifiedExecutionEvidenceResources: 0, + issues: [{ path: "/", code: "artifact-set" }] + }); + }); + + it("rejects a non-v2 or structurally extended Passport", () => { + const input = fixture(); + const result = verifyExecutionPassport({ + ...input, + passport: { ...input.passport, signatures: [] } as unknown as ExecutionPassport, + executionEvidenceResources: [input.executionEvidenceResource] + }); + + expect(result.valid).toBe(false); + expect(result.issues.some((issue) => issue.code === "additionalProperties")).toBe(true); + }); +}); diff --git a/test/stagefabric-evidence.test.ts b/test/stagefabric-evidence.test.ts index a1658fb..a0f8147 100644 --- a/test/stagefabric-evidence.test.ts +++ b/test/stagefabric-evidence.test.ts @@ -16,7 +16,8 @@ import { runConformance, runDemo, serializeStageFabricExecutionPlacementEvidence, - validateDocument + validateDocument, + verifyExecutionPassport } from "../src/index.js"; import type { StageFabricExecutionPlacementEvidence } from "../src/index.js"; import { writeJson, writeText } from "../src/adapters/documents.js"; @@ -156,6 +157,31 @@ describe("StageFabric execution placement evidence adapter", () => { ); }); + it("verifies the complete golden StageFabric artifact from the consumer side", () => { + const fixturePath = fileURLToPath( + new URL("fixtures/stagefabric/execution-placement-evidence.json", import.meta.url) + ); + const source = readFileSync(fixturePath); + const evidence = JSON.parse(source.toString("utf8")) as StageFabricExecutionPlacementEvidence; + const input = passportInput(evidence); + const passport = createExecutionPassport(input); + + expect( + verifyExecutionPassport({ + passport, + bundle: input.bundle, + report: input.report, + oasfRecord: input.oasfRecord, + executionEvidenceResources: [source] + }) + ).toEqual({ + valid: true, + issues: [], + verifiedSubjects: 3, + verifiedExecutionEvidenceResources: 1 + }); + }); + it("emits canonical CLI input for the passport command without provider content", async () => { const directory = mkdtempSync(join(tmpdir(), "agentic-strata-stagefabric-")); try {