Skip to main content

treeship_core/capability/
mod.rs

1//! Pure capability-card verification primitives, shared by the CLI
2//! (`treeship verify-capability`) and the WASM verifier (browser receipt
3//! viewer) so both agree by construction. No I/O: callers supply the parsed
4//! card, the action statements, and the trust roots.
5//!
6//! See docs/specs/agent-capability-cards.md. The honest contract holds here
7//! too: this checks consistency over *captured* evidence (the actions the
8//! caller passes in), never completeness.
9
10use crate::statements::ActionStatement;
11use crate::trust::{TrustRootKind, TrustRootStore};
12
13/// `family.*` matches `family.write`; otherwise an exact match. A bare `*`
14/// matches anything.
15///
16/// The `*` may sit anywhere in the pattern, not only at the end: harness
17/// permission patterns captured by `attest card --from-harness` carry the
18/// glob *inside* a delimiter — `Bash(git:*)` — where a trailing-`*`-only
19/// matcher silently degrades to an exact match that can never fire, and a
20/// card captured from a real config then reports every real action
21/// out-of-scope. One wildcard is supported (the first, matching greedily);
22/// the text before it must prefix the action and the text after it must
23/// suffix the remainder, so `Bash(git:*)` matches `Bash(git:status)` but
24/// not `Bash(gh:pr)` or a `Bash(git:` with the closing paren missing.
25pub fn tool_matches(declared: &str, actual: &str) -> bool {
26    match declared.split_once('*') {
27        Some((prefix, suffix)) => {
28            actual.len() >= prefix.len() + suffix.len()
29                && actual.starts_with(prefix)
30                && actual.ends_with(suffix)
31        }
32        None => declared == actual,
33    }
34}
35
36/// A card is **key-bound** only when its `keyid` is the envelope signer AND
37/// that key is pinned under `AgentCert`. Anything else is self-asserted.
38pub fn is_key_bound(card_keyid: &str, signer_keyid: &str, trust: &TrustRootStore) -> bool {
39    !card_keyid.is_empty()
40        && signer_keyid == card_keyid
41        && trust
42            .roots()
43            .iter()
44            .any(|r| r.key_id == card_keyid && r.kind == TrustRootKind::AgentCert)
45}
46
47/// Generic dispatch labels: an `action` field whose value is one of these is a
48/// placeholder ("the agent called *a* tool"), and the concrete tool name lives
49/// in `meta.tool`. Only for these is `meta.tool` allowed to contribute scope.
50///
51/// This is the security boundary for the cross-check. `meta` is part of the
52/// statement the audited agent signs itself; if `meta.tool` could match for an
53/// action whose `action` field is already a CONCRETE label, a dishonest agent
54/// would attach a benign `meta.tool` (e.g. `file.read`) to a concrete
55/// out-of-scope action (e.g. `payments.charge`) and have it counted in-scope,
56/// silently defeating the very check `verify-capability` exists to run.
57const GENERIC_DISPATCH_LABELS: &[&str] = &[
58    "tool.call",
59    "tool.use",
60    "tool.invoke",
61    "mcp.call",
62    "mcp.tool",
63];
64
65/// The scope-matching candidates for an action. The action label is always
66/// authoritative. `meta.tool` is consulted ONLY when the action label is a
67/// generic dispatch placeholder, so a concrete out-of-scope action cannot be
68/// rescued by an attacker-supplied `meta.tool`.
69fn scope_candidates(action: &ActionStatement) -> Vec<&str> {
70    let label = action.action.as_str();
71    let mut candidates: Vec<&str> = vec![label];
72    if GENERIC_DISPATCH_LABELS.contains(&label) {
73        if let Some(tool) = action
74            .meta
75            .as_ref()
76            .and_then(|m| m.get("tool"))
77            .and_then(|v| v.as_str())
78        {
79            candidates.push(tool);
80        }
81    }
82    candidates
83}
84
85/// Is an action within a declared capability set? The action label is
86/// authoritative; `meta.tool` counts only when the label is a generic
87/// dispatch placeholder (see [`scope_candidates`]).
88pub fn action_in_scope(action: &ActionStatement, declared_tools: &[String]) -> bool {
89    scope_candidates(action)
90        .iter()
91        .any(|c| declared_tools.iter().any(|d| tool_matches(d, c)))
92}
93
94/// The first declared capability an action matches, if any. Same matching as
95/// [`action_in_scope`], but returns *which* capability matched, so callers can
96/// grade each declared capability by whether captured receipts exercise it.
97pub fn matched_capability(action: &ActionStatement, declared_tools: &[String]) -> Option<String> {
98    let candidates = scope_candidates(action);
99    declared_tools
100        .iter()
101        .find(|decl| candidates.iter().any(|c| tool_matches(decl, c)))
102        .cloned()
103}
104
105/// Extract the declared `capabilities.tools` from an agent_card.v1 payload.
106pub fn declared_tools(card_payload: &serde_json::Value) -> Vec<String> {
107    card_payload
108        .get("capabilities")
109        .and_then(|c| c.get("tools"))
110        .and_then(|t| t.as_array())
111        .map(|a| {
112            a.iter()
113                .filter_map(|t| t.as_str().map(str::to_string))
114                .collect()
115        })
116        .unwrap_or_default()
117}
118
119// --- Selective disclosure of the capability set -------------------------------
120//
121// For a card to be presented without revealing its full tool set, the signed
122// payload must not contain the raw tools -- only their salted digests. Each
123// tool is modeled as a disclosure `[salt, tool, true]`; the signed card carries
124// the sorted digest list (`capabilities.tools_sd`), and the disclosures travel
125// alongside the card. A holder reveals the subset it chooses; the verifier
126// reconstructs exactly the revealed tools and nothing else.
127//
128// Consumers (next slice): `attest card` mint computes the commitment;
129// `present --disclose` selects a subset of disclosures; `verify-presentation`
130// and the disclosure-aware replacement for `declared_tools` reconstruct the
131// revealed tools via `disclosed_tools`.
132
133/// Commit a tool set for selective disclosure. Returns the sorted `tools_sd`
134/// digests (which go into the signed card) and the encoded disclosure strings
135/// (which the holder stores and reveals selectively). Order of `tools` does not
136/// affect `tools_sd` (it is sorted), so the digest list never leaks the set's
137/// authoring order.
138pub fn commit_tools(tools: &[String]) -> (Vec<String>, Vec<String>) {
139    let claims: Vec<(String, serde_json::Value)> = tools
140        .iter()
141        .map(|t| (t.clone(), serde_json::Value::Bool(true)))
142        .collect();
143    let c = crate::disclosure::commit(&claims);
144    let disclosures = c.disclosures.iter().map(|d| d.encode()).collect();
145    (c.sd, disclosures)
146}
147
148/// Reconstruct the revealed tools from presented disclosures, checked against
149/// the signed `tools_sd` digest set. Only disclosures whose digest is in the
150/// set and whose value is `true` count; a tampered, foreign, or malformed
151/// disclosure contributes nothing (fail-closed). The result is exactly the
152/// subset the holder chose to reveal -- for a full presentation, every
153/// disclosure; for a selective one, fewer.
154pub fn disclosed_tools(tools_sd: &[String], presented_disclosures: &[String]) -> Vec<String> {
155    let sd_set: std::collections::BTreeSet<String> = tools_sd.iter().cloned().collect();
156    let mut out = Vec::new();
157    for d in presented_disclosures {
158        if let Some((name, value)) = crate::disclosure::verify_disclosure(d, &sd_set) {
159            if value == serde_json::Value::Bool(true) {
160                out.push(name);
161            }
162        }
163    }
164    out
165}
166
167/// Read the signed `capabilities.tools_sd` digest list from a card payload.
168pub fn committed_tool_digests(card_payload: &serde_json::Value) -> Vec<String> {
169    card_payload
170        .get("capabilities")
171        .and_then(|c| c.get("tools_sd"))
172        .and_then(|t| t.as_array())
173        .map(|a| {
174            a.iter()
175                .filter_map(|t| t.as_str().map(str::to_string))
176                .collect()
177        })
178        .unwrap_or_default()
179}
180
181/// Transform a full capability card payload into a *disclosed* one: replace
182/// `capabilities.tools` (the raw list) with `capabilities.tools_sd` (the salted
183/// digests), and return the disclosures for exactly the tools in `reveal`.
184///
185/// The caller re-signs the returned payload (as an `agent_card.v1` receipt) with
186/// the agent's own key and bundles the returned disclosures. Because the raw
187/// tools are gone from the signed bytes, the presentation reveals only the
188/// chosen capabilities; the rest are opaque digests. This is the pure core of
189/// `present --disclose` (the CLI orchestration and re-signing is the consumer).
190pub fn disclose_capabilities(
191    card_payload: &serde_json::Value,
192    reveal: &[String],
193) -> (serde_json::Value, Vec<String>) {
194    let all_tools = declared_tools(card_payload);
195    // One commit call: tools_sd and disclosures share salts and are consistent.
196    // disclosures[i] corresponds to all_tools[i] (commit preserves input order).
197    let (tools_sd, disclosures) = commit_tools(&all_tools);
198
199    let reveal_set: std::collections::BTreeSet<&str> = reveal.iter().map(String::as_str).collect();
200    let selected: Vec<String> = all_tools
201        .iter()
202        .zip(disclosures.iter())
203        .filter(|(t, _)| reveal_set.contains(t.as_str()))
204        .map(|(_, d)| d.clone())
205        .collect();
206
207    let mut disclosed = card_payload.clone();
208    if let Some(caps) = disclosed
209        .get_mut("capabilities")
210        .and_then(|c| c.as_object_mut())
211    {
212        caps.remove("tools");
213        caps.insert("tools_sd".into(), serde_json::json!(tools_sd));
214    }
215    (disclosed, selected)
216}
217
218/// Reconstruct the revealed capabilities from a disclosed card payload and the
219/// disclosures presented with it. Returns exactly the tools whose disclosure
220/// opens a digest in the signed `capabilities.tools_sd`; a tampered or foreign
221/// disclosure contributes nothing. This is the verify side of
222/// `disclose_capabilities`.
223pub fn reconstruct_capabilities(
224    card_payload: &serde_json::Value,
225    disclosures: &[String],
226) -> Vec<String> {
227    let tools_sd = committed_tool_digests(card_payload);
228    disclosed_tools(&tools_sd, disclosures)
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234    use crate::trust::{TrustRoot, TrustRootKind, TrustRootStore};
235
236    #[test]
237    fn exact_and_glob_matching() {
238        assert!(tool_matches("file.write", "file.write"));
239        assert!(!tool_matches("file.write", "file.read"));
240        assert!(tool_matches("file.*", "file.write"));
241        assert!(!tool_matches("file.*", "db.query"));
242        assert!(tool_matches("*", "anything.at.all"));
243    }
244
245    #[test]
246    fn harness_patterns_with_internal_glob_match() {
247        // The shape `attest card --from-harness` captures from a Claude Code
248        // settings.json permissions.allow list: the `*` sits inside the
249        // parenthesized scope, not at the end of the pattern.
250        assert!(tool_matches("Bash(git:*)", "Bash(git:status)"));
251        assert!(tool_matches("Bash(git:*)", "Bash(git:log --oneline)"));
252        assert!(tool_matches("Read(*)", "Read(/etc/hosts)"));
253        // prefix and suffix must both hold — no cross-family bleed, no
254        // matching a truncated action that drops the closing delimiter
255        assert!(!tool_matches("Bash(git:*)", "Bash(gh:pr)"));
256        assert!(!tool_matches("Bash(git:*)", "Bash(git:"));
257        assert!(!tool_matches("Bash(git:*)", "payments.charge"));
258        // the wildcard may match empty: the family root itself is in scope
259        assert!(tool_matches("Bash(git:*)", "Bash(git:)"));
260        // trailing-glob and exact behavior unchanged
261        assert!(tool_matches("file.*", "file.*"));
262        assert!(!tool_matches("Bash(git:status)", "Bash(git:log)"));
263    }
264
265    fn root(key_id: &str, kind: TrustRootKind) -> TrustRoot {
266        TrustRoot {
267            key_id: key_id.into(),
268            public_key: "ed25519:AAAA".into(),
269            kind,
270            label: String::new(),
271            added_at: String::new(),
272        }
273    }
274
275    #[test]
276    fn key_bound_needs_signer_match_and_agentcert() {
277        let agentcert = TrustRootStore::with_roots(vec![root("key_x", TrustRootKind::AgentCert)]);
278        assert!(is_key_bound("key_x", "key_x", &agentcert));
279        assert!(!is_key_bound("key_x", "key_y", &agentcert));
280        assert!(!is_key_bound("", "", &agentcert));
281        let ship = TrustRootStore::with_roots(vec![root("key_x", TrustRootKind::Ship)]);
282        assert!(!is_key_bound("key_x", "key_x", &ship));
283        assert!(!is_key_bound(
284            "key_x",
285            "key_x",
286            &TrustRootStore::with_roots(vec![])
287        ));
288    }
289
290    #[test]
291    fn in_scope_checks_action_and_meta_tool() {
292        let mut a = ActionStatement::new("agent://x", "file.write");
293        assert!(action_in_scope(&a, &["file.*".to_string()]));
294        assert!(!action_in_scope(&a, &["db.query".to_string()]));
295        // meta.tool counts ONLY when the action label is a generic dispatch
296        // placeholder (the legitimate MCP case): tool.call + meta.tool=db.query.
297        a.action = "tool.call".into();
298        a.meta = Some(serde_json::json!({ "tool": "db.query" }));
299        assert!(action_in_scope(&a, &["db.query".to_string()]));
300    }
301
302    #[test]
303    fn meta_tool_cannot_rescue_a_concrete_out_of_scope_action() {
304        // The audited-agent bypass: a CONCRETE out-of-scope action with a
305        // benign meta.tool must still be out of scope. If this regresses, a
306        // dishonest agent hides every off-card action behind meta.tool.
307        let mut a = ActionStatement::new("agent://x", "payments.charge");
308        a.meta = Some(serde_json::json!({ "tool": "file.read" }));
309        let declared = vec!["file.*".to_string()]; // payments NOT declared
310        assert!(
311            !action_in_scope(&a, &declared),
312            "meta.tool must not pull a concrete out-of-scope action in-scope"
313        );
314        assert_eq!(
315            matched_capability(&a, &declared),
316            None,
317            "no declared capability should match the concrete out-of-scope action"
318        );
319        // And the concrete action IS matched when it is actually declared.
320        assert!(action_in_scope(&a, &["payments.*".to_string()]));
321    }
322
323    #[test]
324    fn matched_capability_returns_the_declared_glob() {
325        let a = ActionStatement::new("agent://x", "file.write");
326        let tools = vec!["db.query".to_string(), "file.*".to_string()];
327        assert_eq!(matched_capability(&a, &tools).as_deref(), Some("file.*"));
328        let b = ActionStatement::new("agent://x", "command.run");
329        assert_eq!(matched_capability(&b, &tools), None);
330    }
331
332    #[test]
333    fn full_disclosure_reconstructs_the_whole_tool_set() {
334        let tools = vec![
335            "payments.charge".to_string(),
336            "email.send".to_string(),
337            "file.read".to_string(),
338        ];
339        let (tools_sd, disclosures) = commit_tools(&tools);
340        assert_eq!(tools_sd.len(), 3);
341        assert!(
342            tools_sd.windows(2).all(|w| w[0] <= w[1]),
343            "tools_sd is sorted"
344        );
345
346        // Presenting every disclosure reveals exactly the original set.
347        let revealed = disclosed_tools(&tools_sd, &disclosures);
348        let got: std::collections::BTreeSet<_> = revealed.into_iter().collect();
349        let want: std::collections::BTreeSet<_> = tools.iter().cloned().collect();
350        assert_eq!(got, want);
351    }
352
353    #[test]
354    fn selective_disclosure_reveals_only_the_chosen_tool() {
355        let tools = vec![
356            "payments.charge".to_string(),
357            "email.send".to_string(),
358            "admin.delete".to_string(),
359        ];
360        let (tools_sd, disclosures) = commit_tools(&tools);
361
362        // Reveal ONLY the disclosure for "payments.charge". The verifier must
363        // learn that tool and nothing about the other two.
364        let chosen: Vec<String> = disclosures
365            .iter()
366            .filter(|d| d.contains("payments.charge"))
367            .cloned()
368            .collect();
369        assert_eq!(chosen.len(), 1);
370        let revealed = disclosed_tools(&tools_sd, &chosen);
371        assert_eq!(revealed, vec!["payments.charge".to_string()]);
372    }
373
374    #[test]
375    fn tampered_or_foreign_disclosure_reveals_nothing() {
376        let tools = vec!["payments.charge".to_string()];
377        let (tools_sd, disclosures) = commit_tools(&tools);
378
379        // A disclosure whose value was flipped to a different tool string is
380        // not in the signed digest set -> contributes nothing.
381        let tampered = disclosures[0].replace("payments.charge", "admin.root");
382        assert!(disclosed_tools(&tools_sd, &[tampered]).is_empty());
383
384        // A disclosure minted against a different card entirely -> rejected.
385        let (_other_sd, other_disclosures) = commit_tools(&["admin.root".to_string()]);
386        assert!(disclosed_tools(&tools_sd, &other_disclosures).is_empty());
387    }
388
389    // End-to-end: build a real card, disclose one capability, sign the
390    // digests-only card exactly as the mint does (a receipt with
391    // kind=agent_card.v1), verify the signature, recover the signed payload,
392    // and reconstruct only the revealed capability. Proves the full
393    // present --disclose / verify-presentation crypto with a real Ed25519
394    // signature, independent of the CLI plumbing.
395    #[test]
396    fn disclosed_card_round_trip_with_real_signature() {
397        use crate::attestation::sign::sign;
398        use crate::attestation::signer::Ed25519Signer;
399        use crate::attestation::verify::Verifier;
400        use crate::statements::{payload_type, ReceiptStatement};
401        use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
402
403        let card = serde_json::json!({
404            "schema": "agent_card.v1",
405            "agent": "agent://deployer",
406            "keyid": "key_test_01",
407            "version": "1",
408            "capabilities": { "tools": ["payments.charge", "email.send", "admin.delete"] }
409        });
410
411        let (disclosed, revealed) = disclose_capabilities(&card, &["payments.charge".to_string()]);
412        // The raw tool list is gone from what gets signed; only digests remain.
413        assert!(disclosed["capabilities"].get("tools").is_none());
414        assert!(disclosed["capabilities"].get("tools_sd").is_some());
415
416        // Sign the digests-only card the way the mint signs a card.
417        let signer = Ed25519Signer::generate("key_test_01").unwrap();
418        let mut stmt = ReceiptStatement::new("system://registry", "agent_card.v1");
419        stmt.payload = Some(disclosed);
420        let result = sign(&payload_type("receipt"), &stmt, &signer).unwrap();
421
422        // The signature over the digests-only card verifies.
423        let verifier = Verifier::from_signer(&signer);
424        assert!(verifier.verify(&result.envelope).is_ok());
425
426        // Recover the SIGNED payload (not the local copy) and reconstruct.
427        let payload_bytes = URL_SAFE_NO_PAD.decode(&result.envelope.payload).unwrap();
428        let signed_stmt: ReceiptStatement = serde_json::from_slice(&payload_bytes).unwrap();
429        let signed_card = signed_stmt.payload.expect("card payload present");
430
431        let got = reconstruct_capabilities(&signed_card, &revealed);
432        assert_eq!(got, vec!["payments.charge".to_string()]);
433
434        // A tampered disclosure reveals nothing.
435        let tampered: Vec<String> = revealed
436            .iter()
437            .map(|d| d.replace("payments.charge", "admin.delete"))
438            .collect();
439        assert!(reconstruct_capabilities(&signed_card, &tampered).is_empty());
440    }
441}