Skip to main content

agentshield/egress/policy/
domain.rs

1use serde::{Deserialize, Serialize};
2
3/// Domain-level allow/deny policy using glob-style patterns.
4#[derive(Debug, Clone, Serialize, Deserialize)]
5pub struct DomainPolicy {
6    /// Allowed domain patterns (glob-style: `"*.example.com"`, `"api.github.com"`).
7    #[serde(default)]
8    pub allow: Vec<String>,
9    /// Explicitly denied domain patterns (takes precedence over allow).
10    #[serde(default)]
11    pub deny: Vec<String>,
12}
13
14impl DomainPolicy {
15    /// Check if a domain is allowed by this policy.
16    ///
17    /// Deny rules take precedence over allow rules. If the allow list is
18    /// empty, all domains not explicitly denied are allowed.
19    pub(super) fn is_domain_allowed(&self, domain: &str) -> bool {
20        // Deny takes precedence
21        if self
22            .deny
23            .iter()
24            .any(|pattern| domain_matches(domain, pattern))
25        {
26            return false;
27        }
28        // If allow list is empty, allow all (that aren't denied)
29        if self.allow.is_empty() {
30            return true;
31        }
32        self.allow
33            .iter()
34            .any(|pattern| domain_matches(domain, pattern))
35    }
36}
37
38/// Simple glob matching for domain patterns.
39///
40/// Supports `*.example.com` (matches `sub.example.com` and `example.com`)
41/// and exact matches like `api.github.com`.
42pub(super) fn domain_matches(domain: &str, pattern: &str) -> bool {
43    if let Some(suffix) = pattern.strip_prefix('*') {
44        // "*.example.com" matches "sub.example.com" and "example.com"
45        domain.ends_with(suffix) || domain == &suffix[1..]
46    } else {
47        domain == pattern
48    }
49}
50
51/// Extract the hostname from a URL string or bare domain.
52///
53/// Handles `http://`, `https://` URLs (strips scheme, path, port) and bare
54/// domain names (e.g., `"api.example.com"`). Returns `None` for strings that
55/// cannot be mapped to a useful hostname (e.g., paths, IP-like without dot).
56pub(super) fn extract_domain(url_or_domain: &str) -> Option<String> {
57    // Try stripping http:// or https://
58    let rest = if let Some(r) = url_or_domain.strip_prefix("https://") {
59        r
60    } else if let Some(r) = url_or_domain.strip_prefix("http://") {
61        r
62    } else {
63        // Bare domain: must contain a dot and no slashes
64        if url_or_domain.contains('.') && !url_or_domain.contains('/') {
65            return Some(url_or_domain.to_string());
66        }
67        return None;
68    };
69
70    // Take the host portion (before first '/')
71    let host = rest.split('/').next()?;
72    // Strip port if present
73    let host = host.split(':').next()?;
74
75    if host.is_empty() {
76        return None;
77    }
78    Some(host.to_string())
79}