use crate::statements::ActionStatement;
use crate::trust::{TrustRootKind, TrustRootStore};
pub fn tool_matches(declared: &str, actual: &str) -> bool {
match declared.split_once('*') {
Some((prefix, suffix)) => {
actual.len() >= prefix.len() + suffix.len()
&& actual.starts_with(prefix)
&& actual.ends_with(suffix)
}
None => declared == actual,
}
}
pub fn is_key_bound(card_keyid: &str, signer_keyid: &str, trust: &TrustRootStore) -> bool {
!card_keyid.is_empty()
&& signer_keyid == card_keyid
&& trust
.roots()
.iter()
.any(|r| r.key_id == card_keyid && r.kind == TrustRootKind::AgentCert)
}
const GENERIC_DISPATCH_LABELS: &[&str] = &[
"tool.call",
"tool.use",
"tool.invoke",
"mcp.call",
"mcp.tool",
];
fn scope_candidates(action: &ActionStatement) -> Vec<&str> {
let label = action.action.as_str();
let mut candidates: Vec<&str> = vec![label];
if GENERIC_DISPATCH_LABELS.contains(&label) {
if let Some(tool) = action
.meta
.as_ref()
.and_then(|m| m.get("tool"))
.and_then(|v| v.as_str())
{
candidates.push(tool);
}
}
candidates
}
pub fn action_in_scope(action: &ActionStatement, declared_tools: &[String]) -> bool {
scope_candidates(action)
.iter()
.any(|c| declared_tools.iter().any(|d| tool_matches(d, c)))
}
pub fn matched_capability(action: &ActionStatement, declared_tools: &[String]) -> Option<String> {
let candidates = scope_candidates(action);
declared_tools
.iter()
.find(|decl| candidates.iter().any(|c| tool_matches(decl, c)))
.cloned()
}
pub fn declared_tools(card_payload: &serde_json::Value) -> Vec<String> {
card_payload
.get("capabilities")
.and_then(|c| c.get("tools"))
.and_then(|t| t.as_array())
.map(|a| {
a.iter()
.filter_map(|t| t.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default()
}
pub fn commit_tools(tools: &[String]) -> (Vec<String>, Vec<String>) {
let claims: Vec<(String, serde_json::Value)> = tools
.iter()
.map(|t| (t.clone(), serde_json::Value::Bool(true)))
.collect();
let c = crate::disclosure::commit(&claims);
let disclosures = c.disclosures.iter().map(|d| d.encode()).collect();
(c.sd, disclosures)
}
pub fn disclosed_tools(tools_sd: &[String], presented_disclosures: &[String]) -> Vec<String> {
let sd_set: std::collections::BTreeSet<String> = tools_sd.iter().cloned().collect();
let mut out = Vec::new();
for d in presented_disclosures {
if let Some((name, value)) = crate::disclosure::verify_disclosure(d, &sd_set) {
if value == serde_json::Value::Bool(true) {
out.push(name);
}
}
}
out
}
pub fn committed_tool_digests(card_payload: &serde_json::Value) -> Vec<String> {
card_payload
.get("capabilities")
.and_then(|c| c.get("tools_sd"))
.and_then(|t| t.as_array())
.map(|a| {
a.iter()
.filter_map(|t| t.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default()
}
pub fn disclose_capabilities(
card_payload: &serde_json::Value,
reveal: &[String],
) -> (serde_json::Value, Vec<String>) {
let all_tools = declared_tools(card_payload);
let (tools_sd, disclosures) = commit_tools(&all_tools);
let reveal_set: std::collections::BTreeSet<&str> = reveal.iter().map(String::as_str).collect();
let selected: Vec<String> = all_tools
.iter()
.zip(disclosures.iter())
.filter(|(t, _)| reveal_set.contains(t.as_str()))
.map(|(_, d)| d.clone())
.collect();
let mut disclosed = card_payload.clone();
if let Some(caps) = disclosed
.get_mut("capabilities")
.and_then(|c| c.as_object_mut())
{
caps.remove("tools");
caps.insert("tools_sd".into(), serde_json::json!(tools_sd));
}
(disclosed, selected)
}
pub fn reconstruct_capabilities(
card_payload: &serde_json::Value,
disclosures: &[String],
) -> Vec<String> {
let tools_sd = committed_tool_digests(card_payload);
disclosed_tools(&tools_sd, disclosures)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::trust::{TrustRoot, TrustRootKind, TrustRootStore};
#[test]
fn exact_and_glob_matching() {
assert!(tool_matches("file.write", "file.write"));
assert!(!tool_matches("file.write", "file.read"));
assert!(tool_matches("file.*", "file.write"));
assert!(!tool_matches("file.*", "db.query"));
assert!(tool_matches("*", "anything.at.all"));
}
#[test]
fn harness_patterns_with_internal_glob_match() {
assert!(tool_matches("Bash(git:*)", "Bash(git:status)"));
assert!(tool_matches("Bash(git:*)", "Bash(git:log --oneline)"));
assert!(tool_matches("Read(*)", "Read(/etc/hosts)"));
assert!(!tool_matches("Bash(git:*)", "Bash(gh:pr)"));
assert!(!tool_matches("Bash(git:*)", "Bash(git:"));
assert!(!tool_matches("Bash(git:*)", "payments.charge"));
assert!(tool_matches("Bash(git:*)", "Bash(git:)"));
assert!(tool_matches("file.*", "file.*"));
assert!(!tool_matches("Bash(git:status)", "Bash(git:log)"));
}
fn root(key_id: &str, kind: TrustRootKind) -> TrustRoot {
TrustRoot {
key_id: key_id.into(),
public_key: "ed25519:AAAA".into(),
kind,
label: String::new(),
added_at: String::new(),
}
}
#[test]
fn key_bound_needs_signer_match_and_agentcert() {
let agentcert = TrustRootStore::with_roots(vec![root("key_x", TrustRootKind::AgentCert)]);
assert!(is_key_bound("key_x", "key_x", &agentcert));
assert!(!is_key_bound("key_x", "key_y", &agentcert));
assert!(!is_key_bound("", "", &agentcert));
let ship = TrustRootStore::with_roots(vec![root("key_x", TrustRootKind::Ship)]);
assert!(!is_key_bound("key_x", "key_x", &ship));
assert!(!is_key_bound(
"key_x",
"key_x",
&TrustRootStore::with_roots(vec![])
));
}
#[test]
fn in_scope_checks_action_and_meta_tool() {
let mut a = ActionStatement::new("agent://x", "file.write");
assert!(action_in_scope(&a, &["file.*".to_string()]));
assert!(!action_in_scope(&a, &["db.query".to_string()]));
a.action = "tool.call".into();
a.meta = Some(serde_json::json!({ "tool": "db.query" }));
assert!(action_in_scope(&a, &["db.query".to_string()]));
}
#[test]
fn meta_tool_cannot_rescue_a_concrete_out_of_scope_action() {
let mut a = ActionStatement::new("agent://x", "payments.charge");
a.meta = Some(serde_json::json!({ "tool": "file.read" }));
let declared = vec!["file.*".to_string()]; assert!(
!action_in_scope(&a, &declared),
"meta.tool must not pull a concrete out-of-scope action in-scope"
);
assert_eq!(
matched_capability(&a, &declared),
None,
"no declared capability should match the concrete out-of-scope action"
);
assert!(action_in_scope(&a, &["payments.*".to_string()]));
}
#[test]
fn matched_capability_returns_the_declared_glob() {
let a = ActionStatement::new("agent://x", "file.write");
let tools = vec!["db.query".to_string(), "file.*".to_string()];
assert_eq!(matched_capability(&a, &tools).as_deref(), Some("file.*"));
let b = ActionStatement::new("agent://x", "command.run");
assert_eq!(matched_capability(&b, &tools), None);
}
#[test]
fn full_disclosure_reconstructs_the_whole_tool_set() {
let tools = vec![
"payments.charge".to_string(),
"email.send".to_string(),
"file.read".to_string(),
];
let (tools_sd, disclosures) = commit_tools(&tools);
assert_eq!(tools_sd.len(), 3);
assert!(
tools_sd.windows(2).all(|w| w[0] <= w[1]),
"tools_sd is sorted"
);
let revealed = disclosed_tools(&tools_sd, &disclosures);
let got: std::collections::BTreeSet<_> = revealed.into_iter().collect();
let want: std::collections::BTreeSet<_> = tools.iter().cloned().collect();
assert_eq!(got, want);
}
#[test]
fn selective_disclosure_reveals_only_the_chosen_tool() {
let tools = vec![
"payments.charge".to_string(),
"email.send".to_string(),
"admin.delete".to_string(),
];
let (tools_sd, disclosures) = commit_tools(&tools);
let chosen: Vec<String> = disclosures
.iter()
.filter(|d| d.contains("payments.charge"))
.cloned()
.collect();
assert_eq!(chosen.len(), 1);
let revealed = disclosed_tools(&tools_sd, &chosen);
assert_eq!(revealed, vec!["payments.charge".to_string()]);
}
#[test]
fn tampered_or_foreign_disclosure_reveals_nothing() {
let tools = vec!["payments.charge".to_string()];
let (tools_sd, disclosures) = commit_tools(&tools);
let tampered = disclosures[0].replace("payments.charge", "admin.root");
assert!(disclosed_tools(&tools_sd, &[tampered]).is_empty());
let (_other_sd, other_disclosures) = commit_tools(&["admin.root".to_string()]);
assert!(disclosed_tools(&tools_sd, &other_disclosures).is_empty());
}
#[test]
fn disclosed_card_round_trip_with_real_signature() {
use crate::attestation::sign::sign;
use crate::attestation::signer::Ed25519Signer;
use crate::attestation::verify::Verifier;
use crate::statements::{payload_type, ReceiptStatement};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
let card = serde_json::json!({
"schema": "agent_card.v1",
"agent": "agent://deployer",
"keyid": "key_test_01",
"version": "1",
"capabilities": { "tools": ["payments.charge", "email.send", "admin.delete"] }
});
let (disclosed, revealed) = disclose_capabilities(&card, &["payments.charge".to_string()]);
assert!(disclosed["capabilities"].get("tools").is_none());
assert!(disclosed["capabilities"].get("tools_sd").is_some());
let signer = Ed25519Signer::generate("key_test_01").unwrap();
let mut stmt = ReceiptStatement::new("system://registry", "agent_card.v1");
stmt.payload = Some(disclosed);
let result = sign(&payload_type("receipt"), &stmt, &signer).unwrap();
let verifier = Verifier::from_signer(&signer);
assert!(verifier.verify(&result.envelope).is_ok());
let payload_bytes = URL_SAFE_NO_PAD.decode(&result.envelope.payload).unwrap();
let signed_stmt: ReceiptStatement = serde_json::from_slice(&payload_bytes).unwrap();
let signed_card = signed_stmt.payload.expect("card payload present");
let got = reconstruct_capabilities(&signed_card, &revealed);
assert_eq!(got, vec!["payments.charge".to_string()]);
let tampered: Vec<String> = revealed
.iter()
.map(|d| d.replace("payments.charge", "admin.delete"))
.collect();
assert!(reconstruct_capabilities(&signed_card, &tampered).is_empty());
}
}