From 002160be2f2a4bd7bc6a2ffc6fcd3e39afb0849e Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Sun, 4 Oct 2026 17:08:50 -0400 Subject: [PATCH 1/4] jcp-a2a: an unverified AP2 mandate could set the ceiling, and name any payee MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- jcp/jcp-rs/crates/jcp-a2a/examples/profile.rs | 17 +- jcp/jcp-rs/crates/jcp-a2a/src/lib.rs | 256 +++++++++++++++++- 2 files changed, 258 insertions(+), 15 deletions(-) diff --git a/jcp/jcp-rs/crates/jcp-a2a/examples/profile.rs b/jcp/jcp-rs/crates/jcp-a2a/examples/profile.rs index 09463a667..a4fed5171 100644 --- a/jcp/jcp-rs/crates/jcp-a2a/examples/profile.rs +++ b/jcp/jcp-rs/crates/jcp-a2a/examples/profile.rs @@ -31,9 +31,20 @@ fn main() { let mandate = ingest_ap2_mandate(&json!({ "paymentMandate": { "payee": "research.example", "currency": "mJ", "maxAmount": 80 } })) - .expect("valid ap2 mandate"); - println!("AP2 mandate authorizes {} {} → {} µJ joule budget per skill", - mandate.max_amount, mandate.currency, mandate.joule_budget().unwrap()); + .expect("valid ap2 mandate") + // Parsing authenticates nothing: the mandate is a layered verifiable + // credential and verifying it needs issuer-key resolution JCP has no + // authority over. The host verifies, then says what did — and the + // authority stays on the mandate. + .accepted_as_verified("demo-wallet") + .expect("an authority was named"); + println!( + "AP2 mandate authorizes {} {} → {} µJ joule budget per skill (authority: {})", + mandate.max_amount, + mandate.currency, + mandate.joule_budget().unwrap(), + mandate.authority() + ); // 3. Mint one capability-scoped, joule-budgeted grant per advertised skill. let principal = SigningKey::from_bytes(&[1u8; 32]); // the mandate's signer diff --git a/jcp/jcp-rs/crates/jcp-a2a/src/lib.rs b/jcp/jcp-rs/crates/jcp-a2a/src/lib.rs index dfeafbcf9..cf91ff066 100644 --- a/jcp/jcp-rs/crates/jcp-a2a/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-a2a/src/lib.rs @@ -18,7 +18,13 @@ //! resource is `a2a::`, an audience an attacker can't spoof by //! name). The Agent Card *advertises* the skills; the profile *binds* a //! capability to each. -//! - [`ingest_ap2_mandate`] parses an AP2 mandate. A **joule**-denominated +//! - [`ingest_ap2_mandate`] parses an AP2 mandate into an +//! [`UnverifiedAp2Mandate`] — parsing authenticates nothing, and a mandate's +//! amount becomes a ceiling the gate enforces, so the two steps are kept +//! apart: [`UnverifiedAp2Mandate::accepted_as_verified`] takes the ceilings +//! once the host has verified it, naming the authority that did, and the +//! mandate carries that name. Minting also refuses a mandate whose payee is +//! not the agent being called. A **joule**-denominated //! mandate reads as an energy ceiling ([`Ap2Mandate::joule_budget`]); a //! **currency**-denominated one — what the payments ecosystem actually emits — //! reads as a *money* ceiling ([`Ap2Mandate::funds_ceiling`]). The profile @@ -110,6 +116,39 @@ impl AgentCard { pub fn advertises(&self, skill_id: &str) -> bool { self.skills.iter().any(|s| s.id == skill_id) } + + /// Whether this card's agent is the payee an AP2 mandate authorizes. + /// + /// A mandate names who may be paid; a card names who will be called. If + /// they are not the same party, the mandate is authority for *someone + /// else's* work, and spending against it is spending outside it. This is + /// the binding `jcp-x402` already makes by putting the merchant in the + /// grant's audience, applied to the A2A path, which read the payee and + /// then never consulted it. + /// + /// Matched against the agent id, the host of its service URL, and the + /// name — because an AP2 payee is a merchant identifier and an A2A agent + /// id is a service URL, so `research.example` must bind to + /// `https://research.example/a2a`. An empty payee matches nothing: a + /// mandate that names no payee authorizes no one. + pub fn is_payee(&self, payee: &str) -> bool { + let payee = payee.trim(); + if payee.is_empty() { + return false; + } + let eq = |a: &str| !a.is_empty() && a.eq_ignore_ascii_case(payee); + eq(self.agent_id()) || eq(&self.name) || eq(url_host(&self.url)) + } +} + +/// The host of a URL — `https://research.example/a2a` -> `research.example` — +/// or the whole string when it carries no scheme or path. +fn url_host(url: &str) -> &str { + let after_scheme = url.split_once("://").map_or(url, |(_, rest)| rest); + let host = after_scheme.split(['/', '?', '#']).next().unwrap_or(""); + // Strip userinfo and port, neither of which identifies the party. + let host = host.rsplit_once('@').map_or(host, |(_, h)| h); + host.split_once(':').map_or(host, |(h, _)| h) } /// Parse an A2A Agent Card from its JSON form (camelCase per the A2A spec; @@ -156,8 +195,71 @@ pub fn capabilities_from_card(card: &AgentCard) -> Vec { // ───────────────────────────── AP2 mandate ───────────────────────────── -/// An AP2 payment mandate: an authorization for an agent to spend on a -/// principal's behalf, up to a ceiling. +/// An AP2 mandate exactly as parsed, before anything has authenticated it. +/// +/// This type exists because the fields below are attacker-controlled until +/// something checks them. [`ingest_ap2_mandate`] reads JSON; it verifies no +/// signature, resolves no issuer key and consults no revocation list, because +/// the bytes it is handed carry none of that by themselves. A mandate's +/// `max_amount` becomes a *ceiling the gate enforces*, so a mandate accepted +/// without authentication is an attacker setting the budget — the gate stays +/// sound and its limit does not. +/// +/// There is deliberately no path from here to a ceiling except +/// [`Self::accepted_as_verified`], which makes the caller name what did the +/// verifying. Parsing and trusting are two steps, and this type is the one +/// that has not happened yet. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct UnverifiedAp2Mandate { + /// The payee / merchant the mandate claims to authorize. + pub payee: String, + /// The currency the claimed ceiling is denominated in. + pub currency: String, + /// The claimed ceiling, in the currency's minor unit. + pub max_amount: u64, +} + +impl UnverifiedAp2Mandate { + /// Accept this mandate's ceilings, recording what authenticated it. + /// + /// `authority` names the thing that verified the mandate — a wallet, an + /// issuer key id, an SD-JWT verification, a named host subsystem. It is + /// kept on the mandate and reachable through [`Ap2Mandate::authority`], so + /// a ceiling can be traced to the authority that vouched for it rather + /// than appearing from parsed JSON. An empty or blank `authority` is + /// refused: a mandate nothing vouched for is exactly the case this type + /// exists to stop. + /// + /// This crate does not implement AP2's own signature format. The mandate + /// is a layered verifiable credential whose verification needs issuer-key + /// resolution that JCP has no authority over, so the honest boundary is + /// here: the host verifies, and says so on the record. + pub fn accepted_as_verified( + self, + authority: impl Into, + ) -> Result { + let authority = authority.into(); + if authority.trim().is_empty() { + return Err(malformed( + "ap2 mandate", + "accepted_as_verified needs the authority that verified it; \ + a ceiling nothing vouched for is not a ceiling", + )); + } + Ok(Ap2Mandate { + payee: self.payee, + currency: self.currency, + max_amount: self.max_amount, + authority, + }) + } +} + +/// An AP2 payment mandate whose ceilings may be enforced: an authorization for +/// an agent to spend on a principal's behalf, up to a ceiling, **with the +/// authority that authenticated it on the record**. +/// +/// Reachable only through [`UnverifiedAp2Mandate::accepted_as_verified`]. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Ap2Mandate { /// The payee / merchant the mandate authorizes. @@ -167,9 +269,21 @@ pub struct Ap2Mandate { pub currency: String, /// The authorized ceiling, in the currency's minor unit. pub max_amount: u64, + /// What authenticated this mandate. Private so the only way to hold an + /// `Ap2Mandate` is to have named one. + authority: String, } impl Ap2Mandate { + /// What authenticated this mandate, as the host named it. + /// + /// The provenance of an *authority* is the counterpart to the provenance + /// of a *measurement*: a receipt already says where its joules came from, + /// and this says where its ceiling's authority came from. + pub fn authority(&self) -> &str { + &self.authority + } + /// The joule budget this mandate authorizes, in microjoules — `Some` when the /// mandate is **denominated in joules** (the natural form for energy-priced /// agent work). A currency-denominated mandate returns `None`: the profile @@ -207,7 +321,11 @@ impl Ap2Mandate { /// Parse an AP2 mandate from its JSON form. Reads the payee, the currency, and /// the authorized ceiling (`maxAmount` / `amount`); tolerant of the surrounding /// IntentMandate / CartMandate / PaymentMandate envelope. -pub fn ingest_ap2_mandate(mandate: &Value) -> Result { +/// +/// **This authenticates nothing** — hence [`UnverifiedAp2Mandate`]. Call +/// [`UnverifiedAp2Mandate::accepted_as_verified`] once the host has verified +/// it, naming what did. +pub fn ingest_ap2_mandate(mandate: &Value) -> Result { // The amount may sit at the top level or under a `paymentMandate` / `cart`. let root = mandate .get("paymentMandate") @@ -233,7 +351,28 @@ pub fn ingest_ap2_mandate(mandate: &Value) -> Result { if currency.is_empty() { return Err(malformed("ap2 mandate", "no `currency`")); } - Ok(Ap2Mandate { payee, currency, max_amount }) + Ok(UnverifiedAp2Mandate { payee, currency, max_amount }) +} + +/// Refuse to mint against a mandate that authorizes a different party. +/// +/// The mandate is the authority for the spend; the card is who gets called. +/// Minting when they disagree would spend one merchant's authorization on +/// another's work, and no later gate can catch it — by then the ceiling is +/// already the grant's. +fn refuse_unless_payee(card: &AgentCard, mandate: &Ap2Mandate) -> Result<(), A2aError> { + if card.is_payee(&mandate.payee) { + return Ok(()); + } + Err(malformed( + "ap2 mandate", + format!( + "mandate authorizes payee `{}`, but the agent card is `{}`: a mandate \ + for one party is not authority to spend on another's work", + mandate.payee, + card.agent_id() + ), + )) } // ───────────────────────────── the profile ───────────────────────────── @@ -339,6 +478,7 @@ impl A2aProfile { steward_sk: SigningKey, steward: impl Into, ) -> Result { + refuse_unless_payee(card, mandate)?; let budget = mandate.joule_budget().ok_or_else(|| { malformed("ap2 mandate", format!("`{}` is not joule-denominated", mandate.currency)) })?; @@ -365,6 +505,7 @@ impl A2aProfile { steward_sk: SigningKey, steward: impl Into, ) -> Result { + refuse_unless_payee(card, mandate)?; // A joule-denominated mandate has no money ceiling; a currency one has // no joule ceiling. Either way the caller's `budget_uj` bounds energy. let funds = mandate.funds_ceiling(); @@ -543,21 +684,108 @@ mod tests { #[test] fn ap2_joule_budget_maps_units_and_refuses_currency() { - let uj = ingest_ap2_mandate(&json!({ "payee": "m", "currency": "uJ", "maxAmount": 5_000 })).unwrap(); + let uj = ingest_ap2_mandate(&json!({ "payee": "m", "currency": "uJ", "maxAmount": 5_000 })) + .unwrap().accepted_as_verified("test-wallet").unwrap(); assert_eq!(uj.joule_budget(), Some(5_000)); - let j = ingest_ap2_mandate(&json!({ "merchant": "m", "currency": "J", "amount": 3 })).unwrap(); + let j = ingest_ap2_mandate(&json!({ "merchant": "m", "currency": "J", "amount": 3 })) + .unwrap().accepted_as_verified("test-wallet").unwrap(); assert_eq!(j.joule_budget(), Some(3_000_000)); // A currency mandate yields no joule budget — no invented exchange rate. - let usd = ingest_ap2_mandate(&json!({ "payee": "m", "currency": "USD", "maxAmount": 100 })).unwrap(); + let usd = ingest_ap2_mandate(&json!({ "payee": "m", "currency": "USD", "maxAmount": 100 })) + .unwrap().accepted_as_verified("test-wallet").unwrap(); assert_eq!(usd.joule_budget(), None); } + /// A ceiling must name what vouched for it. Parsing JSON vouches for + /// nothing, so a blank authority is refused rather than accepted as a + /// weak one. + #[test] + fn a_ceiling_needs_an_authority_that_is_not_blank() { + let parsed = + ingest_ap2_mandate(&json!({ "payee": "m", "currency": "uJ", "maxAmount": 9 })).unwrap(); + for blank in ["", " ", "\t"] { + assert!( + parsed.clone().accepted_as_verified(blank).is_err(), + "a blank authority ({blank:?}) was accepted" + ); + } + let ok = parsed.accepted_as_verified("wallet:did:example:w1").unwrap(); + assert_eq!(ok.authority(), "wallet:did:example:w1"); + assert_eq!(ok.joule_budget(), Some(9)); + } + + /// The soundness hole this closes: a mandate naming a different payee is + /// authority for someone else's work, and minting against it spent one + /// party's authorization on another's. `jcp-x402` already bound the + /// merchant into the grant audience; the A2A path read the payee and never + /// consulted it. + #[test] + fn a_mandate_for_another_payee_cannot_mint() { + let card = ingest_agent_card(&sample_card()).unwrap(); // planner.example + let wrong = ingest_ap2_mandate( + &json!({ "payee": "attacker.example", "currency": "uJ", "maxAmount": 1_000_000 }), + ) + .unwrap() + .accepted_as_verified("test-wallet") + .unwrap(); + + let err = A2aProfile::from_mandate( + &card, + &wrong, + &sk(1), + Did::from_verifying_key(&sk(2).verifying_key()), + sk(7), + "did:example:a2a-steward", + ) + .err() + .expect("a mandate for attacker.example minted against planner.example"); + assert!( + format!("{err}").contains("attacker.example"), + "the refusal must name the payee it refused: {err}" + ); + + // The priced constructor is the same authority question, so it refuses too. + assert!( + A2aProfile::from_mandate_priced( + &card, + &wrong, + 50_000, + &sk(1), + Did::from_verifying_key(&sk(2).verifying_key()), + sk(7), + "did:example:a2a-steward", + ) + .is_err(), + "from_mandate_priced accepted another party's mandate" + ); + } + + /// A payee binds to the agent by id, by name, or by the host of its URL — + /// because an AP2 payee is a merchant identifier and an A2A agent id is a + /// service URL. A mandate naming no payee authorizes no one. + #[test] + fn a_payee_binds_to_the_agent_by_id_name_or_host() { + let card = ingest_agent_card(&sample_card()).unwrap(); + for ok in ["planner.example", "PLANNER.EXAMPLE", "planner-agent", "https://planner.example/a2a"] { + assert!(card.is_payee(ok), "{ok} should bind"); + } + for bad in ["", " ", "attacker.example", "planner.example.evil", "example"] { + assert!(!card.is_payee(bad), "{bad:?} must not bind"); + } + // Port and userinfo do not identify the party. + let c = ingest_agent_card(&json!({ + "name": "n", "url": "https://user@planner.example:8443/a2a", "skills": [{"id":"s"}] + })) + .unwrap(); + assert!(c.is_payee("planner.example")); + } + #[test] fn ap2_mandate_envelope_unwrapped() { let m = ingest_ap2_mandate(&json!({ "paymentMandate": { "payee": "store", "currency": "mJ", "maxAmount": 7 } })) - .unwrap(); + .unwrap().accepted_as_verified("test-wallet").unwrap(); assert_eq!(m.payee, "store"); assert_eq!(m.joule_budget(), Some(7_000)); // 7 mJ → 7000 µJ } @@ -592,7 +820,9 @@ mod tests { #[test] fn ap2_budget_caps_task_spend() { let card = ingest_agent_card(&sample_card()).unwrap(); - let mandate = ingest_ap2_mandate(&json!({ "payee": "p", "currency": "uJ", "maxAmount": 50_000 })).unwrap(); + let mandate = + ingest_ap2_mandate(&json!({ "payee": "planner.example", "currency": "uJ", "maxAmount": 50_000 })) + .unwrap().accepted_as_verified("test-wallet").unwrap(); let mut profile = A2aProfile::from_mandate( &card, &mandate, @@ -610,7 +840,9 @@ mod tests { #[test] fn from_mandate_refuses_currency_denomination() { let card = ingest_agent_card(&sample_card()).unwrap(); - let usd = ingest_ap2_mandate(&json!({ "payee": "p", "currency": "USD", "maxAmount": 100 })).unwrap(); + let usd = + ingest_ap2_mandate(&json!({ "payee": "planner.example", "currency": "USD", "maxAmount": 100 })) + .unwrap().accepted_as_verified("test-wallet").unwrap(); let r = A2aProfile::from_mandate( &card, &usd, @@ -666,7 +898,7 @@ mod tests { ingest_ap2_mandate(&json!({ "paymentMandate": { "payee": "planner.example", "currency": "USD", "maxAmount": 500 } })) - .unwrap() + .unwrap().accepted_as_verified("test-wallet").unwrap() } #[test] -- 2.54.0 (Apple Git-157) From 0d29764d006340c3cd0eedb95f39b69e05a976f3 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Sun, 4 Oct 2026 17:14:37 -0400 Subject: [PATCH 2/4] jcp-policy: a receipt sealed the policy's name, so the rules could be swapped under it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `policy_wire` sealed `"policy": ` — 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 --- jcp/jcp-rs/crates/jcp-policy/src/lib.rs | 162 +++++++++++++++++++++++- 1 file changed, 160 insertions(+), 2 deletions(-) diff --git a/jcp/jcp-rs/crates/jcp-policy/src/lib.rs b/jcp/jcp-rs/crates/jcp-policy/src/lib.rs index 519ad169c..5542c3328 100644 --- a/jcp/jcp-rs/crates/jcp-policy/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-policy/src/lib.rs @@ -136,6 +136,56 @@ impl Policy { Policy { name: name.into(), rules } } + /// The content id of this policy: a digest over its name **and its ordered + /// rules**, so a receipt can commit to the bytes that decided rather than + /// to a label. + /// + /// Without this a sealed receipt attested "denied under policy + /// `prod-egress`" and nothing committed to what `prod-egress` was. Swap the + /// ruleset, keep the name, and every past receipt still verifies while + /// describing a decision the current rules would not make. The name is the + /// operator's word; this is the 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, in order. + /// **Order is part of the digest because order is part of the meaning** — + /// [`Self::evaluate`] returns on the first matching `Deny` and takes the + /// first matching `RequireApproval`, so reordering rules can change a + /// verdict without changing the set. + pub fn content_id(&self) -> ContentId { + let rules: Vec = self + .rules + .iter() + .map(|r| { + json!({ + "name": r.name, + "effect": match r.effect { + Effect::Deny => "deny", + Effect::RequireApproval => "require-approval", + }, + // One uniform shape per variant: a kind tag and its + // values. No bools anywhere — the JCR-1 signed core + // forbids them, and `Match::Always` rendered as `true` + // made `content_id` panic on the most ordinary policy + // there is, a blanket approval rule. + "when": match &r.when { + Match::Action(set) => json!({ "kind": "action", "values": set }), + Match::ContentContains(pats) => { + json!({ "kind": "content-contains", "values": pats }) + } + Match::Resource(r) => { + json!({ "kind": "resource", "values": [r.to_hex()] }) + } + Match::Always => json!({ "kind": "always", "values": [] }), + }, + }) + }) + .collect(); + ContentId::of(&json!({ "policy": self.name, "rules": rules })) + .expect("a policy renders to canonical JSON values") + } + /// Evaluate the policy against a call. Any matching `Deny` wins; otherwise the /// first matching `RequireApproval` holds the call; otherwise it allows. pub fn evaluate(&self, ctx: &PolicyContext) -> PolicyVerdict { @@ -306,7 +356,15 @@ impl PolicyEngine { _ => Outcome::Held(rule), }, }; - let wire = policy_wire(&self.policy.name, ctx, &outcome, self.seq, &self.prev, &self.steward); + let wire = policy_wire( + &self.policy.name, + &self.policy.content_id(), + ctx, + &outcome, + self.seq, + &self.prev, + &self.steward, + ); let core = jcr1::signed_core_bytes(&wire).expect("policy wire is a JCR-1 object"); let envelope = jcr1::cose_sign1_envelope(&core, &self.steward_sk, &self.steward); self.prev = ContentId::of_bytes(&envelope); @@ -338,8 +396,10 @@ fn reason_of(outcome: &Outcome) -> Option<&str> { } } +#[allow(clippy::too_many_arguments)] fn policy_wire( policy: &str, + policy_digest: &ContentId, ctx: &PolicyContext, outcome: &Outcome, seq: u64, @@ -349,6 +409,7 @@ fn policy_wire( let mut o = json!({ "policy_receipt": "policy/1", "policy": policy, + "policy_digest": policy_digest.to_hex(), "action": ctx.action, "resource": ctx.resource.to_hex(), "caller": hex::encode(ctx.caller.0), @@ -370,8 +431,11 @@ fn policy_wire( /// The fields recovered from a verified `policy/1` receipt. #[derive(Clone, Debug, PartialEq, Eq)] pub struct PolicyReceipt { - /// The policy name. + /// The policy name — the operator's label, not evidence. pub policy: String, + /// The content id (hex) of the policy that decided: its name and its + /// ordered rules. This is what makes the name checkable. + pub policy_digest: String, /// The action. pub action: String, /// The resource (hex). @@ -410,6 +474,7 @@ pub fn verify(envelope: &[u8], steward: &str, pubkey: &[u8; 32]) -> Result = [ + Match::Always, + Match::Action(vec!["write".into()]), + Match::ContentContains(vec!["write".into()]), + Match::Resource(ContentId::of_bytes(b"write")), + ] + .into_iter() + .map(|w| Policy::new("p", vec![Rule::deny("r", w)]).content_id()) + .collect(); + for i in 0..ids.len() { + for j in i + 1..ids.len() { + assert_ne!(ids[i], ids[j], "two match variants share a digest ({i} vs {j})"); + } + } + } + + /// Order is part of the meaning — `evaluate` returns on the first matching + /// `Deny` — so it must be part of the digest. + #[test] + fn reordering_rules_changes_the_digest() { + let a = Rule::deny("a", Match::Action(vec!["write".into()])); + let b = Rule::require_approval("b", Match::Always); + let one = Policy::new("p", vec![a.clone(), b.clone()]); + let two = Policy::new("p", vec![b, a]); + assert_ne!( + one.content_id(), + two.content_id(), + "rule order changes verdicts but not the digest" + ); + } + + /// The digest must travel on the sealed receipt and come back out, or it is + /// not evidence a third party can check. + #[test] + fn the_sealed_receipt_carries_the_deciding_policys_digest() { + let policy = Policy::new( + "prod-egress", + vec![Rule::deny("no-secrets", Match::ContentContains(vec!["SECRET".into()]))], + ); + let expected = policy.content_id().to_hex(); + let steward_sk = sk(7); + let mut engine = PolicyEngine::new(policy, steward_sk.clone(), "did:example:policy-steward"); + let sealed = engine.decide(&ctx("write", b"this carries a SECRET", did(&sk(2))), None); + let got = verify( + &sealed.receipt, + "did:example:policy-steward", + &steward_sk.verifying_key().to_bytes(), + ) + .expect("a sealed policy receipt verifies"); + assert_eq!(got.outcome, "denied"); + assert_eq!(got.policy, "prod-egress"); + assert_eq!(got.policy_digest, expected, "the receipt must commit to the ruleset"); + } + } -- 2.54.0 (Apple Git-157) From bc658ab98b3800ee9587242929875c60d138c67d Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Sun, 4 Oct 2026 17:17:37 -0400 Subject: [PATCH 3/4] jouleclaw-transparency-log: the consistency proof proved nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- .../jouleclaw-transparency-log/src/lib.rs | 218 ++++++++++++++++-- 1 file changed, 197 insertions(+), 21 deletions(-) diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-transparency-log/src/lib.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-transparency-log/src/lib.rs index 6baa95ffb..40b285d4f 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-transparency-log/src/lib.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-transparency-log/src/lib.rs @@ -295,8 +295,9 @@ pub struct ConsistencyProof { pub old_size: u64, /// New tree size. pub new_size: u64, - /// Proof body (kept opaque here; verification recomputes from - /// the proof's referenced subtrees). + /// The subtree hashes of RFC 6962 §2.1.2, in order. The verifier rebuilds + /// **both** roots from them, so neither root appears here — a path that + /// merely names the two roots is refused. pub audit_path: Vec<[u8; 32]>, } @@ -308,33 +309,105 @@ impl ConsistencyProof { old_root: &[u8; 32], new_root: &[u8; 32], ) -> Result<(), LogError> { - // Verify by walking the audit_path: it carries (old_root, - // ..., new_root). The simple validator confirms first = - // old_root and last = new_root and that adjacent pairs - // hash correctly. - if self.audit_path.is_empty() { - return Err(LogError::ConsistencyMismatch(0)); + // RFC 6962 §2.1.3. The proof is a set of subtree hashes; the verifier + // rebuilds *both* roots from them and compares. That is what makes it + // a proof: the old root is recomputed, not re-read. + // + // The previous implementation checked `audit_path[0] == old_root` and + // `audit_path.last() == new_root` against a path the prover built as + // `vec![old_root, new_root]` — so it compared the operator's claim to + // itself and a split-view operator walked straight through. Its + // comment also said it checked that "adjacent pairs hash correctly", + // which it never did. + let (m, n) = (self.old_size, self.new_size); + if m == 0 || m > n { + return Err(LogError::InvalidConsistencySizes(m, n)); } - if &self.audit_path[0] != old_root { - return Err(LogError::ConsistencyMismatch(0)); + if m == n { + // Nothing was appended: the roots must be equal and a proof + // carrying anything is malformed. + if !self.audit_path.is_empty() { + return Err(LogError::ConsistencyMismatch(0)); + } + return if old_root == new_root { + Ok(()) + } else { + Err(LogError::ConsistencyMismatch(0)) + }; } - if self.audit_path.last() != Some(new_root) { - return Err(LogError::ConsistencyMismatch( - (self.audit_path.len() - 1) as u32, - )); + + let mut node = m - 1; + let mut last = n - 1; + while node & 1 == 1 { + node >>= 1; + last >>= 1; + } + + let mut path = self.audit_path.iter(); + let mut step: u32 = 0; + // When the old tree is a perfect subtree its root is not sent — the + // verifier already has it — so it seeds the walk directly. + let (mut fr, mut sr) = if m.is_power_of_two() { + (*old_root, *old_root) + } else { + let seed = *path.next().ok_or(LogError::ConsistencyMismatch(step))?; + step += 1; + (seed, seed) + }; + + for c in path { + if last == 0 { + return Err(LogError::ConsistencyMismatch(step)); + } + if node & 1 == 1 || node == last { + fr = node_hash(c, &fr); + sr = node_hash(c, &sr); + while node != 0 && node & 1 == 0 { + node >>= 1; + last >>= 1; + } + } else { + sr = node_hash(&sr, c); + } + node >>= 1; + last >>= 1; + step += 1; + } + + if last != 0 || &fr != old_root || &sr != new_root { + return Err(LogError::ConsistencyMismatch(step)); } Ok(()) } } +/// RFC 6962 §2.1.2 `PROOF(m, D[n])`: the subtree hashes an auditor needs to +/// rebuild both roots itself. fn build_consistency_path(hashes: &[[u8; 32]], m: usize, n: usize) -> Vec<[u8; 32]> { - // Minimal consistency-proof shape: [old_root, new_root]. A - // full RFC 6962 §2.1.2 path is provable as well, but the - // shipped log compositions consistently check just root - // equivalence; we keep both roots in the path for verification. - let old_root = merkle_root(&hashes[..m]); - let new_root = merkle_root(&hashes[..n]); - vec![old_root, new_root] + subproof(m, &hashes[..n], true) +} + +/// RFC 6962 §2.1.2 `SUBPROOF(m, D[n], b)`. +/// +/// `b` is the flag the RFC calls "the old tree is a complete subtree of the +/// new one": while it holds, the old root is one the verifier already has and +/// is not sent. That is why a proof for a power-of-two `m` is one element +/// shorter, and why the verifier seeds its walk from `old_root` in that case. +fn subproof(m: usize, d: &[[u8; 32]], b: bool) -> Vec<[u8; 32]> { + let n = d.len(); + if m == n { + return if b { Vec::new() } else { vec![merkle_root(d)] }; + } + let k = largest_power_of_two_lt(n); + if m <= k { + let mut p = subproof(m, &d[..k], b); + p.push(merkle_root(&d[k..])); + p + } else { + let mut p = subproof(m - k, &d[k..], false); + p.push(merkle_root(&d[..k])); + p + } } // ───────────────────────────────────────────────────────────────────── @@ -493,6 +566,109 @@ mod tests { p.verify(&old_root, &t.root()).unwrap(); } + /// The hole this closes. The old prover emitted `[old_root, new_root]` and + /// the old verifier checked exactly those two values — comparing the + /// operator's claim to itself. So this hand-forged "proof", which contains + /// no evidence at all, used to verify. It must not. + #[test] + fn a_proof_that_merely_restates_the_two_roots_is_refused() { + let mut t = MerkleTree::new(); + for s in ["a", "b", "c"] { + t.append(leaf(s)); + } + let old_root = t.root(); + for s in ["d", "e"] { + t.append(leaf(s)); + } + let forged = ConsistencyProof { + old_size: 3, + new_size: 5, + audit_path: vec![old_root, t.root()], + }; + assert!( + forged.verify(&old_root, &t.root()).is_err(), + "a path that just names both roots is not a proof" + ); + // The honest proof for the same transition does verify. + t.consistency_proof(3).unwrap().verify(&old_root, &t.root()).unwrap(); + } + + /// A split view: the operator shows an auditor a new tree that does not + /// extend the old one it published. The proof is built honestly from the + /// *other* history, so every hash in it is real — only the relationship is + /// a lie. This is the attack a consistency proof exists for. + #[test] + fn a_new_tree_that_does_not_extend_the_old_one_is_refused() { + let mut published = MerkleTree::new(); + for s in ["a", "b", "c"] { + published.append(leaf(s)); + } + let published_root = published.root(); + + // A second history that shares a prefix length but not the prefix. + let mut forked = MerkleTree::new(); + for s in ["a", "b", "X"] { + forked.append(leaf(s)); + } + for s in ["d", "e"] { + forked.append(leaf(s)); + } + let proof = forked.consistency_proof(3).unwrap(); + assert!( + proof.verify(&published_root, &forked.root()).is_err(), + "a fork passed as an extension of the published tree" + ); + // Against its own history the same proof is good, so the refusal is + // about the relationship and not a broken prover. + let mut forked_at_3 = MerkleTree::new(); + for s in ["a", "b", "X"] { + forked_at_3.append(leaf(s)); + } + proof.verify(&forked_at_3.root(), &forked.root()).unwrap(); + } + + /// An RFC 6962 implementation is only correct if it is correct for every + /// shape, so this walks every `(m, n)` pair up to 17 leaves — the perfect + /// trees, the unbalanced ones, and `m == n`. + #[test] + fn every_transition_up_to_seventeen_leaves_round_trips() { + let mut roots: Vec<[u8; 32]> = Vec::new(); + let mut t = MerkleTree::new(); + roots.push(t.root()); // size 0 + for i in 0..17 { + t.append(leaf(&format!("leaf-{i}"))); + roots.push(t.root()); + } + let full = t; + let mut checked = 0; + for n in 1..=17usize { + // The tree of size n, and its honest proofs back to every m. + let mut tn = MerkleTree::new(); + for i in 0..n { + tn.append(leaf(&format!("leaf-{i}"))); + } + assert_eq!(tn.root(), roots[n], "root drifted at size {n}"); + for m in 1..=n { + let p = tn + .consistency_proof(m as u64) + .unwrap_or_else(|e| panic!("prover failed for m={m}, n={n}: {e}")); + p.verify(&roots[m], &roots[n]) + .unwrap_or_else(|e| panic!("m={m}, n={n} failed to verify: {e}")); + // A proof is for one transition: it must not verify against a + // different old root. + if m >= 2 { + assert!( + p.verify(&roots[m - 1], &roots[n]).is_err(), + "m={m}, n={n} verified against the wrong old root" + ); + } + checked += 1; + } + } + assert_eq!(checked, 153, "expected every (m,n) pair with 1<=m<=n<=17"); + assert_eq!(full.root(), roots[17]); + } + #[test] fn consistency_proof_rejects_unrelated_roots() { let mut t = MerkleTree::new(); -- 2.54.0 (Apple Git-157) From d9439572788a4ebb6e3bf2bb561cbb697cf35533 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Sun, 4 Oct 2026 17:21:45 -0400 Subject: [PATCH 4/4] jcp-gateway: two doors were open in the chokepoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- jcp/jcp-rs/crates/jcp-gateway/src/lib.rs | 154 +++++++++++++++++++++-- 1 file changed, 143 insertions(+), 11 deletions(-) diff --git a/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs b/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs index 9123fb233..844b94604 100644 --- a/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs @@ -1,8 +1,11 @@ //! The energy-native MCP gateway. //! //! An MCP client speaks to an MCP server through this gateway instead of -//! directly. Most traffic (`initialize`, `tools/list`, notifications) passes -//! through untouched. But every `tools/call` is a **JCP enforcement point**: +//! directly. Discovery traffic (`initialize`, the `*/list` methods, +//! notifications) passes through untouched. But every `tools/call`, +//! `resources/read` and `prompts/get` is a **JCP enforcement point** — the +//! last two because each moves a payload the model then reads, and each used +//! to pass through unbudgeted, unmetered and unsealed: //! //! 1. The tool must have a capability grant; the frame is content-addressed to //! `(server, tool)`, so a grant for `read_file` cannot authorize `shell`. @@ -54,9 +57,10 @@ pub trait Upstream { /// What the gateway did with one client message. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Relayed { - /// A non-`tools/call` message forwarded verbatim; here is the response. + /// An ungated message (discovery, `*/list`) forwarded verbatim; here is + /// the response. PassedThrough(String), - /// A `tools/call` the gateway authorized, metered, and forwarded. + /// A gated call the gateway authorized, metered, and forwarded. /// `cost_uj` is the figure debited from the grant: a hardware reading or /// the meter's estimate. The sealed receipt's `energy_provenance`, and the /// same label beside the figure in the response's `_meta`, say which. @@ -68,6 +72,48 @@ pub enum Relayed { Notified, } +/// The action name and payload of a method this gateway **gates**, or `None` +/// for one it relays untouched. +/// +/// Only `tools/call` used to be gated, and the comparison was a bare +/// `method != "tools/call"`. That left two doors open in the chokepoint: +/// +/// - **`resources/read`** returns server-held bytes to the model. It is a data +/// egress, and it went through unbudgeted, unmetered and unsealed — no +/// grant, no debit, no receipt, nothing in the flow lattice to fold a later +/// call against. +/// - **`prompts/get`** returns server-authored text that becomes model input, +/// on the same terms. +/// +/// Each is keyed so a grant is scoped to one resource or prompt rather than to +/// the method: a grant for `resources/read:file:///a` is not authority to read +/// `file:///b`. The key is the action name, so these ride the same grant map, +/// the same content-addressed `mcp_resource`, the same metering, and the same +/// sealed denial as a tool call — an ungranted read is denied and *recorded*, +/// not silently dropped. +/// +/// `initialize`, `tools/list`, `resources/list`, `prompts/list` and +/// notifications still relay untouched: they are discovery, and they move no +/// payload. What a client may *see* listed is a separate question from what it +/// may read, and deriving the listing from the grant is future work. +fn gated_action(method: &str, params: &Value) -> Option<(String, Value)> { + let str_field = |k: &str| params.get(k).and_then(Value::as_str).unwrap_or("").to_string(); + match method { + "tools/call" => { + Some((str_field("name"), params.get("arguments").cloned().unwrap_or(json!({})))) + } + // The uri is the payload: it is what selects the bytes that come back. + "resources/read" => { + Some((format!("resources/read:{}", str_field("uri")), params.clone())) + } + "prompts/get" => Some(( + format!("prompts/get:{}", str_field("name")), + params.get("arguments").cloned().unwrap_or(json!({})), + )), + _ => None, + } +} + /// The gateway: a JCP runtime, the tool grants, an energy meter, and a /// hash-chained receipt log, in front of one MCP server. pub struct Gateway { @@ -199,19 +245,18 @@ impl Gateway { let method = msg.get("method").and_then(Value::as_str).unwrap_or(""); let id = msg.get("id"); - if method != "tools/call" { + let params = msg.get("params").cloned().unwrap_or(Value::Null); + let Some((action, args)) = gated_action(method, ¶ms) else { if id.is_none() { // A notification: forward fire-and-forget, no response. let _ = up.call(line); return Ok(Relayed::Notified); } return Ok(Relayed::PassedThrough(up.call(line)?)); - } + }; - // ---- A tools/call: the enforcement point. - let params = msg.get("params").cloned().unwrap_or(Value::Null); - let tool = params.get("name").and_then(Value::as_str).unwrap_or(""); - let args = params.get("arguments").cloned().unwrap_or(json!({})); + // ---- A gated method: the enforcement point. + let tool = action.as_str(); let args_bytes = serde_json::to_vec(&args).unwrap_or_default(); // No grant for this tool ⇒ deny. We still SEAL the denial, using any @@ -317,7 +362,7 @@ impl Gateway { /// (failing) decision; if the gateway holds no grants at all, the denial /// is synthesized without a receipt (nothing to bind it to). fn deny_unknown(&mut self, tool: &str, id: Option<&Value>) -> Relayed { - let reason = "no grant for tool".to_string(); + let reason = "no grant for action".to_string(); if let Some(anchor) = self.grants.values().next().cloned() { let grant = anchor.grant.clone(); // Frame addressed to the REQUESTED tool, decided against a grant @@ -692,6 +737,93 @@ mod tests { } } + fn read_resource(uri: &str, id: i64) -> String { + json!({"jsonrpc":"2.0","id":id,"method":"resources/read","params":{"uri":uri}}) + .to_string() + } + + /// The hole this closes: `resources/read` returns server-held bytes to the + /// model, and the gateway relayed it on a bare `method != "tools/call"` — + /// no grant, no debit, no receipt. A data egress straight through the + /// chokepoint. + #[test] + fn an_ungranted_resource_read_is_denied_and_never_reaches_the_server() { + let mut g = gw(); + let mut up = MockServer { calls: 0 }; + let r = g.relay(&read_resource("file:///etc/passwd", 1), &mut up).unwrap(); + assert!( + matches!(r, Relayed::Denied { .. }), + "an ungranted resource read was not denied: {r:?}" + ); + assert_eq!(up.calls, 0, "a denied read NEVER reaches the server"); + } + + /// Granted, the same read is authorized, metered and debited like a tool + /// call — so it enters the ledger and the receipt chain. + #[test] + fn a_granted_resource_read_is_metered_and_debited() { + let mut g = Gateway::new( + "files", + &[("resources/read:file:///ok.txt", 50_000_000)], + SigningKey::from_bytes(&[7u8; 32]), + ); + let mut up = MockServer { calls: 0 }; + let r = g.relay(&read_resource("file:///ok.txt", 1), &mut up).unwrap(); + let Relayed::Allowed { cost_uj, budget_remaining_uj, .. } = r else { + panic!("a granted resource read was not allowed: {r:?}"); + }; + assert_eq!(up.calls, 1); + assert!(budget_remaining_uj < 50_000_000, "the read was not debited"); + let _ = cost_uj; + } + + /// A grant is scoped to one resource. Authority to read one file is not + /// authority to read another, which is the whole reason the uri is in the + /// key rather than the method. + #[test] + fn a_resource_grant_does_not_cover_a_different_uri() { + let mut g = Gateway::new( + "files", + &[("resources/read:file:///ok.txt", 50_000_000)], + SigningKey::from_bytes(&[7u8; 32]), + ); + let mut up = MockServer { calls: 0 }; + let r = g.relay(&read_resource("file:///secret.txt", 1), &mut up).unwrap(); + assert!(matches!(r, Relayed::Denied { .. }), "a grant for one uri covered another"); + assert_eq!(up.calls, 0); + } + + /// `prompts/get` returns server-authored text that becomes model input, on + /// the same terms. + #[test] + fn an_ungranted_prompt_fetch_is_denied() { + let mut g = gw(); + let mut up = MockServer { calls: 0 }; + let line = json!({"jsonrpc":"2.0","id":1,"method":"prompts/get", + "params":{"name":"exfiltrate","arguments":{}}}) + .to_string(); + let r = g.relay(&line, &mut up).unwrap(); + assert!(matches!(r, Relayed::Denied { .. }), "an ungranted prompt fetch was relayed"); + assert_eq!(up.calls, 0); + } + + /// Discovery still relays untouched: it moves no payload, and gating it + /// would refuse a client the right to see what exists. + #[test] + fn discovery_methods_still_pass_through() { + let mut g = gw(); + let mut up = MockServer { calls: 0 }; + for method in ["initialize", "tools/list", "resources/list", "prompts/list"] { + let line = json!({"jsonrpc":"2.0","id":1,"method":method}).to_string(); + let r = g.relay(&line, &mut up).unwrap(); + assert!( + matches!(r, Relayed::PassedThrough(_)), + "{method} should relay untouched, got {r:?}" + ); + } + assert_eq!(up.calls, 4); + } + #[test] fn ungranted_tool_is_denied_without_contacting_upstream() { let mut g = gw(); -- 2.54.0 (Apple Git-157)