1use std::collections::{HashMap, HashSet};
22use std::path::{Component, Path, PathBuf};
23
24use crate::client::{sign_agent_card, verify_card_signature};
25use crate::protocol::AgentCard;
26
27#[derive(Debug, thiserror::Error)]
29#[non_exhaustive]
30pub enum SecurityError {
31 #[error("agent `{0}` is not in the trust registry")]
33 UntrustedAgent(String),
34 #[error("agent `{0}` has been revoked")]
36 RevokedAgent(String),
37 #[error("signature verification failed for `{0}`: {1}")]
39 SignatureMismatch(String, String),
40 #[error("delegation depth {depth} for `{url}` exceeds the limit {limit}")]
42 DeepDelegation {
43 url: String,
45 depth: usize,
47 limit: usize,
49 },
50 #[error("invalid key for `{0}`: {1}")]
52 InvalidKey(String, String),
53 #[error("sandbox denied: {0}")]
55 SandboxDenied(String),
56 #[error("payload of {size} bytes exceeds the {limit} byte limit")]
58 PayloadTooLarge {
59 size: usize,
61 limit: usize,
63 },
64}
65
66#[derive(Debug, Clone, PartialEq, Eq)]
69pub enum TrustRole {
70 Root,
72 Intermediate,
74 Leaf,
76}
77
78impl TrustRole {
79 fn base_trust(&self) -> f64 {
80 match self {
81 TrustRole::Root => 1.0,
82 TrustRole::Intermediate => 0.8,
83 TrustRole::Leaf => 0.6,
84 }
85 }
86}
87
88#[derive(Debug, Clone)]
90pub struct TrustedAgent {
91 pub url: String,
93 pub name: String,
95 pub role: TrustRole,
97 pub verification_key: Vec<u8>,
99}
100
101impl TrustedAgent {
102 pub fn new(url: impl Into<String>, name: impl Into<String>, role: TrustRole) -> Self {
104 Self {
105 url: url.into(),
106 name: name.into(),
107 role,
108 verification_key: Vec::new(),
109 }
110 }
111
112 pub fn with_key(mut self, key: impl Into<Vec<u8>>) -> Self {
114 self.verification_key = key.into();
115 self
116 }
117}
118
119#[derive(Debug, Clone)]
121pub struct TrustConfig {
122 pub max_delegation_depth: usize,
124 pub trust_decay: f64,
126}
127
128impl Default for TrustConfig {
129 fn default() -> Self {
131 Self::new(3, 0.9)
132 }
133}
134
135impl TrustConfig {
136 pub fn new(max_delegation_depth: usize, trust_decay: f64) -> Self {
141 assert!(
142 trust_decay > 0.0 && trust_decay <= 1.0,
143 "trust_decay must be in (0.0, 1.0]"
144 );
145 Self {
146 max_delegation_depth,
147 trust_decay,
148 }
149 }
150
151 pub fn effective_trust(&self, base: f64, hops: usize) -> f64 {
153 base * self.trust_decay.powi(hops as i32)
154 }
155}
156
157#[derive(Debug, Clone)]
159pub struct TrustVerification {
160 pub url: String,
162 pub role: TrustRole,
164 pub trust_score: f64,
166}
167
168#[derive(Debug, Default)]
173pub struct TrustRegistry {
174 agents: HashMap<String, TrustedAgent>,
175 revoked: HashSet<String>,
176 config: TrustConfig,
177}
178
179impl TrustRegistry {
180 pub fn new(config: TrustConfig) -> Self {
182 Self {
183 agents: HashMap::new(),
184 revoked: HashSet::new(),
185 config,
186 }
187 }
188
189 pub fn with_agent(mut self, agent: TrustedAgent) -> Self {
191 self.agents.insert(agent.url.clone(), agent);
192 self
193 }
194
195 pub fn revoke(mut self, url: &str) -> Self {
197 self.revoked.insert(url.to_string());
198 self
199 }
200
201 pub fn is_trusted(&self, url: &str) -> bool {
203 self.agents.contains_key(url) && !self.revoked.contains(url)
204 }
205
206 pub fn verify_card(&self, card: &AgentCard) -> Result<TrustVerification, SecurityError> {
212 let agent = self
213 .agents
214 .get(&card.url)
215 .ok_or_else(|| SecurityError::UntrustedAgent(card.url.clone()))?;
216 if self.revoked.contains(&card.url) {
217 return Err(SecurityError::RevokedAgent(card.url.clone()));
218 }
219 if card.signature.as_deref().is_none_or(|s| s.is_empty()) {
223 return Err(SecurityError::SignatureMismatch(
224 card.url.clone(),
225 "card is unsigned".to_string(),
226 ));
227 }
228 verify_card_signature(card, &agent.verification_key)
229 .map_err(|e| SecurityError::SignatureMismatch(card.url.clone(), e.to_string()))?;
230 Ok(TrustVerification {
231 url: card.url.clone(),
232 role: agent.role.clone(),
233 trust_score: self.config.effective_trust(agent.role.base_trust(), 0),
234 })
235 }
236
237 pub fn verify_chain(&self, cards: &[&AgentCard]) -> Result<TrustVerification, SecurityError> {
245 if cards.is_empty() {
246 return Err(SecurityError::UntrustedAgent("<empty chain>".to_string()));
247 }
248 let hops = cards.len() - 1;
249 if hops > self.config.max_delegation_depth {
250 return Err(SecurityError::DeepDelegation {
251 url: cards.last().map(|c| c.url.clone()).unwrap_or_default(),
252 depth: hops,
253 limit: self.config.max_delegation_depth,
254 });
255 }
256
257 let root = self.verify_card(cards[0])?;
259
260 for (i, child) in cards.iter().enumerate().skip(1) {
263 let parent_url = cards[i - 1].url.clone();
264 let parent_key = self
265 .agents
266 .get(&parent_url)
267 .map(|a| a.verification_key.clone())
268 .ok_or_else(|| SecurityError::UntrustedAgent(parent_url.clone()))?;
269 if self.revoked.contains(&child.url) {
270 return Err(SecurityError::RevokedAgent(child.url.clone()));
271 }
272 verify_card_signature(child, &parent_key)
273 .map_err(|e| SecurityError::SignatureMismatch(child.url.clone(), e.to_string()))?;
274 }
275
276 let leaf = cards[cards.len() - 1];
277 let leaf_agent = self
278 .agents
279 .get(&leaf.url)
280 .ok_or_else(|| SecurityError::UntrustedAgent(leaf.url.clone()))?;
281 Ok(TrustVerification {
282 url: leaf.url.clone(),
283 role: leaf_agent.role.clone(),
284 trust_score: self.config.effective_trust(root.trust_score, hops),
285 })
286 }
287
288 pub fn trust_score(&self, url: &str, hops: usize) -> Option<f64> {
291 let agent = self.agents.get(url)?;
292 if self.revoked.contains(url) {
293 return None;
294 }
295 Some(self.config.effective_trust(agent.role.base_trust(), hops))
296 }
297
298 pub fn issue_card(&self, issuer_url: &str, card: &mut AgentCard) -> Result<(), SecurityError> {
302 let key = self
303 .agents
304 .get(issuer_url)
305 .ok_or_else(|| SecurityError::UntrustedAgent(issuer_url.to_string()))?
306 .verification_key
307 .clone();
308 sign_agent_card(card, &key)
309 .map_err(|e| SecurityError::InvalidKey(issuer_url.to_string(), e.to_string()))
310 }
311}
312
313#[derive(Debug, Clone, PartialEq, Eq)]
315pub enum AccessRequest {
316 ReadPath(PathBuf),
318 WritePath(PathBuf),
320 Network(String),
322}
323
324#[derive(Debug, Clone, Default)]
333pub struct SandboxConfig {
334 allowed_read_paths: Vec<PathBuf>,
335 allowed_write_paths: Vec<PathBuf>,
336 allowed_domains: Vec<String>,
337 max_payload_bytes: Option<usize>,
338}
339
340impl SandboxConfig {
341 pub fn new() -> Self {
343 Self::default()
344 }
345
346 pub fn allow_read(mut self, path: impl Into<PathBuf>) -> Self {
348 self.allowed_read_paths.push(path.into());
349 self
350 }
351
352 pub fn allow_write(mut self, path: impl Into<PathBuf>) -> Self {
354 self.allowed_write_paths.push(path.into());
355 self
356 }
357
358 pub fn allow_domain(mut self, domain: impl Into<String>) -> Self {
360 self.allowed_domains.push(domain.into());
361 self
362 }
363
364 pub fn with_max_payload(mut self, bytes: usize) -> Self {
366 self.max_payload_bytes = Some(bytes);
367 self
368 }
369
370 pub fn accepts_payload(&self, size: usize) -> bool {
372 match self.max_payload_bytes {
373 Some(limit) => size <= limit,
374 None => true,
375 }
376 }
377
378 pub fn check(&self, request: &AccessRequest) -> Result<(), SecurityError> {
380 match request {
381 AccessRequest::ReadPath(path) => self.check_read(path),
382 AccessRequest::WritePath(path) => self.check_write(path),
383 AccessRequest::Network(host) => self.check_network(host),
384 }
385 }
386
387 fn check_read(&self, path: &Path) -> Result<(), SecurityError> {
388 reject_parent_dir(path)?;
389 if self.allowed_read_paths.iter().any(|a| is_within(path, a)) {
390 Ok(())
391 } else {
392 Err(SecurityError::SandboxDenied(format!(
393 "read of `{}` is outside the allowed read roots",
394 path.display()
395 )))
396 }
397 }
398
399 fn check_write(&self, path: &Path) -> Result<(), SecurityError> {
400 reject_parent_dir(path)?;
401 if self.allowed_write_paths.iter().any(|a| is_within(path, a)) {
402 Ok(())
403 } else {
404 Err(SecurityError::SandboxDenied(format!(
405 "write of `{}` is outside the allowed write roots",
406 path.display()
407 )))
408 }
409 }
410
411 fn check_network(&self, host: &str) -> Result<(), SecurityError> {
412 if self.allowed_domains.iter().any(|d| domain_allows(host, d)) {
413 Ok(())
414 } else {
415 Err(SecurityError::SandboxDenied(format!(
416 "network access to `{host}` is not allowed"
417 )))
418 }
419 }
420}
421
422fn reject_parent_dir(path: &Path) -> Result<(), SecurityError> {
429 if path.components().any(|c| matches!(c, Component::ParentDir)) {
430 return Err(SecurityError::SandboxDenied(format!(
431 "path `{}` contains `..`; use a normalized path",
432 path.display()
433 )));
434 }
435 Ok(())
436}
437
438fn normalize(path: &Path) -> PathBuf {
450 let mut ancestor: &Path = path;
451 let mut missing: Vec<std::ffi::OsString> = Vec::new();
452 loop {
453 match std::fs::canonicalize(ancestor) {
454 Ok(canonical) => {
455 let mut base = canonical;
456 for component in missing.iter().rev() {
457 base.push(component);
458 }
459 return base;
460 }
461 Err(_) => match (ancestor.file_name(), ancestor.parent()) {
462 (Some(name), Some(parent)) => {
463 missing.push(name.to_os_string());
464 ancestor = parent;
465 }
466 _ => return path.to_path_buf(),
469 },
470 }
471 }
472}
473
474fn is_within(path: &Path, root: &Path) -> bool {
476 let path = normalize(path);
477 let root = normalize(root);
478 path == root || path.starts_with(&root)
479}
480
481fn domain_allows(host: &str, domain: &str) -> bool {
483 let domain = domain.trim_start_matches('.');
484 host == domain || host.ends_with(&format!(".{domain}"))
485}
486
487#[cfg(test)]
488mod tests {
489 use super::*;
490 use crate::client::sign_agent_card;
491
492 fn card(url: &str) -> AgentCard {
493 AgentCard::new("agent", "desc", url)
494 }
495
496 fn key() -> Vec<u8> {
497 b"trust-secret".to_vec()
498 }
499
500 #[test]
503 fn registry_verifies_known_signed_card() {
504 let mut c = card("https://a.example.com");
505 sign_agent_card(&mut c, &key()).unwrap();
506
507 let registry = TrustRegistry::new(TrustConfig::new(3, 0.9)).with_agent(
508 TrustedAgent::new("https://a.example.com", "A", TrustRole::Leaf).with_key(key()),
509 );
510 let v = registry.verify_card(&c).unwrap();
511 assert_eq!(v.url, "https://a.example.com");
512 assert_eq!(v.role, TrustRole::Leaf);
513 assert_eq!(v.trust_score, 0.6);
514 }
515
516 #[test]
517 fn registry_rejects_unknown_agent() {
518 let mut c = card("https://stranger.example.com");
519 sign_agent_card(&mut c, &key()).unwrap();
520 let registry = TrustRegistry::new(TrustConfig::new(3, 0.9));
521 assert!(matches!(
522 registry.verify_card(&c),
523 Err(SecurityError::UntrustedAgent(_))
524 ));
525 }
526
527 #[test]
528 fn registry_rejects_revoked_agent() {
529 let mut c = card("https://a.example.com");
530 sign_agent_card(&mut c, &key()).unwrap();
531 let registry = TrustRegistry::new(TrustConfig::new(3, 0.9))
532 .with_agent(
533 TrustedAgent::new("https://a.example.com", "A", TrustRole::Leaf).with_key(key()),
534 )
535 .revoke("https://a.example.com");
536 assert!(matches!(
537 registry.verify_card(&c),
538 Err(SecurityError::RevokedAgent(_))
539 ));
540 assert!(!registry.is_trusted("https://a.example.com"));
541 }
542
543 #[test]
544 fn registry_rejects_tampered_signature() {
545 let mut c = card("https://a.example.com");
546 sign_agent_card(&mut c, &key()).unwrap();
547 c.description = "evil".to_string(); let registry = TrustRegistry::new(TrustConfig::new(3, 0.9)).with_agent(
550 TrustedAgent::new("https://a.example.com", "A", TrustRole::Leaf).with_key(key()),
551 );
552 assert!(matches!(
553 registry.verify_card(&c),
554 Err(SecurityError::SignatureMismatch(_, _))
555 ));
556 }
557
558 #[test]
559 fn issue_card_signs_child_with_parent_key() {
560 let registry = TrustRegistry::new(TrustConfig::new(3, 0.9))
561 .with_agent(
562 TrustedAgent::new("https://root.example.com", "Root", TrustRole::Root)
563 .with_key(key()),
564 )
565 .with_agent(
568 TrustedAgent::new("https://child.example.com", "Child", TrustRole::Leaf)
569 .with_key(b"child-key"),
570 );
571 let mut child = card("https://child.example.com");
572 registry
573 .issue_card("https://root.example.com", &mut child)
574 .unwrap();
575 assert!(matches!(
578 registry.verify_card(&child),
579 Err(SecurityError::SignatureMismatch(_, _))
580 ));
581 let mut root = card("https://root.example.com");
583 sign_agent_card(&mut root, &key()).unwrap();
584 let chain = registry.verify_chain(&[&root, &child]).unwrap();
585 assert_eq!(chain.trust_score, 0.9); }
587
588 #[test]
589 fn verify_chain_enforces_depth_limit() {
590 let config = TrustConfig::new(2, 0.9);
592 let registry = TrustRegistry::new(config.clone())
593 .with_agent(TrustedAgent::new("https://r.com", "R", TrustRole::Root).with_key(key()))
594 .with_agent(
595 TrustedAgent::new("https://i.com", "I", TrustRole::Intermediate).with_key(key()),
596 )
597 .with_agent(
598 TrustedAgent::new("https://i2.com", "I2", TrustRole::Intermediate).with_key(key()),
599 )
600 .with_agent(TrustedAgent::new("https://l.com", "L", TrustRole::Leaf).with_key(key()));
601
602 let mut i = card("https://i.com");
604 registry.issue_card("https://r.com", &mut i).unwrap();
605 let mut root = card("https://r.com");
606 sign_agent_card(&mut root, &key()).unwrap();
607 assert!(registry.verify_chain(&[&root, &i]).is_ok());
608
609 let mut i2 = card("https://i2.com");
611 registry.issue_card("https://i.com", &mut i2).unwrap();
612 let mut l = card("https://l.com");
613 registry.issue_card("https://i2.com", &mut l).unwrap();
614 assert!(matches!(
615 registry.verify_chain(&[&root, &i, &i2, &l]),
616 Err(SecurityError::DeepDelegation {
617 depth: 3,
618 limit: 2,
619 ..
620 })
621 ));
622
623 let wide = TrustRegistry::new(TrustConfig::new(3, 0.5))
625 .with_agent(TrustedAgent::new("https://r.com", "R", TrustRole::Root).with_key(key()))
626 .with_agent(
627 TrustedAgent::new("https://i.com", "I", TrustRole::Intermediate).with_key(key()),
628 )
629 .with_agent(
630 TrustedAgent::new("https://i2.com", "I2", TrustRole::Intermediate).with_key(key()),
631 )
632 .with_agent(TrustedAgent::new("https://l.com", "L", TrustRole::Leaf).with_key(key()));
633 let v = wide.verify_chain(&[&root, &i, &i2, &l]).unwrap();
634 assert!((v.trust_score - 0.125).abs() < 1e-9);
636 }
637
638 #[test]
639 fn verify_chain_rejects_child_signed_by_wrong_parent() {
640 let registry = TrustRegistry::new(TrustConfig::new(2, 0.9))
641 .with_agent(TrustedAgent::new("https://r.com", "R", TrustRole::Root).with_key(key()))
642 .with_agent(
643 TrustedAgent::new("https://i.com", "I", TrustRole::Intermediate).with_key(b"other"),
644 );
645
646 let mut root = card("https://r.com");
648 sign_agent_card(&mut root, &key()).unwrap();
649 let mut child = card("https://i.com");
652 sign_agent_card(&mut child, b"other").unwrap();
653 assert!(matches!(
654 registry.verify_chain(&[&root, &child]),
655 Err(SecurityError::SignatureMismatch(_, _))
656 ));
657 }
658
659 #[test]
660 fn trust_score_returns_none_for_unknown_or_revoked() {
661 let registry = TrustRegistry::new(TrustConfig::new(2, 0.9))
662 .with_agent(TrustedAgent::new("https://a.com", "A", TrustRole::Leaf).with_key(key()))
663 .revoke("https://a.com");
664 assert_eq!(registry.trust_score("https://a.com", 0), None);
665 assert_eq!(registry.trust_score("https://nope.com", 0), None);
666 }
667
668 #[test]
671 fn sandbox_allows_reads_inside_allowed_root() {
672 let sandbox = SandboxConfig::new().allow_read("C:/data");
673 assert!(sandbox
674 .check(&AccessRequest::ReadPath("C:/data/file.txt".into()))
675 .is_ok());
676 assert!(sandbox
677 .check(&AccessRequest::ReadPath("C:/data".into()))
678 .is_ok());
679 }
680
681 #[test]
682 fn sandbox_denies_reads_outside_allowed_root() {
683 let sandbox = SandboxConfig::new().allow_read("C:/data");
684 assert!(matches!(
685 sandbox.check(&AccessRequest::ReadPath("C:/other/secret.txt".into())),
686 Err(SecurityError::SandboxDenied(_))
687 ));
688 }
689
690 #[test]
691 fn sandbox_read_and_write_roots_are_separate() {
692 let sandbox = SandboxConfig::new()
693 .allow_read("C:/in")
694 .allow_write("C:/out");
695 assert!(sandbox
696 .check(&AccessRequest::ReadPath("C:/in/a.txt".into()))
697 .is_ok());
698 assert!(sandbox
700 .check(&AccessRequest::ReadPath("C:/out/a.txt".into()))
701 .is_err());
702 assert!(sandbox
703 .check(&AccessRequest::WritePath("C:/in/a.txt".into()))
704 .is_err());
705 assert!(sandbox
706 .check(&AccessRequest::WritePath("C:/out/a.txt".into()))
707 .is_ok());
708 }
709
710 #[test]
711 fn sandbox_network_allows_exact_and_subdomains() {
712 let sandbox = SandboxConfig::new().allow_domain("example.com");
713 assert!(sandbox
714 .check(&AccessRequest::Network("example.com".into()))
715 .is_ok());
716 assert!(sandbox
717 .check(&AccessRequest::Network("api.example.com".into()))
718 .is_ok());
719 assert!(sandbox
720 .check(&AccessRequest::Network("evil.net".into()))
721 .is_err());
722 assert!(sandbox
723 .check(&AccessRequest::Network("notexample.com".into()))
724 .is_err());
725 }
726
727 #[test]
728 fn sandbox_enforces_payload_limit() {
729 let sandbox = SandboxConfig::new().with_max_payload(100);
730 assert!(sandbox.accepts_payload(99));
731 assert!(sandbox.accepts_payload(100));
732 assert!(!sandbox.accepts_payload(101));
733 let unbounded = SandboxConfig::new();
734 assert!(unbounded.accepts_payload(usize::MAX));
735 }
736
737 #[test]
738 fn sandbox_denies_dotdot_traversal() {
739 let sandbox = SandboxConfig::new().allow_read("C:/data");
742 assert!(matches!(
743 sandbox.check(&AccessRequest::ReadPath("C:/data/../secret.txt".into())),
744 Err(SecurityError::SandboxDenied(_))
745 ));
746 assert!(matches!(
747 sandbox.check(&AccessRequest::WritePath(
748 "C:/data/../../etc/cron.d/evil".into()
749 )),
750 Err(SecurityError::SandboxDenied(_))
751 ));
752 assert!(matches!(
755 sandbox.check(&AccessRequest::ReadPath("C:/data/sub/../file.txt".into())),
756 Err(SecurityError::SandboxDenied(_))
757 ));
758 }
759
760 fn temp_root(tag: &str) -> std::path::PathBuf {
761 use std::sync::atomic::{AtomicUsize, Ordering};
762 static COUNTER: AtomicUsize = AtomicUsize::new(0);
763 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
764 std::env::temp_dir().join(format!("lc-a2a-sandbox-{tag}-{}-{n}", std::process::id()))
765 }
766
767 #[test]
768 fn sandbox_resolves_real_paths() {
769 let base = temp_root("real");
770 let root = base.join("data");
771 std::fs::create_dir_all(&root).unwrap();
772 let inside = root.join("file.txt");
773 std::fs::write(&inside, b"x").unwrap();
774 let outside = base.join("secret.txt");
775 std::fs::write(&outside, b"s").unwrap();
776
777 let sandbox = SandboxConfig::new().allow_read(root.clone());
778 assert!(sandbox.check(&AccessRequest::ReadPath(inside)).is_ok());
779 assert!(matches!(
780 sandbox.check(&AccessRequest::ReadPath(outside)),
781 Err(SecurityError::SandboxDenied(_))
782 ));
783
784 #[cfg(unix)]
787 {
788 let link = root.join("link");
789 std::os::unix::fs::symlink(&base.join("secret.txt"), &link).unwrap();
790 assert!(matches!(
791 sandbox.check(&AccessRequest::ReadPath(link)),
792 Err(SecurityError::SandboxDenied(_))
793 ));
794 }
795
796 let _ = std::fs::remove_dir_all(&base);
797 }
798
799 #[test]
800 fn sandbox_allows_writing_new_file_inside_root() {
801 let base = temp_root("write-new");
802 let root = base.join("data");
803 std::fs::create_dir_all(&root).unwrap();
804
805 let sandbox = SandboxConfig::new().allow_write(root.clone());
806 let fresh = root.join("new.txt");
809 assert!(sandbox.check(&AccessRequest::WritePath(fresh)).is_ok());
810 let outside = base.join("elsewhere.txt");
812 assert!(matches!(
813 sandbox.check(&AccessRequest::WritePath(outside)),
814 Err(SecurityError::SandboxDenied(_))
815 ));
816
817 let _ = std::fs::remove_dir_all(&base);
818 }
819}