Skip to main content

lc_a2a/
security.rs

1//! P2-5: defense against malicious agents.
2//!
3//! A4A agents can be impersonated, tampered with, or hostile. This module
4//! provides the building blocks to trust only what you should:
5//!
6//! - **Trust directory** — [`TrustRegistry`] maps agent identities (their card
7//!   `url`) to the verification key the directory attests, and keeps a
8//!   revocation list (CRL). Cards are only trusted when they come from a known
9//!   agent and their signature verifies against the registered key.
10//! - **Trust chain propagation** — [`TrustRegistry::verify_chain`] walks a
11//!   delegation path root → … → leaf, verifying every hop was signed by its
12//!   parent, enforcing a maximum delegation depth and decaying the trust score
13//!   with each hop ([`TrustConfig`]).
14//! - **Least privilege** — [`SandboxConfig`] restricts an agent's file/network
15//!   access and payload size, checked via [`SandboxConfig::check`].
16//!
17//! Signature primitives are the HMAC-SHA256 card signatures shared with the
18//! client (`sign_agent_card` / `verify_card_signature`), so a registry and a
19//! client can agree on the same key material.
20
21use 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/// Error raised when a security check fails.
28#[derive(Debug, thiserror::Error)]
29#[non_exhaustive]
30pub enum SecurityError {
31    /// The agent is not in the trust registry at all.
32    #[error("agent `{0}` is not in the trust registry")]
33    UntrustedAgent(String),
34    /// The agent was issued a certificate but has since been revoked.
35    #[error("agent `{0}` has been revoked")]
36    RevokedAgent(String),
37    /// The card's signature did not verify against the expected key.
38    #[error("signature verification failed for `{0}`: {1}")]
39    SignatureMismatch(String, String),
40    /// A delegation path exceeds the configured depth limit.
41    #[error("delegation depth {depth} for `{url}` exceeds the limit {limit}")]
42    DeepDelegation {
43        /// The agent URL being delegated to.
44        url: String,
45        /// The actual delegation depth.
46        depth: usize,
47        /// The configured maximum delegation depth.
48        limit: usize,
49    },
50    /// The key material registered for an agent is unusable.
51    #[error("invalid key for `{0}`: {1}")]
52    InvalidKey(String, String),
53    /// The sandbox denied a requested access.
54    #[error("sandbox denied: {0}")]
55    SandboxDenied(String),
56    /// A payload exceeded the sandbox's size limit.
57    #[error("payload of {size} bytes exceeds the {limit} byte limit")]
58    PayloadTooLarge {
59        /// Actual payload size in bytes.
60        size: usize,
61        /// Maximum allowed payload size in bytes.
62        limit: usize,
63    },
64}
65
66/// Role of an agent in a trust hierarchy. Drives the base trust score before
67/// hop decay is applied.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub enum TrustRole {
70    /// Fully trusted issuer (self-attested, top of a delegation chain).
71    Root,
72    /// Trusted intermediary that delegates to others.
73    Intermediate,
74    /// Endpoint agent that performs work but does not delegate.
75    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/// An agent the registry trusts, with the key used to verify its card.
89#[derive(Debug, Clone)]
90pub struct TrustedAgent {
91    /// Identity anchor — must match the card's `url`.
92    pub url: String,
93    /// Human-readable name.
94    pub name: String,
95    /// Role in the trust hierarchy.
96    pub role: TrustRole,
97    /// HMAC-SHA256 verification key (the same secret used to sign the card).
98    pub verification_key: Vec<u8>,
99}
100
101impl TrustedAgent {
102    /// Create a trusted agent entry.
103    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    /// Set the verification key (builder style).
113    pub fn with_key(mut self, key: impl Into<Vec<u8>>) -> Self {
114        self.verification_key = key.into();
115        self
116    }
117}
118
119/// Delegation-depth and trust-decay policy for chain verification.
120#[derive(Debug, Clone)]
121pub struct TrustConfig {
122    /// Maximum number of delegation hops (edges) allowed on a chain.
123    pub max_delegation_depth: usize,
124    /// Multiplicative trust factor applied per hop, in `(0.0, 1.0]`.
125    pub trust_decay: f64,
126}
127
128impl Default for TrustConfig {
129    /// Conservative default: depth 3, 0.9 decay per hop.
130    fn default() -> Self {
131        Self::new(3, 0.9)
132    }
133}
134
135impl TrustConfig {
136    /// Create a config with a depth limit and a per-hop decay factor.
137    ///
138    /// # Panics
139    /// Panics if `trust_decay` is not in `(0.0, 1.0]`.
140    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    /// Trust score after `hops` delegation hops from a base score.
152    pub fn effective_trust(&self, base: f64, hops: usize) -> f64 {
153        base * self.trust_decay.powi(hops as i32)
154    }
155}
156
157/// Result of a successful trust verification.
158#[derive(Debug, Clone)]
159pub struct TrustVerification {
160    /// Identity of the verified agent.
161    pub url: String,
162    /// Its role in the hierarchy.
163    pub role: TrustRole,
164    /// Effective trust score after depth/decay adjustments.
165    pub trust_score: f64,
166}
167
168/// Trust directory: known agents, a revocation list (CRL), and chain policy.
169///
170/// Immutable after construction (builder methods return a new registry), so it
171/// can be shared freely behind an `Arc`.
172#[derive(Debug, Default)]
173pub struct TrustRegistry {
174    agents: HashMap<String, TrustedAgent>,
175    revoked: HashSet<String>,
176    config: TrustConfig,
177}
178
179impl TrustRegistry {
180    /// Create an empty registry with the given policy.
181    pub fn new(config: TrustConfig) -> Self {
182        Self {
183            agents: HashMap::new(),
184            revoked: HashSet::new(),
185            config,
186        }
187    }
188
189    /// Register a trusted agent (builder style).
190    pub fn with_agent(mut self, agent: TrustedAgent) -> Self {
191        self.agents.insert(agent.url.clone(), agent);
192        self
193    }
194
195    /// Revoke an agent's credentials (CRL). Returns a new registry.
196    pub fn revoke(mut self, url: &str) -> Self {
197        self.revoked.insert(url.to_string());
198        self
199    }
200
201    /// Whether `url` is a known, non-revoked agent.
202    pub fn is_trusted(&self, url: &str) -> bool {
203        self.agents.contains_key(url) && !self.revoked.contains(url)
204    }
205
206    /// Verify a single card against the registry entry for its own `url`.
207    ///
208    /// The directory attests to the agent's identity: the card must belong to a
209    /// known, non-revoked agent and its signature must verify against the key
210    /// registered for that agent.
211    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        // Unlike the client (which tolerates unsigned cards with a warning), a
220        // registry check REQUIRES a signature — an unsigned card has nothing
221        // tying it to the registered key.
222        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    /// Verify a delegation chain `root -> … -> leaf` of cards.
238    ///
239    /// Every hop must be a known, non-revoked agent. The root card must verify
240    /// against its own registered key; each subsequent card must verify against
241    /// the *previous* hop's key (the parent issued the child's certificate).
242    /// The number of hops is bounded by [`TrustConfig::max_delegation_depth`],
243    /// and the returned score is decayed once per hop.
244    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        // Root: self-attested against the registry's key for that agent.
258        let root = self.verify_card(cards[0])?;
259
260        // Each child must be signed by its parent. The parent is trusted
261        // (verified above or in the previous iteration), so its key is used.
262        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    /// Effective trust score for a known agent after `hops` of delegation, or
289    /// `None` if the agent is unknown or revoked.
290    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    /// Convenience: sign a card as "issued" by `issuer_url` (certificate
299    /// issuance analog). The card's `signature` is computed with the issuer's
300    /// registered key, which is what [`Self::verify_chain`] expects of a child.
301    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/// A resource access attempt to be checked against a [`SandboxConfig`].
314#[derive(Debug, Clone, PartialEq, Eq)]
315pub enum AccessRequest {
316    /// Read a file/directory.
317    ReadPath(PathBuf),
318    /// Write a file/directory.
319    WritePath(PathBuf),
320    /// Contact a network host.
321    Network(String),
322}
323
324/// Least-privilege limits for a delegated agent (P2-5 sandbox).
325///
326/// Path checks are prefix-based: a request is allowed when it is the allowed
327/// directory itself or lives underneath it. Before the prefix test, a request
328/// path containing `..` is rejected outright, and both sides are resolved
329/// against the filesystem so a symlink inside the root cannot smuggle a path
330/// outside it. Network checks allow the host exactly, or any subdomain of an
331/// allowed domain.
332#[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    /// An empty sandbox that denies everything.
342    pub fn new() -> Self {
343        Self::default()
344    }
345
346    /// Allow reading within `path` (builder style).
347    pub fn allow_read(mut self, path: impl Into<PathBuf>) -> Self {
348        self.allowed_read_paths.push(path.into());
349        self
350    }
351
352    /// Allow writing within `path` (builder style).
353    pub fn allow_write(mut self, path: impl Into<PathBuf>) -> Self {
354        self.allowed_write_paths.push(path.into());
355        self
356    }
357
358    /// Allow network access to `domain` and its subdomains (builder style).
359    pub fn allow_domain(mut self, domain: impl Into<String>) -> Self {
360        self.allowed_domains.push(domain.into());
361        self
362    }
363
364    /// Cap the payload size accepted from the agent (builder style).
365    pub fn with_max_payload(mut self, bytes: usize) -> Self {
366        self.max_payload_bytes = Some(bytes);
367        self
368    }
369
370    /// Whether a payload of `size` bytes is within the configured limit.
371    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    /// Enforce the sandbox for a single access request.
379    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
422/// Reject a request path that climbs out of its sandbox root with `..`.
423///
424/// `Path::starts_with` is a *lexical* component-prefix test: `C:/data/../secret`
425/// matches the prefix `C:/data`, but the OS resolves it to `C:/secret`. The
426/// sandbox never trusts a `..` in an external request, even when it would
427/// resolve back inside the root — the caller must pass a normalized path.
428fn 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
438/// Resolve `path` against the filesystem so symlinks cannot smuggle a request
439/// outside its sandbox root, normalizing both sides of the prefix test the
440/// same way.
441///
442/// Canonicalizes the deepest ancestor that exists on disk, then re-attaches the
443/// remaining lexical components. That keeps the two sides consistent even when
444/// they exist at different depths: a not-yet-created `C:/data` and a
445/// `C:/data/file.txt` both resolve to the canonical `C:\` drive root plus their
446/// own remaining components, so the prefix test still holds. Falls back to the
447/// lexical path when nothing on disk resolves — `..` was already rejected and
448/// the prefix comparison remains as a final defense.
449fn 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                // Reached a path with no parent (e.g. a bare relative name):
467                // nothing on disk resolves, so fall back to the lexical path.
468                _ => return path.to_path_buf(),
469            },
470        }
471    }
472}
473
474/// Whether `path` is `root` or lives underneath it, after symlink resolution.
475fn 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
481/// Whether `host` is `domain` exactly or one of its subdomains.
482fn 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    // ---- Trust registry ----
501
502    #[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(); // tamper after signing
548
549        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            // Child is registered with its OWN key, which differs from the key
566            // the parent uses to issue it.
567            .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        // The child's card is signed by the parent, so direct verification
576        // against the child's own registered key fails…
577        assert!(matches!(
578            registry.verify_card(&child),
579            Err(SecurityError::SignatureMismatch(_, _))
580        ));
581        // …but a chain verifies the parent-issued signature (root self-signed).
582        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); // 1.0 * 0.9^1
586    }
587
588    #[test]
589    fn verify_chain_enforces_depth_limit() {
590        // Build a chain of 4 cards = 3 hops, signed parent->child.
591        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        // root -> i: 1 hop (allowed).
603        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        // root -> i -> i2 -> l: 3 hops (exceeds limit 2).
610        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        // Relaxed config allows it and decays the score per hop.
624        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        // root base 1.0, decayed 0.5^3 = 0.125.
635        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        // Root self-signed with its registered key.
647        let mut root = card("https://r.com");
648        sign_agent_card(&mut root, &key()).unwrap();
649        // Child signed by its OWN key, which differs from the parent's
650        // registered key — the chain must reject the parent-issued claim.
651        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    // ---- Sandbox ----
669
670    #[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        // Read of the write-only root is denied, and vice versa.
699        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        // `..` must never be accepted, even when the lexical prefix matches —
740        // this is the exact sandbox escape reported for `C:/data/../secret`.
741        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        // A path that would resolve back inside is still rejected: the sandbox
753        // does not trust `..`, regardless of where it resolves.
754        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        // A symlink inside the root pointing outside must be denied. Skipped on
785        // platforms where creating symlinks needs privileges (Windows admin).
786        #[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        // A fresh file that does not exist yet: parent is resolved and the file
807        // name re-attached — still allowed, no symlink to escape through.
808        let fresh = root.join("new.txt");
809        assert!(sandbox.check(&AccessRequest::WritePath(fresh)).is_ok());
810        // Writing outside the root is denied even when the target does not exist.
811        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}