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.
15pub fn tool_matches(declared: &str, actual: &str) -> bool {
16    if let Some(prefix) = declared.strip_suffix('*') {
17        actual.starts_with(prefix)
18    } else {
19        declared == actual
20    }
21}
22
23/// A card is **key-bound** only when its `keyid` is the envelope signer AND
24/// that key is pinned under `AgentCert`. Anything else is self-asserted.
25pub fn is_key_bound(card_keyid: &str, signer_keyid: &str, trust: &TrustRootStore) -> bool {
26    !card_keyid.is_empty()
27        && signer_keyid == card_keyid
28        && trust
29            .roots()
30            .iter()
31            .any(|r| r.key_id == card_keyid && r.kind == TrustRootKind::AgentCert)
32}
33
34/// Is an action within a declared capability set? Checks the action label and
35/// the optional `meta.tool` against each declared capability (exact, or a
36/// `family.*` glob).
37pub fn action_in_scope(action: &ActionStatement, declared_tools: &[String]) -> bool {
38    let mut candidates: Vec<&str> = vec![action.action.as_str()];
39    if let Some(tool) = action
40        .meta
41        .as_ref()
42        .and_then(|m| m.get("tool"))
43        .and_then(|v| v.as_str())
44    {
45        candidates.push(tool);
46    }
47    candidates
48        .iter()
49        .any(|c| declared_tools.iter().any(|d| tool_matches(d, c)))
50}
51
52/// The first declared capability an action matches, if any. Same matching as
53/// [`action_in_scope`], but returns *which* capability matched, so callers can
54/// grade each declared capability by whether captured receipts exercise it.
55pub fn matched_capability(action: &ActionStatement, declared_tools: &[String]) -> Option<String> {
56    let mut candidates: Vec<&str> = vec![action.action.as_str()];
57    if let Some(tool) = action
58        .meta
59        .as_ref()
60        .and_then(|m| m.get("tool"))
61        .and_then(|v| v.as_str())
62    {
63        candidates.push(tool);
64    }
65    declared_tools
66        .iter()
67        .find(|decl| candidates.iter().any(|c| tool_matches(decl, c)))
68        .cloned()
69}
70
71/// Extract the declared `capabilities.tools` from an agent_card.v1 payload.
72pub fn declared_tools(card_payload: &serde_json::Value) -> Vec<String> {
73    card_payload
74        .get("capabilities")
75        .and_then(|c| c.get("tools"))
76        .and_then(|t| t.as_array())
77        .map(|a| {
78            a.iter()
79                .filter_map(|t| t.as_str().map(str::to_string))
80                .collect()
81        })
82        .unwrap_or_default()
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88    use crate::trust::{TrustRoot, TrustRootKind, TrustRootStore};
89
90    #[test]
91    fn exact_and_glob_matching() {
92        assert!(tool_matches("file.write", "file.write"));
93        assert!(!tool_matches("file.write", "file.read"));
94        assert!(tool_matches("file.*", "file.write"));
95        assert!(!tool_matches("file.*", "db.query"));
96        assert!(tool_matches("*", "anything.at.all"));
97    }
98
99    fn root(key_id: &str, kind: TrustRootKind) -> TrustRoot {
100        TrustRoot {
101            key_id: key_id.into(),
102            public_key: "ed25519:AAAA".into(),
103            kind,
104            label: String::new(),
105            added_at: String::new(),
106        }
107    }
108
109    #[test]
110    fn key_bound_needs_signer_match_and_agentcert() {
111        let agentcert = TrustRootStore::with_roots(vec![root("key_x", TrustRootKind::AgentCert)]);
112        assert!(is_key_bound("key_x", "key_x", &agentcert));
113        assert!(!is_key_bound("key_x", "key_y", &agentcert));
114        assert!(!is_key_bound("", "", &agentcert));
115        let ship = TrustRootStore::with_roots(vec![root("key_x", TrustRootKind::Ship)]);
116        assert!(!is_key_bound("key_x", "key_x", &ship));
117        assert!(!is_key_bound("key_x", "key_x", &TrustRootStore::with_roots(vec![])));
118    }
119
120    #[test]
121    fn in_scope_checks_action_and_meta_tool() {
122        let mut a = ActionStatement::new("agent://x", "file.write");
123        assert!(action_in_scope(&a, &["file.*".to_string()]));
124        assert!(!action_in_scope(&a, &["db.query".to_string()]));
125        // meta.tool also counts
126        a.action = "tool.call".into();
127        a.meta = Some(serde_json::json!({ "tool": "db.query" }));
128        assert!(action_in_scope(&a, &["db.query".to_string()]));
129    }
130
131    #[test]
132    fn matched_capability_returns_the_declared_glob() {
133        let a = ActionStatement::new("agent://x", "file.write");
134        let tools = vec!["db.query".to_string(), "file.*".to_string()];
135        assert_eq!(matched_capability(&a, &tools).as_deref(), Some("file.*"));
136        let b = ActionStatement::new("agent://x", "command.run");
137        assert_eq!(matched_capability(&b, &tools), None);
138    }
139}