treeship_core/capability/
mod.rs1use crate::statements::ActionStatement;
11use crate::trust::{TrustRootKind, TrustRootStore};
12
13pub 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
36pub 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
47const GENERIC_DISPATCH_LABELS: &[&str] =
58 &["tool.call", "tool.use", "tool.invoke", "mcp.call", "mcp.tool"];
59
60fn 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
80pub 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
89pub 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
100pub 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 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 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 assert!(tool_matches("Bash(git:*)", "Bash(git:)"));
143 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 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 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()]; 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 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}