chio_guards/
egress_allowlist.rs1use glob::Pattern;
8
9use chio_kernel::{GuardContext, KernelError, Verdict};
10
11use crate::action::{extract_action, ToolAction};
12
13#[derive(Debug, thiserror::Error)]
15pub enum EgressAllowlistConfigError {
16 #[error("invalid egress allowlist pattern `{pattern}`: {source}")]
18 InvalidAllowPattern {
19 pattern: String,
20 #[source]
21 source: glob::PatternError,
22 },
23 #[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 "*.openai.com".to_string(),
36 "*.anthropic.com".to_string(),
37 "api.github.com".to_string(),
38 "*.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
48pub 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 for pattern in &self.block_patterns {
96 if pattern.matches(&domain) {
97 return false;
98 }
99 }
100
101 for pattern in &self.allow_patterns {
103 if pattern.matches(&domain) {
104 return true;
105 }
106 }
107
108 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 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}