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/// Is an action within a declared capability set? Checks the action label and
48/// the optional `meta.tool` against each declared capability (exact, or a
49/// `family.*` glob).
50pub fn action_in_scope(action: &ActionStatement, declared_tools: &[String]) -> bool {
51    let mut candidates: Vec<&str> = vec![action.action.as_str()];
52    if let Some(tool) = action
53        .meta
54        .as_ref()
55        .and_then(|m| m.get("tool"))
56        .and_then(|v| v.as_str())
57    {
58        candidates.push(tool);
59    }
60    candidates
61        .iter()
62        .any(|c| declared_tools.iter().any(|d| tool_matches(d, c)))
63}
64
65/// The first declared capability an action matches, if any. Same matching as
66/// [`action_in_scope`], but returns *which* capability matched, so callers can
67/// grade each declared capability by whether captured receipts exercise it.
68pub fn matched_capability(action: &ActionStatement, declared_tools: &[String]) -> Option<String> {
69    let mut candidates: Vec<&str> = vec![action.action.as_str()];
70    if let Some(tool) = action
71        .meta
72        .as_ref()
73        .and_then(|m| m.get("tool"))
74        .and_then(|v| v.as_str())
75    {
76        candidates.push(tool);
77    }
78    declared_tools
79        .iter()
80        .find(|decl| candidates.iter().any(|c| tool_matches(decl, c)))
81        .cloned()
82}
83
84/// Extract the declared `capabilities.tools` from an agent_card.v1 payload.
85pub fn declared_tools(card_payload: &serde_json::Value) -> Vec<String> {
86    card_payload
87        .get("capabilities")
88        .and_then(|c| c.get("tools"))
89        .and_then(|t| t.as_array())
90        .map(|a| {
91            a.iter()
92                .filter_map(|t| t.as_str().map(str::to_string))
93                .collect()
94        })
95        .unwrap_or_default()
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101    use crate::trust::{TrustRoot, TrustRootKind, TrustRootStore};
102
103    #[test]
104    fn exact_and_glob_matching() {
105        assert!(tool_matches("file.write", "file.write"));
106        assert!(!tool_matches("file.write", "file.read"));
107        assert!(tool_matches("file.*", "file.write"));
108        assert!(!tool_matches("file.*", "db.query"));
109        assert!(tool_matches("*", "anything.at.all"));
110    }
111
112    #[test]
113    fn harness_patterns_with_internal_glob_match() {
114        // The shape `attest card --from-harness` captures from a Claude Code
115        // settings.json permissions.allow list: the `*` sits inside the
116        // parenthesized scope, not at the end of the pattern.
117        assert!(tool_matches("Bash(git:*)", "Bash(git:status)"));
118        assert!(tool_matches("Bash(git:*)", "Bash(git:log --oneline)"));
119        assert!(tool_matches("Read(*)", "Read(/etc/hosts)"));
120        // prefix and suffix must both hold — no cross-family bleed, no
121        // matching a truncated action that drops the closing delimiter
122        assert!(!tool_matches("Bash(git:*)", "Bash(gh:pr)"));
123        assert!(!tool_matches("Bash(git:*)", "Bash(git:"));
124        assert!(!tool_matches("Bash(git:*)", "payments.charge"));
125        // the wildcard may match empty: the family root itself is in scope
126        assert!(tool_matches("Bash(git:*)", "Bash(git:)"));
127        // trailing-glob and exact behavior unchanged
128        assert!(tool_matches("file.*", "file.*"));
129        assert!(!tool_matches("Bash(git:status)", "Bash(git:log)"));
130    }
131
132    fn root(key_id: &str, kind: TrustRootKind) -> TrustRoot {
133        TrustRoot {
134            key_id: key_id.into(),
135            public_key: "ed25519:AAAA".into(),
136            kind,
137            label: String::new(),
138            added_at: String::new(),
139        }
140    }
141
142    #[test]
143    fn key_bound_needs_signer_match_and_agentcert() {
144        let agentcert = TrustRootStore::with_roots(vec![root("key_x", TrustRootKind::AgentCert)]);
145        assert!(is_key_bound("key_x", "key_x", &agentcert));
146        assert!(!is_key_bound("key_x", "key_y", &agentcert));
147        assert!(!is_key_bound("", "", &agentcert));
148        let ship = TrustRootStore::with_roots(vec![root("key_x", TrustRootKind::Ship)]);
149        assert!(!is_key_bound("key_x", "key_x", &ship));
150        assert!(!is_key_bound("key_x", "key_x", &TrustRootStore::with_roots(vec![])));
151    }
152
153    #[test]
154    fn in_scope_checks_action_and_meta_tool() {
155        let mut a = ActionStatement::new("agent://x", "file.write");
156        assert!(action_in_scope(&a, &["file.*".to_string()]));
157        assert!(!action_in_scope(&a, &["db.query".to_string()]));
158        // meta.tool also counts
159        a.action = "tool.call".into();
160        a.meta = Some(serde_json::json!({ "tool": "db.query" }));
161        assert!(action_in_scope(&a, &["db.query".to_string()]));
162    }
163
164    #[test]
165    fn matched_capability_returns_the_declared_glob() {
166        let a = ActionStatement::new("agent://x", "file.write");
167        let tools = vec!["db.query".to_string(), "file.*".to_string()];
168        assert_eq!(matched_capability(&a, &tools).as_deref(), Some("file.*"));
169        let b = ActionStatement::new("agent://x", "command.run");
170        assert_eq!(matched_capability(&b, &tools), None);
171    }
172}