use reqwest::Url;
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum EgressPolicyMode {
#[default]
Off,
Warn,
Block,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct EgressPolicyConfig {
#[serde(default)]
pub mode: EgressPolicyMode,
#[serde(default)]
pub allow_hosts: Vec<String>,
#[serde(default)]
pub deny_hosts: Vec<String>,
#[serde(default = "default_block_private_ips")]
pub block_private_ips: bool,
}
fn default_block_private_ips() -> bool {
true
}
impl Default for EgressPolicyConfig {
fn default() -> Self {
Self {
mode: EgressPolicyMode::Off,
allow_hosts: Vec::new(),
deny_hosts: Vec::new(),
block_private_ips: true,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum EgressDecision {
Allow,
Warn(String),
Block(String),
}
impl EgressDecision {
fn violation(config: &EgressPolicyConfig, reason: String) -> Self {
match config.mode {
EgressPolicyMode::Off => Self::Allow,
EgressPolicyMode::Warn => Self::Warn(reason),
EgressPolicyMode::Block => Self::Block(reason),
}
}
}
pub fn evaluate_url(config: &EgressPolicyConfig, raw_url: &str) -> EgressDecision {
if config.mode == EgressPolicyMode::Off {
return EgressDecision::Allow;
}
let parsed = match Url::parse(raw_url) {
Ok(url) => url,
Err(error) => {
return EgressDecision::violation(
config,
format!("invalid outbound URL `{raw_url}`: {error}"),
)
}
};
if !matches!(parsed.scheme(), "http" | "https") {
return EgressDecision::violation(
config,
format!("outbound URL scheme `{}` is not permitted", parsed.scheme()),
);
}
let Some(host) = parsed.host_str().map(|value| value.to_ascii_lowercase()) else {
return EgressDecision::violation(config, "outbound URL has no host".into());
};
if config
.deny_hosts
.iter()
.any(|pattern| host_matches(pattern, &host))
{
return EgressDecision::violation(
config,
format!("outbound host `{host}` matches egress_policy.deny_hosts"),
);
}
if !config.allow_hosts.is_empty()
&& !config
.allow_hosts
.iter()
.any(|pattern| host_matches(pattern, &host))
{
return EgressDecision::violation(
config,
format!("outbound host `{host}` is not in egress_policy.allow_hosts"),
);
}
if config.block_private_ips {
if let Err(error) = crate::internal::tool_runtime::url_safety::check_url_private_ip(&parsed)
{
return EgressDecision::violation(config, format!("egress SSRF guard: {error}"));
}
}
EgressDecision::Allow
}
pub fn evaluate_tool_input(
config: &EgressPolicyConfig,
input: &serde_json::Value,
) -> Vec<(String, EgressDecision)> {
if config.mode == EgressPolicyMode::Off {
return Vec::new();
}
let mut urls = Vec::new();
collect_urls(input, &mut urls);
urls.sort();
urls.dedup();
urls.into_iter()
.map(|url| {
let decision = evaluate_url(config, &url);
(url, decision)
})
.filter(|(_, decision)| !matches!(decision, EgressDecision::Allow))
.collect()
}
fn collect_urls(value: &serde_json::Value, out: &mut Vec<String>) {
match value {
serde_json::Value::String(text) => {
for prefix in ["https://", "http://"] {
let mut rest = text.as_str();
while let Some(index) = rest.find(prefix) {
let candidate = &rest[index..];
let end = candidate
.find(|ch: char| {
ch.is_whitespace()
|| matches!(
ch,
'"' | '\'' | ')' | ']' | '}' | '<' | '>' | '`' | ';' | ','
)
})
.unwrap_or(candidate.len());
let url = candidate[..end]
.trim_end_matches(['.', ':', '!', '?'])
.to_string();
if !url.is_empty() {
out.push(url);
}
rest = &candidate[end..];
if rest.is_empty() {
break;
}
}
}
}
serde_json::Value::Array(values) => {
for value in values {
collect_urls(value, out);
}
}
serde_json::Value::Object(values) => {
for (key, value) in values {
if key != "__microclaw_auth" {
collect_urls(value, out);
}
}
}
_ => {}
}
}
fn host_matches(pattern: &str, host: &str) -> bool {
let pattern = pattern.trim().trim_end_matches('.').to_ascii_lowercase();
if let Some(suffix) = pattern.strip_prefix("*.") {
host != suffix && host.ends_with(&format!(".{suffix}"))
} else {
host == pattern
}
}
#[cfg(test)]
mod tests {
use super::*;
fn blocking() -> EgressPolicyConfig {
EgressPolicyConfig {
mode: EgressPolicyMode::Block,
..Default::default()
}
}
#[test]
fn blocks_private_and_metadata_destinations() {
assert!(matches!(
evaluate_url(&blocking(), "http://127.0.0.1/admin"),
EgressDecision::Block(_)
));
assert!(matches!(
evaluate_url(&blocking(), "http://169.254.169.254/latest/meta-data"),
EgressDecision::Block(_)
));
}
#[test]
fn allowlist_supports_subdomain_patterns() {
let config = EgressPolicyConfig {
mode: EgressPolicyMode::Block,
allow_hosts: vec!["api.example.com".into(), "*.trusted.test".into()],
block_private_ips: false,
..Default::default()
};
assert_eq!(
evaluate_url(&config, "https://api.example.com/v1"),
EgressDecision::Allow
);
assert_eq!(
evaluate_url(&config, "https://a.trusted.test/v1"),
EgressDecision::Allow
);
assert!(matches!(
evaluate_url(&config, "https://example.net/v1"),
EgressDecision::Block(_)
));
}
#[test]
fn finds_urls_embedded_in_tool_input() {
let input = serde_json::json!({
"command": "curl https://allowed.example/a && curl http://127.0.0.1/x"
});
let config = EgressPolicyConfig {
mode: EgressPolicyMode::Block,
deny_hosts: vec!["127.0.0.1".into()],
block_private_ips: false,
..Default::default()
};
let violations = evaluate_tool_input(&config, &input);
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].0, "http://127.0.0.1/x");
}
}