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",
59 "tool.use",
60 "tool.invoke",
61 "mcp.call",
62 "mcp.tool",
63];
64
65fn scope_candidates(action: &ActionStatement) -> Vec<&str> {
70 let label = action.action.as_str();
71 let mut candidates: Vec<&str> = vec![label];
72 if GENERIC_DISPATCH_LABELS.contains(&label) {
73 if let Some(tool) = action
74 .meta
75 .as_ref()
76 .and_then(|m| m.get("tool"))
77 .and_then(|v| v.as_str())
78 {
79 candidates.push(tool);
80 }
81 }
82 candidates
83}
84
85pub fn action_in_scope(action: &ActionStatement, declared_tools: &[String]) -> bool {
89 scope_candidates(action)
90 .iter()
91 .any(|c| declared_tools.iter().any(|d| tool_matches(d, c)))
92}
93
94pub fn matched_capability(action: &ActionStatement, declared_tools: &[String]) -> Option<String> {
98 let candidates = scope_candidates(action);
99 declared_tools
100 .iter()
101 .find(|decl| candidates.iter().any(|c| tool_matches(decl, c)))
102 .cloned()
103}
104
105pub fn declared_tools(card_payload: &serde_json::Value) -> Vec<String> {
107 card_payload
108 .get("capabilities")
109 .and_then(|c| c.get("tools"))
110 .and_then(|t| t.as_array())
111 .map(|a| {
112 a.iter()
113 .filter_map(|t| t.as_str().map(str::to_string))
114 .collect()
115 })
116 .unwrap_or_default()
117}
118
119pub fn commit_tools(tools: &[String]) -> (Vec<String>, Vec<String>) {
139 let claims: Vec<(String, serde_json::Value)> = tools
140 .iter()
141 .map(|t| (t.clone(), serde_json::Value::Bool(true)))
142 .collect();
143 let c = crate::disclosure::commit(&claims);
144 let disclosures = c.disclosures.iter().map(|d| d.encode()).collect();
145 (c.sd, disclosures)
146}
147
148pub fn disclosed_tools(tools_sd: &[String], presented_disclosures: &[String]) -> Vec<String> {
155 let sd_set: std::collections::BTreeSet<String> = tools_sd.iter().cloned().collect();
156 let mut out = Vec::new();
157 for d in presented_disclosures {
158 if let Some((name, value)) = crate::disclosure::verify_disclosure(d, &sd_set) {
159 if value == serde_json::Value::Bool(true) {
160 out.push(name);
161 }
162 }
163 }
164 out
165}
166
167pub fn committed_tool_digests(card_payload: &serde_json::Value) -> Vec<String> {
169 card_payload
170 .get("capabilities")
171 .and_then(|c| c.get("tools_sd"))
172 .and_then(|t| t.as_array())
173 .map(|a| {
174 a.iter()
175 .filter_map(|t| t.as_str().map(str::to_string))
176 .collect()
177 })
178 .unwrap_or_default()
179}
180
181pub fn disclose_capabilities(
191 card_payload: &serde_json::Value,
192 reveal: &[String],
193) -> (serde_json::Value, Vec<String>) {
194 let all_tools = declared_tools(card_payload);
195 let (tools_sd, disclosures) = commit_tools(&all_tools);
198
199 let reveal_set: std::collections::BTreeSet<&str> = reveal.iter().map(String::as_str).collect();
200 let selected: Vec<String> = all_tools
201 .iter()
202 .zip(disclosures.iter())
203 .filter(|(t, _)| reveal_set.contains(t.as_str()))
204 .map(|(_, d)| d.clone())
205 .collect();
206
207 let mut disclosed = card_payload.clone();
208 if let Some(caps) = disclosed
209 .get_mut("capabilities")
210 .and_then(|c| c.as_object_mut())
211 {
212 caps.remove("tools");
213 caps.insert("tools_sd".into(), serde_json::json!(tools_sd));
214 }
215 (disclosed, selected)
216}
217
218pub fn reconstruct_capabilities(
224 card_payload: &serde_json::Value,
225 disclosures: &[String],
226) -> Vec<String> {
227 let tools_sd = committed_tool_digests(card_payload);
228 disclosed_tools(&tools_sd, disclosures)
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234 use crate::trust::{TrustRoot, TrustRootKind, TrustRootStore};
235
236 #[test]
237 fn exact_and_glob_matching() {
238 assert!(tool_matches("file.write", "file.write"));
239 assert!(!tool_matches("file.write", "file.read"));
240 assert!(tool_matches("file.*", "file.write"));
241 assert!(!tool_matches("file.*", "db.query"));
242 assert!(tool_matches("*", "anything.at.all"));
243 }
244
245 #[test]
246 fn harness_patterns_with_internal_glob_match() {
247 assert!(tool_matches("Bash(git:*)", "Bash(git:status)"));
251 assert!(tool_matches("Bash(git:*)", "Bash(git:log --oneline)"));
252 assert!(tool_matches("Read(*)", "Read(/etc/hosts)"));
253 assert!(!tool_matches("Bash(git:*)", "Bash(gh:pr)"));
256 assert!(!tool_matches("Bash(git:*)", "Bash(git:"));
257 assert!(!tool_matches("Bash(git:*)", "payments.charge"));
258 assert!(tool_matches("Bash(git:*)", "Bash(git:)"));
260 assert!(tool_matches("file.*", "file.*"));
262 assert!(!tool_matches("Bash(git:status)", "Bash(git:log)"));
263 }
264
265 fn root(key_id: &str, kind: TrustRootKind) -> TrustRoot {
266 TrustRoot {
267 key_id: key_id.into(),
268 public_key: "ed25519:AAAA".into(),
269 kind,
270 label: String::new(),
271 added_at: String::new(),
272 }
273 }
274
275 #[test]
276 fn key_bound_needs_signer_match_and_agentcert() {
277 let agentcert = TrustRootStore::with_roots(vec![root("key_x", TrustRootKind::AgentCert)]);
278 assert!(is_key_bound("key_x", "key_x", &agentcert));
279 assert!(!is_key_bound("key_x", "key_y", &agentcert));
280 assert!(!is_key_bound("", "", &agentcert));
281 let ship = TrustRootStore::with_roots(vec![root("key_x", TrustRootKind::Ship)]);
282 assert!(!is_key_bound("key_x", "key_x", &ship));
283 assert!(!is_key_bound(
284 "key_x",
285 "key_x",
286 &TrustRootStore::with_roots(vec![])
287 ));
288 }
289
290 #[test]
291 fn in_scope_checks_action_and_meta_tool() {
292 let mut a = ActionStatement::new("agent://x", "file.write");
293 assert!(action_in_scope(&a, &["file.*".to_string()]));
294 assert!(!action_in_scope(&a, &["db.query".to_string()]));
295 a.action = "tool.call".into();
298 a.meta = Some(serde_json::json!({ "tool": "db.query" }));
299 assert!(action_in_scope(&a, &["db.query".to_string()]));
300 }
301
302 #[test]
303 fn meta_tool_cannot_rescue_a_concrete_out_of_scope_action() {
304 let mut a = ActionStatement::new("agent://x", "payments.charge");
308 a.meta = Some(serde_json::json!({ "tool": "file.read" }));
309 let declared = vec!["file.*".to_string()]; assert!(
311 !action_in_scope(&a, &declared),
312 "meta.tool must not pull a concrete out-of-scope action in-scope"
313 );
314 assert_eq!(
315 matched_capability(&a, &declared),
316 None,
317 "no declared capability should match the concrete out-of-scope action"
318 );
319 assert!(action_in_scope(&a, &["payments.*".to_string()]));
321 }
322
323 #[test]
324 fn matched_capability_returns_the_declared_glob() {
325 let a = ActionStatement::new("agent://x", "file.write");
326 let tools = vec!["db.query".to_string(), "file.*".to_string()];
327 assert_eq!(matched_capability(&a, &tools).as_deref(), Some("file.*"));
328 let b = ActionStatement::new("agent://x", "command.run");
329 assert_eq!(matched_capability(&b, &tools), None);
330 }
331
332 #[test]
333 fn full_disclosure_reconstructs_the_whole_tool_set() {
334 let tools = vec![
335 "payments.charge".to_string(),
336 "email.send".to_string(),
337 "file.read".to_string(),
338 ];
339 let (tools_sd, disclosures) = commit_tools(&tools);
340 assert_eq!(tools_sd.len(), 3);
341 assert!(
342 tools_sd.windows(2).all(|w| w[0] <= w[1]),
343 "tools_sd is sorted"
344 );
345
346 let revealed = disclosed_tools(&tools_sd, &disclosures);
348 let got: std::collections::BTreeSet<_> = revealed.into_iter().collect();
349 let want: std::collections::BTreeSet<_> = tools.iter().cloned().collect();
350 assert_eq!(got, want);
351 }
352
353 #[test]
354 fn selective_disclosure_reveals_only_the_chosen_tool() {
355 let tools = vec![
356 "payments.charge".to_string(),
357 "email.send".to_string(),
358 "admin.delete".to_string(),
359 ];
360 let (tools_sd, disclosures) = commit_tools(&tools);
361
362 let chosen: Vec<String> = disclosures
365 .iter()
366 .filter(|d| d.contains("payments.charge"))
367 .cloned()
368 .collect();
369 assert_eq!(chosen.len(), 1);
370 let revealed = disclosed_tools(&tools_sd, &chosen);
371 assert_eq!(revealed, vec!["payments.charge".to_string()]);
372 }
373
374 #[test]
375 fn tampered_or_foreign_disclosure_reveals_nothing() {
376 let tools = vec!["payments.charge".to_string()];
377 let (tools_sd, disclosures) = commit_tools(&tools);
378
379 let tampered = disclosures[0].replace("payments.charge", "admin.root");
382 assert!(disclosed_tools(&tools_sd, &[tampered]).is_empty());
383
384 let (_other_sd, other_disclosures) = commit_tools(&["admin.root".to_string()]);
386 assert!(disclosed_tools(&tools_sd, &other_disclosures).is_empty());
387 }
388
389 #[test]
396 fn disclosed_card_round_trip_with_real_signature() {
397 use crate::attestation::sign::sign;
398 use crate::attestation::signer::Ed25519Signer;
399 use crate::attestation::verify::Verifier;
400 use crate::statements::{payload_type, ReceiptStatement};
401 use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
402
403 let card = serde_json::json!({
404 "schema": "agent_card.v1",
405 "agent": "agent://deployer",
406 "keyid": "key_test_01",
407 "version": "1",
408 "capabilities": { "tools": ["payments.charge", "email.send", "admin.delete"] }
409 });
410
411 let (disclosed, revealed) = disclose_capabilities(&card, &["payments.charge".to_string()]);
412 assert!(disclosed["capabilities"].get("tools").is_none());
414 assert!(disclosed["capabilities"].get("tools_sd").is_some());
415
416 let signer = Ed25519Signer::generate("key_test_01").unwrap();
418 let mut stmt = ReceiptStatement::new("system://registry", "agent_card.v1");
419 stmt.payload = Some(disclosed);
420 let result = sign(&payload_type("receipt"), &stmt, &signer).unwrap();
421
422 let verifier = Verifier::from_signer(&signer);
424 assert!(verifier.verify(&result.envelope).is_ok());
425
426 let payload_bytes = URL_SAFE_NO_PAD.decode(&result.envelope.payload).unwrap();
428 let signed_stmt: ReceiptStatement = serde_json::from_slice(&payload_bytes).unwrap();
429 let signed_card = signed_stmt.payload.expect("card payload present");
430
431 let got = reconstruct_capabilities(&signed_card, &revealed);
432 assert_eq!(got, vec!["payments.charge".to_string()]);
433
434 let tampered: Vec<String> = revealed
436 .iter()
437 .map(|d| d.replace("payments.charge", "admin.delete"))
438 .collect();
439 assert!(reconstruct_capabilities(&signed_card, &tampered).is_empty());
440 }
441}