Four things our signed artifacts sealed by name instead of by bytes #153
Merged
dcharlot
merged 4 commits from 2026-10-05 08:00:48 -04:00
fix/ap2-mandate-authority into main 2026-10-05
jcp-gateway: two doors were open in the chokepoint
All checks were successful
jouleclaw public surface must stay documented / Public-surface audit (jouleclaw-rs) (pull_request) Successful in 1m4s
standards workspaces build + test / eoc-rs (pull_request) Successful in 1m58s
standards workspaces build + test / jcp-rs (pull_request) Successful in 1m29s
standards workspaces build + test / proof-rs (pull_request) Successful in 1m15s
standards workspaces build + test / sandbox-rs (pull_request) Successful in 2m12s
standards workspaces build + test / smart-byte-rs (pull_request) Successful in 3m46s
standards workspaces build + test / jouleclaw-rs (pull_request) Successful in 9m48s
standards workspaces build + test / aac-rs (pull_request) Successful in 4m19s
standards workspaces build + test / joule-code-rs (pull_request) Successful in 3m19s
standards workspaces build + test / joulecontract-rs (pull_request) Successful in 1m43s
standards workspaces build + test / map-rs (pull_request) Successful in 2m0s
standards workspaces build + test / mesh-rs (pull_request) Successful in 1m28s
standards workspaces build + test / openpay (pull_request) Successful in 9m13s
standards workspaces build + test / arl-rs (pull_request) Successful in 5m9s
standards workspaces build + test / joule-ui-rs (pull_request) Successful in 5m4s
standards workspaces build + test / jouledesk-rs (pull_request) Successful in 5m5s
standards workspaces build + test / joulehook-rs (pull_request) Successful in 3m42s
standards workspaces build + test / sense-rs (pull_request) Successful in 5m4s
standards workspaces build + test / macOS energy backends (pull_request) Successful in 6m56s
The enforcement point gated on `method != "tools/call"`, so every other MCP
method relayed untouched. Two of them move a payload:
- **`resources/read`** returns server-held bytes to the model. It passed
through with no grant, no debit, no receipt, and nothing admitted to the
flow lattice — so a later call carrying those bytes had nothing to be
folded against. A data egress straight through the chokepoint.
- **`prompts/get`** returns server-authored text that becomes model input, on
the same terms.
`gated_action` now classifies a message into the action it gates, and both ride
the machinery `tools/call` already had: the same grant map, the same
content-addressed `mcp_resource`, the same flow labelling, the same metering
and debit, and the same **sealed** denial — an ungranted read is recorded, not
silently dropped. The enforcement body is unchanged; only the classification
moved, which is why all 13 pre-existing tests pass untouched.
Each is keyed so a grant is scoped to one resource rather than to the method:
`resources/read:file:///ok.txt` is not authority to read `file:///secret.txt`,
and there is a test for exactly that. For a read the uri *is* the payload,
since it selects the bytes that come back.
Discovery — `initialize`, `tools/list`, `resources/list`, `prompts/list`,
notifications — still relays untouched, and a test pins that: those move no
payload, and gating them would refuse a client the right to see what exists.
What a client may see listed is a separate question from what it may read, and
deriving the listing from the grant is the next piece (it is also what no
incumbent does, because `verify_chain` already makes the filter verifiable
offline rather than trusted).
Five new tests: an ungranted read is denied and never reaches the server, a
granted one is metered and debited, a grant for one uri does not cover
another, an ungranted prompt fetch is denied, and discovery still passes
through. Two doc comments that described the old behaviour as the design — the
module header and `Relayed::PassedThrough` — now say what is actually gated.
381 tests pass across the jcp workspace, 0 clippy findings in the crate.
`jcp/SPEC.md` names no MCP method, so there is no spec text to correct.
Fourth from the 2026-10-04 sweep's partial set; the three before it are
845e6cc8, aa74283e and a29e65b9.
Co-Authored-By: Claude Opus 5 <[email protected]>
jouleclaw-transparency-log: the consistency proof proved nothing
`build_consistency_path` returned `vec![old_root, new_root]` — the two values
the operator was already asserting — and `verify` checked that
`audit_path[0] == old_root` and `audit_path.last() == new_root`. That compares a
claim to itself. A log operator serving a split view, two histories sharing no
prefix, produced a "proof" that verified, which is precisely the attack a
consistency proof exists to catch. The verifier's own comment also claimed it
checked that "adjacent pairs hash correctly"; it never did. The in-code note
said a full RFC 6962 §2.1.2 path "is provable as well" — it is, so it is now
what ships.
`subproof` implements §2.1.2 `SUBPROOF(m, D[n], b)` and `verify` implements the
§2.1.3 walk, rebuilding *both* roots from the subtree hashes and comparing. The
old root is recomputed, not re-read, which is the whole difference. Neither root
travels in the path any more, so the forged shape is structurally refused.
The existing `MerkleTree` already used correct RFC 6962 conventions — `0x00`
leaf and `0x01` node prefixes, `largest_power_of_two_lt` splits — and the
inclusion proof was already real, so the proof composes with them unchanged;
all 17 pre-existing tests pass untouched.
Three new tests, each one the old code could not have passed:
- **The forged proof is refused.** A hand-built `[old_root, new_root]` path,
containing no evidence at all, used to verify; it now errors, while the
honest proof for the same transition still verifies.
- **A fork is refused as an extension.** An operator publishes a 3-leaf tree,
then shows an auditor a 5-leaf tree built over a *different* third leaf.
Every hash in that proof is real — only the relationship is a lie. It is
rejected against the published root and accepted against its own, so the
refusal is about the relationship and not a broken prover.
- **Every shape.** All 153 `(m, n)` pairs with 1 ≤ m ≤ n ≤ 17 round-trip:
perfect trees, unbalanced ones, and `m == n`. Each proof is additionally
checked *not* to verify against the neighbouring old root, so a proof binds
one transition rather than a range.
The `audit_path` doc comment said verification "recomputes from the proof's
referenced subtrees", which was aspirational; it is now true, and says which
hashes are in there and that neither root is.
Note this crate still has **zero consumers** — three grep hits, all its own
files — so no JCP decision can yet be given an inclusion proof. That is the
next piece, and it was worth fixing the proof before wiring anything to it.
Third of the "sealed by name / verified not at all" family from the 2026-10-04
sweep: 845e6cc8 (AP2 mandate authority), aa74283e (policy digest), this.
Co-Authored-By: Claude Opus 5 <[email protected]>
jcp-policy: a receipt sealed the policy's name, so the rules could be swapped under it
`policy_wire` sealed `"policy": <name>` — a `&str` label. The receipt committed
to the action, the resource, the caller and a hash of the call's content, and to
the deciding ruleset only by what the operator called it. So a sealed receipt
attested "denied under `prod-egress`" while nothing committed to what
`prod-egress` *was*: change the rules, keep the name, and every past receipt
still verifies while describing a decision the current policy would not make.
That is the load-bearing claim of a policy receipt, and it was the one thing
unbound.
`Policy::content_id()` digests the name **and the ordered rules**, and the wire
now carries `policy_digest` beside `policy`, recovered by `verify` into
`PolicyReceipt`. The name stays, relabelled in its doc comment as the
operator's word rather than evidence.
The rendering is written out rather than derived, so the digest's definition is
readable and a second implementation can reproduce it: each rule contributes
its name, its effect, and its match as a uniform `{kind, values}` pair.
**Order is in the digest because order is in the meaning** — `evaluate` returns
on the first matching `Deny` and takes the first matching `RequireApproval`, so
reordering the same set of rules can change a verdict.
One of the new tests caught a bug in this commit before it shipped:
`Match::Always` first rendered as a JSON `true`, and the JCR-1 signed core
forbids bools, so `content_id()` panicked on "require approval for every
write" — the most ordinary policy there is. Every variant is now pinned to
render, to be stable, and to be distinguishable from the others.
No sealed `policy/1` vector exists in `jcp/conformance`, so nothing pinned
changes; `jcp/SPEC.md` does not describe this profile, and jouleclaw's
`PolicyReceipt` is an unrelated JSON-Lines sibling that is untouched. 376 tests
pass across the jcp workspace, 0 clippy findings in the crates touched.
Second of the family of "sealed by name, not by digest" holes the 2026-10-04
sweep named; the AP2 mandate pair is 845e6cc8. Still open from that family:
`jouleclaw-transparency-log::build_consistency_path` returns `[old_root,
new_root]` instead of an RFC 6962 proof, and no grant binds a tool's parameter
schema.
Co-Authored-By: Claude Opus 5 <[email protected]>
jcp-a2a: an unverified AP2 mandate could set the ceiling, and name any payee
Two soundness holes on the A2A path, both verified in the code before fixing.
**1. A forged mandate minted a real grant.** `ingest_ap2_mandate` is a JSON
field reader: it verifies no signature, resolves no issuer key and consults no
revocation list. Its output then became a ceiling the gate enforces, via
`joule_budget()` / `funds_ceiling()`. So the gate was sound and its *limit*
came from unauthenticated input — anyone who could hand us JSON could set the
budget.
Parsing and trusting are now two types. `ingest_ap2_mandate` returns
`UnverifiedAp2Mandate`, which has no ceiling accessors at all; the only way to
reach them is `accepted_as_verified(authority)`, which refuses a blank
authority and keeps the name on the mandate behind `Ap2Mandate::authority()`.
This crate deliberately does not implement AP2's own signature format — the
mandate is a layered verifiable credential whose verification needs issuer-key
resolution JCP has no authority over — so the honest boundary is that the host
verifies and says what did. The provenance of an *authority* is the counterpart
to the provenance of a *measurement*: the receipt already says where its joules
came from, and now a ceiling can say where its authority came from.
That this is a real boundary and not a comment is what the compiler showed:
every existing call site that treated parsed JSON as an enforceable ceiling
stopped compiling and had to declare an authority.
**2. The payee was read and then never consulted.** `Ap2Mandate.payee` appeared
exactly once outside parsing — in a test assertion. So a mandate authorizing
`attacker.example` minted a grant that spent against `planner.example`'s work:
one party's authorization spent on another's. `jcp-x402` already closes this by
putting the merchant in the grant's audience ("a charge for a different
merchant ... still denies"); the pattern simply was not applied here. Both
`from_mandate` and `from_mandate_priced` now refuse unless `card.is_payee()`,
which binds the payee to the agent id, the name, or the host of the service URL
— because an AP2 payee is a merchant identifier while an A2A agent id is a
service URL, so `research.example` must bind to `https://research.example/a2a`.
Userinfo and port do not identify the party and are stripped. An empty payee
binds to nothing: a mandate that names no payee authorizes no one.
Worth noting what did *not* have to change: both real cards in the tree already
named matching payees (`planner.example`, `research.example`), so the binding
was always the intent — it just was not enforced. Only tests using placeholder
payees (`"p"`, `"m"`) had to be given real ones, which is the defect in
miniature.
14 tests pass, 0 clippy findings in this crate (the two in the workspace are
pre-existing in `jcp-schema::flow` on main). Three new invariants: a ceiling
needs a non-blank authority, a mandate for another payee cannot mint through
either constructor, and a payee binds by id/name/host but not by suffix
(`planner.example.evil` is refused).
Found by the 2026-10-04 pillar sweep, which flagged both against
`ingest_ap2_mandate` while verifying AP2/Verifiable-Intent under FIDO; I
confirmed each against the source before changing anything.
Co-Authored-By: Claude Opus 5 <[email protected]>