Skip to main content

chio_guards/
egress_allowlist.rs

1//! Egress allowlist guard -- controls network egress by domain.
2//!
3//! Adapted from ClawdStrike's `guards/egress_allowlist.rs`.  The domain
4//! matching logic is reimplemented here without the `hush_proxy::DomainPolicy`
5//! dependency, using simple glob matching instead.
6
7use glob::Pattern;
8
9use chio_kernel::{GuardContext, KernelError, Verdict};
10
11use crate::action::{extract_action, ToolAction};
12
13/// Errors produced when building an [`EgressAllowlistGuard`].
14#[derive(Debug, thiserror::Error)]
15pub enum EgressAllowlistConfigError {
16    /// An allowlist pattern was not a valid glob.
17    #[error("invalid egress allowlist pattern `{pattern}`: {source}")]
18    InvalidAllowPattern {
19        pattern: String,
20        #[source]
21        source: glob::PatternError,
22    },
23    /// A blocklist pattern was not a valid glob.
24    #[error("invalid egress blocklist pattern `{pattern}`: {source}")]
25    InvalidBlockPattern {
26        pattern: String,
27        #[source]
28        source: glob::PatternError,
29    },
30}
31
32fn default_allow_patterns() -> Vec<String> {
33    vec![
34        // Common AI/ML APIs
35        "*.openai.com".to_string(),
36        "*.anthropic.com".to_string(),
37        "api.github.com".to_string(),
38        // Package registries
39        "*.npmjs.org".to_string(),
40        "registry.npmjs.org".to_string(),
41        "pypi.org".to_string(),
42        "files.pythonhosted.org".to_string(),
43        "crates.io".to_string(),
44        "static.crates.io".to_string(),
45    ]
46}
47
48/// Guard that controls network egress via domain allowlist.
49///
50/// By default, only well-known AI API and package registry domains are
51/// allowed. All other egress is denied (fail-closed).
52pub struct EgressAllowlistGuard {
53    allow_patterns: Vec<Pattern>,
54    block_patterns: Vec<Pattern>,
55}
56
57impl EgressAllowlistGuard {
58    pub fn new() -> Self {
59        match Self::with_lists(default_allow_patterns(), vec![]) {
60            Ok(guard) => guard,
61            Err(error) => panic!("default egress patterns must be valid: {error}"),
62        }
63    }
64
65    pub fn with_lists(
66        allow: Vec<String>,
67        block: Vec<String>,
68    ) -> Result<Self, EgressAllowlistConfigError> {
69        let allow_patterns = allow
70            .into_iter()
71            .map(|pattern| {
72                Pattern::new(&pattern).map_err(|source| {
73                    EgressAllowlistConfigError::InvalidAllowPattern { pattern, source }
74                })
75            })
76            .collect::<Result<Vec<_>, _>>()?;
77        let block_patterns = block
78            .into_iter()
79            .map(|pattern| {
80                Pattern::new(&pattern).map_err(|source| {
81                    EgressAllowlistConfigError::InvalidBlockPattern { pattern, source }
82                })
83            })
84            .collect::<Result<Vec<_>, _>>()?;
85        Ok(Self {
86            allow_patterns,
87            block_patterns,
88        })
89    }
90
91    pub fn is_allowed(&self, domain: &str) -> bool {
92        let domain = domain.to_lowercase();
93
94        // Block list takes precedence.
95        for pattern in &self.block_patterns {
96            if pattern.matches(&domain) {
97                return false;
98            }
99        }
100
101        // Check allow list.
102        for pattern in &self.allow_patterns {
103            if pattern.matches(&domain) {
104                return true;
105            }
106        }
107
108        // Default: deny (fail-closed).
109        false
110    }
111}
112
113impl Default for EgressAllowlistGuard {
114    fn default() -> Self {
115        Self::new()
116    }
117}
118
119impl chio_kernel::Guard for EgressAllowlistGuard {
120    fn name(&self) -> &str {
121        "egress-allowlist"
122    }
123
124    fn evaluate(&self, ctx: &GuardContext) -> Result<Verdict, KernelError> {
125        let action = extract_action(&ctx.request.tool_name, &ctx.request.arguments);
126
127        let host = match &action {
128            ToolAction::NetworkEgress(h, _) => h.as_str(),
129            _ => return Ok(Verdict::Allow),
130        };
131
132        if self.is_allowed(host) {
133            Ok(Verdict::Allow)
134        } else {
135            Ok(Verdict::Deny)
136        }
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn allows_default_domains() {
146        let guard = EgressAllowlistGuard::new();
147        assert!(guard.is_allowed("api.openai.com"));
148        assert!(guard.is_allowed("api.anthropic.com"));
149        assert!(guard.is_allowed("api.github.com"));
150        assert!(guard.is_allowed("registry.npmjs.org"));
151    }
152
153    #[test]
154    fn blocks_unknown_domains() {
155        let guard = EgressAllowlistGuard::new();
156        assert!(!guard.is_allowed("evil.com"));
157        assert!(!guard.is_allowed("random-site.org"));
158        assert!(!guard.is_allowed("malware.bad"));
159    }
160
161    #[test]
162    fn block_list_takes_precedence() {
163        let guard = EgressAllowlistGuard::with_lists(
164            vec!["*.mycompany.com".to_string()],
165            vec!["blocked.mycompany.com".to_string()],
166        )
167        .expect("valid egress patterns");
168        assert!(guard.is_allowed("api.mycompany.com"));
169        assert!(!guard.is_allowed("blocked.mycompany.com"));
170        assert!(!guard.is_allowed("other.com"));
171    }
172
173    #[test]
174    fn wildcard_subdomain_matching() {
175        let guard = EgressAllowlistGuard::with_lists(vec!["*.example.com".to_string()], vec![])
176            .expect("valid egress patterns");
177        assert!(guard.is_allowed("api.example.com"));
178        assert!(guard.is_allowed("www.example.com"));
179        // Bare domain does not match *.example.com with glob
180        assert!(!guard.is_allowed("example.com"));
181    }
182
183    #[test]
184    fn rejects_invalid_block_pattern() {
185        let error = match EgressAllowlistGuard::with_lists(
186            vec!["*.example.com".to_string()],
187            vec!["[".to_string()],
188        ) {
189            Ok(_) => panic!("invalid block pattern should fail"),
190            Err(error) => error,
191        };
192        assert!(error
193            .to_string()
194            .contains("invalid egress blocklist pattern"));
195    }
196}