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]>
944 lines
38 KiB
Rust
944 lines
38 KiB
Rust
//! The **A2A + AP2 profile of JCP** — the open agent-interop wire, made
|
|
//! capability-scoped, joule-budgeted, decided outside the model, and sealed.
|
|
//!
|
|
//! [A2A](https://a2a-protocol.org) is the open agent-to-agent wire: an **Agent
|
|
//! Card** advertises an agent's `skills`, and a peer drives them over JSON-RPC
|
|
//! (`message/send`). [AP2](https://ap2-protocol.org) layers payment **mandates**
|
|
//! that authorize an agent to spend on a principal's behalf. Both describe *what*
|
|
//! an agent may do and *how much* it may spend — but the authorization lives in
|
|
//! the wire, enforced by whoever holds the transport, denominated in dollars.
|
|
//!
|
|
//! JCP already has the enforcement primitive: a signed **Grant** = capability ⊗
|
|
//! joule-budget, decided outside the model and sealed in a JCR-1 receipt
|
|
//! ([`jcp_bridge`] already frames an A2A task as a [`Frame`]). This crate is the
|
|
//! ingest that closes the loop:
|
|
//!
|
|
//! - [`ingest_agent_card`] parses an A2A Agent Card; [`capabilities_from_card`]
|
|
//! turns each **skill** into a content-addressed JCP [`Capability`] (the
|
|
//! resource is `a2a:<agent>:<skill>`, 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 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
|
|
//! never invents a currency→joule exchange rate: the two are carried as two
|
|
//! independent ceilings (SPEC §4.8), enforced separately.
|
|
//! - [`A2aProfile`] mints one capability-scoped grant per skill and gates every
|
|
//! A2A task against it through a [`jcp_bridge::Bridge`]: a call to a skill the
|
|
//! Agent Card did not advertise — or one past *either* ceiling — is denied
|
|
//! **before** it reaches the peer, and the denial seals just like an allow,
|
|
//! recording which ceiling bound. For a currency mandate,
|
|
//! [`A2aProfile::from_mandate_priced`] takes the money ceiling from the mandate
|
|
//! and the joule ceiling from the operator (the mandate has no authority over
|
|
//! energy), then [`A2aProfile::serve_task_priced`] enforces both. The model's
|
|
//! judgement never enters the decision; the held capability and the two
|
|
//! budgets do.
|
|
//!
|
|
//! Ingest, not compete: the A2A/AP2 wire is the surface; JCP contributes the
|
|
//! capability, the energy budget, and the cryptographic audit it lacks.
|
|
|
|
use std::collections::BTreeMap;
|
|
|
|
use ed25519_dalek::SigningKey;
|
|
use jcp_bridge::{a2a_resource, Bridge, Gated};
|
|
use jcp_grant::issue;
|
|
use jcp_runtime::Price;
|
|
use jcp_schema::{Capability, Did, Energy, Funds, Grant, SignedGrant};
|
|
use serde_json::Value;
|
|
|
|
/// Errors ingesting an Agent Card, an AP2 mandate, or an A2A request.
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum A2aError {
|
|
/// The document was not the expected JSON shape.
|
|
#[error("malformed {what}: {detail}")]
|
|
Malformed {
|
|
/// Which document.
|
|
what: &'static str,
|
|
/// What was wrong.
|
|
detail: String,
|
|
},
|
|
/// The grant for a skill could not be issued.
|
|
#[error("grant issue failed: {0}")]
|
|
Grant(#[from] jcp_grant::GrantError),
|
|
}
|
|
|
|
fn malformed(what: &'static str, detail: impl Into<String>) -> A2aError {
|
|
A2aError::Malformed { what, detail: detail.into() }
|
|
}
|
|
|
|
// ───────────────────────────── A2A Agent Card ─────────────────────────────
|
|
|
|
/// One advertised capability of an A2A agent (an A2A `AgentSkill`).
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct AgentSkill {
|
|
/// The skill id (the action a grant authorizes).
|
|
pub id: String,
|
|
/// Human-readable name.
|
|
pub name: String,
|
|
/// What the skill does.
|
|
pub description: String,
|
|
/// Free-form tags.
|
|
pub tags: Vec<String>,
|
|
}
|
|
|
|
/// An A2A **Agent Card**: the agent's identity and the skills it advertises.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct AgentCard {
|
|
/// The agent name.
|
|
pub name: String,
|
|
/// The agent's service URL (the stable identity a capability binds to).
|
|
pub url: String,
|
|
/// The advertised version.
|
|
pub version: String,
|
|
/// The skills the agent advertises.
|
|
pub skills: Vec<AgentSkill>,
|
|
}
|
|
|
|
impl AgentCard {
|
|
/// The stable identity a capability's audience binds to: the service URL,
|
|
/// falling back to the name.
|
|
pub fn agent_id(&self) -> &str {
|
|
if self.url.is_empty() {
|
|
&self.name
|
|
} else {
|
|
&self.url
|
|
}
|
|
}
|
|
|
|
/// Whether the card advertises a skill with this id.
|
|
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;
|
|
/// unknown fields are tolerated).
|
|
pub fn ingest_agent_card(card: &Value) -> Result<AgentCard, A2aError> {
|
|
let obj = card.as_object().ok_or_else(|| malformed("agent card", "not a JSON object"))?;
|
|
let s = |k: &str| obj.get(k).and_then(Value::as_str).unwrap_or("").to_string();
|
|
let name = s("name");
|
|
let url = s("url");
|
|
if name.is_empty() && url.is_empty() {
|
|
return Err(malformed("agent card", "neither `name` nor `url` present"));
|
|
}
|
|
let skills = obj
|
|
.get("skills")
|
|
.and_then(Value::as_array)
|
|
.map(|arr| arr.iter().filter_map(parse_skill).collect())
|
|
.unwrap_or_default();
|
|
Ok(AgentCard { name, url, version: s("version"), skills })
|
|
}
|
|
|
|
fn parse_skill(v: &Value) -> Option<AgentSkill> {
|
|
let id = v.get("id").and_then(Value::as_str)?.to_string();
|
|
if id.is_empty() {
|
|
return None;
|
|
}
|
|
let str_field = |k: &str| v.get(k).and_then(Value::as_str).unwrap_or("").to_string();
|
|
let tags = v
|
|
.get("tags")
|
|
.and_then(Value::as_array)
|
|
.map(|a| a.iter().filter_map(|t| t.as_str().map(str::to_string)).collect())
|
|
.unwrap_or_default();
|
|
Some(AgentSkill { id, name: str_field("name"), description: str_field("description"), tags })
|
|
}
|
|
|
|
/// The JCP capabilities an Agent Card advertises — one content-addressed
|
|
/// capability per skill, bound to the `a2a:<agent>:<skill>` audience.
|
|
pub fn capabilities_from_card(card: &AgentCard) -> Vec<Capability> {
|
|
let agent = card.agent_id();
|
|
card.skills
|
|
.iter()
|
|
.map(|s| Capability::new(a2a_resource(agent, &s.id), [s.id.clone()]))
|
|
.collect()
|
|
}
|
|
|
|
// ───────────────────────────── AP2 mandate ─────────────────────────────
|
|
|
|
/// 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<String>,
|
|
) -> Result<Ap2Mandate, A2aError> {
|
|
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.
|
|
pub payee: String,
|
|
/// The currency the ceiling is denominated in (`uJ` / `µJ` / `joule` for a
|
|
/// joule-denominated mandate; an ISO currency otherwise).
|
|
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
|
|
/// never invents a currency→joule exchange rate; the joule ceiling must be
|
|
/// supplied alongside it.
|
|
pub fn joule_budget(&self) -> Option<u64> {
|
|
match self.currency.trim().to_ascii_lowercase().as_str() {
|
|
"uj" | "µj" | "ujoule" | "microjoule" | "microjoules" => Some(self.max_amount),
|
|
"j" | "joule" | "joules" => self.max_amount.checked_mul(1_000_000),
|
|
"mj" | "millijoule" | "millijoules" => self.max_amount.checked_mul(1_000),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// The **money** ceiling this mandate authorizes (SPEC §4.8) — `Some` for a
|
|
/// currency-denominated mandate, which is what the payments ecosystem
|
|
/// actually emits.
|
|
///
|
|
/// This is the counterpart to [`joule_budget`](Self::joule_budget), and the
|
|
/// two are deliberately disjoint: a mandate denominated in joules is an
|
|
/// energy ceiling and yields no money ceiling; one denominated in a currency
|
|
/// is a money ceiling and yields no joule ceiling. **No exchange rate is
|
|
/// invented in either direction.** A currency mandate therefore does not
|
|
/// bound energy at all — the operator supplies that ceiling, because the
|
|
/// mandate has no authority over it. Two ceilings, two sources of authority:
|
|
/// exactly why they are enforced separately rather than converted.
|
|
pub fn funds_ceiling(&self) -> Option<Funds> {
|
|
if self.joule_budget().is_some() {
|
|
return None; // joule-denominated: an energy ceiling, not money
|
|
}
|
|
Some(Funds::new(self.currency.trim(), self.max_amount))
|
|
}
|
|
}
|
|
|
|
/// 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.
|
|
///
|
|
/// **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<UnverifiedAp2Mandate, A2aError> {
|
|
// The amount may sit at the top level or under a `paymentMandate` / `cart`.
|
|
let root = mandate
|
|
.get("paymentMandate")
|
|
.or_else(|| mandate.get("cart"))
|
|
.unwrap_or(mandate);
|
|
let obj = root.as_object().ok_or_else(|| malformed("ap2 mandate", "not a JSON object"))?;
|
|
let payee = obj
|
|
.get("payee")
|
|
.or_else(|| obj.get("merchant"))
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("")
|
|
.to_string();
|
|
let currency = obj
|
|
.get("currency")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("")
|
|
.to_string();
|
|
let max_amount = obj
|
|
.get("maxAmount")
|
|
.or_else(|| obj.get("amount"))
|
|
.and_then(Value::as_u64)
|
|
.ok_or_else(|| malformed("ap2 mandate", "no `maxAmount`/`amount` uint"))?;
|
|
if currency.is_empty() {
|
|
return Err(malformed("ap2 mandate", "no `currency`"));
|
|
}
|
|
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 ─────────────────────────────
|
|
|
|
/// The result of gating one A2A task: re-exported from [`jcp_bridge`].
|
|
pub use jcp_bridge::Gated as TaskGate;
|
|
|
|
/// An A2A agent's advertised skills, minted into capability-scoped joule-budgeted
|
|
/// JCP grants, with every task gated and sealed.
|
|
///
|
|
/// Construct it from an Agent Card and a joule budget (typically read from an AP2
|
|
/// mandate), then call [`A2aProfile::serve_task`] for each A2A invocation. The
|
|
/// host forwards the task to the real A2A peer **only** when the gate allows.
|
|
pub struct A2aProfile {
|
|
bridge: Bridge,
|
|
agent: String,
|
|
caller: Did,
|
|
grants: BTreeMap<String, SignedGrant>,
|
|
anchor: Option<SignedGrant>,
|
|
steward: String,
|
|
steward_pk: [u8; 32],
|
|
receipts: Vec<Vec<u8>>,
|
|
now: u64,
|
|
}
|
|
|
|
impl A2aProfile {
|
|
/// Mint one grant per advertised skill, each scoped to that skill's audience
|
|
/// and capped at `budget_uj`, and stand up a sealing gateway.
|
|
///
|
|
/// `issuer_sk` is the principal authorizing the agent (an AP2 mandate's
|
|
/// signer); `subject` is the calling agent that holds the grants and drives
|
|
/// the tasks; `steward_sk` seals every decision.
|
|
pub fn new(
|
|
card: &AgentCard,
|
|
budget_uj: u64,
|
|
issuer_sk: &SigningKey,
|
|
subject: Did,
|
|
steward_sk: SigningKey,
|
|
steward: impl Into<String>,
|
|
) -> Result<Self, A2aError> {
|
|
Self::new_priced(card, budget_uj, None, issuer_sk, subject, steward_sk, steward)
|
|
}
|
|
|
|
/// Mint one **dual-ceiling** grant per advertised skill (SPEC §4.8): a joule
|
|
/// ceiling and, optionally, a money ceiling — each enforced independently.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn new_priced(
|
|
card: &AgentCard,
|
|
budget_uj: u64,
|
|
budget_funds: Option<Funds>,
|
|
issuer_sk: &SigningKey,
|
|
subject: Did,
|
|
steward_sk: SigningKey,
|
|
steward: impl Into<String>,
|
|
) -> Result<Self, A2aError> {
|
|
let issuer = Did::from_verifying_key(&issuer_sk.verifying_key());
|
|
let agent = card.agent_id().to_string();
|
|
let mut grants = BTreeMap::new();
|
|
for cap in capabilities_from_card(card) {
|
|
let action = cap.actions.first().cloned().unwrap_or_default();
|
|
let grant = Grant {
|
|
issuer,
|
|
subject,
|
|
capability: cap,
|
|
budget_uj,
|
|
budget_funds: budget_funds.clone(),
|
|
caveats: Vec::new(),
|
|
expiry: 0,
|
|
parent: None,
|
|
delegate_depth: None,
|
|
nonce: [0; 16],
|
|
};
|
|
grants.insert(action, issue(issuer_sk, grant)?);
|
|
}
|
|
if grants.is_empty() {
|
|
return Err(malformed("agent card", "advertises no skills to bind"));
|
|
}
|
|
let anchor = grants.values().next().cloned();
|
|
let steward = steward.into();
|
|
let steward_pk = steward_sk.verifying_key().to_bytes();
|
|
Ok(Self {
|
|
bridge: Bridge::new(steward_sk, steward.clone()),
|
|
agent,
|
|
caller: subject,
|
|
grants,
|
|
anchor,
|
|
steward,
|
|
steward_pk,
|
|
receipts: Vec::new(),
|
|
now: 0,
|
|
})
|
|
}
|
|
|
|
/// Mint from an AP2 mandate directly — the mandate's joule authorization is
|
|
/// the per-skill budget. Errors if the mandate is not joule-denominated (the
|
|
/// profile invents no exchange rate; use [`A2aProfile::new`] with an explicit
|
|
/// joule budget for a currency mandate).
|
|
pub fn from_mandate(
|
|
card: &AgentCard,
|
|
mandate: &Ap2Mandate,
|
|
issuer_sk: &SigningKey,
|
|
subject: Did,
|
|
steward_sk: SigningKey,
|
|
steward: impl Into<String>,
|
|
) -> Result<Self, A2aError> {
|
|
refuse_unless_payee(card, mandate)?;
|
|
let budget = mandate.joule_budget().ok_or_else(|| {
|
|
malformed("ap2 mandate", format!("`{}` is not joule-denominated", mandate.currency))
|
|
})?;
|
|
Self::new(card, budget, issuer_sk, subject, steward_sk, steward)
|
|
}
|
|
|
|
/// Ingest a **currency-denominated** AP2 mandate — the form the payments
|
|
/// ecosystem actually emits — as a dual-ceiling profile (SPEC §4.8).
|
|
///
|
|
/// The mandate supplies the **money** ceiling; the caller supplies the
|
|
/// **joule** ceiling, because the mandate has no authority over energy and
|
|
/// JCP will not invent a rate to derive one. Both are then enforced
|
|
/// independently on every task, and the receipt records which one bound.
|
|
///
|
|
/// This is what a real AP2 mandate (`currency: "USD"`) resolves to: before
|
|
/// dual ceilings it yielded no joule ceiling at all and could not be gated.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn from_mandate_priced(
|
|
card: &AgentCard,
|
|
mandate: &Ap2Mandate,
|
|
budget_uj: u64,
|
|
issuer_sk: &SigningKey,
|
|
subject: Did,
|
|
steward_sk: SigningKey,
|
|
steward: impl Into<String>,
|
|
) -> Result<Self, A2aError> {
|
|
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();
|
|
Self::new_priced(card, budget_uj, funds, issuer_sk, subject, steward_sk, steward)
|
|
}
|
|
|
|
/// Serve an A2A task against **both** ceilings — the priced dual of
|
|
/// [`serve_task`](Self::serve_task). `price` is what this task costs in
|
|
/// money; `cost_uj` is what it costs in energy.
|
|
pub fn serve_task_priced(
|
|
&mut self,
|
|
skill_id: &str,
|
|
payload: &[u8],
|
|
cost_uj: u64,
|
|
price: &Price,
|
|
) -> Gated {
|
|
let signed = self
|
|
.grants
|
|
.get(skill_id)
|
|
.or(self.anchor.as_ref())
|
|
.expect("profile always holds at least one grant (new rejects empty cards)")
|
|
.clone();
|
|
let gated = self.bridge.call_a2a_priced(
|
|
&signed,
|
|
self.caller,
|
|
&self.agent,
|
|
skill_id,
|
|
payload,
|
|
Energy::estimator(cost_uj),
|
|
price,
|
|
self.now,
|
|
);
|
|
self.receipts.push(gated.receipt.clone());
|
|
gated
|
|
}
|
|
|
|
/// Money debited against a skill's grant, in its ceiling's minor units.
|
|
pub fn spent_minor(&self, skill_id: &str) -> u64 {
|
|
self.grants
|
|
.get(skill_id)
|
|
.map_or(0, |sg| self.bridge.runtime().spent_minor(&sg.grant.id()))
|
|
}
|
|
|
|
/// Gate one A2A task: `skill_id` is the skill the task targets, `payload` its
|
|
/// message bytes, `cost_uj` the energy to attribute. On allow the cost debits
|
|
/// the skill's grant; either way the decision seals a JCR-1 receipt. Forward
|
|
/// to the real A2A peer only when the returned gate `allowed()`.
|
|
pub fn serve_task(&mut self, skill_id: &str, payload: &[u8], cost_uj: u64) -> Gated {
|
|
// An ungranted skill still decides (and seals) against an anchor grant:
|
|
// its frame's audience can't match, so the runtime denies it — recorded.
|
|
let signed = self
|
|
.grants
|
|
.get(skill_id)
|
|
.or(self.anchor.as_ref())
|
|
.expect("profile always holds at least one grant (new rejects empty cards)")
|
|
.clone();
|
|
let gated = self.bridge.call_a2a(
|
|
&signed,
|
|
self.caller,
|
|
&self.agent,
|
|
skill_id,
|
|
payload,
|
|
Energy::estimator(cost_uj),
|
|
self.now,
|
|
);
|
|
self.receipts.push(gated.receipt.clone());
|
|
gated
|
|
}
|
|
|
|
/// The granted skill ids, sorted.
|
|
pub fn granted_skills(&self) -> Vec<&str> {
|
|
self.grants.keys().map(String::as_str).collect()
|
|
}
|
|
|
|
/// Total joules debited across all skill grants, in µJ.
|
|
pub fn spent_uj(&self) -> u64 {
|
|
self.grants.values().map(|sg| self.bridge.spent(&sg.grant.id())).sum()
|
|
}
|
|
|
|
/// The sealed JCR-1 decision receipts (allow + deny), in order.
|
|
pub fn receipts(&self) -> &[Vec<u8>] {
|
|
&self.receipts
|
|
}
|
|
|
|
/// The sealing steward id (the `kid` a verifier resolves).
|
|
pub fn steward(&self) -> &str {
|
|
&self.steward
|
|
}
|
|
|
|
/// The steward's public key — a third party verifies the receipts with this.
|
|
pub fn steward_pubkey(&self) -> [u8; 32] {
|
|
self.steward_pk
|
|
}
|
|
}
|
|
|
|
// ───────────────────────── A2A request parsing ─────────────────────────
|
|
|
|
/// The target skill and message bytes of an A2A `message/send` request.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct TaskRequest {
|
|
/// The skill the task targets (`params.metadata.skillId`).
|
|
pub skill_id: String,
|
|
/// The flattened message text (the `params.message.parts` text).
|
|
pub payload: String,
|
|
}
|
|
|
|
/// Parse an A2A JSON-RPC `message/send` request into the target skill and its
|
|
/// message text. The skill id is taken from `params.metadata.skillId`; the
|
|
/// payload concatenates the text of the message's `parts`.
|
|
pub fn parse_message_send(req: &Value) -> Result<TaskRequest, A2aError> {
|
|
if req.get("method").and_then(Value::as_str) != Some("message/send") {
|
|
return Err(malformed("a2a request", "method is not `message/send`"));
|
|
}
|
|
let params = req.get("params").ok_or_else(|| malformed("a2a request", "no `params`"))?;
|
|
let skill_id = params
|
|
.get("metadata")
|
|
.and_then(|m| m.get("skillId"))
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("")
|
|
.to_string();
|
|
let message = params.get("message").ok_or_else(|| malformed("a2a request", "no `message`"))?;
|
|
let payload = message
|
|
.get("parts")
|
|
.and_then(Value::as_array)
|
|
.map(|parts| {
|
|
parts
|
|
.iter()
|
|
.filter_map(|p| p.get("text").and_then(Value::as_str))
|
|
.collect::<Vec<_>>()
|
|
.join(" ")
|
|
})
|
|
.unwrap_or_default();
|
|
Ok(TaskRequest { skill_id, payload })
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use jcp_schema::Verdict;
|
|
use serde_json::json;
|
|
|
|
fn sk(s: u8) -> SigningKey {
|
|
SigningKey::from_bytes(&[s; 32])
|
|
}
|
|
|
|
fn sample_card() -> Value {
|
|
json!({
|
|
"name": "planner-agent",
|
|
"url": "https://planner.example/a2a",
|
|
"version": "1.0.0",
|
|
"capabilities": { "streaming": true, "pushNotifications": false },
|
|
"skills": [
|
|
{ "id": "plan", "name": "Plan", "description": "Decompose a goal", "tags": ["reasoning"] },
|
|
{ "id": "summarize", "name": "Summarize", "description": "Summarize text", "tags": ["nlp"] }
|
|
]
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
fn ingests_card_into_capabilities() {
|
|
let card = ingest_agent_card(&sample_card()).unwrap();
|
|
assert_eq!(card.name, "planner-agent");
|
|
assert_eq!(card.skills.len(), 2);
|
|
assert!(card.advertises("plan"));
|
|
let caps = capabilities_from_card(&card);
|
|
assert_eq!(caps.len(), 2);
|
|
// The capability binds to the content-addressed a2a:<agent>:<skill>.
|
|
assert_eq!(caps[0].resource, a2a_resource(card.agent_id(), "plan"));
|
|
assert_eq!(caps[0].actions, vec!["plan".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_empty_card() {
|
|
assert!(ingest_agent_card(&json!({ "skills": [] })).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn ap2_joule_budget_maps_units_and_refuses_currency() {
|
|
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().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().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://[email protected]: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().accepted_as_verified("test-wallet").unwrap();
|
|
assert_eq!(m.payee, "store");
|
|
assert_eq!(m.joule_budget(), Some(7_000)); // 7 mJ → 7000 µJ
|
|
}
|
|
|
|
#[test]
|
|
fn advertised_skill_allowed_unadvertised_denied_both_sealed() {
|
|
let card = ingest_agent_card(&sample_card()).unwrap();
|
|
let mut profile = A2aProfile::new(
|
|
&card,
|
|
1_000_000,
|
|
&sk(1), // principal / mandate signer
|
|
Did::from_verifying_key(&sk(2).verifying_key()), // calling agent
|
|
sk(7), // steward
|
|
"did:example:a2a-steward",
|
|
)
|
|
.unwrap();
|
|
|
|
// An advertised skill is allowed and the cost is debited.
|
|
let g = profile.serve_task("plan", b"decompose: ship the feature", 40_000);
|
|
assert!(g.allowed());
|
|
// A skill the Agent Card never advertised is denied before it reaches
|
|
// the peer — the model's choice never enters the decision.
|
|
let bad = profile.serve_task("delete_account", b"drop everything", 1);
|
|
assert!(!bad.allowed());
|
|
assert!(matches!(bad.decision.verdict, Verdict::Deny(_)));
|
|
// Both decisions sealed a receipt.
|
|
assert_eq!(profile.receipts().len(), 2);
|
|
assert_eq!(profile.granted_skills(), vec!["plan", "summarize"]);
|
|
assert_eq!(profile.spent_uj(), 40_000);
|
|
}
|
|
|
|
#[test]
|
|
fn ap2_budget_caps_task_spend() {
|
|
let card = ingest_agent_card(&sample_card()).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,
|
|
&sk(1),
|
|
Did::from_verifying_key(&sk(2).verifying_key()),
|
|
sk(7),
|
|
"did:example:a2a-steward",
|
|
)
|
|
.unwrap();
|
|
assert!(profile.serve_task("plan", b"a", 30_000).allowed());
|
|
// 30k spent of a 50k joule budget; a second 30k task exceeds it.
|
|
assert!(!profile.serve_task("plan", b"b", 30_000).allowed());
|
|
}
|
|
|
|
#[test]
|
|
fn from_mandate_refuses_currency_denomination() {
|
|
let card = ingest_agent_card(&sample_card()).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,
|
|
&sk(1),
|
|
Did::from_verifying_key(&sk(2).verifying_key()),
|
|
sk(7),
|
|
"did:example:a2a-steward",
|
|
);
|
|
assert!(r.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn parses_a2a_message_send() {
|
|
let req = json!({
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"method": "message/send",
|
|
"params": {
|
|
"message": { "role": "user", "parts": [{ "kind": "text", "text": "summarize this" }] },
|
|
"metadata": { "skillId": "summarize" }
|
|
}
|
|
});
|
|
let t = parse_message_send(&req).unwrap();
|
|
assert_eq!(t.skill_id, "summarize");
|
|
assert_eq!(t.payload, "summarize this");
|
|
assert!(parse_message_send(&json!({ "method": "tasks/get" })).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn sealed_task_receipt_verifies() {
|
|
let card = ingest_agent_card(&sample_card()).unwrap();
|
|
let mut profile = A2aProfile::new(
|
|
&card,
|
|
1_000_000,
|
|
&sk(1),
|
|
Did::from_verifying_key(&sk(2).verifying_key()),
|
|
sk(7),
|
|
"did:example:a2a-steward",
|
|
)
|
|
.unwrap();
|
|
let g = profile.serve_task("plan", b"goal", 10_000);
|
|
assert!(g.allowed());
|
|
// The sealed receipt verifies under the steward key — a third party
|
|
// trusts the decision, not the gateway.
|
|
let core = jcp_receipt::extract(&g.receipt, profile.steward(), &profile.steward_pubkey()).unwrap();
|
|
assert_eq!(core.verdict, "allow");
|
|
assert_eq!(core.cost_uj, 10_000);
|
|
}
|
|
|
|
/// A **USD** AP2 mandate — the form the payments ecosystem actually emits —
|
|
/// carries no joule ceiling (no rate is invented), but it DOES bound money.
|
|
fn usd_mandate() -> Ap2Mandate {
|
|
ingest_ap2_mandate(&json!({
|
|
"paymentMandate": { "payee": "planner.example", "currency": "USD", "maxAmount": 500 }
|
|
}))
|
|
.unwrap().accepted_as_verified("test-wallet").unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn usd_mandate_yields_a_money_ceiling_not_a_joule_one() {
|
|
let m = usd_mandate();
|
|
assert_eq!(m.joule_budget(), None); // no exchange rate invented
|
|
assert_eq!(m.funds_ceiling(), Some(Funds::new("USD", 500)));
|
|
}
|
|
|
|
/// The whole point of the dual-ceiling design: a real USD mandate is now
|
|
/// gateable. The operator supplies the joule ceiling (the mandate has no
|
|
/// authority over energy); the mandate supplies the money ceiling; each
|
|
/// binds on its own terms.
|
|
#[test]
|
|
fn usd_mandate_gates_on_money_while_joules_have_room() {
|
|
let card = ingest_agent_card(&sample_card()).unwrap();
|
|
let mut profile = A2aProfile::from_mandate_priced(
|
|
&card,
|
|
&usd_mandate(),
|
|
10_000_000, // operator's generous joule ceiling
|
|
&sk(1),
|
|
Did::from_verifying_key(&sk(2).verifying_key()),
|
|
sk(7),
|
|
"did:example:a2a-steward",
|
|
)
|
|
.unwrap();
|
|
|
|
// First task: $3.00 of the $5.00 mandate, trivial joules → allowed.
|
|
let g1 = profile.serve_task_priced("plan", b"goal", 1_000, &Price::new("USD", 300));
|
|
assert!(g1.allowed());
|
|
assert_eq!(profile.spent_minor("plan"), 300);
|
|
|
|
// Second task: another $3.00 would exceed the $5.00 ceiling — denied on
|
|
// MONEY, though joules had ample room. The receipt records exactly that.
|
|
let g2 = profile.serve_task_priced("plan", b"goal", 1_000, &Price::new("USD", 300));
|
|
assert!(!g2.allowed());
|
|
let core = jcp_receipt::extract(&g2.receipt, profile.steward(), &profile.steward_pubkey()).unwrap();
|
|
assert_eq!(core.verdict, "deny");
|
|
assert_eq!(core.deny_reason.as_deref(), Some("funds-exceeded"));
|
|
// The denied task debited neither dimension.
|
|
assert_eq!(profile.spent_minor("plan"), 300);
|
|
}
|
|
}
|