use glob::Pattern;
use chio_kernel::{GuardContext, KernelError, Verdict};
use crate::action::{extract_action, ToolAction};
#[derive(Debug, thiserror::Error)]
pub enum EgressAllowlistConfigError {
#[error("invalid egress allowlist pattern `{pattern}`: {source}")]
InvalidAllowPattern {
pattern: String,
#[source]
source: glob::PatternError,
},
#[error("invalid egress blocklist pattern `{pattern}`: {source}")]
InvalidBlockPattern {
pattern: String,
#[source]
source: glob::PatternError,
},
}
fn default_allow_patterns() -> Vec<String> {
vec![
"*.openai.com".to_string(),
"*.anthropic.com".to_string(),
"api.github.com".to_string(),
"*.npmjs.org".to_string(),
"registry.npmjs.org".to_string(),
"pypi.org".to_string(),
"files.pythonhosted.org".to_string(),
"crates.io".to_string(),
"static.crates.io".to_string(),
]
}
pub struct EgressAllowlistGuard {
allow_patterns: Vec<Pattern>,
block_patterns: Vec<Pattern>,
}
impl EgressAllowlistGuard {
pub fn new() -> Self {
match Self::with_lists(default_allow_patterns(), vec![]) {
Ok(guard) => guard,
Err(error) => panic!("default egress patterns must be valid: {error}"),
}
}
pub fn with_lists(
allow: Vec<String>,
block: Vec<String>,
) -> Result<Self, EgressAllowlistConfigError> {
let allow_patterns = allow
.into_iter()
.map(|pattern| {
Pattern::new(&pattern).map_err(|source| {
EgressAllowlistConfigError::InvalidAllowPattern { pattern, source }
})
})
.collect::<Result<Vec<_>, _>>()?;
let block_patterns = block
.into_iter()
.map(|pattern| {
Pattern::new(&pattern).map_err(|source| {
EgressAllowlistConfigError::InvalidBlockPattern { pattern, source }
})
})
.collect::<Result<Vec<_>, _>>()?;
Ok(Self {
allow_patterns,
block_patterns,
})
}
pub fn is_allowed(&self, domain: &str) -> bool {
let domain = domain.to_lowercase();
for pattern in &self.block_patterns {
if pattern.matches(&domain) {
return false;
}
}
for pattern in &self.allow_patterns {
if pattern.matches(&domain) {
return true;
}
}
false
}
}
impl Default for EgressAllowlistGuard {
fn default() -> Self {
Self::new()
}
}
impl chio_kernel::Guard for EgressAllowlistGuard {
fn name(&self) -> &str {
"egress-allowlist"
}
fn evaluate(&self, ctx: &GuardContext) -> Result<Verdict, KernelError> {
let action = extract_action(&ctx.request.tool_name, &ctx.request.arguments);
let host = match &action {
ToolAction::NetworkEgress(h, _) => h.as_str(),
_ => return Ok(Verdict::Allow),
};
if self.is_allowed(host) {
Ok(Verdict::Allow)
} else {
Ok(Verdict::Deny)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn allows_default_domains() {
let guard = EgressAllowlistGuard::new();
assert!(guard.is_allowed("api.openai.com"));
assert!(guard.is_allowed("api.anthropic.com"));
assert!(guard.is_allowed("api.github.com"));
assert!(guard.is_allowed("registry.npmjs.org"));
}
#[test]
fn blocks_unknown_domains() {
let guard = EgressAllowlistGuard::new();
assert!(!guard.is_allowed("evil.com"));
assert!(!guard.is_allowed("random-site.org"));
assert!(!guard.is_allowed("malware.bad"));
}
#[test]
fn block_list_takes_precedence() {
let guard = EgressAllowlistGuard::with_lists(
vec!["*.mycompany.com".to_string()],
vec!["blocked.mycompany.com".to_string()],
)
.expect("valid egress patterns");
assert!(guard.is_allowed("api.mycompany.com"));
assert!(!guard.is_allowed("blocked.mycompany.com"));
assert!(!guard.is_allowed("other.com"));
}
#[test]
fn wildcard_subdomain_matching() {
let guard = EgressAllowlistGuard::with_lists(vec!["*.example.com".to_string()], vec![])
.expect("valid egress patterns");
assert!(guard.is_allowed("api.example.com"));
assert!(guard.is_allowed("www.example.com"));
assert!(!guard.is_allowed("example.com"));
}
#[test]
fn rejects_invalid_block_pattern() {
let error = match EgressAllowlistGuard::with_lists(
vec!["*.example.com".to_string()],
vec!["[".to_string()],
) {
Ok(_) => panic!("invalid block pattern should fail"),
Err(error) => error,
};
assert!(error
.to_string()
.contains("invalid egress blocklist pattern"));
}
}