chio_guards/
egress_allowlist.rs1use std::sync::OnceLock;
7
8use glob::Pattern;
9
10use chio_kernel::{GuardContext, GuardDecision, KernelError};
11
12use crate::action::{extract_action_checked, ToolAction};
13
14#[derive(Debug, thiserror::Error)]
16pub enum EgressAllowlistConfigError {
17 #[error("invalid egress allowlist pattern `{pattern}`: {source}")]
19 InvalidAllowPattern {
20 pattern: String,
21 #[source]
22 source: glob::PatternError,
23 },
24 #[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 "*.openai.com".to_string(),
37 "*.anthropic.com".to_string(),
38 "api.github.com".to_string(),
39 "*.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#[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 for pattern in &self.block_patterns {
105 if pattern.matches(&domain) {
106 return false;
107 }
108 }
109
110 for pattern in &self.allow_patterns {
112 if pattern.matches(&domain) {
113 return true;
114 }
115 }
116
117 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 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}