diff --git a/jcp/SPEC.md b/jcp/SPEC.md index ea4ec9f58..6625422d6 100644 --- a/jcp/SPEC.md +++ b/jcp/SPEC.md @@ -108,6 +108,7 @@ Grant { capability: { resource: content-id, actions: [string] } budget_uj: uint ; the joule ceiling this grant may ever spend budget_funds: { currency: string, minor: uint }? ; OPTIONAL money ceiling (§4.8) + caveats: [caveat] ; OPTIONAL embedded conditions (§4.9); omitted when empty expiry: uint ; unix seconds; 0 = no expiry parent: content-id? ; the grant this was attenuated from delegate_depth: uint? ; further hops permitted; absent = unlimited, 0 = leaf-only (§4.4) @@ -303,6 +304,76 @@ cannot be shown to narrow without a rate) MUST be refused. A plain attenuation that names no money ceiling **inherits** the parent's unchanged — it is never silently dropped, which would amplify. Conformance class **C22**. +### 4.9 Caveats: the open attenuation axis (OPTIONAL) + +The axes of §4.1 narrow by set-subtraction — remove actions, lower a ceiling, +shorten an expiry. They cannot express a condition the issuer did not anticipate. +A grant MAY therefore carry **caveats**: conditions embedded *inside* the signed +token that MUST all hold for a decision to be allowed. + +The point is that a caveat travels **with the capability**. Any enforcement point +holding the signed grant checks it offline — no call back to the issuer, no +service-side policy to consult, no shared configuration. That is what +distinguishes a caveat from a policy engine: policy is something the verifier +must be told; a caveat is something the token carries. + +A conforming implementation MUST support: + +``` +ActionIn([string]) ; the action must be in this set — MAY narrow below the capability +PayloadIs(content-id) ; the frame's payload must be exactly this — a ONE-SHOT binding +CallerIs(did:key) ; the caller must be this principal +NotBefore(uint) ; unix seconds +NotAfter(uint) ; unix seconds — a window narrower than `expiry` +MaxCostUj(uint) ; a ceiling on ONE invocation, distinct from the cumulative budget +``` + +Every predicate ranges only over what an enforcement point holds when it decides: +the frame, the caller, the clock, and the metered cost. There is deliberately **no +content predicate** — the runtime sees a frame's `payload_hash`, never its bytes — +so a condition on content is expressible only as `PayloadIs`, binding an exact +payload rather than inspecting it. An implementation MUST NOT invent a predicate +that requires data the decision point does not have. + +Two of these express authority the fixed axes cannot. `PayloadIs` makes a grant +**one-shot**: it authorizes one specific call, so a leaked grant cannot be +replayed against different arguments. `MaxCostUj` bounds a **single invocation**, +where `budget_uj` bounds the cumulative total — so a delegate cannot spend an +entire remaining budget in one call. + +**The metered path (REQUIRED).** A caveat whose predicate ranges over the +invocation's cost — `MaxCostUj` — cannot be evaluated in the two-phase metered +flow at authorization time, because the cost is not yet known. Such a caveat MUST +be enforced at the **debit point** instead, and an implementation MUST NOT report +a grant as authorized in a way that implies a cost-dependent caveat has passed. +Every cost-independent caveat MUST be enforced in both phases. + +When a measured cost breaches a cost-dependent caveat at the debit point, the +work has already run and the joules are real: the implementation MUST still debit +them — energy that was spent is always accounted — and MUST record the breach in +the verdict, so the sealed receipt is evidence of it. Debiting silently, or +suppressing the debit to make the record look clean, are both non-conforming. + +**Attenuation (REQUIRED).** Caveats only accumulate. A child MUST carry every +caveat of its parent; a plain attenuation inherits them unchanged, and a +caveat-adding attenuation appends. Dropping an inherited caveat is an +amplification and MUST be refused. + +Caveats compare by **exact identity**, not by an ordering over their parameters: +a child retains a parent's caveat only by carrying that identical caveat. There is +deliberately no notion of "a tighter `MaxCostUj` supersedes a looser one" — such +an ordering would have to be defined per variant, and a verifier that got it +wrong would silently widen authority. A delegator that wants a tighter bound adds +it **alongside** the inherited one; since all caveats must hold, the tighter of +the two governs. This is the conservative reading: it can never widen, and it may +be relaxed later without breaking existing grants, whereas the reverse is not +true. A decision whose grant carries an unsatisfied +caveat is denied `caveat-unsatisfied`, sealed like any other denial. + +An empty caveat set is omitted from the canonical encoding, so a grant without +caveats is byte-identical to one minted before they existed — its content id and +signature are unchanged. Conformance class **C24**. + ## 5. Energy (REQUIRED) Every metered cost is integer microjoules with a provenance tag: `HwShunt` @@ -555,4 +626,5 @@ gate. - **C20 — standing grants:** an event-triggered firing mints a strictly-weaker child that traces to the standing grant; the wrong event class, an exhausted firing count, and a firing past the cumulative budget are each refused without advancing the ledger; a policy that would amplify its grant is refused at author time. - **C21 — checkpoints + amendments:** an agent checkpoints its own state; only the grant's issuer — a principal distinct from the agent — may amend, only to tighten; an agent steering itself, a non-issuer amending, a widening amendment, and an amendment bound to a different checkpoint are each refused. - **C23 — receipt binds its payload + profile migration:** a `jcp/2` receipt commits the `payload_hash` it decided over; substituting only the payload (same grant, action, and cost) fails verification — the forgery a payload-blind receipt could not detect; and receipts published under `jcp/1` still verify, because the verifier recomputes the preimage under the profile the receipt names rather than assuming one. +- **C24 — caveats:** a caveat embedded in the grant is enforced offline by the decision point; `PayloadIs` makes a grant one-shot (a replay with different arguments is denied `caveat-unsatisfied`); `MaxCostUj` bounds a single invocation independently of the cumulative budget; a caveat may narrow below the capability; attenuation inherits every parent caveat and refuses to drop one; and a grant with no caveats encodes byte-identically to one minted before they existed. - **C22 — dual ceilings:** a money ceiling beside the joule ceiling is enforced independently — the first to bind denies (`funds-exceeded` / `budget-exceeded`), a foreign-currency cost is refused `currency-mismatch` rather than converted, an allow debits both dimensions and a deny debits neither, and an absent money ceiling encodes byte-identically to a joules-only grant (so its content id and signature are unchanged). Attenuation narrows money independently: dropping, raising, or re-denominating a bounded parent's ceiling is refused; a plain attenuation inherits it. diff --git a/jcp/jcp-rs/crates/jcp-a2a/src/lib.rs b/jcp/jcp-rs/crates/jcp-a2a/src/lib.rs index c612a129a..dfeafbcf9 100644 --- a/jcp/jcp-rs/crates/jcp-a2a/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-a2a/src/lib.rs @@ -300,6 +300,7 @@ impl A2aProfile { capability: cap, budget_uj, budget_funds: budget_funds.clone(), + caveats: Vec::new(), expiry: 0, parent: None, delegate_depth: None, diff --git a/jcp/jcp-rs/crates/jcp-bridge/examples/mint_token.rs b/jcp/jcp-rs/crates/jcp-bridge/examples/mint_token.rs index c64a2a5af..1588b91a8 100644 --- a/jcp/jcp-rs/crates/jcp-bridge/examples/mint_token.rs +++ b/jcp/jcp-rs/crates/jcp-bridge/examples/mint_token.rs @@ -17,6 +17,7 @@ fn main() { capability: Capability::new(ContentId::of_bytes(b"resource"), ["read", "write"]), budget_uj: 5_000_000, budget_funds: None, + caveats: Vec::new(), expiry: 0, parent: None, delegate_depth: Some(3), diff --git a/jcp/jcp-rs/crates/jcp-bridge/src/lib.rs b/jcp/jcp-rs/crates/jcp-bridge/src/lib.rs index 2b34cee7e..65368cd89 100644 --- a/jcp/jcp-rs/crates/jcp-bridge/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-bridge/src/lib.rs @@ -268,6 +268,7 @@ impl LocalToolGate { budget_uj, // An MCP tool call carries no payment context — joules alone bound it. budget_funds: None, + caveats: Vec::new(), expiry: 0, parent: None, delegate_depth: None, @@ -366,6 +367,7 @@ mod tests { capability: Capability::new(mcp_resource("fs", "read_file"), ["read_file"]), budget_uj: 100_000, budget_funds: None, + caveats: Vec::new(), expiry: 0, parent: None, delegate_depth: None, @@ -409,6 +411,7 @@ mod tests { capability: Capability::new(a2a_resource("planner", "plan"), ["plan"]), budget_uj: 10_000, budget_funds: None, + caveats: Vec::new(), expiry: 0, parent: None, delegate_depth: None, diff --git a/jcp/jcp-rs/crates/jcp-bridge/src/oauth.rs b/jcp/jcp-rs/crates/jcp-bridge/src/oauth.rs index fbaf8bca1..4b2f3dff1 100644 --- a/jcp/jcp-rs/crates/jcp-bridge/src/oauth.rs +++ b/jcp/jcp-rs/crates/jcp-bridge/src/oauth.rs @@ -271,6 +271,7 @@ mod tests { capability: Capability::new(resource(), ["read", "write"]), budget_uj: 5_000_000, budget_funds: None, + caveats: Vec::new(), expiry: 0, parent: None, delegate_depth: Some(3), diff --git a/jcp/jcp-rs/crates/jcp-channel/src/lib.rs b/jcp/jcp-rs/crates/jcp-channel/src/lib.rs index dabd92cbb..f78936958 100644 --- a/jcp/jcp-rs/crates/jcp-channel/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-channel/src/lib.rs @@ -185,6 +185,7 @@ mod tests { capability: Capability::new(r, ["read"]), budget_uj: 10_000, budget_funds: None, + caveats: Vec::new(), expiry: 0, parent: None, delegate_depth: None, diff --git a/jcp/jcp-rs/crates/jcp-cli/src/main.rs b/jcp/jcp-rs/crates/jcp-cli/src/main.rs index a43ff097f..c2e88f69c 100644 --- a/jcp/jcp-rs/crates/jcp-cli/src/main.rs +++ b/jcp/jcp-rs/crates/jcp-cli/src/main.rs @@ -34,6 +34,7 @@ fn main() { capability: Capability::new(db, ["read"]), budget_uj: 100_000, budget_funds: None, + caveats: Vec::new(), expiry: 0, parent: None, delegate_depth: None, diff --git a/jcp/jcp-rs/crates/jcp-conformance/src/bin/gen_vectors.rs b/jcp/jcp-rs/crates/jcp-conformance/src/bin/gen_vectors.rs index a62d22f24..a7d1c4808 100644 --- a/jcp/jcp-rs/crates/jcp-conformance/src/bin/gen_vectors.rs +++ b/jcp/jcp-rs/crates/jcp-conformance/src/bin/gen_vectors.rs @@ -27,6 +27,7 @@ fn main() { capability: Capability::new(resource, ["read"]), budget_uj: 100, budget_funds: None, + caveats: Vec::new(), expiry: 0, parent: None, delegate_depth: None, @@ -83,6 +84,7 @@ fn main() { capability: standing_cap, budget_uj: 1_000, budget_funds: None, + caveats: Vec::new(), expiry: 0, parent: None, delegate_depth: None, @@ -100,7 +102,7 @@ fn main() { issuer: Did::from_verifying_key(&issuer.verifying_key()), subject: agent_did, capability: Capability::new(resource, ["read", "write"]), - budget_uj: 1_000, expiry: 0, parent: None, delegate_depth: None, nonce: [6; 16], budget_funds: None, + budget_uj: 1_000, expiry: 0, parent: None, delegate_depth: None, nonce: [6; 16], budget_funds: None, caveats: Vec::new(), }).expect("run grant"); let cp = checkpoint(&agent, &run_grant, ContentId::of_bytes(b"state-1"), 0, [7; 16]).expect("cp"); let am = amend(&issuer, &run_grant, &cp, 400, Some(ContentId::of_bytes(b"instr")), [8; 16]).expect("amend"); @@ -129,6 +131,7 @@ fn main() { capability: Capability::new(resource, ["read"]), budget_uj: 1_000_000, budget_funds: Some(Funds::new("USD", 500)), + caveats: Vec::new(), expiry: 0, parent: None, delegate_depth: None, diff --git a/jcp/jcp-rs/crates/jcp-conformance/src/lib.rs b/jcp/jcp-rs/crates/jcp-conformance/src/lib.rs index 1871bd809..be73a049c 100644 --- a/jcp/jcp-rs/crates/jcp-conformance/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-conformance/src/lib.rs @@ -27,11 +27,11 @@ //! the precise reason; canonical CBOR rejects floats/bools. use ed25519_dalek::SigningKey; -use jcp_grant::{attenuate, attenuate_priced, issue, revoke, verify as verify_grant, verify_chain, GrantError}; +use jcp_grant::{attenuate, attenuate_priced, caveats_narrow, issue, revoke, verify as verify_grant, verify_chain, GrantError}; use jcp_runtime::{Price, Runtime}; use jcp_schema::{ canonical_cbor, from_canonical_cbor, Capability, ContentId, DenyReason, Did, Energy, Frame, - FrameKind, Funds, Grant, SignedGrant, Trust, Verdict, + Caveat, FrameKind, Funds, Grant, SignedGrant, Trust, Verdict, }; /// A single conformance check. @@ -65,6 +65,7 @@ pub fn checks() -> Vec { Check { id: "C21 checkpoint + amendment under separation of duties", run: c21_checkpoint }, Check { id: "C22 dual ceilings: money beside joules, no rate invented", run: c22_dual_ceilings }, Check { id: "C23 receipt binds its payload + jcp/1 still verifies", run: c23_payload_binding }, + Check { id: "C24 caveats: one-shot, per-call cost, monotone attenuation", run: c24_caveats }, Check { id: "C-neg expired / forged / wrong-caller", run: cneg_denials }, Check { id: "C-neg canonical rejects floats", run: cneg_canonical }, ] @@ -84,6 +85,7 @@ fn grant(issuer: &SigningKey, holder: Did, resource: ContentId, actions: &[&str] capability: Capability::new(resource, actions.iter().copied()), budget_uj: budget, budget_funds: None, + caveats: Vec::new(), expiry, parent: None, delegate_depth: None, @@ -291,6 +293,78 @@ fn c23_payload_binding() -> Result<(), String> { Ok(()) } +/// C24 — caveats (SPEC §4.9): conditions carried inside the grant, checked +/// offline by the decision point, accumulating monotonically down a chain. +fn c24_caveats() -> Result<(), String> { + let r = ContentId::of_bytes(b"db"); + let holder = did(&sk(2)); + let intended = ContentId::of_bytes(b"approved-args"); + + let mut g = grant(&sk(1), holder, r, &["read", "write"], 1_000_000, 0).grant; + g.caveats = vec![Caveat::PayloadIs(intended), Caveat::MaxCostUj(500)]; + let sg = issue(&sk(1), g).map_err(|e| e.to_string())?; + let mut rt = Runtime::new(); + + // The authorized call: right payload, within the per-call cap. + let mut ok = frame(&sg, r, "read", Trust::Control); + ok.payload_hash = intended; + if !rt.decide(&ok, &sg, holder, Energy::measured(100), 10).is_allow() { + return Err("the caveat-satisfying call was denied".into()); + } + + // One-shot: same grant, same action, different arguments → refused. + let mut replay = frame(&sg, r, "read", Trust::Control); + replay.payload_hash = ContentId::of_bytes(b"attacker-args"); + if rt.decide(&replay, &sg, holder, Energy::measured(1), 10).verdict + != Verdict::Deny(DenyReason::CaveatUnsatisfied) + { + return Err("a replay with different arguments was not denied".into()); + } + + // Per-call ceiling bites even though the cumulative budget has ample room. + if rt.decide(&ok, &sg, holder, Energy::measured(900), 10).verdict + != Verdict::Deny(DenyReason::CaveatUnsatisfied) + { + return Err("MaxCostUj did not bound a single invocation".into()); + } + + // The metered path: a cost-dependent caveat cannot bind at authorize (the + // cost is unknown), so it binds at the debit point — and the joules are + // still accounted, because they were really spent. + let mut rt2 = Runtime::new(); + if !rt2.authorize(&ok, &sg, holder, 10).is_allow() { + return Err("authorize failed a caveat it cannot evaluate".into()); + } + if rt2.spent(&sg.grant.id()) != 0 { + return Err("authorize debited".into()); + } + let charged = rt2.charge(&sg.grant, Energy::measured(900)); + if charged.verdict != Verdict::Deny(DenyReason::CaveatUnsatisfied) { + return Err("a cost-dependent caveat did not bind at the debit point".into()); + } + if rt2.spent(&sg.grant.id()) != 900 { + return Err("spent joules were hidden after a caveat breach".into()); + } + + // Attenuation inherits every parent caveat, and dropping one is refused. + let child = attenuate(&sg, &sk(2), did(&sk(3)), Capability::new(r, ["read"]), 1_000, 0, [4; 16]) + .map_err(|e| e.to_string())?; + if !caveats_narrow(&sg.grant.caveats, &child.grant.caveats) { + return Err("a plain attenuation dropped a parent caveat".into()); + } + if caveats_narrow(&sg.grant.caveats, &[]) { + return Err("clearing caveats counted as narrowing".into()); + } + + // No caveats ⇒ the field is absent from the encoding entirely. + let plain = grant(&sk(1), holder, r, &["read"], 100, 0); + let bytes = canonical_cbor(&plain.grant).map_err(|e| e.to_string())?; + if String::from_utf8_lossy(&bytes).contains("caveats") { + return Err("an empty caveat set must not appear in the encoding".into()); + } + Ok(()) +} + fn c3_budget() -> Result<(), String> { let r = ContentId::of_bytes(b"db"); let holder = did(&sk(2)); @@ -482,6 +556,7 @@ fn c10_delegation_control() -> Result<(), String> { capability: Capability::new(r, ["read"]), budget_uj: 1000, budget_funds: None, + caveats: Vec::new(), expiry: 0, parent: None, delegate_depth: Some(1), diff --git a/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs b/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs index 1920c23c4..df97b91e9 100644 --- a/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs @@ -87,6 +87,7 @@ impl Gateway { capability: Capability::new(mcp_resource(server, tool), [*tool]), budget_uj: *budget, budget_funds: None, + caveats: Vec::new(), expiry: 0, parent: None, delegate_depth: None, diff --git a/jcp/jcp-rs/crates/jcp-grant/src/checkpoint.rs b/jcp/jcp-rs/crates/jcp-grant/src/checkpoint.rs index fc540cd81..732341688 100644 --- a/jcp/jcp-rs/crates/jcp-grant/src/checkpoint.rs +++ b/jcp/jcp-rs/crates/jcp-grant/src/checkpoint.rs @@ -231,6 +231,7 @@ pub fn apply_amendment( capability: grant.grant.capability.clone(), budget_uj: sa.amendment.new_budget_uj, budget_funds: None, + caveats: Vec::new(), expiry: grant.grant.expiry, parent: Some(grant.grant.id()), delegate_depth: grant.grant.delegate_depth, @@ -259,6 +260,7 @@ mod tests { capability: cap, budget_uj: 1_000, budget_funds: None, + caveats: Vec::new(), expiry: 0, parent: None, delegate_depth: None, @@ -304,7 +306,7 @@ mod tests { let cap = Capability::new(ContentId::of_bytes(b"t"), ["read"]); let self_grant = Grant { issuer: did(&agent), subject: did(&agent), capability: cap, - budget_uj: 1_000, expiry: 0, parent: None, delegate_depth: None, nonce: [0u8; 16], budget_funds: None, + budget_uj: 1_000, expiry: 0, parent: None, delegate_depth: None, nonce: [0u8; 16], budget_funds: None, caveats: Vec::new(), }; let g = issue(&agent, self_grant).unwrap(); let cp = checkpoint(&agent, &g, ContentId::of_bytes(b"s"), 0, [1u8; 16]).unwrap(); diff --git a/jcp/jcp-rs/crates/jcp-grant/src/lib.rs b/jcp/jcp-rs/crates/jcp-grant/src/lib.rs index 0cd37cf1f..aaa96af75 100644 --- a/jcp/jcp-rs/crates/jcp-grant/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-grant/src/lib.rs @@ -14,7 +14,7 @@ use std::collections::HashSet; use ed25519_dalek::{Signature, Signer, SigningKey, Verifier}; use jcp_schema::{ - canonical_cbor, Capability, ContentId, Did, Funds, Grant, KeyEvent, Revocation, SchemaError, + canonical_cbor, Capability, Caveat, ContentId, Did, Funds, Grant, KeyEvent, Revocation, SchemaError, SignedGrant, SignedKeyEvent, SignedRevocation, }; @@ -109,6 +109,10 @@ pub fn attenuate( subject: to_subject, capability, budget_uj, + // Caveats are INHERITED wholesale (§4.9). A child carries every one of + // its parent's; `attenuate_caveated` is the path that adds more. Losing + // one here would silently widen a delegate's authority. + caveats: p.caveats.clone(), // The money ceiling is INHERITED, never dropped. Defaulting this to // `None` would silently hand a child unbounded spending authority where // its parent was bounded — an amplification, and precisely the bug a @@ -165,6 +169,44 @@ pub fn attenuate_priced( issue(delegator_sk, sg.grant) } +/// Whether a child's caveat set is no weaker than its parent's — it must retain +/// every parent caveat. Adding more only narrows (SPEC §4.9). +pub fn caveats_narrow(parent: &[Caveat], child: &[Caveat]) -> bool { + parent.iter().all(|c| child.contains(c)) +} + +/// Attenuate while **adding** caveats — the open attenuation axis (SPEC §4.9). +/// +/// The child keeps every caveat of its parent and gains `add`, so authority +/// narrows monotonically: a delegate can constrain further but never relax what +/// it was handed. Unlike the fixed axes, the added condition is arbitrary and +/// travels *inside* the signed grant, so any verifier checks it offline. +#[allow(clippy::too_many_arguments)] +pub fn attenuate_caveated( + parent: &SignedGrant, + delegator_sk: &SigningKey, + to_subject: Did, + capability: Capability, + budget_uj: u64, + add: Vec, + expiry: u64, + nonce: [u8; 16], +) -> Result { + let mut sg = attenuate(parent, delegator_sk, to_subject, capability, budget_uj, expiry, nonce)?; + // `attenuate` already inherited the parent's caveats; append the new ones, + // skipping duplicates so the set stays canonical. + for c in add { + if !sg.grant.caveats.contains(&c) { + sg.grant.caveats.push(c); + } + } + // Belt and braces: the result must still be no weaker than the parent. + if !caveats_narrow(&parent.grant.caveats, &sg.grant.caveats) { + return Err(GrantError::Amplifies("caveats weaker than parent")); + } + issue(delegator_sk, sg.grant) +} + /// Split a grant into sibling shards for **distributed enforcement points** /// (SPEC §4.5). Each shard is an ordinary attenuated child; the helper's value /// is the conservation check at mint time: **the shard budgets must sum to no @@ -454,6 +496,7 @@ mod tests { capability: Capability::new(ContentId::of_bytes(b"db"), ["read", "write"]), budget_uj: 10_000, budget_funds: None, + caveats: Vec::new(), expiry: 1000, parent: None, delegate_depth: None, @@ -540,6 +583,7 @@ mod tests { capability: Capability::new(ContentId::of_bytes(b"db"), ["read"]), budget_uj: 1, budget_funds: None, + caveats: Vec::new(), expiry: 0, parent: None, delegate_depth: None, @@ -576,6 +620,62 @@ mod tests { assert_eq!(child.grant.parent, Some(parent.grant.id())); } + /// Caveats are inherited by a plain attenuation — never silently dropped, + /// which would widen the delegate's authority. + #[test] + fn attenuate_inherits_caveats() { + let issuer = sk(1); + let holder = sk(2); + let mut g = root(&issuer, did(&holder)).grant; + g.caveats = vec![Caveat::MaxCostUj(500)]; + let parent = issue(&issuer, g).unwrap(); + let child = attenuate( + &parent, &holder, did(&sk(3)), + Capability::new(ContentId::of_bytes(b"db"), ["read"]), 5_000, 500, [2; 16], + ) + .unwrap(); + assert_eq!(child.grant.caveats, vec![Caveat::MaxCostUj(500)]); + } + + /// The open axis: a delegator ADDS an arbitrary condition the delegate + /// cannot remove, and the result still carries the parent's. + #[test] + fn attenuate_caveated_only_narrows() { + let issuer = sk(1); + let holder = sk(2); + let r = ContentId::of_bytes(b"db"); + let mut g = root(&issuer, did(&holder)).grant; + g.caveats = vec![Caveat::MaxCostUj(500)]; + let parent = issue(&issuer, g).unwrap(); + + let child = attenuate_caveated( + &parent, &holder, did(&sk(3)), Capability::new(r, ["read"]), + 5_000, vec![Caveat::PayloadIs(ContentId::of_bytes(b"one-shot"))], 500, [2; 16], + ) + .unwrap(); + assert!(verify(&child).is_ok()); + // Keeps the parent's AND gains the new one. + assert!(child.grant.caveats.contains(&Caveat::MaxCostUj(500))); + assert!(child.grant.caveats.contains(&Caveat::PayloadIs(ContentId::of_bytes(b"one-shot")))); + assert!(caveats_narrow(&parent.grant.caveats, &child.grant.caveats)); + + // Adding a duplicate does not grow the set. + let twice = attenuate_caveated( + &parent, &holder, did(&sk(3)), Capability::new(r, ["read"]), + 5_000, vec![Caveat::MaxCostUj(500)], 500, [3; 16], + ) + .unwrap(); + assert_eq!(twice.grant.caveats.len(), 1); + } + + #[test] + fn caveats_narrow_rejects_a_dropped_condition() { + let parent = vec![Caveat::MaxCostUj(500), Caveat::NotAfter(900)]; + assert!(caveats_narrow(&parent, &[Caveat::MaxCostUj(500), Caveat::NotAfter(900), Caveat::CallerIs(did(&sk(4)))])); + assert!(!caveats_narrow(&parent, &[Caveat::MaxCostUj(500)]), "dropping a parent caveat must not count as narrowing"); + assert!(!caveats_narrow(&parent, &[]), "clearing caveats must not count as narrowing"); + } + #[test] fn attenuation_cannot_amplify() { let issuer = sk(1); @@ -687,6 +787,7 @@ mod tests { capability: Capability::new(r, ["read"]), budget_uj: 1000, budget_funds: None, + caveats: Vec::new(), expiry: 0, parent: None, delegate_depth: Some(1), @@ -710,6 +811,7 @@ mod tests { capability: Capability::new(r, ["read"]), budget_uj: 100, budget_funds: None, + caveats: Vec::new(), expiry: 0, parent: Some(child.grant.id()), delegate_depth: None, diff --git a/jcp/jcp-rs/crates/jcp-grant/src/standing.rs b/jcp/jcp-rs/crates/jcp-grant/src/standing.rs index b5e28f65c..0e77c887e 100644 --- a/jcp/jcp-rs/crates/jcp-grant/src/standing.rs +++ b/jcp/jcp-rs/crates/jcp-grant/src/standing.rs @@ -213,6 +213,7 @@ mod tests { capability: cap.clone(), budget_uj: 1_000, budget_funds: None, + caveats: Vec::new(), expiry: 0, parent: None, delegate_depth: None, @@ -297,7 +298,7 @@ mod tests { let cap = Capability::new(ContentId::of_bytes(b"r"), ["read"]); let grant = Grant { issuer: did(&issuer), subject: did(&issuer), capability: cap.clone(), - budget_uj: 250, expiry: 0, parent: None, delegate_depth: None, nonce: [0u8; 16], budget_funds: None, + budget_uj: 250, expiry: 0, parent: None, delegate_depth: None, nonce: [0u8; 16], budget_funds: None, caveats: Vec::new(), }; let sg = crate::issue(&issuer, grant).unwrap(); let policy = issue_policy( @@ -320,7 +321,7 @@ mod tests { let cap = Capability::new(ContentId::of_bytes(b"r"), ["read"]); let grant = Grant { issuer: did(&issuer), subject: did(&issuer), capability: cap.clone(), - budget_uj: 100, expiry: 0, parent: None, delegate_depth: None, nonce: [0u8; 16], budget_funds: None, + budget_uj: 100, expiry: 0, parent: None, delegate_depth: None, nonce: [0u8; 16], budget_funds: None, caveats: Vec::new(), }; let sg = crate::issue(&issuer, grant).unwrap(); // per-fire budget over the grant's ceiling is refused at author time. diff --git a/jcp/jcp-rs/crates/jcp-orchestrate/src/lib.rs b/jcp/jcp-rs/crates/jcp-orchestrate/src/lib.rs index ec6fb401b..b9c29d64e 100644 --- a/jcp/jcp-rs/crates/jcp-orchestrate/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-orchestrate/src/lib.rs @@ -228,6 +228,7 @@ impl Orchestrator { capability: Capability::new(harness_resource(&id), kinds.iter().copied()), budget_uj, budget_funds: None, + caveats: Vec::new(), expiry: 0, parent: None, delegate_depth: None, diff --git a/jcp/jcp-rs/crates/jcp-receipt/src/lib.rs b/jcp/jcp-rs/crates/jcp-receipt/src/lib.rs index 0b4d63d7c..ad74ca5d1 100644 --- a/jcp/jcp-rs/crates/jcp-receipt/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-receipt/src/lib.rs @@ -479,6 +479,7 @@ mod tests { capability: Capability::new(resource, ["read"]), budget_uj: 100, budget_funds: None, + caveats: Vec::new(), expiry: 0, parent: None, delegate_depth: None, @@ -511,6 +512,7 @@ mod tests { capability: Capability::new(resource, ["read"]), budget_uj: 100, budget_funds: None, + caveats: Vec::new(), expiry: 0, parent: None, delegate_depth: None, @@ -601,6 +603,7 @@ mod tests { capability: Capability::new(resource, ["read"]), budget_uj: 1000, budget_funds: None, + caveats: Vec::new(), expiry: 0, parent: None, delegate_depth: None, diff --git a/jcp/jcp-rs/crates/jcp-runtime/src/lib.rs b/jcp/jcp-rs/crates/jcp-runtime/src/lib.rs index 81629633c..a4ed25d34 100644 --- a/jcp/jcp-rs/crates/jcp-runtime/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-runtime/src/lib.rs @@ -67,6 +67,31 @@ pub struct Runtime { trust_roots: HashMap>, } + +/// Whether every caveat carried inside the grant holds (SPEC §4.9). +/// +/// `cost_uj` is `None` in the two-phase metered path ([`Runtime::authorize`]), +/// where the true cost is not yet known: cost-dependent caveats are skipped +/// there and bind later at [`Runtime::decide`] time. Every other caveat is +/// enforced in both paths. +fn caveats_unsatisfied( + g: &jcp_schema::Grant, + frame: &Frame, + caller: Did, + cost_uj: Option, + now: u64, +) -> Option { + for c in &g.caveats { + if c.is_cost_dependent() && cost_uj.is_none() { + continue; // not yet knowable; binds at decide time + } + if !c.holds(frame, caller, cost_uj.unwrap_or(0), now) { + return Some(DenyReason::CaveatUnsatisfied); + } + } + None +} + impl Runtime { pub fn new() -> Self { Self::default() @@ -118,6 +143,10 @@ impl Runtime { if let Some(r) = self.check(frame, sg, caller, now) { return deny(r); } + // 6b. Caveats (§4.9). + if let Some(r) = caveats_unsatisfied(g, frame, caller, Some(cost.uj()), now) { + return deny(r); + } // 7. The JOULE ceiling. Checked first so that when both would bind, the // receipt names energy — JCP's primary dimension. @@ -216,6 +245,10 @@ impl Runtime { if let Some(r) = self.check(frame, sg, caller, now) { return deny(r); } + // 6b. Caveats carried inside the grant (§4.9) — the open attenuation axis. + if let Some(r) = caveats_unsatisfied(g, frame, caller, Some(cost.uj()), now) { + return deny(r); + } // 7. Budget: prior spend + this cost must not exceed the ceiling. let spent = self.spent(&gid); let c = cost.uj(); @@ -288,7 +321,9 @@ impl Runtime { let g = &sg.grant; let gid = g.id(); let remaining = g.budget_uj.saturating_sub(self.spent(&gid)); - if let Some(r) = self.check(frame, sg, caller, now) { + if let Some(r) = self.check(frame, sg, caller, now) + .or_else(|| caveats_unsatisfied(g, frame, caller, None, now)) + { return Decision { verdict: Verdict::Deny(r), cost: Energy::measured(0), @@ -322,8 +357,16 @@ impl Runtime { let debited = measured.uj().min(grant.budget_uj.saturating_sub(spent)); let new_spent = spent + debited; self.spent_uj.insert(gid, new_spent); + // Cost-dependent caveats (§4.9) could not be evaluated at `authorize` — + // the cost was unknown there. They bind HERE. The work has already run + // and the joules are real, so the debit stands either way: what the + // breach changes is the VERDICT, so the sealed receipt is evidence that + // the invocation exceeded its authorized per-call ceiling rather than + // silently absorbing it. Hiding the breach would be the dishonest move, + // exactly as capping-without-recording would be for a budget overrun. + let breached = grant.caveats.iter().any(|c| c.cost_breached(measured.uj())); Decision { - verdict: Verdict::Allow, + verdict: if breached { Verdict::Deny(DenyReason::CaveatUnsatisfied) } else { Verdict::Allow }, cost: measured, budget_remaining_uj: grant.budget_uj - new_spent, } @@ -428,7 +471,7 @@ impl Runtime { mod tests { use super::*; use ed25519_dalek::SigningKey; - use jcp_schema::Funds; + use jcp_schema::{Caveat, Funds}; use jcp_grant::issue; use jcp_schema::{Capability, FrameKind, Grant}; @@ -449,6 +492,7 @@ mod tests { capability: Capability::new(resource, ["read"]), budget_uj: 100, budget_funds: None, + caveats: Vec::new(), expiry: 1000, parent: None, delegate_depth: None, @@ -535,6 +579,7 @@ mod tests { capability: Capability::new(resource, ["read"]), budget_uj, budget_funds: Some(Funds::new("USD", minor)), + caveats: Vec::new(), expiry: 1000, parent: None, delegate_depth: None, @@ -617,6 +662,142 @@ mod tests { assert_eq!(rt.spent_minor(&sg.grant.id()), 0); // nothing tracked } + fn caveated(cavs: Vec) -> (SignedGrant, Did, ContentId) { + let issuer = sk(1); + let holder = sk(2); + let resource = ContentId::of_bytes(b"db"); + let g = Grant { + issuer: did(&issuer), + subject: did(&holder), + capability: Capability::new(resource, ["read", "write"]), + budget_uj: 1_000_000, + budget_funds: None, + caveats: cavs, + expiry: 0, + parent: None, + delegate_depth: None, + nonce: [0; 16], + }; + (issue(&issuer, g).unwrap(), did(&holder), resource) + } + + /// A one-shot grant: bound to an exact payload, so a leaked grant cannot be + /// replayed against different arguments. This is the capability the fixed + /// axes cannot express — `capability` narrows to an ACTION, never to a call. + #[test] + fn payload_is_makes_a_grant_one_shot() { + let intended = ContentId::of_bytes(b"the-approved-args"); + let (sg, holder, r) = caveated(vec![Caveat::PayloadIs(intended)]); + let mut rt = Runtime::new(); + + let mut f = frame(&sg, r, "read", Trust::Control); + f.payload_hash = intended; + assert!(rt.decide(&f, &sg, holder, Energy::measured(1), 10).is_allow()); + + // Same grant, same action, different arguments → refused. + let mut replay = frame(&sg, r, "read", Trust::Control); + replay.payload_hash = ContentId::of_bytes(b"attacker-args"); + let d = rt.decide(&replay, &sg, holder, Energy::measured(1), 10); + assert_eq!(d.verdict, Verdict::Deny(DenyReason::CaveatUnsatisfied)); + } + + /// A per-invocation ceiling, distinct from the cumulative budget: a delegate + /// cannot burn the whole remaining budget in one call. + #[test] + fn max_cost_bounds_a_single_call_not_the_total() { + let (sg, holder, r) = caveated(vec![Caveat::MaxCostUj(500)]); + let mut rt = Runtime::new(); + let f = frame(&sg, r, "read", Trust::Control); + // Well within the 1_000_000 cumulative budget, but over the per-call cap. + let d = rt.decide(&f, &sg, holder, Energy::measured(900), 10); + assert_eq!(d.verdict, Verdict::Deny(DenyReason::CaveatUnsatisfied)); + assert_eq!(rt.spent(&sg.grant.id()), 0); + // Under the cap → allowed, and repeatable. + assert!(rt.decide(&f, &sg, holder, Energy::measured(400), 10).is_allow()); + assert!(rt.decide(&f, &sg, holder, Energy::measured(400), 10).is_allow()); + assert_eq!(rt.spent(&sg.grant.id()), 800); + } + + #[test] + fn time_window_and_action_and_caller_caveats_bind() { + let (sg, holder, r) = caveated(vec![Caveat::NotBefore(100), Caveat::NotAfter(200)]); + let mut rt = Runtime::new(); + let f = frame(&sg, r, "read", Trust::Control); + assert!(rt.decide(&f, &sg, holder, Energy::measured(1), 50).verdict != Verdict::Allow); + assert!(rt.decide(&f, &sg, holder, Energy::measured(1), 150).is_allow()); + assert!(rt.decide(&f, &sg, holder, Energy::measured(1), 250).verdict != Verdict::Allow); + + // ActionIn narrows BELOW the capability, which permits read+write. + let (sg2, holder2, r2) = caveated(vec![Caveat::ActionIn(vec!["read".into()])]); + let mut rt2 = Runtime::new(); + let w = frame(&sg2, r2, "write", Trust::Control); + assert_eq!( + rt2.decide(&w, &sg2, holder2, Energy::measured(1), 10).verdict, + Verdict::Deny(DenyReason::CaveatUnsatisfied), + "a caveat must be able to narrow below the capability" + ); + + // CallerIs pins the principal. + let (sg3, _h3, r3) = caveated(vec![Caveat::CallerIs(did(&sk(9)))]); + let mut rt3 = Runtime::new(); + let f3 = frame(&sg3, r3, "read", Trust::Control); + assert_eq!( + rt3.decide(&f3, &sg3, did(&sk(2)), Energy::measured(1), 10).verdict, + Verdict::Deny(DenyReason::CaveatUnsatisfied) + ); + } + + /// A grant with no caveats must behave exactly as before — the field is + /// absent from the encoding, so identity and signature are unchanged. + #[test] + fn no_caveats_is_byte_identical_and_unaffected() { + let (plain, holder, r) = setup(); + let bytes = jcp_schema::canonical_cbor(&plain.grant).unwrap(); + assert!(!String::from_utf8_lossy(&bytes).contains("caveats")); + let mut rt = Runtime::new(); + assert!(rt.decide(&frame(&plain, r, "read", Trust::Control), &plain, holder, Energy::measured(1), 10).is_allow()); + } + + /// The two-phase metered path: a cost-dependent caveat is unevaluable at + /// authorize (the cost is unknown), so it MUST bind at the debit point — + /// and the joules are still debited, because they were really spent. + #[test] + fn cost_caveat_binds_at_the_debit_point() { + let (sg, holder, r) = caveated(vec![Caveat::MaxCostUj(500)]); + let mut rt = Runtime::new(); + let f = frame(&sg, r, "read", Trust::Control); + + // Phase one: authorize cannot know the cost, so it does not pretend to. + let a = rt.authorize(&f, &sg, holder, 10); + assert!(a.is_allow(), "authorize must not fail a caveat it cannot evaluate"); + assert_eq!(rt.spent(&sg.grant.id()), 0, "authorize never debits"); + + // Phase two: the measured cost breaches the per-call ceiling. + let d = rt.charge(&sg.grant, Energy::measured(900)); + assert_eq!( + d.verdict, + Verdict::Deny(DenyReason::CaveatUnsatisfied), + "a cost-dependent caveat must bind at the debit point" + ); + // …and the energy is STILL accounted: it was really consumed. + assert_eq!(rt.spent(&sg.grant.id()), 900, "spent joules must never be hidden"); + + // Within the ceiling, charge allows and debits normally. + let d2 = rt.charge(&sg.grant, Energy::measured(100)); + assert!(d2.is_allow()); + assert_eq!(rt.spent(&sg.grant.id()), 1_000); + } + + /// Cost-INdependent caveats are enforced in both phases, including authorize. + #[test] + fn cost_independent_caveats_bind_at_authorize() { + let (sg, _h, r) = caveated(vec![Caveat::CallerIs(did(&sk(9)))]); + let mut rt = Runtime::new(); + let f = frame(&sg, r, "read", Trust::Control); + let a = rt.authorize(&f, &sg, did(&sk(2)), 10); + assert_eq!(a.verdict, Verdict::Deny(DenyReason::CaveatUnsatisfied)); + } + #[test] fn expired_grant_denied() { let (sg, holder, r) = setup(); // expiry 1000 @@ -664,6 +845,7 @@ mod tests { capability: Capability::new(r, ["read"]), budget_uj: 1000, budget_funds: None, + caveats: Vec::new(), expiry: 0, parent: None, delegate_depth: None, diff --git a/jcp/jcp-rs/crates/jcp-schema/src/lib.rs b/jcp/jcp-rs/crates/jcp-schema/src/lib.rs index 11adc0bb1..ee8b811ed 100644 --- a/jcp/jcp-rs/crates/jcp-schema/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-schema/src/lib.rs @@ -221,6 +221,71 @@ impl Funds { } } +/// A **caveat** — an extra condition carried *inside* the grant that MUST hold +/// for a decision to be allowed (SPEC §4.9). +/// +/// Attenuation's fixed axes (capability, budgets, expiry, depth) narrow by +/// set-subtraction: they can only remove actions or lower ceilings. A caveat is +/// the open axis — a delegator embeds an arbitrary condition the delegate cannot +/// remove, and **any** verifier holding the signed grant can check it offline, +/// with no call back to the issuer and no service-side policy to consult. +/// +/// Every variant predicates only over what an enforcement point actually holds +/// when it decides: the frame, the caller, the clock, and the metered cost. +/// Notably there is **no content predicate** — the runtime sees a frame's +/// `payload_hash`, never its bytes — so a content condition is expressible only +/// as [`Caveat::PayloadIs`], binding an exact payload rather than inspecting it. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum Caveat { + /// The action must be one of these — narrower than the capability's set. + ActionIn(Vec), + /// The frame's payload must be exactly this content id: a **one-shot** + /// binding. The grant authorizes this specific call and nothing else, so a + /// leaked grant cannot be replayed against different arguments. + PayloadIs(ContentId), + /// The caller must be this principal. + CallerIs(Did), + /// Not valid before this unix second. + NotBefore(u64), + /// Not valid after this unix second — a window narrower than `expiry`. + NotAfter(u64), + /// A ceiling on **one invocation's** cost, in µJ. Distinct from the grant's + /// `budget_uj`, which is cumulative: this bounds any single call, so a + /// delegate cannot spend the whole remaining budget in one shot. + MaxCostUj(u64), +} + +impl Caveat { + /// Whether this caveat can only be evaluated once the invocation's cost is + /// known. Such a caveat is unevaluable in the two-phase metered path at + /// authorize time and binds at the debit point instead (SPEC §4.9). + pub fn is_cost_dependent(&self) -> bool { + matches!(self, Caveat::MaxCostUj(_)) + } + + /// Whether a cost-dependent caveat is breached by a measured cost. Needs no + /// frame or clock, so it is checkable at the debit point. + pub fn cost_breached(&self, cost_uj: u64) -> bool { + match self { + Caveat::MaxCostUj(max) => cost_uj > *max, + _ => false, + } + } + + /// Whether this caveat holds. `cost_uj` is the metered cost of the + /// invocation being decided; `now` the enforcement point's clock. + pub fn holds(&self, frame: &Frame, caller: Did, cost_uj: u64, now: u64) -> bool { + match self { + Caveat::ActionIn(set) => set.iter().any(|a| a == &frame.action), + Caveat::PayloadIs(id) => *id == frame.payload_hash, + Caveat::CallerIs(did) => *did == caller, + Caveat::NotBefore(t) => now >= *t, + Caveat::NotAfter(t) => now <= *t, + Caveat::MaxCostUj(max) => cost_uj <= *max, + } + } +} + /// The **metered capability** (SPEC §4) — JCP's central object. A signed, /// content-addressed token binding a `subject` to a `capability` *and* a joule /// `budget`. A `parent` links it into an attenuation chain (object-capability @@ -241,6 +306,15 @@ pub struct Grant { /// first to bind denies. #[serde(skip_serializing_if = "Option::is_none", default)] pub budget_funds: Option, + /// Conditions carried **inside** the grant that MUST all hold (SPEC §4.9). + /// + /// Empty = no caveats, and the field is omitted from the canonical encoding + /// entirely — so a grant without caveats is byte-identical to one minted + /// before they existed, and its content id and signature are unchanged. + /// Attenuation may only **add** caveats: a child carries every one of its + /// parent's, so authority narrows monotonically down a chain. + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub caveats: Vec, /// Unix seconds after which the grant is invalid; `0` means no expiry. pub expiry: u64, /// The parent grant this was attenuated from, if any. @@ -381,6 +455,9 @@ pub enum DenyReason { /// `BudgetExceeded` (joules) on purpose: which ceiling bound is exactly /// the fact the receipt exists to record. FundsExceeded, + /// A caveat carried inside the grant did not hold (SPEC §4.9) — the open + /// attenuation axis. The label names which kind failed. + CaveatUnsatisfied, /// A cost was presented in a currency the grant's money ceiling is not /// denominated in. JCP invents no exchange rate, so this is a denial rather /// than a conversion. @@ -413,6 +490,7 @@ impl DenyReason { DenyReason::BudgetExceeded => "budget-exceeded", DenyReason::FundsExceeded => "funds-exceeded", DenyReason::CurrencyMismatch => "currency-mismatch", + DenyReason::CaveatUnsatisfied => "caveat-unsatisfied", DenyReason::UntrustedControl => "untrusted-control", DenyReason::AttenuationViolation => "attenuation-violation", DenyReason::Revoked => "revoked", @@ -470,6 +548,7 @@ mod tests { capability: Capability::new(ContentId::of_bytes(b"db"), ["read"]), budget_uj: 1000, budget_funds: None, + caveats: Vec::new(), expiry: 0, parent: None, delegate_depth: None, @@ -510,6 +589,7 @@ mod tests { capability: Capability::new(r, ["read"]), budget_uj: 100, budget_funds: None, + caveats: Vec::new(), expiry: 0, parent: None, delegate_depth: None, diff --git a/jcp/jcp-rs/crates/jcp-threshold/src/lib.rs b/jcp/jcp-rs/crates/jcp-threshold/src/lib.rs index 6ce6475b4..2b7e4b181 100644 --- a/jcp/jcp-rs/crates/jcp-threshold/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-threshold/src/lib.rs @@ -263,6 +263,7 @@ mod tests { capability: Capability::new(r, ["delete"]), budget_uj: 1000, budget_funds: None, + caveats: Vec::new(), expiry: 0, parent: None, delegate_depth: None, diff --git a/jcp/jcp-rs/crates/jcp-wire/src/lib.rs b/jcp/jcp-rs/crates/jcp-wire/src/lib.rs index 220d52c53..de4effdc3 100644 --- a/jcp/jcp-rs/crates/jcp-wire/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-wire/src/lib.rs @@ -306,6 +306,7 @@ mod tests { capability: Capability::new(r, ["read"]), budget_uj: 100_000, budget_funds: None, + caveats: Vec::new(), expiry: 0, parent: None, delegate_depth: None, diff --git a/jcp/jcp-rs/crates/jcp-x402/src/lib.rs b/jcp/jcp-rs/crates/jcp-x402/src/lib.rs index e60516fe6..ce81d2a53 100644 --- a/jcp/jcp-rs/crates/jcp-x402/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-x402/src/lib.rs @@ -264,6 +264,7 @@ impl X402Profile { capability: Capability::new(x402_resource(&network, &pay_to), ["pay"]), budget_uj, budget_funds: Some(budget_funds), + caveats: Vec::new(), expiry: 0, parent: None, delegate_depth: None,