From 16f0763626af9735c96edbaeb8bdd27846596b6a Mon Sep 17 00:00:00 2001 From: Antonio Antenore Date: Sun, 19 Jul 2026 12:02:53 +0200 Subject: [PATCH 1/2] feat: bind execution evidence to observed runs --- README.md | 26 +- adapters/stagefabric.mapping.yaml | 8 +- docs/adr/0004-execution-passport.md | 2 +- docs/adr/0005-run-bound-execution-evidence.md | 26 ++ docs/architecture.md | 2 +- docs/contracts-and-conformance.md | 4 +- docs/interoperability.md | 5 +- .../execution-passport/v1/README.md | 6 +- .../execution-passport/v2/README.md | 75 ++++ docs/threat-model.md | 2 + package-lock.json | 4 +- package.json | 2 +- schemas/v1/agentic-strata.schema.json | 22 +- scripts/package-smoke.mjs | 84 ++++- src/adapters/documents.ts | 44 ++- src/adapters/stagefabric-evidence.ts | 346 ++++++++++++++++++ src/cli-program.ts | 32 +- src/contracts/registry.ts | 85 ++++- src/contracts/types.ts | 14 +- src/core/passport.ts | 32 +- src/index.ts | 1 + test/demo-documents-explain.test.ts | 10 + test/execution-passport-cli.test.ts | 6 +- test/execution-passport.test.ts | 30 +- test/fixtures/stagefabric/README.md | 8 + .../execution-placement-evidence.json | 1 + test/stagefabric-evidence.test.ts | 262 +++++++++++++ 27 files changed, 1079 insertions(+), 60 deletions(-) create mode 100644 docs/adr/0005-run-bound-execution-evidence.md create mode 100644 docs/spec/attestations/execution-passport/v2/README.md create mode 100644 src/adapters/stagefabric-evidence.ts create mode 100644 test/fixtures/stagefabric/README.md create mode 100644 test/fixtures/stagefabric/execution-placement-evidence.json create mode 100644 test/stagefabric-evidence.test.ts diff --git a/README.md b/README.md index 99dfa38..e81eb4f 100644 --- a/README.md +++ b/README.md @@ -37,8 +37,8 @@ Logical strata may share one process. The rule is a dependency and responsibilit - A composite cache fingerprint that requires both semantic and operational equivalence: policy, capability set, authoritative context, tenant, privacy, constraints, and outcome are all bound. - An RFC 8785 JSON Canonicalization Scheme implementation with cross-language golden vectors, strict duplicate-member rejection at the JSON boundary, and a hash-linked receipt chain with tamper and missing-evidence detection. - Runtime conformance checks for rooted authority, causal evidence, actual budget consumption, exact-action idempotency, approval, read-back or compensation, predeclared criterion evidence bindings, layer direction, and model/authority separation. -- An [Execution Passport](docs/spec/attestations/execution-passport/v1/README.md) that binds one run bundle, its complete conformance report, and one externally validated OASF record in a strict in-toto Statement without embedding those documents. -- Mapping-only adapter documents for AG-UI, A2A, MCP, OpenTelemetry GenAI, CloudEvents, an external policy engine, OASF, and content-free execution-placement evidence. +- An [Execution Passport v2](docs/spec/attestations/execution-passport/v2/README.md) that binds one run bundle, its complete conformance report, one externally validated OASF record, and optional run-bound observations in a strict in-toto Statement without embedding those documents. +- Mapping-only adapter documents for AG-UI, A2A, MCP, OpenTelemetry GenAI, CloudEvents, an external policy engine, and OASF, plus an executable StageFabric evidence-reduction adapter. - A neutral prepare/commit/read-back demo with exact-action approval and duplicate suppression. ## Quick start @@ -72,7 +72,23 @@ node dist/cli.js passport .tmp/demo/run.bundle.json \ node dist/cli.js validate .tmp/demo/execution-passport.json ``` -The output file contains exact RFC 8785 Statement bytes suitable for an external DSSE/Sigstore adapter. Repeat `--execution-evidence` only with strict `ExecutionEvidenceBinding` documents containing descriptors and digests, never provider output. +When StageFabric produces a sealed content-free placement artifact, reduce it to a run-bound descriptor before Passport creation: + +```bash +node dist/cli.js bind-stagefabric /path/to/stagefabric-evidence.json \ + --uri urn:stagefabric:evidence:run-001 \ + --output .tmp/demo/stagefabric-binding.json +node dist/cli.js passport .tmp/demo/run.bundle.json \ + --report .tmp/demo/conformance-report.json \ + --oasf-record /path/to/validated-oasf-record.json \ + --oasf-media-type "$OASF_MEDIA_TYPE" \ + --execution-evidence .tmp/demo/stagefabric-binding.json \ + --output .tmp/demo/execution-passport.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`. + +`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. All commands accept JSON; `validate` and `lint` also accept YAML. Build once with `npm run build` before invoking the repository-local CLI. Input documents are bounded to 16 MiB, JSON member names must be unique, and YAML alias expansion is limited. @@ -115,7 +131,7 @@ Read [Architecture](docs/architecture.md), [Contracts and conformance](docs/cont - 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. - 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 strict descriptors with no payload or `content` field. 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. +- 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. - A safe fingerprint binds the declared canonical intent. It does not prove that an upstream intent normalizer chose the correct meaning. - Adapter documents are integration contracts, not bundled protocol SDKs or policy engines. - The current release is an alpha contract surface. Version consumers explicitly before production adoption. @@ -126,6 +142,6 @@ Read [Architecture](docs/architecture.md), [Contracts and conformance](docs/cont npm run release:check ``` -The release gate runs strict lint and types, 60+ tests with coverage thresholds, build, repository hygiene, production audit, `publint`, and Are the Types Wrong. +The release gate runs strict lint and types, 80+ tests with coverage thresholds, build, repository hygiene, production audit, `publint`, and Are the Types Wrong. Apache-2.0 licensed. See [CONTRIBUTING.md](CONTRIBUTING.md) and [SECURITY.md](SECURITY.md). diff --git a/adapters/stagefabric.mapping.yaml b/adapters/stagefabric.mapping.yaml index 3d6f2a3..f9275e4 100644 --- a/adapters/stagefabric.mapping.yaml +++ b/adapters/stagefabric.mapping.yaml @@ -2,14 +2,14 @@ contractType: AdapterMapping apiVersion: agenticstrata.dev/v1 mappingId: adapter/stagefabric protocol: stagefabric -protocolVersion: configurable +protocolVersion: v1alpha1 direction: inbound contractBindings: - strataContract: ExecutionEvidenceBinding - protocolObject: ExecutionPlacementEvidence ResourceDescriptor - notes: Only role, producer, disclosure mode, resource name, canonical digest, media type, and an optional bounded stable HTTPS or URN reference are admitted; metadata must be trusted and redacted or pseudonymized when sensitive. + protocolObject: ExecutionPlacementEvidence + notes: The executable adapter validates strict source schema, semantic trace, timestamp grammar, seal, and exact canonical-JSON-plus-LF writer bytes; converts the prefixed run digest; normalizes observedAt; fixes authority to observation-only; and hashes the retrievable file bytes. - strataContract: ExecutionPassport protocolObject: predicate executionEvidence entry - notes: No payload or content field is admitted; the full provider result remains outside the mapping. + notes: Passport creation rejects a source from another run. No payload or content field is admitted, and placement never grants executable authority. lossPolicy: explicit-loss extensionPoint: true diff --git a/docs/adr/0004-execution-passport.md b/docs/adr/0004-execution-passport.md index a5b550a..45f52de 100644 --- a/docs/adr/0004-execution-passport.md +++ b/docs/adr/0004-execution-passport.md @@ -1,6 +1,6 @@ # ADR 0004: Execution Passport as a strict external attestation statement -Status: accepted +Status: accepted for v1; superseded by ADR 0005 for new Passport evidence ## Context diff --git a/docs/adr/0005-run-bound-execution-evidence.md b/docs/adr/0005-run-bound-execution-evidence.md new file mode 100644 index 0000000..37f63a1 --- /dev/null +++ b/docs/adr/0005-run-bound-execution-evidence.md @@ -0,0 +1,26 @@ +# ADR 0005: Run-bound execution evidence is observation-only + +Status: accepted + +## Context + +Execution Passport v1 admitted strict content-free placement descriptors, but the descriptor did not cryptographically identify the run it observed. A valid descriptor could therefore be attached to another run if its resource digest remained unchanged. Placement metadata must also never be interpreted as permission to execute. + +## Decision + +Execution Passport v2 requires every `ExecutionEvidenceBinding` to carry `runIdDigest = SHA256(RFC8785(runId))`, normalized `observedAt`, and `authority = observation-only`. Passport creation validates the binding and rejects any run-digest mismatch or observation later than the bound report before emission. + +Provider integrations are explicit reduction adapters. They validate the external artifact schema and producer seal, bind the complete artifact by digest, and emit only a strict descriptor. + +The StageFabric adapter binds the exact canonical JSON file bytes written by StageFabric, including its trailing LF, rather than hashing a reparsed object. Its CLI path rejects alternative encodings so a URI can be verified byte-for-byte against the descriptor. + +The evidence can explain where execution was observed. It cannot issue a grant, widen a grant, authorize a capability, or override an authority check. Provider payloads and result objects remain outside the Passport. + +## Consequences + +- Cross-run descriptor reuse fails closed. +- Future or differently serialized observations fail closed. +- Consumers can distinguish observation from authorization mechanically. +- External formats remain replaceable behind narrow validation adapters. +- A producer seal still provides integrity, not identity; authenticity remains an external signature or trusted-channel concern. +- V1 evidence cannot be upgraded by relabeling. It must be regenerated with a proven run binding or omitted. diff --git a/docs/architecture.md b/docs/architecture.md index 6ffd372..fa97203 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -78,7 +78,7 @@ Passport creation is a deterministic packaging step after conformance evaluation 2. the canonical digest of the complete `ConformanceReport`, including its own `digest` field; 3. the canonical digest of one opaque, externally validated OASF record. -Optional execution-placement evidence is already reduced by its producer to a `ResourceDescriptor` with no payload or `content` field. AgenticStrata admits only the descriptor, producer identity, fixed role, and `content-free` disclosure marker; descriptor metadata must be trusted and redacted or pseudonymized when sensitive, and its URI may be omitted. It never consumes a provider’s full execution result through this boundary. +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, semantic trace, 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 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. diff --git a/docs/contracts-and-conformance.md b/docs/contracts-and-conformance.md index 28f99b5..f2196f1 100644 --- a/docs/contracts-and-conformance.md +++ b/docs/contracts-and-conformance.md @@ -44,13 +44,13 @@ Every report binds the run bundle, selected rule list, effective merged profile ## Execution Passport invariants -The [v1 specification](spec/attestations/execution-passport/v1/README.md) is an in-toto Statement with one project-owned predicate URI. Its subject tuple has fixed order and length: run bundle, complete conformance report, external OASF record. Each subject is a strict `ResourceDescriptor` containing only a name, one SHA-256 digest, a media type, and an optional bounded stable HTTPS or URN reference. The report subject uses `digestValue(report)`, not `report.digest`: the former binds the complete serialized report, while the latter seals the report content before its own digest field is added. +The [v2 specification](spec/attestations/execution-passport/v2/README.md) is an in-toto Statement with one project-owned predicate URI. Its subject tuple has fixed order and length: run bundle, complete conformance report, external OASF record. Each subject is a strict `ResourceDescriptor` containing only a name, one SHA-256 digest, a media type, and an optional bounded stable HTTPS or URN reference. The report subject uses `digestValue(report)`, not `report.digest`: the former binds the complete serialized report, while the latter seals the report content before its own digest field is added. The predicate records `runStatus` and `conformanceStatus` separately. Creation does not require either status to be successful, so an incomplete or failed run can be bound honestly. It does require unambiguous run identity, a valid receipt chain, a sealed report and evaluator identity, ordered unique check identifiers bound by `rulesDigest`, status derived from those checks, matching bundle and manifest digests, report/receipt status agreement, report generation no earlier than any included observed evidence, and issuance no earlier than report generation. OASF input is accepted only as an opaque I-JSON object and canonicalized for its subject digest. AgenticStrata deliberately does not carry an OASF schema and therefore does not claim semantic validity. A caller must validate the record externally and provide the owner-declared media type. -`executionEvidence` accepts only strict bindings with no payload or `content` field. The producer computes and publishes the evidence artifact digest; AgenticStrata carries its descriptor but never receives the full provider result through the CLI. Descriptor metadata must be trusted and redacted or pseudonymized when sensitive, and the URI may be omitted. +`executionEvidence` accepts only strict bindings with no payload or `content` field. Every binding carries the canonical digest of the observed run identifier, normalized observation time, and fixed `observation-only` authority; a run mismatch or observation later than the report fails Passport creation. The StageFabric reduction adapter verifies the source schema, semantic invariants, producer seal, timestamp grammar, and exact writer encoding, then binds the complete canonical-JSON-plus-LF file bytes. AgenticStrata never imports a full provider result. Descriptor metadata must be trusted and redacted or pseudonymized when sensitive, and the URI may be omitted. 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. diff --git a/docs/interoperability.md b/docs/interoperability.md index 860fe45..26c367d 100644 --- a/docs/interoperability.md +++ b/docs/interoperability.md @@ -12,13 +12,14 @@ AgenticStrata owns application-level runtime invariants. It deliberately leaves | Policy engine | Grant, approval, attenuation query | The external enforcement point remains authoritative. | | OASF | Opaque agent-record subject descriptor | AgenticStrata binds canonical bytes but delegates semantic validation and media-type ownership. | | in-toto / DSSE | Execution Passport Statement and optional external envelope | The core owns deterministic binding; signing, identity, transparency, and trusted time stay outside it. | -| Execution-placement provider | Content-free evidence descriptor | No payload or `content` field is admitted; descriptor metadata remains subject to producer trust, redaction, and pseudonymization policy. | +| StageFabric | Sealed content-free placement artifact reduced to an evidence descriptor | Adapter verifies schema, seal, exact writer bytes, run binding, and observation time; the result is observation-only and cannot expand authority. | +| Other execution-placement provider | Content-free, run-bound evidence descriptor | No payload or `content` field is admitted; producer validation remains an explicit adapter responsibility. | Adapter YAML files are declarative mappings with an explicit `lossPolicy`. They contain no vendor SDK and can be replaced without changing the core contract registry. ## Attestation exchange -The Execution Passport uses the project-controlled predicate namespace documented at [`docs/spec/attestations/execution-passport/v1`](spec/attestations/execution-passport/v1/README.md). The core writes exact RFC 8785 Statement bytes. A deployment may pass those bytes to an external DSSE/Sigstore adapter using payload type `application/vnd.in-toto+json`; no signing key, certificate flow, or Sigstore dependency enters AgenticStrata. +The Execution Passport uses the project-controlled predicate namespace documented at [`docs/spec/attestations/execution-passport/v2`](spec/attestations/execution-passport/v2/README.md). The core writes exact RFC 8785 Statement bytes. A deployment may pass those bytes to an external DSSE/Sigstore adapter using payload type `application/vnd.in-toto+json`; no signing key, certificate flow, or Sigstore dependency enters AgenticStrata. Consumers choose one of two explicit policies: diff --git a/docs/spec/attestations/execution-passport/v1/README.md b/docs/spec/attestations/execution-passport/v1/README.md index 8860063..d663a80 100644 --- a/docs/spec/attestations/execution-passport/v1/README.md +++ b/docs/spec/attestations/execution-passport/v1/README.md @@ -1,6 +1,8 @@ # AgenticStrata Execution Passport v1 -Status: alpha, versioned contract +Status: superseded historical contract + +New producers must use [Execution Passport v2](../v2/README.md). V1 execution-evidence descriptors did not bind the observed run and must not be promoted by changing only the predicate URI. This document is retained for migration analysis; `validate` retains read-only v1 Statement support while the current creation schema and builder implement v2. Predicate type: @@ -10,7 +12,7 @@ Predicate type: Execution Passport v1 is an in-toto Statement that binds one runtime bundle, the complete conformance report produced for that bundle, and one external OASF agent record. It packages lineage; it is not a success certificate, an OASF validator, a digital signature, or a trusted timestamp. -The normative machine-readable shape is `$defs.ExecutionPassport` in [`schemas/v1/agentic-strata.schema.json`](../../../../../schemas/v1/agentic-strata.schema.json). +The historical shape is described here. The current `$defs.ExecutionPassport` in [`schemas/v1/agentic-strata.schema.json`](../../../../../schemas/v1/agentic-strata.schema.json) implements v2. ## Statement envelope diff --git a/docs/spec/attestations/execution-passport/v2/README.md b/docs/spec/attestations/execution-passport/v2/README.md new file mode 100644 index 0000000..5db77b0 --- /dev/null +++ b/docs/spec/attestations/execution-passport/v2/README.md @@ -0,0 +1,75 @@ +# AgenticStrata Execution Passport v2 + +Status: alpha, versioned contract + +Predicate type: + +`https://github.com/aantenore/AgenticStrata/tree/main/docs/spec/attestations/execution-passport/v2` + +## Purpose + +Execution Passport v2 is a strict in-toto Statement that binds one runtime bundle, the complete conformance report produced for it, one externally validated OASF record, and optional content-free execution observations. It packages lineage; it is not a success certificate, an OASF validator, a digital signature, a trusted timestamp, or an authority grant. + +The normative machine-readable shape is `$defs.ExecutionPassport` in [`schemas/v1/agentic-strata.schema.json`](../../../../../schemas/v1/agentic-strata.schema.json). + +## Statement envelope and subjects + +The top-level object contains exactly `_type`, `subject`, `predicateType`, and `predicate`. `_type` is `https://in-toto.io/Statement/v1`; `predicateType` is the v2 URI above. Signatures belong to an external envelope. + +The ordered subject tuple has fixed length and meaning: + +| Index | `name` | `mediaType` | SHA-256 input | +| --- | --- | --- | --- | +| 0 | `agentic-strata-run-bundle` | `application/vnd.aantenore.agenticstrata.run-bundle+json` | RFC 8785 canonical `RunBundle` | +| 1 | `agentic-strata-conformance-report` | `application/vnd.aantenore.agenticstrata.conformance-report+json` | RFC 8785 canonical complete `ConformanceReport` | +| 2 | `oasf-agent-record` | Declared by the external owner | RFC 8785 canonical opaque OASF JSON record | + +Each subject is a strict `ResourceDescriptor`: name, one lowercase hexadecimal SHA-256 digest, media type, and optionally a bounded stable HTTPS or URN reference. Credentialed URLs, query strings, fragments, local paths, inline content, and extra fields are invalid. + +## Predicate + +The predicate contains the shared run identifier, selected conformance profile, separate run and conformance statuses, evaluator digest, terminal receipt digest, self-asserted issuance time, and zero or more execution-evidence bindings. Passport presence never changes either status and never implies success. + +## Run-bound execution evidence + +Every `ExecutionEvidenceBinding` contains exactly: + +- `contractType: ExecutionEvidenceBinding`; +- `role: execution-placement`; +- a provider-neutral `producer` identifier; +- `runIdDigest`, computed as SHA-256 over RFC 8785 canonical JSON encoding of the Passport `runId`; +- `observedAt`, normalized to an RFC 3339 UTC date-time by a validated provider adapter; +- `authority: observation-only`; +- `disclosure: content-free`; +- one strict `ResourceDescriptor` for the complete separately stored evidence artifact. + +The builder rejects a binding when `runIdDigest` does not match the bound run, when two evidence artifacts share a digest, when `observedAt` is later than the report, or when the descriptor is not strict. An observation can support lineage but cannot grant, widen, or substitute executable authority. + +For StageFabric, the resource digest is SHA-256 over the exact UTF-8 bytes written by its safe writer: canonical JSON for the complete evidence object, including the producer seal, followed by one LF byte. The CLI rejects YAML, pretty JSON, missing or extra whitespace, and other reserializations. If a descriptor carries a URI, retrieval must return those exact bytes. + +Provider adapters must validate their source contract and seal before creating a binding. They must not copy stage names, targets, prompts, model input or output, credentials, paths, or provider result objects into the binding. Descriptor metadata can still be sensitive and must be redacted or pseudonymized; the URI is optional. + +## Creation checks + +A conforming builder fails closed unless: + +1. bundle and report satisfy their schemas and seals; +2. ordered report checks are unique, match `rulesDigest`, and derive the reported status; +3. report bundle and manifest digests match the supplied runtime bundle; +4. the receipt chain is intact and every observed run identifier agrees; +5. report run status agrees with the terminal receipt shape; +6. report generation is no earlier than bundle or external execution observations and issuance is no earlier than the report; +7. every subject descriptor and evidence binding is strict; +8. every execution-evidence `runIdDigest` equals the canonical digest of the Passport run identifier. + +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. + +## 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. + +For authenticity, wrap the exact RFC 8785 Statement bytes in a DSSE envelope with payload type `application/vnd.in-toto+json`, then enforce signer identity, trust material, freshness, revocation, and transparency policy outside AgenticStrata. Consumers explicitly choose either unsigned integrity or authenticated-required policy; there is no automatic downgrade. + +## Versioning and v1 migration + +V2 adds mandatory run binding, observation time, and observation-only authority semantics to execution evidence. V1 evidence descriptors did not identify the observed run, so a valid v1 descriptor must not be promoted by merely changing the predicate URI. Recreate the descriptor from a producer artifact that proves the run binding, or omit it. The registry retains read-only validation of archived v1 Statements while all creation emits v2. Breaking predicate changes require a new versioned path. diff --git a/docs/threat-model.md b/docs/threat-model.md index 204b5df..bc2fa51 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -35,6 +35,8 @@ Model output, retrieved content, external messages, tool descriptions, and impor | 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 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. | +| An external observation is backdated, postdates its report, or is reserialized behind its URI | The binding carries normalized `observedAt`; report chronology includes it; the StageFabric adapter verifies the producer timestamp grammar and hashes exact canonical writer bytes including LF. | Dates are self-asserted unless the producer is authenticated and backed by trusted time. Availability of the URI remains external. | | A report claims success after checks are removed, reordered, duplicated, or changed | Creation requires unique ordered check identifiers, binds that order to `rulesDigest`, and derives report status from the checks. | Hash consistency does not authenticate the evaluator or its implementation. | | A report or Passport appears to predate evidence it assesses | Creation requires report generation after every included observed evidence timestamp and issuance after report generation. | These are self-asserted consistency checks, not trusted timestamps; non-repudiation requires external infrastructure. | | An OASF digest is mistaken for semantic validation | The record is treated as opaque I-JSON and documentation requires validation by the owning ecosystem before binding. | A caller can still bind an invalid record if it ignores that prerequisite. | diff --git a/package-lock.json b/package-lock.json index f267802..33e5632 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agentic-strata", - "version": "0.2.0-alpha.1", + "version": "0.3.0-alpha.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agentic-strata", - "version": "0.2.0-alpha.1", + "version": "0.3.0-alpha.1", "license": "Apache-2.0", "dependencies": { "ajv": "^8.17.1", diff --git a/package.json b/package.json index f5ec14f..a1d600c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agentic-strata", - "version": "0.2.0-alpha.1", + "version": "0.3.0-alpha.1", "description": "Executable, contract-first reference architecture for enterprise agentic applications.", "type": "module", "license": "Apache-2.0", diff --git a/schemas/v1/agentic-strata.schema.json b/schemas/v1/agentic-strata.schema.json index b8b6aaa..58c43c0 100644 --- a/schemas/v1/agentic-strata.schema.json +++ b/schemas/v1/agentic-strata.schema.json @@ -20,6 +20,7 @@ { "$ref": "#/$defs/ArtifactAttestation" }, { "$ref": "#/$defs/TraceEvent" }, { "$ref": "#/$defs/ConformanceReport" }, + { "$ref": "#/$defs/ExecutionEvidenceBinding" }, { "$ref": "#/$defs/ExecutionPassport" }, { "$ref": "#/$defs/AdapterMapping" }, { "$ref": "#/$defs/RunBundle" } @@ -75,12 +76,25 @@ "type": "object", "additionalProperties": false, "properties": { + "contractType": { "type": "string", "const": "ExecutionEvidenceBinding" }, "role": { "type": "string", "const": "execution-placement" }, "producer": { "$ref": "#/$defs/Identifier" }, + "runIdDigest": { "$ref": "#/$defs/Digest" }, + "observedAt": { "type": "string", "format": "date-time" }, + "authority": { "type": "string", "const": "observation-only" }, "resource": { "$ref": "#/$defs/ResourceDescriptor" }, "disclosure": { "type": "string", "const": "content-free" } }, - "required": ["role", "producer", "resource", "disclosure"] + "required": [ + "contractType", + "role", + "producer", + "runIdDigest", + "observedAt", + "authority", + "resource", + "disclosure" + ] }, "Identifier": { "type": "string", @@ -1055,10 +1069,10 @@ "additionalProperties": false, "properties": { "name": { "type": "string", "const": "agentic-strata" }, - "version": { "type": "string", "const": "0.2.0-alpha.1" }, + "version": { "type": "string", "const": "0.3.0-alpha.1" }, "revision": { "type": "string", - "const": "conformance-2026-07-17.3" + "const": "conformance-2026-07-19.1" }, "digest": { "$ref": "#/$defs/Digest" } }, @@ -1193,7 +1207,7 @@ }, "predicateType": { "type": "string", - "const": "https://github.com/aantenore/AgenticStrata/tree/main/docs/spec/attestations/execution-passport/v1" + "const": "https://github.com/aantenore/AgenticStrata/tree/main/docs/spec/attestations/execution-passport/v2" }, "predicate": { "$ref": "#/$defs/ExecutionPassportPredicate" } }, diff --git a/scripts/package-smoke.mjs b/scripts/package-smoke.mjs index 9f4f620..565a75b 100644 --- a/scripts/package-smoke.mjs +++ b/scripts/package-smoke.mjs @@ -2,6 +2,7 @@ import { execFileSync } from "node:child_process"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; const project = resolve(import.meta.dirname, ".."); const temporary = mkdtempSync(join(tmpdir(), "agentic-strata-consumer-")); @@ -35,12 +36,16 @@ try { ); const probe = [ - 'import { createExecutionPassport, runConformance, runDemo, validateDocument } from "agentic-strata";', + 'import { createExecutionPassport, createStageFabricExecutionEvidenceBinding, digestValue, runConformance, runDemo, validateDocument } from "agentic-strata";', 'const result = runDemo();', 'const report = runConformance(result.bundle, "enterprise");', - 'const passport = createExecutionPassport({ bundle: result.bundle, report, oasfRecord: { opaqueExternalRecord: true }, oasfMediaType: "application/json" });', - 'if (report.status !== "pass" || report.runStatus !== "completed" || result.readBack !== true || !validateDocument(passport).valid || passport.subject.length !== 3) process.exit(2);', - 'console.log(JSON.stringify({ status: report.status, runStatus: report.runStatus, readBack: result.readBack, passport: "pass" }));' + 'const sha256 = (value) => `sha256:${digestValue(value)}`;', + 'const identity = { stageIdDigest: sha256("stage"), targetIdDigest: sha256("target"), zoneDigest: sha256("zone"), adapterKindDigest: sha256("adapter") };', + 'const unsigned = { apiVersion: "stagefabric.dev/v1alpha1", kind: "ExecutionPlacementEvidence", producer: "stagefabric", disclosure: "content-free", authority: "observation-only", runIdDigest: sha256(result.bundle.execution.runId), observedAt: "2026-07-17T12:00:00.000Z", planDigest: sha256("plan"), bindingDigest: sha256("binding"), snapshotDigest: sha256("snapshot"), egressDigest: sha256("egress"), placements: [{ ...identity, attempt: 1, status: "succeeded", reasonCode: "completed" }], trace: [{ ...identity, attempt: 1, status: "succeeded", reasonCode: "completed" }] };', + 'const executionEvidence = createStageFabricExecutionEvidenceBinding({ ...unsigned, digest: sha256(unsigned) });', + 'const passport = createExecutionPassport({ bundle: result.bundle, report, oasfRecord: { opaqueExternalRecord: true }, oasfMediaType: "application/json", executionEvidence: [executionEvidence] });', + 'if (report.status !== "pass" || report.runStatus !== "completed" || result.readBack !== true || !validateDocument(passport).valid || passport.subject.length !== 3 || passport.predicate.executionEvidence.length !== 1) process.exit(2);', + 'console.log(JSON.stringify({ status: report.status, runStatus: report.runStatus, readBack: result.readBack, stageFabricAdapter: "pass", passport: "pass" }));' ].join("\n"); const output = run(process.execPath, ["--input-type=module", "--eval", probe], temporary); process.stdout.write(output); @@ -52,15 +57,75 @@ try { process.platform === "win32" ? "agentic-strata.cmd" : "agentic-strata" ); const version = run(executable, ["--version"], temporary).trim(); - if (version !== "0.2.0-alpha.1") { + if (version !== "0.3.0-alpha.1") { throw new Error(`Installed CLI reported unexpected version: ${version}`); } const cliOutput = join(temporary, "cli-demo"); run(executable, ["demo", "--output", cliOutput, "--profile", "enterprise"], temporary); run(executable, ["validate", join(cliOutput, "run.bundle.json")], temporary); + const installed = await import( + pathToFileURL( + join(temporary, "node_modules", "agentic-strata", "dist", "index.js") + ).href + ); const oasfRecord = join(temporary, "external-oasf.json"); + const stageFabricEvidence = join(temporary, "stagefabric-evidence.json"); + const stageFabricBinding = join(temporary, "stagefabric-binding.json"); const passportOutput = join(temporary, "execution-passport.json"); writeFileSync(oasfRecord, '{"opaqueExternalRecord":true}\n', "utf8"); + const sha256 = (value) => `sha256:${installed.digestValue(value)}`; + const identity = { + stageIdDigest: sha256("stage"), + targetIdDigest: sha256("target"), + zoneDigest: sha256("zone"), + adapterKindDigest: sha256("adapter") + }; + const unsignedEvidence = { + apiVersion: "stagefabric.dev/v1alpha1", + kind: "ExecutionPlacementEvidence", + producer: "stagefabric", + disclosure: "content-free", + authority: "observation-only", + runIdDigest: sha256("run-change-demo-001"), + observedAt: "2026-07-17T12:00:00.000Z", + planDigest: sha256("plan"), + bindingDigest: sha256("binding"), + snapshotDigest: sha256("snapshot"), + egressDigest: sha256("egress"), + placements: [ + { ...identity, attempt: 1, status: "succeeded", reasonCode: "completed" } + ], + trace: [ + { + ...identity, + attempt: 1, + status: "succeeded", + reasonCode: "completed" + } + ] + }; + const sealedEvidence = { + ...unsignedEvidence, + digest: sha256(unsignedEvidence) + }; + writeFileSync( + stageFabricEvidence, + installed.serializeStageFabricExecutionPlacementEvidence(sealedEvidence), + "utf8" + ); + run( + executable, + [ + "bind-stagefabric", + stageFabricEvidence, + "--uri", + "urn:stagefabric:evidence:package-smoke", + "--output", + stageFabricBinding + ], + temporary + ); + run(executable, ["validate", stageFabricBinding], temporary); run( executable, [ @@ -72,6 +137,8 @@ try { oasfRecord, "--oasf-media-type", "application/json", + "--execution-evidence", + stageFabricBinding, "--issued-at", "2026-07-17T12:00:01.000Z", "--output", @@ -81,7 +148,12 @@ try { ); run(executable, ["validate", passportOutput], temporary); console.log( - JSON.stringify({ cliVersion: version, cliDemo: "pass", cliPassport: "pass" }) + JSON.stringify({ + cliVersion: version, + cliDemo: "pass", + cliStageFabricBinding: "pass", + cliPassport: "pass" + }) ); } finally { rmSync(temporary, { recursive: true, force: true }); diff --git a/src/adapters/documents.ts b/src/adapters/documents.ts index 82167a5..c6fbdaa 100644 --- a/src/adapters/documents.ts +++ b/src/adapters/documents.ts @@ -1,5 +1,6 @@ import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { dirname, extname } from "node:path"; +import { TextDecoder } from "node:util"; import { parse, parseDocument } from "yaml"; @@ -7,7 +8,7 @@ import { canonicalize } from "../core/canonical.js"; export const MAX_DOCUMENT_BYTES = 16 * 1024 * 1024; -export function readDocument(path: string): unknown { +function readSource(path: string): string { const metadata = statSync(path); if (!metadata.isFile()) { throw new Error("Contract input must be a regular file."); @@ -15,14 +16,18 @@ export function readDocument(path: string): unknown { if (metadata.size > MAX_DOCUMENT_BYTES) { throw new Error(`Contract input exceeds the ${MAX_DOCUMENT_BYTES}-byte limit.`); } - const source = readFileSync(path, "utf8"); - const extension = extname(path).toLowerCase(); - if (extension === ".yaml" || extension === ".yml") { - const value = parse(source, { maxAliasCount: 100, strict: true }) as unknown; - canonicalize(value); - return value; + const bytes = readFileSync(path); + if (bytes.byteLength > MAX_DOCUMENT_BYTES) { + throw new Error(`Contract input exceeds the ${MAX_DOCUMENT_BYTES}-byte limit.`); } + try { + return new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + throw new SyntaxError("Contract input must be valid UTF-8."); + } +} +function parseStrictJson(source: string): unknown { const value = JSON.parse(source) as unknown; const duplicateCheck = parseDocument(source, { schema: "json", @@ -39,6 +44,31 @@ export function readDocument(path: string): unknown { return value; } +export interface JsonDocumentWithSource { + document: unknown; + source: string; +} + +export function readJsonDocumentWithSource(path: string): JsonDocumentWithSource { + const extension = extname(path).toLowerCase(); + if (extension === ".yaml" || extension === ".yml") { + throw new Error("This input must be a JSON file."); + } + const source = readSource(path); + return { document: parseStrictJson(source), source }; +} + +export function readDocument(path: string): unknown { + const source = readSource(path); + const extension = extname(path).toLowerCase(); + if (extension === ".yaml" || extension === ".yml") { + const value = parse(source, { maxAliasCount: 100, strict: true }) as unknown; + canonicalize(value); + return value; + } + return parseStrictJson(source); +} + export function writeJson(path: string, value: unknown): void { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, "utf8"); diff --git a/src/adapters/stagefabric-evidence.ts b/src/adapters/stagefabric-evidence.ts new file mode 100644 index 0000000..dd8c2e3 --- /dev/null +++ b/src/adapters/stagefabric-evidence.ts @@ -0,0 +1,346 @@ +import { createHash } from "node:crypto"; + +import { Ajv2020, type ValidateFunction } from "ajv/dist/2020.js"; +import addFormatsImport from "ajv-formats"; +import type { FormatsPlugin } from "ajv-formats"; + +import type { Digest, ExecutionEvidenceBinding } from "../contracts/types.js"; +import { validateAs } from "../contracts/registry.js"; +import { canonicalize, digestValue, omitDigest } from "../core/canonical.js"; + +export const STAGEFABRIC_EXECUTION_EVIDENCE_MEDIA_TYPE = + "application/vnd.stagefabric.execution-placement-evidence+json" as const; + +type StageFabricDigest = `sha256:${string}`; + +export interface StageFabricExecutionPlacement { + stageIdDigest: StageFabricDigest; + targetIdDigest: StageFabricDigest; + zoneDigest: StageFabricDigest; + adapterKindDigest: StageFabricDigest; + attempt: number; + status: "succeeded"; + reasonCode: "completed"; +} + +export interface StageFabricExecutionTraceEvent { + stageIdDigest: StageFabricDigest; + targetIdDigest: StageFabricDigest; + zoneDigest: StageFabricDigest; + adapterKindDigest: StageFabricDigest; + attempt: number; + status: "succeeded" | "failed"; + reasonCode: + | "completed" + | "retryable_pre_output_status" + | "adapter_not_registered" + | "adapter_failed" + | "invalid_outputs" + | "input_policy_rejected" + | "output_policy_rejected"; + statusCode?: number; +} + +export interface StageFabricExecutionPlacementEvidence { + apiVersion: "stagefabric.dev/v1alpha1"; + kind: "ExecutionPlacementEvidence"; + producer: "stagefabric"; + disclosure: "content-free"; + authority: "observation-only"; + runIdDigest: StageFabricDigest; + observedAt: string; + planDigest: StageFabricDigest; + bindingDigest: StageFabricDigest; + snapshotDigest: StageFabricDigest; + egressDigest: StageFabricDigest; + placements: StageFabricExecutionPlacement[]; + trace: StageFabricExecutionTraceEvent[]; + digest: StageFabricDigest; +} + +export type StageFabricEvidenceErrorCode = + | "stagefabric_evidence_invalid" + | "stagefabric_evidence_digest_mismatch" + | "stagefabric_evidence_encoding_mismatch" + | "stagefabric_evidence_binding_invalid"; + +export class StageFabricEvidenceError extends Error { + readonly code: StageFabricEvidenceErrorCode; + + constructor(code: StageFabricEvidenceErrorCode) { + super(code); + this.name = "StageFabricEvidenceError"; + this.code = code; + } +} + +const digestSchema = { type: "string", pattern: "^sha256:[a-f0-9]{64}$" } as const; +const identityDigestProperties = { + stageIdDigest: digestSchema, + targetIdDigest: digestSchema, + zoneDigest: digestSchema, + adapterKindDigest: digestSchema +} as const; + +const schema = { + type: "object", + additionalProperties: false, + properties: { + apiVersion: { const: "stagefabric.dev/v1alpha1" }, + kind: { const: "ExecutionPlacementEvidence" }, + producer: { const: "stagefabric" }, + disclosure: { const: "content-free" }, + authority: { const: "observation-only" }, + runIdDigest: digestSchema, + observedAt: { type: "string" }, + planDigest: digestSchema, + bindingDigest: digestSchema, + snapshotDigest: digestSchema, + egressDigest: digestSchema, + placements: { + type: "array", + minItems: 1, + maxItems: 1024, + items: { + type: "object", + additionalProperties: false, + properties: { + ...identityDigestProperties, + attempt: { type: "integer", minimum: 1, maximum: 33 }, + status: { const: "succeeded" }, + reasonCode: { const: "completed" } + }, + required: [ + "stageIdDigest", + "targetIdDigest", + "zoneDigest", + "adapterKindDigest", + "attempt", + "status", + "reasonCode" + ] + } + }, + trace: { + type: "array", + minItems: 1, + maxItems: 33792, + items: { + type: "object", + additionalProperties: false, + properties: { + ...identityDigestProperties, + attempt: { type: "integer", minimum: 1, maximum: 33 }, + status: { enum: ["succeeded", "failed"] }, + reasonCode: { + enum: [ + "completed", + "retryable_pre_output_status", + "adapter_not_registered", + "adapter_failed", + "invalid_outputs", + "input_policy_rejected", + "output_policy_rejected" + ] + }, + statusCode: { type: "integer", minimum: 100, maximum: 599 } + }, + required: [ + "stageIdDigest", + "targetIdDigest", + "zoneDigest", + "adapterKindDigest", + "attempt", + "status", + "reasonCode" + ] + } + }, + digest: digestSchema + }, + required: [ + "apiVersion", + "kind", + "producer", + "disclosure", + "authority", + "runIdDigest", + "observedAt", + "planDigest", + "bindingDigest", + "snapshotDigest", + "egressDigest", + "placements", + "trace", + "digest" + ] +} as const; + +const ajv = new Ajv2020({ allErrors: true, strict: true }); +const addFormats = addFormatsImport as unknown as FormatsPlugin; +addFormats(ajv); +const validateEvidence: ValidateFunction = + ajv.compile(schema); + +function deepCopy(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +function prefixedDigest(value: unknown): StageFabricDigest { + return `sha256:${digestValue(value)}`; +} + +function unprefixedDigest(value: StageFabricDigest): Digest { + return value.slice("sha256:".length); +} + +const stageFabricTimestamp = new RegExp( + "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|" + + "\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|" + + "(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))" + + "T(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?" + + "(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$" +); + +function placementKey( + value: Pick< + StageFabricExecutionPlacement | StageFabricExecutionTraceEvent, + "stageIdDigest" | "targetIdDigest" | "zoneDigest" | "adapterKindDigest" | "attempt" + > +): string { + return [ + value.stageIdDigest, + value.targetIdDigest, + value.zoneDigest, + value.adapterKindDigest, + String(value.attempt) + ].join("\u0000"); +} + +function isSemanticallyCoherent( + evidence: StageFabricExecutionPlacementEvidence +): boolean { + if (!stageFabricTimestamp.test(evidence.observedAt)) return false; + const placementIndexByStage = new Map(); + const placementKeys = new Set(); + for (const [index, placement] of evidence.placements.entries()) { + if (placementIndexByStage.has(placement.stageIdDigest)) return false; + placementIndexByStage.set(placement.stageIdDigest, index); + placementKeys.add(placementKey(placement)); + } + + const nextAttemptByStage = new Map(); + const completedStages = new Set(); + const successfulPlacements = new Set(); + let currentPlacementIndex = 0; + for (const event of evidence.trace) { + const completed = event.reasonCode === "completed"; + const retryable = event.reasonCode === "retryable_pre_output_status"; + if ((event.status === "succeeded") !== completed) return false; + if ((event.statusCode !== undefined) !== retryable) return false; + if (placementIndexByStage.get(event.stageIdDigest) !== currentPlacementIndex) { + return false; + } + if (completedStages.has(event.stageIdDigest)) return false; + + const expectedAttempt = nextAttemptByStage.get(event.stageIdDigest) ?? 1; + if (event.attempt !== expectedAttempt) return false; + nextAttemptByStage.set(event.stageIdDigest, expectedAttempt + 1); + + if (event.status === "succeeded") { + const key = placementKey(event); + if (!placementKeys.has(key) || successfulPlacements.has(key)) return false; + successfulPlacements.add(key); + completedStages.add(event.stageIdDigest); + currentPlacementIndex += 1; + } + } + + return evidence.placements.every((placement) => + successfulPlacements.has(placementKey(placement)) + ); +} + +export function parseStageFabricExecutionPlacementEvidence( + input: unknown +): StageFabricExecutionPlacementEvidence { + if (!validateEvidence(input)) { + throw new StageFabricEvidenceError("stagefabric_evidence_invalid"); + } + const evidence = deepCopy(input); + if (!isSemanticallyCoherent(evidence)) { + throw new StageFabricEvidenceError("stagefabric_evidence_invalid"); + } + if (prefixedDigest(omitDigest(evidence)) !== evidence.digest) { + throw new StageFabricEvidenceError("stagefabric_evidence_digest_mismatch"); + } + return evidence; +} + +function serializeValidatedEvidence( + evidence: StageFabricExecutionPlacementEvidence +): string { + return `${canonicalize(evidence)}\n`; +} + +export function serializeStageFabricExecutionPlacementEvidence(input: unknown): string { + return serializeValidatedEvidence(parseStageFabricExecutionPlacementEvidence(input)); +} + +export function computeStageFabricExecutionPlacementArtifactDigest( + input: unknown +): Digest { + return createHash("sha256") + .update(serializeStageFabricExecutionPlacementEvidence(input), "utf8") + .digest("hex"); +} + +export function requireStageFabricExecutionPlacementArtifactEncoding( + input: unknown, + source: string +): StageFabricExecutionPlacementEvidence { + const evidence = parseStageFabricExecutionPlacementEvidence(input); + if (source !== serializeValidatedEvidence(evidence)) { + throw new StageFabricEvidenceError("stagefabric_evidence_encoding_mismatch"); + } + return evidence; +} + +export interface CreateStageFabricExecutionEvidenceBindingOptions { + uri?: string; +} + +/** + * Reduces a validated content-free StageFabric artifact to the descriptor + * accepted by Execution Passport v2. The observation can support lineage but + * can never grant or expand runtime authority. + */ +export function createStageFabricExecutionEvidenceBinding( + input: unknown, + options: CreateStageFabricExecutionEvidenceBindingOptions = {} +): ExecutionEvidenceBinding { + const evidence = parseStageFabricExecutionPlacementEvidence(input); + const binding: ExecutionEvidenceBinding = { + contractType: "ExecutionEvidenceBinding", + role: "execution-placement", + producer: evidence.producer, + runIdDigest: unprefixedDigest(evidence.runIdDigest), + observedAt: new Date(evidence.observedAt).toISOString(), + authority: "observation-only", + resource: { + name: "stagefabric-execution-placement", + digest: { + sha256: createHash("sha256") + .update(serializeValidatedEvidence(evidence), "utf8") + .digest("hex") + }, + mediaType: STAGEFABRIC_EXECUTION_EVIDENCE_MEDIA_TYPE, + ...(options.uri === undefined ? {} : { uri: options.uri }) + }, + disclosure: "content-free" + }; + if (!validateAs("ExecutionEvidenceBinding", binding).valid) { + throw new StageFabricEvidenceError("stagefabric_evidence_binding_invalid"); + } + return binding; +} diff --git a/src/cli-program.ts b/src/cli-program.ts index a3155cd..d179f61 100644 --- a/src/cli-program.ts +++ b/src/cli-program.ts @@ -4,10 +4,15 @@ import { Command, InvalidArgumentError } from "commander"; import { readDocument, + readJsonDocumentWithSource, writeCanonicalJson, writeJson, writeText } from "./adapters/documents.js"; +import { + createStageFabricExecutionEvidenceBinding, + requireStageFabricExecutionPlacementArtifactEncoding +} from "./adapters/stagefabric-evidence.js"; import { validateAs, validateDocument } from "./contracts/registry.js"; import { CONFORMANCE_PROFILES, EVALUATOR_VERSION } from "./contracts/types.js"; import type { @@ -74,7 +79,7 @@ function requireExecutionEvidence(path: string): ExecutionEvidenceBinding { const validation = validateAs("ExecutionEvidenceBinding", document); if (!validation.valid) { printValidation(validation); - throw new Error("The input is not a content-free ExecutionEvidenceBinding."); + throw new Error("The input is not a valid v2 content-free ExecutionEvidenceBinding."); } return document as ExecutionEvidenceBinding; } @@ -146,6 +151,31 @@ export function createCliProgram(): Command { } ); + program + .command("bind-stagefabric") + .description( + "Validate sealed StageFabric placement evidence and emit an observation-only passport binding." + ) + .argument("") + .option("--uri ", "stable HTTPS or URN reference to the evidence artifact") + .option("-o, --output ", "write exact canonical binding bytes") + .action((file: string, options: { uri?: string; output?: string }) => { + const source = readJsonDocumentWithSource(resolve(file)); + requireStageFabricExecutionPlacementArtifactEncoding( + source.document, + source.source + ); + const binding = createStageFabricExecutionEvidenceBinding( + source.document, + options.uri === undefined ? {} : { uri: options.uri } + ); + if (options.output === undefined) { + process.stdout.write(canonicalize(binding)); + } else { + writeCanonicalJson(resolve(options.output), binding); + } + }); + program .command("passport") .description("Create a canonical in-toto Execution Passport without embedding external content.") diff --git a/src/contracts/registry.ts b/src/contracts/registry.ts index 57a1974..4c28c8e 100644 --- a/src/contracts/registry.ts +++ b/src/contracts/registry.ts @@ -7,6 +7,7 @@ import type { FormatsPlugin } from "ajv-formats"; import { EXECUTION_PASSPORT_PREDICATE_TYPE, + EXECUTION_PASSPORT_V1_PREDICATE_TYPE, IN_TOTO_STATEMENT_V1_TYPE, type CapabilityContract, type ContractType, @@ -26,12 +27,68 @@ if (typeof rawSchemaId !== "string") { } const schemaId = rawSchemaId; +function requireRecord(value: unknown, label: string): Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${label} must be an object.`); + } + return value as Record; +} + +function createLegacyPassportSchema(): { + id: string; + schema: Record; +} { + const legacy = structuredClone(schema); + const id = `${schemaId}/execution-passport-v1`; + legacy.$id = id; + const definitions = requireRecord(legacy.$defs, "Schema definitions"); + const binding = requireRecord( + definitions.ExecutionEvidenceBinding, + "ExecutionEvidenceBinding definition" + ); + const bindingProperties = requireRecord( + binding.properties, + "ExecutionEvidenceBinding properties" + ); + for (const property of ["contractType", "runIdDigest", "observedAt", "authority"]) { + Reflect.deleteProperty(bindingProperties, property); + } + if (!Array.isArray(binding.required)) { + throw new Error("ExecutionEvidenceBinding required fields must be an array."); + } + binding.required = binding.required.filter( + (field): field is string => + typeof field === "string" && + !["contractType", "runIdDigest", "observedAt", "authority"].includes(field) + ); + + const passport = requireRecord( + definitions.ExecutionPassport, + "ExecutionPassport definition" + ); + const passportProperties = requireRecord( + passport.properties, + "ExecutionPassport properties" + ); + const predicateType = requireRecord( + passportProperties.predicateType, + "ExecutionPassport predicateType" + ); + predicateType.const = EXECUTION_PASSPORT_V1_PREDICATE_TYPE; + return { id, schema: legacy }; +} + const ajv = new Ajv2020({ allErrors: true, strict: true, allowUnionTypes: true }); const addFormats = addFormatsImport as unknown as FormatsPlugin; addFormats(ajv); ajv.addSchema(schema, schemaId); +const legacyPassportSchema = createLegacyPassportSchema(); +ajv.addSchema(legacyPassportSchema.schema, legacyPassportSchema.id); const validators = new Map(); +const legacyPassportValidator = ajv.compile({ + $ref: `${legacyPassportSchema.id}#/$defs/ExecutionPassport` +}); function validatorFor(schemaDefinition: SchemaDefinition): ValidateFunction { const cached = validators.get(schemaDefinition); @@ -87,19 +144,26 @@ export function validateDocument(document: unknown): ValidationResult { ] }; } - if (candidate.predicateType !== EXECUTION_PASSPORT_PREDICATE_TYPE) { + if (candidate.predicateType === EXECUTION_PASSPORT_PREDICATE_TYPE) { + return validateAs("ExecutionPassport", document); + } + if (candidate.predicateType === EXECUTION_PASSPORT_V1_PREDICATE_TYPE) { + const valid = legacyPassportValidator(document); return { - valid: false, - issues: [ - { - path: "/predicateType", - code: "const", - message: "The in-toto predicate type is missing or unsupported." - } - ] + valid, + issues: valid ? [] : toIssues(legacyPassportValidator.errors) }; } - return validateAs("ExecutionPassport", document); + return { + valid: false, + issues: [ + { + path: "/predicateType", + code: "const", + message: "The in-toto predicate type is missing or unsupported." + } + ] + }; } const contractType = candidate.contractType; @@ -159,6 +223,7 @@ export const contractTypes = new Set([ "ArtifactAttestation", "TraceEvent", "ConformanceReport", + "ExecutionEvidenceBinding", "AdapterMapping", "RunBundle" ]); diff --git a/src/contracts/types.ts b/src/contracts/types.ts index ba71bbd..a297bb3 100644 --- a/src/contracts/types.ts +++ b/src/contracts/types.ts @@ -1,11 +1,13 @@ export const API_VERSION = "agenticstrata.dev/v1" as const; export const EVALUATOR_NAME = "agentic-strata" as const; -export const EVALUATOR_VERSION = "0.2.0-alpha.1" as const; -export const EVALUATOR_REVISION = "conformance-2026-07-17.3" as const; +export const EVALUATOR_VERSION = "0.3.0-alpha.1" as const; +export const EVALUATOR_REVISION = "conformance-2026-07-19.1" as const; export const IN_TOTO_STATEMENT_V1_TYPE = "https://in-toto.io/Statement/v1" as const; -export const EXECUTION_PASSPORT_PREDICATE_TYPE = +export const EXECUTION_PASSPORT_V1_PREDICATE_TYPE = "https://github.com/aantenore/AgenticStrata/tree/main/docs/spec/attestations/execution-passport/v1" as const; +export const EXECUTION_PASSPORT_PREDICATE_TYPE = + "https://github.com/aantenore/AgenticStrata/tree/main/docs/spec/attestations/execution-passport/v2" as const; export const EXECUTION_PASSPORT_SUBJECTS = { runBundle: "agentic-strata-run-bundle", conformanceReport: "agentic-strata-conformance-report", @@ -413,8 +415,12 @@ export interface OasfRecordResourceDescriptor extends ResourceDescriptor { } export interface ExecutionEvidenceBinding { + contractType: "ExecutionEvidenceBinding"; role: "execution-placement"; producer: string; + runIdDigest: Digest; + observedAt: string; + authority: "observation-only"; resource: ResourceDescriptor; disclosure: "content-free"; } @@ -504,13 +510,13 @@ export type ContractType = | ArtifactAttestation["contractType"] | TraceEvent["contractType"] | ConformanceReport["contractType"] + | ExecutionEvidenceBinding["contractType"] | AdapterMapping["contractType"] | RunBundle["contractType"]; export type SchemaDefinition = | ContractType | "ResourceDescriptor" - | "ExecutionEvidenceBinding" | "ExecutionPassport"; export interface ValidationIssue { diff --git a/src/core/passport.ts b/src/core/passport.ts index 66cebed..a6c3a8a 100644 --- a/src/core/passport.ts +++ b/src/core/passport.ts @@ -60,7 +60,8 @@ function copyResourceDescriptor(resource: ResourceDescriptor): ResourceDescripto } function copyExecutionEvidence( - bindings: readonly ExecutionEvidenceBinding[] + bindings: readonly ExecutionEvidenceBinding[], + expectedRunIdDigest: string ): ExecutionEvidenceBinding[] { const seenDigests = new Set(); return bindings.map((binding, index) => { @@ -72,10 +73,17 @@ function copyExecutionEvidence( if (seenDigests.has(digest)) { throw new Error(`Execution evidence digest ${digest} is duplicated.`); } + if (binding.runIdDigest !== expectedRunIdDigest) { + throw new Error(`Execution evidence binding ${index} belongs to a different run.`); + } seenDigests.add(digest); return { + contractType: binding.contractType, role: binding.role, producer: binding.producer, + runIdDigest: binding.runIdDigest, + observedAt: binding.observedAt, + authority: binding.authority, resource: copyResourceDescriptor(binding.resource), disclosure: binding.disclosure }; @@ -170,7 +178,10 @@ interface ObservedTimestamp { value: string; } -function observedTimestamps(bundle: RunBundle): ObservedTimestamp[] { +function observedTimestamps( + bundle: RunBundle, + executionEvidence: readonly ExecutionEvidenceBinding[] +): ObservedTimestamp[] { return [ { label: "budget usage", value: bundle.budgetUsage.observedAt }, ...bundle.traceEvents.map((event) => ({ @@ -196,19 +207,24 @@ function observedTimestamps(bundle: RunBundle): ObservedTimestamp[] { ...bundle.approvals.map((approval) => ({ label: `approval ${approval.approvalId}`, value: approval.issuedAt + })), + ...executionEvidence.map((evidence, index) => ({ + label: `execution evidence ${index} from ${evidence.producer}`, + value: evidence.observedAt })) ]; } function requireReportAfterObservedEvidence( bundle: RunBundle, - report: ConformanceReport + report: ConformanceReport, + executionEvidence: readonly ExecutionEvidenceBinding[] ): void { const reportMillis = Date.parse(report.generatedAt); if (!Number.isFinite(reportMillis)) { throw new Error("ConformanceReport generatedAt must be a valid date-time."); } - for (const observed of observedTimestamps(bundle)) { + for (const observed of observedTimestamps(bundle, executionEvidence)) { const observedMillis = Date.parse(observed.value); if (!Number.isFinite(observedMillis)) { throw new Error(`${observed.label} must have a valid observed date-time.`); @@ -234,10 +250,14 @@ export function createExecutionPassport( input: CreateExecutionPassportInput ): ExecutionPassport { requireBoundReport(input.bundle, input.report); - requireReportAfterObservedEvidence(input.bundle, input.report); requireOpaqueJsonRecord(input.oasfRecord); const runId = requireConsistentRunIdentity(input.bundle); + const executionEvidence = copyExecutionEvidence( + input.executionEvidence ?? [], + digestValue(runId) + ); + requireReportAfterObservedEvidence(input.bundle, input.report, executionEvidence); const terminalReceiptDigest = terminalBinding(input.bundle, input.report); const issuedAt = input.issuedAt ?? new Date().toISOString(); requireIssuedAt(issuedAt, input.report); @@ -281,7 +301,7 @@ export function createExecutionPassport( evaluatorDigest: input.report.evaluator.digest, terminalReceiptDigest, issuedAt, - executionEvidence: copyExecutionEvidence(input.executionEvidence ?? []) + executionEvidence } }; diff --git a/src/index.ts b/src/index.ts index 0ddad89..a92b363 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ export * from "./contracts/types.js"; export * from "./contracts/registry.js"; +export * from "./adapters/stagefabric-evidence.js"; export * from "./conformance/config.js"; export * from "./conformance/engine.js"; export * from "./conformance/manifest.js"; diff --git a/test/demo-documents-explain.test.ts b/test/demo-documents-explain.test.ts index 6b5a284..44c6519 100644 --- a/test/demo-documents-explain.test.ts +++ b/test/demo-documents-explain.test.ts @@ -103,6 +103,7 @@ describe("JSON and YAML document boundary", () => { const duplicatePath = join(directory, "duplicate.json"); const escapedDuplicatePath = join(directory, "escaped-duplicate.json"); const surrogatePath = join(directory, "surrogate.json"); + const malformedUtf8Path = join(directory, "malformed-utf8.json"); writeFileSync(duplicatePath, '{"scope":"deny","scope":"allow"}\n', "utf8"); writeFileSync( escapedDuplicatePath, @@ -110,8 +111,17 @@ describe("JSON and YAML document boundary", () => { "utf8" ); writeFileSync(surrogatePath, '{"value":"\\ud800"}\n', "utf8"); + writeFileSync( + malformedUtf8Path, + Buffer.from([ + ...Buffer.from('{"value":"', "utf8"), + 0x80, + ...Buffer.from('"}\n', "utf8") + ]) + ); expect(() => readDocument(duplicatePath)).toThrow("unique object member names"); expect(() => readDocument(escapedDuplicatePath)).toThrow("unique object member names"); expect(() => readDocument(surrogatePath)).toThrow("lone Unicode surrogates"); + expect(() => readDocument(malformedUtf8Path)).toThrow("valid UTF-8"); }); }); diff --git a/test/execution-passport-cli.test.ts b/test/execution-passport-cli.test.ts index 5a80c61..aa18b67 100644 --- a/test/execution-passport-cli.test.ts +++ b/test/execution-passport-cli.test.ts @@ -17,8 +17,12 @@ import type { ExecutionEvidenceBinding } from "../src/index.js"; function evidenceBinding(): ExecutionEvidenceBinding { return { + contractType: "ExecutionEvidenceBinding", role: "execution-placement", producer: "placement-provider", + runIdDigest: digestValue("run-change-demo-001"), + observedAt: "2026-07-17T11:59:59.000Z", + authority: "observation-only", resource: { name: "execution-placement-evidence", digest: { sha256: digestValue({ contentFreeFixture: true }) }, @@ -118,7 +122,7 @@ describe("passport CLI", () => { ], { from: "user" } ) - ).rejects.toThrow("not a content-free ExecutionEvidenceBinding"); + ).rejects.toThrow("not a valid v2 content-free ExecutionEvidenceBinding"); } finally { process.exitCode = previousExitCode; errorOutput.mockRestore(); diff --git a/test/execution-passport.test.ts b/test/execution-passport.test.ts index 9909571..b157391 100644 --- a/test/execution-passport.test.ts +++ b/test/execution-passport.test.ts @@ -4,6 +4,7 @@ import { API_VERSION, EXECUTION_PASSPORT_PREDICATE_TYPE, EXECUTION_PASSPORT_SUBJECTS, + EXECUTION_PASSPORT_V1_PREDICATE_TYPE, appendTraceEvent, createExecutionPassport, digestValue, @@ -49,10 +50,14 @@ function resealReport( return seal({ ...omitDigest(report), ...patch }); } -function contentFreeEvidence(): ExecutionEvidenceBinding { +function contentFreeEvidence(runId = "run-change-demo-001"): ExecutionEvidenceBinding { return { + contractType: "ExecutionEvidenceBinding", role: "execution-placement", producer: "placement-provider", + runIdDigest: digestValue(runId), + observedAt: "2026-07-17T11:59:59.000Z", + authority: "observation-only", resource: { name: "execution-placement-evidence", digest: { sha256: digestValue({ opaqueEvidenceFixture: true }) }, @@ -104,6 +109,24 @@ describe("Execution Passport", () => { expect(validateDocument(passport)).toEqual({ valid: true, issues: [] }); }); + it("retains read-only validation for archived v1 Passports", () => { + const bundle = runDemo().bundle; + const current = create(bundle, reportFor(bundle), { + executionEvidence: [contentFreeEvidence()] + }); + const legacy = structuredClone(current) as unknown as Record; + legacy.predicateType = EXECUTION_PASSPORT_V1_PREDICATE_TYPE; + const predicate = legacy.predicate as { executionEvidence: Record[] }; + for (const binding of predicate.executionEvidence) { + for (const field of ["contractType", "runIdDigest", "observedAt", "authority"]) { + Reflect.deleteProperty(binding, field); + } + } + + expect(validateDocument(legacy)).toEqual({ valid: true, issues: [] }); + expect(validateAs("ExecutionPassport", legacy).valid).toBe(false); + }); + it("treats OASF as opaque canonical JSON rather than claiming semantic validity", () => { const bundle = runDemo().bundle; const report = reportFor(bundle); @@ -176,6 +199,11 @@ describe("Execution Passport", () => { expect(() => create(bundle, reportFor(bundle), { executionEvidence: [evidence, evidence] }) ).toThrow("is duplicated"); + + const unrelated = contentFreeEvidence("unrelated-run"); + expect(() => + create(bundle, reportFor(bundle), { executionEvidence: [unrelated] }) + ).toThrow("belongs to a different run"); }); it("rejects inline, credentialed, unstable, oversized, and local subject URIs", () => { diff --git a/test/fixtures/stagefabric/README.md b/test/fixtures/stagefabric/README.md new file mode 100644 index 0000000..10d76c0 --- /dev/null +++ b/test/fixtures/stagefabric/README.md @@ -0,0 +1,8 @@ +# StageFabric interoperability golden + +`execution-placement-evidence.json` was emitted with StageFabric `0.7.0-alpha.1` at commit `d6bd6b5` by its exported `sealExecutionPlacementEvidence` and `canonicalJson` functions. It covers one retry followed by fallback success, a second successful stage, and the minute-precision timestamp accepted by the StageFabric contract. + +- Producer seal over evidence content without `digest`: `sha256:8bedc2e796afff222dbb15c35eb2f07f4dde50d97e49d669d813e06950fb0c79` +- SHA-256 over exact emitted file bytes, including trailing LF: `aa0d03a6af7b6e5e03afd1d43530077578d8e67cba58295f7d86c88bd2fad632` + +The AgenticStrata tests must consume these bytes without rebuilding the fixture through AgenticStrata helpers. Regenerate it from StageFabric when its evidence contract changes. diff --git a/test/fixtures/stagefabric/execution-placement-evidence.json b/test/fixtures/stagefabric/execution-placement-evidence.json new file mode 100644 index 0000000..2aaebdb --- /dev/null +++ b/test/fixtures/stagefabric/execution-placement-evidence.json @@ -0,0 +1 @@ +{"apiVersion":"stagefabric.dev/v1alpha1","authority":"observation-only","bindingDigest":"sha256:913be61629bf526ec23a58e7ea7c74eae0ddcdbb6a5ad15692123aadd8e06b7f","digest":"sha256:8bedc2e796afff222dbb15c35eb2f07f4dde50d97e49d669d813e06950fb0c79","disclosure":"content-free","egressDigest":"sha256:e488dbbf1e8d1eaaf05552bc324fe649148c9d293ec23867df28a7c081b39e84","kind":"ExecutionPlacementEvidence","observedAt":"2026-07-19T09:30Z","placements":[{"adapterKindDigest":"sha256:1ca01ef65fd94a3fb6ba2d1966819780836fdc735a4225ab060a95545c623a6e","attempt":2,"reasonCode":"completed","stageIdDigest":"sha256:89438965526859e01c02ad5eb864f181d1f8d47cf4d2b1872ccf817fdab20cb0","status":"succeeded","targetIdDigest":"sha256:0717269af9846d0033dcee3e24f464bd4a5122da9d19adfb20c48afb8469569c","zoneDigest":"sha256:ea17e25c6c7aa6ef9407f976a670e87e37bc7e2a86b7bd4a3305bb16f1ba6052"},{"adapterKindDigest":"sha256:4fef77f3d418fc53b165b61c0a90b6e1d698496fb0b0bbcbeab8b83755eab972","attempt":1,"reasonCode":"completed","stageIdDigest":"sha256:9f8079b3ae66d64904bcba3561430001111e4c1c4298d613d1d7c6120f35849e","status":"succeeded","targetIdDigest":"sha256:1f045eb0c10376bc8c09d8890dc96aa5afda8a435c766e180847c76fdb61f03d","zoneDigest":"sha256:1f045eb0c10376bc8c09d8890dc96aa5afda8a435c766e180847c76fdb61f03d"}],"planDigest":"sha256:dbf9868785714c2519824d5a01bea297a3ef16fa141bec86a4caff8320cdbc4c","producer":"stagefabric","runIdDigest":"sha256:9caf45678ae151d605da9477f5c7f9d9afb1c9b5e653333ca8f61f4ccd0d54d1","snapshotDigest":"sha256:20ccb3d1c6ef3be0acf3dc63016cbd4b0ffeb7c4ca329fdf7ae02a591c5de486","trace":[{"adapterKindDigest":"sha256:1ca01ef65fd94a3fb6ba2d1966819780836fdc735a4225ab060a95545c623a6e","attempt":1,"reasonCode":"retryable_pre_output_status","stageIdDigest":"sha256:89438965526859e01c02ad5eb864f181d1f8d47cf4d2b1872ccf817fdab20cb0","status":"failed","statusCode":503,"targetIdDigest":"sha256:b93122022a0dba21936317283287ed6c3dd0b363aad3eb6928401a4ff638886c","zoneDigest":"sha256:24ed65044b6346a4e6fa04ef1f480bc8ff128f36eeafa6ab5d1ab3804997d28f"},{"adapterKindDigest":"sha256:1ca01ef65fd94a3fb6ba2d1966819780836fdc735a4225ab060a95545c623a6e","attempt":2,"reasonCode":"completed","stageIdDigest":"sha256:89438965526859e01c02ad5eb864f181d1f8d47cf4d2b1872ccf817fdab20cb0","status":"succeeded","targetIdDigest":"sha256:0717269af9846d0033dcee3e24f464bd4a5122da9d19adfb20c48afb8469569c","zoneDigest":"sha256:ea17e25c6c7aa6ef9407f976a670e87e37bc7e2a86b7bd4a3305bb16f1ba6052"},{"adapterKindDigest":"sha256:4fef77f3d418fc53b165b61c0a90b6e1d698496fb0b0bbcbeab8b83755eab972","attempt":1,"reasonCode":"completed","stageIdDigest":"sha256:9f8079b3ae66d64904bcba3561430001111e4c1c4298d613d1d7c6120f35849e","status":"succeeded","targetIdDigest":"sha256:1f045eb0c10376bc8c09d8890dc96aa5afda8a435c766e180847c76fdb61f03d","zoneDigest":"sha256:1f045eb0c10376bc8c09d8890dc96aa5afda8a435c766e180847c76fdb61f03d"}]} diff --git a/test/stagefabric-evidence.test.ts b/test/stagefabric-evidence.test.ts new file mode 100644 index 0000000..28808ed --- /dev/null +++ b/test/stagefabric-evidence.test.ts @@ -0,0 +1,262 @@ +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +import { + STAGEFABRIC_EXECUTION_EVIDENCE_MEDIA_TYPE, + StageFabricEvidenceError, + computeStageFabricExecutionPlacementArtifactDigest, + createExecutionPassport, + createStageFabricExecutionEvidenceBinding, + digestValue, + omitDigest, + runConformance, + runDemo, + serializeStageFabricExecutionPlacementEvidence, + validateDocument +} from "../src/index.js"; +import type { StageFabricExecutionPlacementEvidence } from "../src/index.js"; +import { writeJson, writeText } from "../src/adapters/documents.js"; +import { createCliProgram } from "../src/cli-program.js"; + +type UnsealedStageFabricEvidence = Omit; + +function sha256(value: unknown): `sha256:${string}` { + return `sha256:${digestValue(value)}`; +} + +function stageFabricEvidence( + runId = "run-change-demo-001" +): StageFabricExecutionPlacementEvidence { + const identity = { + stageIdDigest: sha256("summarize"), + targetIdDigest: sha256("local-runtime"), + zoneDigest: sha256("private"), + adapterKindDigest: sha256("openai-compatible") + }; + const unsigned: UnsealedStageFabricEvidence = { + apiVersion: "stagefabric.dev/v1alpha1", + kind: "ExecutionPlacementEvidence", + producer: "stagefabric", + disclosure: "content-free", + authority: "observation-only", + runIdDigest: sha256(runId), + observedAt: "2026-07-19T09:30:00.000Z", + planDigest: sha256({ plan: "fixture" }), + bindingDigest: sha256({ binding: "fixture" }), + snapshotDigest: sha256({ snapshot: "fixture" }), + egressDigest: sha256({ egress: "fixture" }), + placements: [ + { + ...identity, + attempt: 1, + status: "succeeded", + reasonCode: "completed" + } + ], + trace: [ + { + ...identity, + attempt: 1, + status: "succeeded", + reasonCode: "completed" + } + ] + }; + return { ...unsigned, digest: sha256(unsigned) }; +} + +function passportInput(evidence: StageFabricExecutionPlacementEvidence) { + const bundle = runDemo().bundle; + return { + bundle, + report: runConformance(bundle, "enterprise", { + generatedAt: "2026-07-19T09:30:01.000Z" + }), + oasfRecord: { id: "fixture-agent" }, + oasfMediaType: "application/json", + executionEvidence: [createStageFabricExecutionEvidenceBinding(evidence)], + issuedAt: "2026-07-19T09:30:02.000Z" + } as const; +} + +describe("StageFabric execution placement evidence adapter", () => { + it("matches a retrying golden artifact emitted by StageFabric", () => { + const fixturePath = fileURLToPath( + new URL("fixtures/stagefabric/execution-placement-evidence.json", import.meta.url) + ); + const source = readFileSync(fixturePath, "utf8"); + const evidence = JSON.parse(source) as unknown; + + expect(serializeStageFabricExecutionPlacementEvidence(evidence)).toBe(source); + expect(computeStageFabricExecutionPlacementArtifactDigest(evidence)).toBe( + "aa0d03a6af7b6e5e03afd1d43530077578d8e67cba58295f7d86c88bd2fad632" + ); + expect(createStageFabricExecutionEvidenceBinding(evidence).resource.digest.sha256) + .toBe("aa0d03a6af7b6e5e03afd1d43530077578d8e67cba58295f7d86c88bd2fad632"); + }); + + it("reduces a sealed content-free observation to a run-bound descriptor", () => { + const evidence = stageFabricEvidence(); + const binding = createStageFabricExecutionEvidenceBinding(evidence, { + uri: "https://example.test/evidence/stagefabric.json" + }); + + expect(binding).toEqual({ + contractType: "ExecutionEvidenceBinding", + role: "execution-placement", + producer: "stagefabric", + runIdDigest: digestValue("run-change-demo-001"), + observedAt: evidence.observedAt, + authority: "observation-only", + resource: { + name: "stagefabric-execution-placement", + digest: { + sha256: computeStageFabricExecutionPlacementArtifactDigest(evidence) + }, + mediaType: STAGEFABRIC_EXECUTION_EVIDENCE_MEDIA_TYPE, + uri: "https://example.test/evidence/stagefabric.json" + }, + disclosure: "content-free" + }); + expect(validateDocument(binding)).toEqual({ valid: true, issues: [] }); + + const serialized = JSON.stringify(binding); + expect(serialized).not.toContain("summarize"); + expect(serialized).not.toContain("local-runtime"); + expect(serialized).not.toContain("private"); + }); + + it("creates a valid Execution Passport only for the observed run", () => { + const passport = createExecutionPassport(passportInput(stageFabricEvidence())); + expect(passport.predicate.executionEvidence).toHaveLength(1); + expect(passport.predicate.executionEvidence[0]?.authority).toBe("observation-only"); + + const unrelated = stageFabricEvidence("different-run"); + expect(() => createExecutionPassport(passportInput(unrelated))).toThrow( + "belongs to a different run" + ); + }); + + it("emits canonical CLI input for the passport command without provider content", async () => { + const directory = mkdtempSync(join(tmpdir(), "agentic-strata-stagefabric-")); + try { + const inputPath = join(directory, "stagefabric-evidence.json"); + const outputPath = join(directory, "binding.json"); + writeText( + inputPath, + serializeStageFabricExecutionPlacementEvidence(stageFabricEvidence()) + ); + + await createCliProgram().parseAsync( + [ + "bind-stagefabric", + inputPath, + "--uri", + "urn:stagefabric:evidence:fixture-1", + "--output", + outputPath + ], + { from: "user" } + ); + + const raw = readFileSync(outputPath, "utf8"); + const binding = JSON.parse(raw) as unknown; + expect(raw).toBe(JSON.stringify(binding)); + expect(raw).not.toContain("summarize"); + expect(raw).not.toContain("local-runtime"); + expect(raw).not.toContain(directory); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); + + it("requires the exact canonical JSON bytes emitted by StageFabric", async () => { + const directory = mkdtempSync(join(tmpdir(), "agentic-strata-stagefabric-bytes-")); + try { + const inputPath = join(directory, "pretty-evidence.json"); + writeJson(inputPath, stageFabricEvidence()); + + await expect( + createCliProgram().parseAsync(["bind-stagefabric", inputPath], { + from: "user" + }) + ).rejects.toThrow("stagefabric_evidence_encoding_mismatch"); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); + + it("rejects tampering and content-bearing extensions", () => { + const evidence = stageFabricEvidence(); + expect(() => + createStageFabricExecutionEvidenceBinding({ + ...evidence, + planDigest: sha256("tampered") + }) + ).toThrowError( + new StageFabricEvidenceError("stagefabric_evidence_digest_mismatch") + ); + + expect(() => + createStageFabricExecutionEvidenceBinding({ + ...evidence, + content: "provider-output" + }) + ).toThrowError(new StageFabricEvidenceError("stagefabric_evidence_invalid")); + }); + + it("rejects sealed but semantically incoherent placement traces", () => { + const valid = stageFabricEvidence(); + const unsigned = omitDigest(valid); + const incoherent = { + ...unsigned, + trace: [{ ...unsigned.trace[0], attempt: 2 }] + }; + expect(() => + createStageFabricExecutionEvidenceBinding({ + ...incoherent, + digest: sha256(incoherent) + }) + ).toThrowError(new StageFabricEvidenceError("stagefabric_evidence_invalid")); + }); + + it("matches the StageFabric timestamp grammar and report chronology", () => { + const resealAt = (observedAt: string) => { + const unsigned = { ...omitDigest(stageFabricEvidence()), observedAt }; + return { ...unsigned, digest: sha256(unsigned) }; + }; + + expect(() => createStageFabricExecutionEvidenceBinding(resealAt("2026-07-19T09:30Z"))) + .not.toThrow(); + expect(() => createStageFabricExecutionEvidenceBinding(resealAt("2026-07-19t09:30:00z"))) + .toThrowError(new StageFabricEvidenceError("stagefabric_evidence_invalid")); + expect(() => createExecutionPassport(passportInput(resealAt("2030-01-01T00:00Z")))) + .toThrow("cannot precede execution evidence"); + }); + + it("rejects authority expansion and unstable or credentialed references", () => { + const evidence = stageFabricEvidence(); + expect(() => + createStageFabricExecutionEvidenceBinding({ + ...evidence, + authority: "grants-execution" + }) + ).toThrowError(new StageFabricEvidenceError("stagefabric_evidence_invalid")); + + for (const uri of [ + "https://user:secret@example.test/evidence.json", + "https://example.test/evidence.json?version=1", + "file:///tmp/evidence.json" + ]) { + expect(() => + createStageFabricExecutionEvidenceBinding(evidence, { uri }) + ).toThrowError( + new StageFabricEvidenceError("stagefabric_evidence_binding_invalid") + ); + } + }); +}); From 91a86476fbc681aebd18499a8418467d1f39eb69 Mon Sep 17 00:00:00 2001 From: Antonio Antenore Date: Sun, 19 Jul 2026 12:07:17 +0200 Subject: [PATCH 2/2] ci: preserve canonical JSON bytes on Windows --- .gitattributes | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..67ff7c6 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Canonical JSON fixtures and lockfiles are byte-sensitive across platforms. +*.json text eol=lf