jcp: caveats — the open attenuation axis (SPEC §4.9, C24) #30
20 changed files with 543 additions and 10 deletions
72
jcp/SPEC.md
72
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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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> {
|
|||
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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<Caveat>,
|
||||
expiry: u64,
|
||||
nonce: [u8; 16],
|
||||
) -> Result<SignedGrant, GrantError> {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -67,6 +67,31 @@ pub struct Runtime {
|
|||
trust_roots: HashMap<ContentId, Vec<Did>>,
|
||||
}
|
||||
|
||||
|
||||
/// 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<u64>,
|
||||
now: u64,
|
||||
) -> Option<DenyReason> {
|
||||
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<Caveat>) -> (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,
|
||||
|
|
|
|||
|
|
@ -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<String>),
|
||||
/// 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<Funds>,
|
||||
/// 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<Caveat>,
|
||||
/// 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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue