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
47pub 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
65pub 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
84pub 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 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 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 assert!(tool_matches("Bash(git:*)", "Bash(git:)"));
127 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 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}