Skip to content

Align receipts with SCITT: refusal receipts, COSE envelope, offline verifier - #5

Merged
sunilp merged 9 commits into
mainfrom
scitt-alignment
Oct 11, 2026
Merged

sunilp merged 9 commits into
mainfrom
scitt-alignment

Conversation

@sunilp

@sunilp sunilp commented Oct 10, 2026 •

Copy link
Copy Markdown
Member

Why

A receipt is only evidence if someone who does not trust the emitter can check it. Today an AgentBoundary receipt is tamper-evident (receipt_hash) but not signed, there is no standalone verifier, and the v0.1 schema rejects the honest receipt for "approval was required and never came". Meanwhile the SCITT RFCs (9943, 9942) are out and draft-noa-scitt-ai-agent-receipt-01 profiles them for agent action receipts. This branch lines AgentBoundary up with that work where it makes sense, records refusals, and ships an offline verifier.

Plan

  1. Mapping document. Compare AgentBoundary fields with draft-noa-scitt-ai-agent-receipt-01 (individual Internet-Draft, -01 of 15 August 2026, the latest in the IETF archive when fetched) and the RFCs it builds on, grounded in the fetched texts. Say what is equivalent, what differs, and what each side lacks.
  2. Denial receipts. A refused action produces a receipt with a refusal block (deny, require-approval, expired), so the record shows what was not allowed.
  3. COSE_Sign1 envelope (Ed25519) over the JCS receipt, alongside the bare JSON format, with an optional RFC 9943 Signed Statement header shape. No transparency service is run or contacted.
  4. Offline verifier CLI, agentboundary verify <receipt|chain>: signature, canonical form, hash chain, and an explicit list of what was and was not checked. No network.
  5. Conformance scenarios for all of the above; the existing 40 stay green.

Constraints: the published v0.1 wire format is unchanged; everything new is optional and lives in the v0.2-alpha draft; TDD, with every guard mutation-checked against an unmutated control run first.

What changed

Refusals (v0.2-alpha). New optional refusal: {verdict, reason_code?}. LEVEL_2_REFUSAL_EXECUTED fires on a refusal whose action reports it ran; LEVEL_2_REFUSAL_DECISION_MISMATCH fires on a verdict that contradicts policy.decision (nothing is consistent with allow). The v0.2-alpha schema now requires approval only when a require-approval action proceeded, which is what v0.1 §2.6/§4.10 already said in prose. The stale and unauthorized approval checks are skipped only when a receipt records a refusal and execution.status=blocked, and the skip is reported as an info marker. The reference runtime gets a v0.2-alpha mode (setup.receipt_version) that enforces the approver list and approval window itself and emits refusal receipts; its default v0.1 mode is unchanged because scenarios 11 and 12 need a runtime that runs on a bad approval.

COSE envelope. agentboundary.cose: tagged COSE_Sign1, protected alg -19 only (RFC 9864; -8 and everything else rejected), protected UTF-8 kid resolved against a JWK Set, RFC 8949 §4.2.1 deterministic protected header, attached payload that must be exactly JCS(receipt), empty external_aad, optional CWT Claims iss/sub (RFC 9597) for the Signed Statement shape. On top of OpenSSL's Ed25519 verify it rejects non-reduced S, non-canonical public keys and the eight small-order keys. The last one matters: cryptography loads the identity key and OpenSSL 4.0.3 accepted a forged R=identity, S=0 signature under it (pinned in a test).

Verifier. agentboundary verify reads receipts, envelopes, JSON arrays and .jsonl. Per receipt: strict parsing (duplicate members, NaN/Infinity, unpaired surrogates, __proto__/prototype/constructor rejected), envelope checks, payload canonical form, schema, receipt_hash, and the conformance checks that need only the receipt. For several receipts, the chain in the order given: starts at genesis, each prior_receipt names its predecessor and matches the hash the predecessor's content recomputes to (not the hash it declares), no repeated receipt_id, each actor's verified receipts signed by one key (compared by key fingerprint, not kid), every item readable, and at least one receipt present. --keys implies --require-signature, so an unsigned receipt cannot sit in a signed chain. Malformed input is reported, never a crash. Each check is pass, fail, or not checked with a reason, and every report lists what the verifier cannot establish (truth of the record, signer identity beyond the keyring, timestamps, tail truncation, equivocation, NFC). Also agentboundary sign (refuses a receipt that is not schema-valid or whose hash does not match) and agentboundary keygen (private JWK written 0600, never overwrites).

Scenarios. 14 new scenarios, 41 to 54, in scenarios-v0.2-alpha/: five refusal receipts, two refusal lies, a verifying envelope, four broken envelopes (tampered payload, alg -8, unknown kid, non-canonical payload), and the Signed Statement shape with and without its claims. They sit beside scenarios/ rather than in it because the loader recurses and the published 0.2.0a1 runner closes setup in its meta-schema; one new key under scenarios/ makes that runner reject the whole directory. A test now holds the frozen 40 to the setup keys that runner knows. CI and release check the wheel bundles both directories.

Docs. docs/spec/scitt-alignment.md (the mapping), docs/spec/v0.2-alpha.md §2.3, §2.4, §5.4, §5.5, §9, §10, and the README.

How this relates to the draft

AgentBoundary receipts are aligned with the draft-noa-scitt-ai-agent-receipt-01 field set where a field has a counterpart, and the envelope follows the draft's envelope choices (§4.1 to §4.3) for a receipt with no native signature. They are not noa.receipt/0.1 receipts and do not conform to that profile: a verifier for the draft rejects them (different spec literal, closed object, no native sig). Main differences, all spelled out in the mapping doc: no native in-payload signature, no chain id or sequence number, no principal class / risk class / reversibility, numbers are not integer-only, and NFC is not required. The draft pins the cofactorless Ed25519 equation; AgentBoundary relies on OpenSSL, held to cofactorless behaviour by a regression test with a cofactored-only signature. Nothing is registered with a Transparency Service and Transparency Receipts are reported as not checked.

Also fixed on the way

  • Main was red. Scenario 38 started failing once the wall clock passed its approval window (approval at 2026-06-15T14:22:30Z, 3600s window, issued_at = now). Scenario 12 had drifted the same way and scenario 17 would have broken on 2027-01-01. Fixed by widening those three windows, a value-only change so the published 0.2.0a1 runner still passes all 40 from this tree. New scenarios that test a window pin the clock with a new setup.now.
  • The spec said the provenance map has 28 paths; the schema and code had 30 (now 32 with the refusal paths). Tests now hold the stated count and the listed paths to the code.

New dependencies

  • cryptography>=42: Ed25519 sign/verify. The standard Python crypto library (PyCA, OpenSSL-backed), with wheels for every platform CI covers.
  • cbor2>=5.6: decoding untrusted CBOR. Maintained, has a C extension, and fails cleanly on hostile input (fuzzed in the tests). The deterministic encoder is our own small one, because cbor2's canonical mode is not documented to follow RFC 8949 §4.2.1 key ordering, and the verifier's determinism check needs exactly that ordering.

Both are imported lazily, so agentboundary run does not load them.

Review

An independent adversarial review and Copilot both ran on the first push. Neither found a way to forge a single receipt or envelope. They did find, and the last commit fixes with a regression test for each:

  • a kid shared by two keys hid a second signer from the chain continuity check (now compared by key fingerprint, verified receipts only);
  • with --keys, an unsigned receipt could sit in a signed chain (a keyring now implies --require-signature), and empty input passed (now NO_RECEIPTS);
  • a CBOR true key satisfied the alg pin because True == 1 in Python (labels must now be integers or text), and alg was accepted in the unprotected header;
  • signature length and S checks were skipped without a keyring;
  • crashes on overflowing numbers, deep nesting, cyclic CBOR and schema-invalid chain members;
  • fail-open runtime approval guards: forward-dated approvals, missing or bool ids, malformed windows;
  • keygen could truncate its own private key when both paths were the same; meta-schema gaps let unrunnable scenarios load.

Testing

  • 452 fast tests and 8 slow ones (wheel build and clean install) pass locally; ruff and mypy --strict are clean; the npm wrapper tests pass. CI is green.
  • Mutation checks: 107 mutations of the new guards (refusal checks, approval exemption, runtime approval guards, schema and meta-schema rules, every COSE verifier rejection, key and signature checks, strict JSON, chain checks, CLI exit codes, keygen file safety, scenario harness, README and spec count guards), each run after an unmutated control. 106 fail at least one test. The one survivor is equivalent (it widens a list filter on a field that is only set for verified signatures). Ten others survived their first run and each exposed a real test gap that is now closed.
  • Interop check outside the repo: an envelope from agentboundary sign verifies with libsodium (PyNaCl) over a Sig_structure built by cbor2, and its payload re-serializes byte for byte under RFC 8785.
  • The published 0.2.0a1 runner passes all 40 scenarios in scenarios/ from this tree.

Not in this PR

  • No version tag and no publish. The version moves to 0.2.0-alpha.2 because 0.2.0-alpha.1 is already on PyPI.
  • The npm wrapper still pins the Python package to its own version, 0.1.0, which predates verify. The docs therefore point to pip install --pre agentboundary for the new commands.
  • No translator to or from noa.receipt/0.1, and no Transparency Service interaction.

🤖 Generated with Claude Code

Scenario 38 fails on main: its approval is stamped
2026-06-15T14:22:30Z, its policy window is 3600s, and the runtime stamps
issued_at with the current time, so LEVEL_4_STALE_APPROVAL now fires on
a scenario meant to pass. Scenario 12 drifted the same way (it now also
trips the stale check, muddying the one failure it isolates), and
scenario 17 would have broken on 2027-01-01.

None of the three tests the window itself, so widen each window to a
century. This is a value-only change: the published 0.2.0a1 runner loads
and passes all 40 scenarios from this tree. Adding a new setup key to
these files instead would make that runner reject them, since its
meta-schema closes setup.

Separately, add an optional setup.now that pins the reference runtime's
issued_at and execution.completed_at, for new scenarios that do test a
window. Without it the runtime keeps using the wall clock.
A receipt today shows what an agent did. For an auditor the actions it
tried and was stopped from taking matter as much, and the v0.1 schema
gets in the way: it requires an approval block on every require-approval
receipt, so the honest receipt for "approval was required and never
came" fails validation, even though spec v0.1 sections 2.6 and 4.10
require approval only when the action proceeded.

The published v0.1 schema stays as it is. In the v0.2-alpha draft:

- add an optional refusal block, {verdict: deny | require-approval |
  expired, reason_code?}, that says why a blocked action did not run
- require approval only when a require-approval action proceeded
  (execution.status success or failure), matching the v0.1 prose
- add LEVEL_2_REFUSAL_EXECUTED (a refusal on an action that reports it
  ran) and LEVEL_2_REFUSAL_DECISION_MISMATCH (a verdict that contradicts
  policy.decision)
- skip the stale and unauthorized approval checks only when a receipt
  records a refusal AND execution.status=blocked, and say so with an
  info marker; those checks exist to catch an action that ran on a bad
  approval
- add refusal.verdict and refusal.reason_code to the provenance paths

The reference runtime gets a v0.2-alpha mode (setup.receipt_version)
that enforces the approver list and approval window itself and emits a
refusal receipt for every refused action. The window guard is written so
a NaN window or an unreadable timestamp refuses. The default v0.1 mode is
unchanged, since scenarios 11 and 12 rely on it to hand the verifier a
receipt that ran on a bad approval.

Also corrects the spec's provenance path count (it said 28; the schema
and code had 30, now 32) and adds tests that keep the stated count and
the listed paths in step with the code.
receipt_hash makes a receipt tamper-evident but not attributable: anyone
with write access to the store can rehash a forged receipt (spec v0.1
section 7.6). A signature fixes that, and signing in COSE rather than a
bespoke format lets a third party check it with COSE tooling and lets the
same object be carried as an RFC 9943 Signed Statement.

The envelope sits next to the bare JSON receipt and does not change the
receipt's wire format. It follows the envelope choices of
draft-noa-scitt-ai-agent-receipt-01 section 4 where they apply: tagged
COSE_Sign1, alg -19 only (RFC 9864; -8 and everything else rejected),
kid resolved against a JWK Set keyring, protected header in RFC 8949
deterministic encoding, attached payload that must be the JCS form of
the receipt, empty external_aad, and optional CWT Claims (iss, sub) for
the Signed Statement shape. Nothing is registered anywhere.

On top of OpenSSL's Ed25519 verify, the verifier rejects non-reduced S,
non-canonical public keys and the eight small-order keys. The last one
is not hypothetical: cryptography loads the identity key and OpenSSL
4.0.3 accepted a forged R=identity, S=0 signature under it.

Also adds parse_json_strict, which rejects duplicate members, NaN and
Infinity, unpaired surrogates and __proto__/prototype/constructor, all
of which json.loads accepts.

New dependencies: cryptography (Ed25519; the de facto Python crypto
library, OpenSSL-backed) and cbor2 (CBOR decoding of untrusted input).
The deterministic encoder is our own, because cbor2's canonical mode is
not documented to follow RFC 8949 section 4.2.1 key ordering.
A receipt is only worth something if a third party can check it without
JamJet. agentboundary verify takes receipts, COSE envelopes, a JSON
array or a .jsonl stream and checks, locally and without network access:
strict parsing (duplicate members, NaN, lone surrogates), the envelope
and its Ed25519 signature against a JWK Set the caller supplies, that the
signed payload is exactly JCS(receipt), the schema, receipt_hash, the
conformance checks that need only the receipt, and, when several
receipts are given, the chain in the order presented: starts at genesis,
every prior_receipt link names its predecessor and matches the hash its
content recomputes to (not the hash it declares), no repeated
receipt_id, one signing kid per actor.

The report gives every check one of pass, fail or not checked, and
always lists what this verifier cannot establish (truncation,
equivocation, signer identity beyond the keyring, timestamps, NFC), so a
pass cannot be read as more than it is. --signed-statement adds the
RFC 9943 header checks; --require-signature fails anything unsigned.

Also adds agentboundary sign (refuses a receipt that is not schema-valid
or whose hash does not match) and agentboundary keygen (private JWK
written 0600, never overwrites).
Fourteen scenarios, 41 to 54, exercise the new behaviour end to end
through agentboundary run: five refusal receipts (policy deny, approval
missing, approval expired, unauthorized approver, unresolved
escalation), two lies a refusal can tell, a signed envelope that
verifies, four broken envelopes (tampered payload, alg -8, unknown kid,
non-canonical payload), and the Signed Statement shape with and without
its CWT claims. A scenario opts into signing with setup.envelope, which
holds a fixed, public test seed.

They live in scenarios-v0.2-alpha/, not under scenarios/. The loader
recurses, and the published 0.2.0a1 runner closes setup in its
meta-schema, so one new key under scenarios/ would make that runner
reject the whole directory. The frozen v0.1 suite stays at 40 and a
test now holds it to the setup keys that runner knows.

Also: both scenario harnesses include envelope failure codes, the wheel
bundles the new directory and CI/release check it, an attached SCITT
Transparency Receipt is reported as not checked in every mode, iss is
labelled issuer-asserted, and the README and test-count claims are
guarded by tests.
v0.2-alpha.md gains section 9 (the optional COSE_Sign1 envelope: what a
signer emits and what a verifier must reject, with the failure codes)
and section 10 (agentboundary verify: what it checks, how it reports,
and what it never establishes).

scitt-alignment.md compares AgentBoundary receipts field by field with
draft-noa-scitt-ai-agent-receipt-01 (an individual Internet-Draft, -01
of 15 August 2026) and the SCITT and COSE RFCs it builds on: verdicts,
canonicalization, envelope, Signed Statement and registration, chains,
and what each side has that the other lacks. It states the claim
narrowly: aligned with that draft's field set where a field has a
counterpart, and not a receipt in that draft's format.
0.2.0-alpha.1 is on PyPI, and this line adds the refusal block, the
COSE envelope, the verify/sign/keygen commands and two dependencies.
Not tagged or published.
@sunilp
sunilp requested a balanced review from Copilot October 10, 2026 19:12
@coderabbitai

coderabbitai Bot commented Oct 10, 2026 •

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 45d4a4ac-93ad-4926-8ce9-415abc033e38

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

The lint script is `ruff format {args:src tests}`, and CI calls it as
`hatch run lint:fmt --check`. Passing --check replaces the default
paths, so the check has been running over the whole repo. That went
unnoticed until a newer ruff started formatting Python blocks inside
Markdown and failed on adapters/anthropic-permission-policy/mapping.md,
whose aligned comments are deliberate. Pass the paths explicitly in CI
and in lint:all, matching the style check's scope.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The verifier and key-generation paths contain security and malformed-input handling gaps that can produce false passes or crashes.

9 open findings
What changed in this PR

Adds v0.2-alpha refusal receipts, signed COSE envelopes, and offline receipt/chain verification while preserving v0.1 compatibility.

Changes:

  • Implements refusal semantics and runtime approval enforcement.
  • Adds Ed25519 COSE signing, key generation, and offline verification.
  • Expands schemas, documentation, scenarios, tests, packaging, and CI.
File Description
tests/​test_verify.py Tests verifier, chains, and CLI commands.
tests/​test_scenarios_loader.py Validates frozen and draft suite shapes.
tests/​test_scenarios_e2e.py Runs both scenario suites.
tests/​test_runtime.py Tests deterministic runtime timestamps.
tests/​test_refusal.py Tests refusal schema and behavior.
tests/​test_hashing.py Tests strict JSON parsing.
tests/​test_cose.py Tests COSE encoding and verification.
tests/​test_cli.py Tests draft scenario CLI execution.
src/​agentboundary/​verify.py Implements offline receipt and chain verification.
src/​agentboundary/​scenarios/​envelope.py Adds envelope scenario support.
src/​agentboundary/​scenarios/​_meta-schema.json Defines draft scenario configuration.
src/​agentboundary/​runtime.py Emits and enforces v0.2-alpha refusals.
src/​agentboundary/​report.py Defines verification result types.
src/​agentboundary/​provenance.py Adds refusal provenance paths.
src/​agentboundary/​hashing.py Adds strict JSON parsing.
src/​agentboundary/​cose.py Implements COSE signing and verification.
src/​agentboundary/​conformance.py Adds refusal conformance checks.
src/​agentboundary/​cli.py Adds verify, sign, and keygen commands.
scenarios/​38-approval-without-context.yaml Prevents time-dependent expiry.
scenarios/​17-approval-inside-window.yaml Extends approval window.
scenarios/​12-unauthorized-approver.yaml Extends approval window.
scenarios-v0.2-alpha/​41-refusal-policy-deny.yaml Covers policy-denial receipts.
scenarios-v0.2-alpha/​42-refusal-approval-missing.yaml Covers missing approval.
scenarios-v0.2-alpha/​43-refusal-approval-expired.yaml Covers expired approval.
scenarios-v0.2-alpha/​44-refusal-unauthorized-approver.yaml Covers unauthorized approval.
scenarios-v0.2-alpha/​45-refusal-escalation-unresolved.yaml Covers unresolved escalation.
scenarios-v0.2-alpha/​46-refusal-claims-execution.yaml Detects executed refusals.
scenarios-v0.2-alpha/​47-refusal-contradicts-policy.yaml Detects policy mismatch.
scenarios-v0.2-alpha/​48-envelope-verifies.yaml Covers valid envelopes.
scenarios-v0.2-alpha/​49-envelope-payload-tampered.yaml Covers payload tampering.
scenarios-v0.2-alpha/​50-envelope-alg-downgrade.yaml Covers algorithm rejection.
scenarios-v0.2-alpha/​51-envelope-unknown-kid.yaml Covers unresolved keys.
scenarios-v0.2-alpha/​52-envelope-non-canonical-payload.yaml Covers noncanonical payloads.
scenarios-v0.2-alpha/​53-signed-statement-refusal.yaml Covers Signed Statement refusals.
scenarios-v0.2-alpha/​54-signed-statement-missing-claims.yaml Covers missing CWT claims.
README.md Documents verification and draft features.
pyproject.toml Adds dependencies, version, and package data.
docs/​spec/​v0.2-alpha.md Specifies refusals and signed envelopes.
docs/​spec/​scitt-alignment.md Maps receipts to SCITT concepts.
docs/​schemas/​action-receipt-v0.2-alpha.json Adds refusal schema and approval relaxation.
.github/​workflows/​release.yml Checks draft scenarios in wheels.
.github/​workflows/​ci.yml Verifies packaged draft scenarios.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/agentboundary/cli.py Outdated
Comment on lines +438 to +442
fd = os.open(private_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(_json.dumps(private_jwk(key, kid), indent=2) + "\n")
public_path.write_text(
_json.dumps({"keys": [public_jwk(key, kid)]}, indent=2) + "\n", encoding="utf-8"

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 59727a4, with a regression test.

Comment thread src/agentboundary/cose.py
Comment on lines +478 to +479
alg = protected.get(HDR_ALG)
if type(alg) is not int or alg != ALG_ED25519:

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 59727a4, with a regression test.

Comment thread src/agentboundary/cose.py Outdated
Comment on lines +588 to +599
name = "cose.signature"
if keyring is None:
return not_checked(name, "no keyring supplied; the signature was not verified")
candidates = keyring.lookup(kid)
if not candidates:
return failed(name, "COSE_KID_UNRESOLVED", f"kid {kid!r} is not in the keyring")
if len(signature) != 64:
return failed(name, "COSE_SIGNATURE_INVALID", "Ed25519 signature must be 64 bytes")
if not int.from_bytes(signature[32:], "little") < _L:
return failed(
name, "COSE_SIGNATURE_INVALID", "signature S is not reduced modulo the group order"
)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 59727a4, with a regression test.

Comment thread src/agentboundary/verify.py Outdated


def _signer_continuity(reports: list[ReceiptReport]) -> Check:
signed = [r for r in reports if r.form == "cose" and r.kid is not None]

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 59727a4, with a regression test.

Comment thread src/agentboundary/scenarios/_meta-schema.json
Comment thread src/agentboundary/scenarios/_meta-schema.json Outdated
Comment on lines +319 to +323
if len(reports) < 2:
detail = "one receipt presented; there is no chain to check"
if reports and isinstance((reports[0].receipt or {}).get("prior_receipt"), dict):
detail += " (its prior_receipt points at a receipt that was not presented)"
return [not_checked("chain", detail)]

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 59727a4, with a regression test.

Comment thread src/agentboundary/verify.py Outdated
Comment on lines +377 to +385
seen: dict[Any, dict[str, Any]] = {}
dups: list[str] = []
for r in receipts:
rid = r.get("receipt_id")
if rid in seen:
same = canonical_json(seen[rid]) == canonical_json(r)
dups.append(f"{rid!r} ({'byte-identical copy' if same else 'different content'})")
else:
seen[rid] = r

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 59727a4, with a regression test.

Comment thread src/agentboundary/verify.py Outdated
return not_checked("chain.signer", "fewer than two signed receipts; nothing to compare")
kids_by_actor: dict[Any, set[str]] = {}
for r in signed:
actor_id = ((r.receipt or {}).get("actor") or {}).get("id")

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 59727a4, with a regression test.

An independent adversarial review and Copilot found no way to forge a
single receipt or envelope, but found ways to make a chain pass that
should fail, inputs that crashed the verifier, and approval guards in
the v0.2-alpha runtime that failed open.

Chain:
- A kid shared by two keys in the keyring hid a second signer from the
  continuity check, which compared kid strings. It now compares the
  fingerprint of the key that actually verified, and only counts
  receipts whose signature verified.
- With --keys, an unsigned bare receipt could sit in a signed chain and
  pass. A keyring now implies --require-signature.
- An empty array or .jsonl passed. It now fails with NO_RECEIPTS.

Envelope:
- cbor2 decodes CBOR true as Python True, and True == 1, so a protected
  {true: -19} satisfied the alg pin with no label 1 present. Header and
  CWT claim labels must now be integers or text strings, and crit
  labels likewise. alg in the unprotected header is rejected.
- The signature length and S checks ran only when a keyring was given;
  they now run regardless.
- Any content type was reported as a pass; only ours is.
- A regression vector valid only under the cofactored Ed25519 equation
  is now rejected in a test, holding the verifier to the cofactorless
  behaviour draft-noa-scitt-ai-agent-receipt-01 requires.

No more crashes: overflowing floats, integers beyond 2**53 - 1, very
deep nesting, cyclic CBOR values and schema-invalid chain members are
reported, and an unexpected error becomes VERIFIER_ERROR, not a
traceback.

Runtime (v0.2-alpha mode): a forward-dated approval stretched the
window without limit, an approver entry without an id matched an
approval without an approver, True matched 1, and a malformed window or
approver crashed. Each now refuses, with new reason codes
approval-postdates-action and approval-window-invalid. A 60 second
allowance covers clock skew.

Also: keygen refuses one path for both files and creates the public
file with O_EXCL, and the scenario meta-schema now requires issuer and
subject together and the operands each envelope inject op needs.
@sunilp
sunilp merged commit a266f51 into main Oct 11, 2026
17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants