Skip to main content

chio_guards/
egress_allowlist.rs

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