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", "tool.use", "tool.invoke", "mcp.call", "mcp.tool"];
59
60/// The scope-matching candidates for an action. The action label is always
61/// authoritative. `meta.tool` is consulted ONLY when the action label is a
62/// generic dispatch placeholder, so a concrete out-of-scope action cannot be
63/// rescued by an attacker-supplied `meta.tool`.
64fn scope_candidates(action: &ActionStatement) -> Vec<&str> {
65    let label = action.action.as_str();
66    let mut candidates: Vec<&str> = vec![label];
67    if GENERIC_DISPATCH_LABELS.contains(&label) {
68        if let Some(tool) = action
69            .meta
70            .as_ref()
71            .and_then(|m| m.get("tool"))
72            .and_then(|v| v.as_str())
73        {
74            candidates.push(tool);
75        }
76    }
77    candidates
78}
79
80/// Is an action within a declared capability set? The action label is
81/// authoritative; `meta.tool` counts only when the label is a generic
82/// dispatch placeholder (see [`scope_candidates`]).
83pub fn action_in_scope(action: &ActionStatement, declared_tools: &[String]) -> bool {
84    scope_candidates(action)
85        .iter()
86        .any(|c| declared_tools.iter().any(|d| tool_matches(d, c)))
87}
88
89/// The first declared capability an action matches, if any. Same matching as
90/// [`action_in_scope`], but returns *which* capability matched, so callers can
91/// grade each declared capability by whether captured receipts exercise it.
92pub fn matched_capability(action: &ActionStatement, declared_tools: &[String]) -> Option<String> {
93    let candidates = scope_candidates(action);
94    declared_tools
95        .iter()
96        .find(|decl| candidates.iter().any(|c| tool_matches(decl, c)))
97        .cloned()
98}
99
100/// Extract the declared `capabilities.tools` from an agent_card.v1 payload.
101pub fn declared_tools(card_payload: &serde_json::Value) -> Vec<String> {
102    card_payload
103        .get("capabilities")
104        .and_then(|c| c.get("tools"))
105        .and_then(|t| t.as_array())
106        .map(|a| {
107            a.iter()
108                .filter_map(|t| t.as_str().map(str::to_string))
109                .collect()
110        })
111        .unwrap_or_default()
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117    use crate::trust::{TrustRoot, TrustRootKind, TrustRootStore};
118
119    #[test]
120    fn exact_and_glob_matching() {
121        assert!(tool_matches("file.write", "file.write"));
122        assert!(!tool_matches("file.write", "file.read"));
123        assert!(tool_matches("file.*", "file.write"));
124        assert!(!tool_matches("file.*", "db.query"));
125        assert!(tool_matches("*", "anything.at.all"));
126    }
127
128    #[test]
129    fn harness_patterns_with_internal_glob_match() {
130        // The shape `attest card --from-harness` captures from a Claude Code
131        // settings.json permissions.allow list: the `*` sits inside the
132        // parenthesized scope, not at the end of the pattern.
133        assert!(tool_matches("Bash(git:*)", "Bash(git:status)"));
134        assert!(tool_matches("Bash(git:*)", "Bash(git:log --oneline)"));
135        assert!(tool_matches("Read(*)", "Read(/etc/hosts)"));
136        // prefix and suffix must both hold — no cross-family bleed, no
137        // matching a truncated action that drops the closing delimiter
138        assert!(!tool_matches("Bash(git:*)", "Bash(gh:pr)"));
139        assert!(!tool_matches("Bash(git:*)", "Bash(git:"));
140        assert!(!tool_matches("Bash(git:*)", "payments.charge"));
141        // the wildcard may match empty: the family root itself is in scope
142        assert!(tool_matches("Bash(git:*)", "Bash(git:)"));
143        // trailing-glob and exact behavior unchanged
144        assert!(tool_matches("file.*", "file.*"));
145        assert!(!tool_matches("Bash(git:status)", "Bash(git:log)"));
146    }
147
148    fn root(key_id: &str, kind: TrustRootKind) -> TrustRoot {
149        TrustRoot {
150            key_id: key_id.into(),
151            public_key: "ed25519:AAAA".into(),
152            kind,
153            label: String::new(),
154            added_at: String::new(),
155        }
156    }
157
158    #[test]
159    fn key_bound_needs_signer_match_and_agentcert() {
160        let agentcert = TrustRootStore::with_roots(vec![root("key_x", TrustRootKind::AgentCert)]);
161        assert!(is_key_bound("key_x", "key_x", &agentcert));
162        assert!(!is_key_bound("key_x", "key_y", &agentcert));
163        assert!(!is_key_bound("", "", &agentcert));
164        let ship = TrustRootStore::with_roots(vec![root("key_x", TrustRootKind::Ship)]);
165        assert!(!is_key_bound("key_x", "key_x", &ship));
166        assert!(!is_key_bound("key_x", "key_x", &TrustRootStore::with_roots(vec![])));
167    }
168
169    #[test]
170    fn in_scope_checks_action_and_meta_tool() {
171        let mut a = ActionStatement::new("agent://x", "file.write");
172        assert!(action_in_scope(&a, &["file.*".to_string()]));
173        assert!(!action_in_scope(&a, &["db.query".to_string()]));
174        // meta.tool counts ONLY when the action label is a generic dispatch
175        // placeholder (the legitimate MCP case): tool.call + meta.tool=db.query.
176        a.action = "tool.call".into();
177        a.meta = Some(serde_json::json!({ "tool": "db.query" }));
178        assert!(action_in_scope(&a, &["db.query".to_string()]));
179    }
180
181    #[test]
182    fn meta_tool_cannot_rescue_a_concrete_out_of_scope_action() {
183        // The audited-agent bypass: a CONCRETE out-of-scope action with a
184        // benign meta.tool must still be out of scope. If this regresses, a
185        // dishonest agent hides every off-card action behind meta.tool.
186        let mut a = ActionStatement::new("agent://x", "payments.charge");
187        a.meta = Some(serde_json::json!({ "tool": "file.read" }));
188        let declared = vec!["file.*".to_string()]; // payments NOT declared
189        assert!(
190            !action_in_scope(&a, &declared),
191            "meta.tool must not pull a concrete out-of-scope action in-scope"
192        );
193        assert_eq!(
194            matched_capability(&a, &declared),
195            None,
196            "no declared capability should match the concrete out-of-scope action"
197        );
198        // And the concrete action IS matched when it is actually declared.
199        assert!(action_in_scope(&a, &["payments.*".to_string()]));
200    }
201
202    #[test]
203    fn matched_capability_returns_the_declared_glob() {
204        let a = ActionStatement::new("agent://x", "file.write");
205        let tools = vec!["db.query".to_string(), "file.*".to_string()];
206        assert_eq!(matched_capability(&a, &tools).as_deref(), Some("file.*"));
207        let b = ActionStatement::new("agent://x", "command.run");
208        assert_eq!(matched_capability(&b, &tools), None);
209    }
210}