Skip to content

feat: portable authority envelope schema and standalone verifier (#5055) - #5165

Merged
chernistry merged 5 commits into
mainfrom
run-20260902T0220Z-issue5055
Sep 2, 2026
Merged

feat: portable authority envelope schema and standalone verifier (#5055)#5165
chernistry merged 5 commits into
mainfrom
run-20260902T0220Z-issue5055

Conversation

@chernistry

Copy link
Copy Markdown
Collaborator

What

Adds the first two slices of the portable authority envelope: a versioned schema with a canonical serialization and committed golden vectors (E1), and a standalone verifier that validates them without the bernstein package (E2).

In scope

  • schemas/authority-envelope-v1.json — sections principal, grants, decisions, evidence, coverage, section_digests, signature, with the hash preimages written out so an independent implementer can reproduce them.
  • tests/fixtures/authority-envelope-vectors/ — one deliberately partial envelope, one tampered copy, and a deterministic builder.
  • verify_cli/bernstein_verify_envelope/ — the verifier, its CLI, and its wheel metadata.
  • docs/interop/authority-envelope.md — what an envelope proves and, at greater length, what it does not.

Explicitly not in scope

  • No producer. Nothing under src/bernstein/ changes, and no existing decision record is rewritten to emit an envelope. That is E3.
  • No external key pinning (--jwk / --public-key). An envelope verified against the key it carries is reported as trust-on-first-use, and the documentation says so plainly. That is E5.
  • No revocation checking, no wall-clock expiry check. The verifier compares a decision's timestamp against the grant it cites, which is clock-free and keeps the golden vector valid forever; whether a grant was revoked is not something the envelope carries.

Decision this PR made

The issue left the canonical form open (cbor2/JCS). JCS (RFC 8785), no CBOR. Reasons: the envelope's sections are JSON-native and are read by humans as often as by verifiers; the repository's existing authority machinery already signs JCS bytes with a detached EdDSA JWS (core/interop/a2a_card.py, core/security/agent_card_signer.py), so the shape is one an existing reader already knows; and dropping CBOR keeps the auditor wheel at two dependencies (cryptography, click) instead of three. The audit-receipt verifier needs cbor2 because COSE_Sign1 is a CBOR format — nothing here is.

Why

Everything the repository records about authority is shaped for its own consumption. GovernanceDecision (src/bernstein/core/security/governance.py:217) is anchored to a run spine and stored under lineage_root/<run_id>/, so it cannot exist outside a run we scheduled. The run receipt (src/bernstein/core/replay/run_receipt.py:216) has the right shape — self-describing, verifiable offline — but carries no principal, no grant chain, and no policy decision reference. The compliance pack (verify_cli/bernstein_verify/verify.py:455) reads its signing keys out of the same zip, so the bundle certifies itself.

The result an operator hits: the evidence only means something on the install that produced it. Handing it to a gateway, another agent runtime, a policy engine, or an auditor's own tooling requires re-implementing internals first.

The schema is also the thing the neighbouring work needs in order to agree on a target, which is why it lands before any producer.

How

The envelope is one JSON object. section_digests and signature are what make it checkable:

  • The signature is a detached compact JWS (RFC 7515 Appendix F, alg=EdDSA) whose signing input is BASE64URL(JCS(header)) || "." || BASE64URL(JCS(body)), where the body is every top-level member except signature — the same construction _sign_card_body already uses for capability cards.
  • section_digests records a sha256 over the canonical bytes of each section. Without it a mutation anywhere reports only "the signature failed"; with it the verifier names the section that moved.

Every other hash exists so the verifier can re-derive rather than trust, in the idiom _access_inputs_hash (src/bernstein/core/security/governance.py:383) already establishes for role decisions:

  • principal.id_binding recomputes over the principal id and its JWK.
  • grants[].grant_hash chains to the parent's, so rewriting an ancestor invalidates every descendant. Attenuation is checked structurally: subset scope, no later expiry, issuer equal to the previous subject, chain terminating at the principal.
  • decisions[].inputs_hash covers the decision's own policy inputs and the cited grant's hash. An allow for an action outside that grant's scope is rejected, as is a decision timestamped after the grant expired.
  • coverage is re-derived: the verifier computes which decisions carry evidence and which do not, and rejects a coverage section that does not name every gap. An envelope with no coverage section is refused outright rather than read as complete.

The verifier re-implements JCS locally instead of importing canonicalize_jcs. Sharing the code would prove the two sides agree with themselves; the committed golden vector proves an independent reader agrees with the producer.

Tests

tests/unit/test_authority_envelope_verifier.py. Every one of these failed on the unmodified tree — the first run reported ImportError: No module named bernstein_verify_envelope — and each was written before the code it pins.

  1. test_golden_vector_verifies_in_a_subprocess_without_bernsteinload-bearing. Runs the verifier in a real subprocess where a meta-path finder makes import bernstein raise, and asserts every section passes. If the verifier ever grows a bernstein.* import, this dies with ImportError.
  2. test_the_independence_probe_actually_blocks_bernstein — proves the blocker in test 1 is real, so test 1 cannot pass merely because nothing tried the import.
  3. test_verifier_package_imports_neither_bernstein_nor_the_network — no bernstein., httpx, requests, urllib, socket or aiohttp on any import line.
  4. test_one_byte_mutation_in_decisions_names_the_decisions_sectionload-bearing. One flipped verdict fails, and the report names section:decisions, not just the signature.
  5. test_one_byte_mutation_in_grants_names_the_grants_section — the section named is the one that changed, not a hard-coded first section.
  6. test_committed_tampered_vector_is_rejected — the committed negative vector stays rejected.
  7. test_envelope_without_a_coverage_section_is_refused — silence about scope is a refusal.
  8. test_coverage_that_hides_an_uncovered_decision_is_refused — a decision with no evidence must be named in coverage.uncovered.
  9. test_coverage_names_the_gap_on_the_passing_vector — the passing vector is partial and reports its own gap.
  10. test_grant_chain_that_widens_scope_is_rejected — a child link may only narrow.
  11. test_allow_verdict_outside_the_referenced_grant_scope_is_rejected — an allow must follow from the grant it cites, not merely be signed.
  12. test_decision_inputs_hash_is_recomputed_not_trusted — editing recorded inputs breaks the recomputed hash.
  13. test_decision_taken_after_its_grant_expired_is_rejected — decisions fall inside the cited link's window.
  14. test_evidence_referencing_an_unknown_decision_is_rejected — evidence attaches to a decision the envelope carries.
  15. test_principal_id_rebound_to_another_key_is_rejected — the identifier is bound to its key material.
  16. test_schema_accepts_the_golden_vector — the vector validates against the committed schema.
  17. test_schema_requires_the_coverage_section — coverage is required by the schema, not only by the verifier.

Local runs (17 passed):

uv run python scripts/run_tests.py --parallel 2 tests/unit/test_authority_envelope_verifier.py \
    tests/unit/test_distribution_manifests.py tests/unit/test_co_change_neighbours.py

--affected origin/main selects several hundred files here, so per the contributor guidance the run above covers the modules touched plus their readers instead: nothing under src/ changes, the new verifier package is imported only by the new test file, and mkdocs.yml is read only by tests/unit/test_tui_render_freshness.py, which was also run. That file has one failure — test_the_committed_render_matches_what_the_dashboard_draws — which reproduces unchanged on origin/main in this environment and is unrelated to this branch. CI runs the full suite.

Checklist

  • uv run ruff check src/ passes — unchanged by this PR (no files under src/ are touched); the two UP042 findings it reports here are pre-existing on origin/main with this ruff version. ruff check and ruff format --check are clean on every file this PR adds.
  • uv run pyright src/ passes — unchanged; [tool.pyright] include = ["src"], and verify_cli/ is outside that scope exactly as the existing standalone verifiers are.
  • uv run python scripts/run_tests.py -x — see the scoped runs above.
  • New code has type hints

Documentation duty

  • User-visible README section updated — N/A (no README change; the new page is docs/interop/authority-envelope.md, linked from the MkDocs nav).
  • docs/operations/<area>.md updated — N/A (no operator surface changes; there is no producer in this slice).
  • docs/api/ schema regenerated if a public surface changed — N/A (no public API change).
  • uv run bernstein agents-md sync — N/A (no module under src/bernstein/ added or changed).
  • Tests cover the documented behaviour — the "what it does not prove" list in the docs matches the checks the verifier runs and the ones it deliberately does not.

Part of #5055

Remaining

  • E3 — a producer for the run-scoped case, built from the existing GovernanceDecision and run-receipt fields.
  • E4 — a coverage statement emitted by that producer. The schema section and the verifier's refusal of an unstated gap land here; what remains is a producer that computes coverage from a real run rather than a hand-built vector.
  • E5 — external key pinning (--jwk / --public-key) and a test that an envelope re-signed with a different key is rejected when pinned and reported as trust-on-first-use when not. The verifier already reports trust-on-first-use; it cannot yet be given a pin.

chernistry and others added 5 commits September 2, 2026 02:32
The envelope records who acted, under which delegated authority, and which
policy decision allowed it, in a form that carries its own recomputation
inputs. Canonical form is RFC 8785 JCS; every hash it carries is derived from
material the envelope itself records.
The verifier re-implements RFC 8785 JCS, the detached EdDSA JWS and every hash
preimage the envelope carries, so an auditor checks an envelope with
cryptography and click alone. Each recorded hash is recomputed rather than
trusted, and per-section digests let a failure name the section that moved
instead of only reporting a broken signature. An envelope with no coverage
section is refused rather than read as complete.
The limits matter more than the guarantees for a reader deciding whether to
rely on one: trust-on-first-use keys, no revocation state, and whatever the
envelope's own coverage section declines to cover.
@bernstein-the-conductor

bernstein-the-conductor Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

VERDICT: approve

Self-describing authority records — principal, grant chain, policy decisions, evidence hashes, coverage statement — verified offline by a standalone wheel with cryptography + click only and zero bernstein.* imports. 17 tests pass; ruff clean.


What the PR does

Adds two slices (E1–E2) of the portable authority envelope:

  • schemas/authority-envelope-v1.json — versioned JSON Schema covering principal binding, chained grant hashes, decision input hashes, evidence digests, coverage, section digests, and a detached EdDSA JWS.
  • verify_cli/bernstein_verify_envelope/ — standalone verifier and CLI (bernstein-verify-envelope verify <path>). Two dependencies: cryptography, click. Air-gap-proven by a test that runs it in a subprocess where import bernstein raises.
  • tests/fixtures/authority-envelope-vectors/ — committed golden vector (deliberately partial: 2 decisions, 1 uncovered), a tampered copy, and a deterministic builder.
  • tests/unit/test_authority_envelope_verifier.py — 17 tests covering: independence from bernstein, air-gap, golden vector, section-naming on mutation, coverage-as-field, recomputation (grant attenuation, inputs hash, expiry ordering), evidence binding, and schema validation.
  • docs/interop/authority-envelope.md — canonical format docs plus an explicit "what it does not prove" list.

No src/bernstein/ changes. No producer yet (E3 deferred).

Verification

Ran uv run pytest tests/unit/test_authority_envelope_verifier.py -x -v — 17 passed.
Ran uv run ruff check verify_cli/bernstein_verify_envelope/ — all checks passed.

Nits (non-blocking)

  • verify_cli/bernstein_verify_receipt/verify.py and verify_cli/bernstein_verify_receipt/__main__.py: whitespace/style-only diffs unrelated to the envelope work (line-joining error-detail strings, decorator one-liner). By this is not attributed to the diff.
  • tests/unit/test_orchestrator.py: adds one blank line; no functional change to any test class.
  • The standalone verify.py re-implements JCS, JWS, Ed25519 locally rather than importing from src/bernstein/core/security/agent_card_signer.py — this is intentional (independence requirement) and documented.

bernstein v3.19.0 - unattended review run run-20260902T043106p1385935Z - no operator in the loop

Signed review receipt - verify with bernstein review-receipt verify

field value
diff_hash sha256:c46960b78141145861bcd95672613b1f36a0be7ac978b3ee0c6d85a657e5ec8f
journal_entry_hash sha256:d54b294eaab12e1367c7f4a00ae3fba8196e4af7442c93c11a4dd6eaae1a0383

@bernstein-the-conductor bernstein-the-conductor Bot added the fleet-approved Reviewed, fixed and verified by the unattended contour label Sep 2, 2026
@bernstein-the-conductor
bernstein-the-conductor Bot marked this pull request as ready for review September 2, 2026 04:48
@chernistry
chernistry added this pull request to the merge queue Sep 2, 2026
@github-actions
github-actions Bot requested a review from Chirag6722 September 2, 2026 04:48
@chernistry chernistry added the fleet-blocked Unattended fix budget exhausted; needs an operator label Sep 2, 2026
Merged via the queue into main with commit f9c1eea Sep 2, 2026
63 of 64 checks passed
@chernistry
chernistry deleted the run-20260902T0220Z-issue5055 branch September 2, 2026 06:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs fleet-approved Reviewed, fixed and verified by the unattended contour fleet-blocked Unattended fix budget exhausted; needs an operator size/xl tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant