use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use crate::client::{sign_agent_card, verify_card_signature};
use crate::protocol::AgentCard;
#[derive(Debug, thiserror::Error)]
pub enum SecurityError {
#[error("agent `{0}` is not in the trust registry")]
UntrustedAgent(String),
#[error("agent `{0}` has been revoked")]
RevokedAgent(String),
#[error("signature verification failed for `{0}`: {1}")]
SignatureMismatch(String, String),
#[error("delegation depth {depth} for `{url}` exceeds the limit {limit}")]
DeepDelegation {
url: String,
depth: usize,
limit: usize,
},
#[error("invalid key for `{0}`: {1}")]
InvalidKey(String, String),
#[error("sandbox denied: {0}")]
SandboxDenied(String),
#[error("payload of {size} bytes exceeds the {limit} byte limit")]
PayloadTooLarge { size: usize, limit: usize },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TrustRole {
Root,
Intermediate,
Leaf,
}
impl TrustRole {
fn base_trust(&self) -> f64 {
match self {
TrustRole::Root => 1.0,
TrustRole::Intermediate => 0.8,
TrustRole::Leaf => 0.6,
}
}
}
#[derive(Debug, Clone)]
pub struct TrustedAgent {
pub url: String,
pub name: String,
pub role: TrustRole,
pub verification_key: Vec<u8>,
}
impl TrustedAgent {
pub fn new(url: impl Into<String>, name: impl Into<String>, role: TrustRole) -> Self {
Self {
url: url.into(),
name: name.into(),
role,
verification_key: Vec::new(),
}
}
pub fn with_key(mut self, key: impl Into<Vec<u8>>) -> Self {
self.verification_key = key.into();
self
}
}
#[derive(Debug, Clone)]
pub struct TrustConfig {
pub max_delegation_depth: usize,
pub trust_decay: f64,
}
impl Default for TrustConfig {
fn default() -> Self {
Self::new(3, 0.9)
}
}
impl TrustConfig {
pub fn new(max_delegation_depth: usize, trust_decay: f64) -> Self {
assert!(
trust_decay > 0.0 && trust_decay <= 1.0,
"trust_decay must be in (0.0, 1.0]"
);
Self {
max_delegation_depth,
trust_decay,
}
}
pub fn effective_trust(&self, base: f64, hops: usize) -> f64 {
base * self.trust_decay.powi(hops as i32)
}
}
#[derive(Debug, Clone)]
pub struct TrustVerification {
pub url: String,
pub role: TrustRole,
pub trust_score: f64,
}
#[derive(Debug, Default)]
pub struct TrustRegistry {
agents: HashMap<String, TrustedAgent>,
revoked: HashSet<String>,
config: TrustConfig,
}
impl TrustRegistry {
pub fn new(config: TrustConfig) -> Self {
Self {
agents: HashMap::new(),
revoked: HashSet::new(),
config,
}
}
pub fn with_agent(mut self, agent: TrustedAgent) -> Self {
self.agents.insert(agent.url.clone(), agent);
self
}
pub fn revoke(mut self, url: &str) -> Self {
self.revoked.insert(url.to_string());
self
}
pub fn is_trusted(&self, url: &str) -> bool {
self.agents.contains_key(url) && !self.revoked.contains(url)
}
pub fn verify_card(&self, card: &AgentCard) -> Result<TrustVerification, SecurityError> {
let agent = self
.agents
.get(&card.url)
.ok_or_else(|| SecurityError::UntrustedAgent(card.url.clone()))?;
if self.revoked.contains(&card.url) {
return Err(SecurityError::RevokedAgent(card.url.clone()));
}
if card.signature.as_deref().is_none_or(|s| s.is_empty()) {
return Err(SecurityError::SignatureMismatch(
card.url.clone(),
"card is unsigned".to_string(),
));
}
verify_card_signature(card, &agent.verification_key)
.map_err(|e| SecurityError::SignatureMismatch(card.url.clone(), e.to_string()))?;
Ok(TrustVerification {
url: card.url.clone(),
role: agent.role.clone(),
trust_score: self.config.effective_trust(agent.role.base_trust(), 0),
})
}
pub fn verify_chain(&self, cards: &[&AgentCard]) -> Result<TrustVerification, SecurityError> {
if cards.is_empty() {
return Err(SecurityError::UntrustedAgent("<empty chain>".to_string()));
}
let hops = cards.len() - 1;
if hops > self.config.max_delegation_depth {
return Err(SecurityError::DeepDelegation {
url: cards.last().map(|c| c.url.clone()).unwrap_or_default(),
depth: hops,
limit: self.config.max_delegation_depth,
});
}
let root = self.verify_card(cards[0])?;
for (i, child) in cards.iter().enumerate().skip(1) {
let parent_url = cards[i - 1].url.clone();
let parent_key = self
.agents
.get(&parent_url)
.map(|a| a.verification_key.clone())
.ok_or_else(|| SecurityError::UntrustedAgent(parent_url.clone()))?;
if self.revoked.contains(&child.url) {
return Err(SecurityError::RevokedAgent(child.url.clone()));
}
verify_card_signature(child, &parent_key)
.map_err(|e| SecurityError::SignatureMismatch(child.url.clone(), e.to_string()))?;
}
let leaf = cards[cards.len() - 1];
let leaf_agent = self
.agents
.get(&leaf.url)
.ok_or_else(|| SecurityError::UntrustedAgent(leaf.url.clone()))?;
Ok(TrustVerification {
url: leaf.url.clone(),
role: leaf_agent.role.clone(),
trust_score: self.config.effective_trust(root.trust_score, hops),
})
}
pub fn trust_score(&self, url: &str, hops: usize) -> Option<f64> {
let agent = self.agents.get(url)?;
if self.revoked.contains(url) {
return None;
}
Some(self.config.effective_trust(agent.role.base_trust(), hops))
}
pub fn issue_card(&self, issuer_url: &str, card: &mut AgentCard) -> Result<(), SecurityError> {
let key = self
.agents
.get(issuer_url)
.ok_or_else(|| SecurityError::UntrustedAgent(issuer_url.to_string()))?
.verification_key
.clone();
sign_agent_card(card, &key)
.map_err(|e| SecurityError::InvalidKey(issuer_url.to_string(), e.to_string()))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AccessRequest {
ReadPath(PathBuf),
WritePath(PathBuf),
Network(String),
}
#[derive(Debug, Clone, Default)]
pub struct SandboxConfig {
allowed_read_paths: Vec<PathBuf>,
allowed_write_paths: Vec<PathBuf>,
allowed_domains: Vec<String>,
max_payload_bytes: Option<usize>,
}
impl SandboxConfig {
pub fn new() -> Self {
Self::default()
}
pub fn allow_read(mut self, path: impl Into<PathBuf>) -> Self {
self.allowed_read_paths.push(path.into());
self
}
pub fn allow_write(mut self, path: impl Into<PathBuf>) -> Self {
self.allowed_write_paths.push(path.into());
self
}
pub fn allow_domain(mut self, domain: impl Into<String>) -> Self {
self.allowed_domains.push(domain.into());
self
}
pub fn with_max_payload(mut self, bytes: usize) -> Self {
self.max_payload_bytes = Some(bytes);
self
}
pub fn accepts_payload(&self, size: usize) -> bool {
match self.max_payload_bytes {
Some(limit) => size <= limit,
None => true,
}
}
pub fn check(&self, request: &AccessRequest) -> Result<(), SecurityError> {
match request {
AccessRequest::ReadPath(path) => self.check_read(path),
AccessRequest::WritePath(path) => self.check_write(path),
AccessRequest::Network(host) => self.check_network(host),
}
}
fn check_read(&self, path: &Path) -> Result<(), SecurityError> {
if self.allowed_read_paths.iter().any(|a| is_within(path, a)) {
Ok(())
} else {
Err(SecurityError::SandboxDenied(format!(
"read of `{}` is outside the allowed read roots",
path.display()
)))
}
}
fn check_write(&self, path: &Path) -> Result<(), SecurityError> {
if self.allowed_write_paths.iter().any(|a| is_within(path, a)) {
Ok(())
} else {
Err(SecurityError::SandboxDenied(format!(
"write of `{}` is outside the allowed write roots",
path.display()
)))
}
}
fn check_network(&self, host: &str) -> Result<(), SecurityError> {
if self.allowed_domains.iter().any(|d| domain_allows(host, d)) {
Ok(())
} else {
Err(SecurityError::SandboxDenied(format!(
"network access to `{host}` is not allowed"
)))
}
}
}
fn is_within(path: &Path, root: &Path) -> bool {
path == root || path.starts_with(root)
}
fn domain_allows(host: &str, domain: &str) -> bool {
let domain = domain.trim_start_matches('.');
host == domain || host.ends_with(&format!(".{domain}"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::client::sign_agent_card;
fn card(url: &str) -> AgentCard {
AgentCard::new("agent", "desc", url)
}
fn key() -> Vec<u8> {
b"trust-secret".to_vec()
}
#[test]
fn registry_verifies_known_signed_card() {
let mut c = card("https://a.example.com");
sign_agent_card(&mut c, &key()).unwrap();
let registry = TrustRegistry::new(TrustConfig::new(3, 0.9)).with_agent(
TrustedAgent::new("https://a.example.com", "A", TrustRole::Leaf).with_key(key()),
);
let v = registry.verify_card(&c).unwrap();
assert_eq!(v.url, "https://a.example.com");
assert_eq!(v.role, TrustRole::Leaf);
assert_eq!(v.trust_score, 0.6);
}
#[test]
fn registry_rejects_unknown_agent() {
let mut c = card("https://stranger.example.com");
sign_agent_card(&mut c, &key()).unwrap();
let registry = TrustRegistry::new(TrustConfig::new(3, 0.9));
assert!(matches!(
registry.verify_card(&c),
Err(SecurityError::UntrustedAgent(_))
));
}
#[test]
fn registry_rejects_revoked_agent() {
let mut c = card("https://a.example.com");
sign_agent_card(&mut c, &key()).unwrap();
let registry = TrustRegistry::new(TrustConfig::new(3, 0.9))
.with_agent(
TrustedAgent::new("https://a.example.com", "A", TrustRole::Leaf).with_key(key()),
)
.revoke("https://a.example.com");
assert!(matches!(
registry.verify_card(&c),
Err(SecurityError::RevokedAgent(_))
));
assert!(!registry.is_trusted("https://a.example.com"));
}
#[test]
fn registry_rejects_tampered_signature() {
let mut c = card("https://a.example.com");
sign_agent_card(&mut c, &key()).unwrap();
c.description = "evil".to_string();
let registry = TrustRegistry::new(TrustConfig::new(3, 0.9)).with_agent(
TrustedAgent::new("https://a.example.com", "A", TrustRole::Leaf).with_key(key()),
);
assert!(matches!(
registry.verify_card(&c),
Err(SecurityError::SignatureMismatch(_, _))
));
}
#[test]
fn issue_card_signs_child_with_parent_key() {
let registry = TrustRegistry::new(TrustConfig::new(3, 0.9))
.with_agent(
TrustedAgent::new("https://root.example.com", "Root", TrustRole::Root)
.with_key(key()),
)
.with_agent(
TrustedAgent::new("https://child.example.com", "Child", TrustRole::Leaf)
.with_key(b"child-key"),
);
let mut child = card("https://child.example.com");
registry
.issue_card("https://root.example.com", &mut child)
.unwrap();
assert!(matches!(
registry.verify_card(&child),
Err(SecurityError::SignatureMismatch(_, _))
));
let mut root = card("https://root.example.com");
sign_agent_card(&mut root, &key()).unwrap();
let chain = registry.verify_chain(&[&root, &child]).unwrap();
assert_eq!(chain.trust_score, 0.9); }
#[test]
fn verify_chain_enforces_depth_limit() {
let config = TrustConfig::new(2, 0.9);
let registry = TrustRegistry::new(config.clone())
.with_agent(TrustedAgent::new("https://r.com", "R", TrustRole::Root).with_key(key()))
.with_agent(
TrustedAgent::new("https://i.com", "I", TrustRole::Intermediate).with_key(key()),
)
.with_agent(
TrustedAgent::new("https://i2.com", "I2", TrustRole::Intermediate).with_key(key()),
)
.with_agent(TrustedAgent::new("https://l.com", "L", TrustRole::Leaf).with_key(key()));
let mut i = card("https://i.com");
registry.issue_card("https://r.com", &mut i).unwrap();
let mut root = card("https://r.com");
sign_agent_card(&mut root, &key()).unwrap();
assert!(registry.verify_chain(&[&root, &i]).is_ok());
let mut i2 = card("https://i2.com");
registry.issue_card("https://i.com", &mut i2).unwrap();
let mut l = card("https://l.com");
registry.issue_card("https://i2.com", &mut l).unwrap();
assert!(matches!(
registry.verify_chain(&[&root, &i, &i2, &l]),
Err(SecurityError::DeepDelegation {
depth: 3,
limit: 2,
..
})
));
let wide = TrustRegistry::new(TrustConfig::new(3, 0.5))
.with_agent(TrustedAgent::new("https://r.com", "R", TrustRole::Root).with_key(key()))
.with_agent(
TrustedAgent::new("https://i.com", "I", TrustRole::Intermediate).with_key(key()),
)
.with_agent(
TrustedAgent::new("https://i2.com", "I2", TrustRole::Intermediate).with_key(key()),
)
.with_agent(TrustedAgent::new("https://l.com", "L", TrustRole::Leaf).with_key(key()));
let v = wide.verify_chain(&[&root, &i, &i2, &l]).unwrap();
assert!((v.trust_score - 0.125).abs() < 1e-9);
}
#[test]
fn verify_chain_rejects_child_signed_by_wrong_parent() {
let registry = TrustRegistry::new(TrustConfig::new(2, 0.9))
.with_agent(TrustedAgent::new("https://r.com", "R", TrustRole::Root).with_key(key()))
.with_agent(
TrustedAgent::new("https://i.com", "I", TrustRole::Intermediate).with_key(b"other"),
);
let mut root = card("https://r.com");
sign_agent_card(&mut root, &key()).unwrap();
let mut child = card("https://i.com");
sign_agent_card(&mut child, b"other").unwrap();
assert!(matches!(
registry.verify_chain(&[&root, &child]),
Err(SecurityError::SignatureMismatch(_, _))
));
}
#[test]
fn trust_score_returns_none_for_unknown_or_revoked() {
let registry = TrustRegistry::new(TrustConfig::new(2, 0.9))
.with_agent(TrustedAgent::new("https://a.com", "A", TrustRole::Leaf).with_key(key()))
.revoke("https://a.com");
assert_eq!(registry.trust_score("https://a.com", 0), None);
assert_eq!(registry.trust_score("https://nope.com", 0), None);
}
#[test]
fn sandbox_allows_reads_inside_allowed_root() {
let sandbox = SandboxConfig::new().allow_read("C:/data");
assert!(sandbox
.check(&AccessRequest::ReadPath("C:/data/file.txt".into()))
.is_ok());
assert!(sandbox
.check(&AccessRequest::ReadPath("C:/data".into()))
.is_ok());
}
#[test]
fn sandbox_denies_reads_outside_allowed_root() {
let sandbox = SandboxConfig::new().allow_read("C:/data");
assert!(matches!(
sandbox.check(&AccessRequest::ReadPath("C:/other/secret.txt".into())),
Err(SecurityError::SandboxDenied(_))
));
}
#[test]
fn sandbox_read_and_write_roots_are_separate() {
let sandbox = SandboxConfig::new()
.allow_read("C:/in")
.allow_write("C:/out");
assert!(sandbox
.check(&AccessRequest::ReadPath("C:/in/a.txt".into()))
.is_ok());
assert!(sandbox
.check(&AccessRequest::ReadPath("C:/out/a.txt".into()))
.is_err());
assert!(sandbox
.check(&AccessRequest::WritePath("C:/in/a.txt".into()))
.is_err());
assert!(sandbox
.check(&AccessRequest::WritePath("C:/out/a.txt".into()))
.is_ok());
}
#[test]
fn sandbox_network_allows_exact_and_subdomains() {
let sandbox = SandboxConfig::new().allow_domain("example.com");
assert!(sandbox
.check(&AccessRequest::Network("example.com".into()))
.is_ok());
assert!(sandbox
.check(&AccessRequest::Network("api.example.com".into()))
.is_ok());
assert!(sandbox
.check(&AccessRequest::Network("evil.net".into()))
.is_err());
assert!(sandbox
.check(&AccessRequest::Network("notexample.com".into()))
.is_err());
}
#[test]
fn sandbox_enforces_payload_limit() {
let sandbox = SandboxConfig::new().with_max_payload(100);
assert!(sandbox.accepts_payload(99));
assert!(sandbox.accepts_payload(100));
assert!(!sandbox.accepts_payload(101));
let unbounded = SandboxConfig::new();
assert!(unbounded.accepts_payload(usize::MAX));
}
}