treeship_core/capability/
mod.rs1use crate::statements::ActionStatement;
11use crate::trust::{TrustRootKind, TrustRootStore};
12
13pub 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
23pub 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
34pub 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
52pub 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
71pub 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 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}