Skip to main content

microclaw_engine/internal/tool_runtime/
egress.rs

1//! Native outbound-network policy for agent-controlled requests.
2//!
3//! The policy is evaluated at the shared tool registry before built-in, MCP,
4//! or dynamic plugin tools execute. Configured runtime endpoints are validated
5//! separately during application config loading. Redirect-capable HTTP clients
6//! should continue to use `url_safety::ssrf_redirect_policy` so every hop is
7//! checked as well.
8
9use reqwest::Url;
10use serde::{Deserialize, Serialize};
11
12#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "lowercase")]
14pub enum EgressPolicyMode {
15    #[default]
16    Off,
17    Warn,
18    Block,
19}
20
21#[derive(Clone, Debug, Serialize, Deserialize)]
22pub struct EgressPolicyConfig {
23    #[serde(default)]
24    pub mode: EgressPolicyMode,
25    /// When non-empty, every HTTP(S) destination must match one entry.
26    /// Entries may be exact hosts or `*.example.com` suffix patterns.
27    #[serde(default)]
28    pub allow_hosts: Vec<String>,
29    #[serde(default)]
30    pub deny_hosts: Vec<String>,
31    /// Reject loopback, private, link-local, CGNAT, metadata, multicast, and
32    /// unresolved destinations. Enabled by default whenever policy is active.
33    #[serde(default = "default_block_private_ips")]
34    pub block_private_ips: bool,
35}
36
37fn default_block_private_ips() -> bool {
38    true
39}
40
41impl Default for EgressPolicyConfig {
42    fn default() -> Self {
43        Self {
44            mode: EgressPolicyMode::Off,
45            allow_hosts: Vec::new(),
46            deny_hosts: Vec::new(),
47            block_private_ips: true,
48        }
49    }
50}
51
52#[derive(Clone, Debug, PartialEq, Eq)]
53pub enum EgressDecision {
54    Allow,
55    Warn(String),
56    Block(String),
57}
58
59impl EgressDecision {
60    fn violation(config: &EgressPolicyConfig, reason: String) -> Self {
61        match config.mode {
62            EgressPolicyMode::Off => Self::Allow,
63            EgressPolicyMode::Warn => Self::Warn(reason),
64            EgressPolicyMode::Block => Self::Block(reason),
65        }
66    }
67}
68
69pub fn evaluate_url(config: &EgressPolicyConfig, raw_url: &str) -> EgressDecision {
70    if config.mode == EgressPolicyMode::Off {
71        return EgressDecision::Allow;
72    }
73    let parsed = match Url::parse(raw_url) {
74        Ok(url) => url,
75        Err(error) => {
76            return EgressDecision::violation(
77                config,
78                format!("invalid outbound URL `{raw_url}`: {error}"),
79            )
80        }
81    };
82    if !matches!(parsed.scheme(), "http" | "https") {
83        return EgressDecision::violation(
84            config,
85            format!("outbound URL scheme `{}` is not permitted", parsed.scheme()),
86        );
87    }
88    let Some(host) = parsed.host_str().map(|value| value.to_ascii_lowercase()) else {
89        return EgressDecision::violation(config, "outbound URL has no host".into());
90    };
91    if config
92        .deny_hosts
93        .iter()
94        .any(|pattern| host_matches(pattern, &host))
95    {
96        return EgressDecision::violation(
97            config,
98            format!("outbound host `{host}` matches egress_policy.deny_hosts"),
99        );
100    }
101    if !config.allow_hosts.is_empty()
102        && !config
103            .allow_hosts
104            .iter()
105            .any(|pattern| host_matches(pattern, &host))
106    {
107        return EgressDecision::violation(
108            config,
109            format!("outbound host `{host}` is not in egress_policy.allow_hosts"),
110        );
111    }
112    if config.block_private_ips {
113        if let Err(error) = crate::internal::tool_runtime::url_safety::check_url_private_ip(&parsed)
114        {
115            return EgressDecision::violation(config, format!("egress SSRF guard: {error}"));
116        }
117    }
118    EgressDecision::Allow
119}
120
121pub fn evaluate_tool_input(
122    config: &EgressPolicyConfig,
123    input: &serde_json::Value,
124) -> Vec<(String, EgressDecision)> {
125    if config.mode == EgressPolicyMode::Off {
126        return Vec::new();
127    }
128    let mut urls = Vec::new();
129    collect_urls(input, &mut urls);
130    urls.sort();
131    urls.dedup();
132    urls.into_iter()
133        .map(|url| {
134            let decision = evaluate_url(config, &url);
135            (url, decision)
136        })
137        .filter(|(_, decision)| !matches!(decision, EgressDecision::Allow))
138        .collect()
139}
140
141fn collect_urls(value: &serde_json::Value, out: &mut Vec<String>) {
142    match value {
143        serde_json::Value::String(text) => {
144            // Capture URLs embedded in shell commands as well as exact URL
145            // arguments. Stop at common shell/JSON/Markdown delimiters.
146            for prefix in ["https://", "http://"] {
147                let mut rest = text.as_str();
148                while let Some(index) = rest.find(prefix) {
149                    let candidate = &rest[index..];
150                    let end = candidate
151                        .find(|ch: char| {
152                            ch.is_whitespace()
153                                || matches!(
154                                    ch,
155                                    '"' | '\'' | ')' | ']' | '}' | '<' | '>' | '`' | ';' | ','
156                                )
157                        })
158                        .unwrap_or(candidate.len());
159                    let url = candidate[..end]
160                        .trim_end_matches(['.', ':', '!', '?'])
161                        .to_string();
162                    if !url.is_empty() {
163                        out.push(url);
164                    }
165                    rest = &candidate[end..];
166                    if rest.is_empty() {
167                        break;
168                    }
169                }
170            }
171        }
172        serde_json::Value::Array(values) => {
173            for value in values {
174                collect_urls(value, out);
175            }
176        }
177        serde_json::Value::Object(values) => {
178            for (key, value) in values {
179                if key != "__microclaw_auth" {
180                    collect_urls(value, out);
181                }
182            }
183        }
184        _ => {}
185    }
186}
187
188fn host_matches(pattern: &str, host: &str) -> bool {
189    let pattern = pattern.trim().trim_end_matches('.').to_ascii_lowercase();
190    if let Some(suffix) = pattern.strip_prefix("*.") {
191        host != suffix && host.ends_with(&format!(".{suffix}"))
192    } else {
193        host == pattern
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    fn blocking() -> EgressPolicyConfig {
202        EgressPolicyConfig {
203            mode: EgressPolicyMode::Block,
204            ..Default::default()
205        }
206    }
207
208    #[test]
209    fn blocks_private_and_metadata_destinations() {
210        assert!(matches!(
211            evaluate_url(&blocking(), "http://127.0.0.1/admin"),
212            EgressDecision::Block(_)
213        ));
214        assert!(matches!(
215            evaluate_url(&blocking(), "http://169.254.169.254/latest/meta-data"),
216            EgressDecision::Block(_)
217        ));
218    }
219
220    #[test]
221    fn allowlist_supports_subdomain_patterns() {
222        let config = EgressPolicyConfig {
223            mode: EgressPolicyMode::Block,
224            allow_hosts: vec!["api.example.com".into(), "*.trusted.test".into()],
225            block_private_ips: false,
226            ..Default::default()
227        };
228        assert_eq!(
229            evaluate_url(&config, "https://api.example.com/v1"),
230            EgressDecision::Allow
231        );
232        assert_eq!(
233            evaluate_url(&config, "https://a.trusted.test/v1"),
234            EgressDecision::Allow
235        );
236        assert!(matches!(
237            evaluate_url(&config, "https://example.net/v1"),
238            EgressDecision::Block(_)
239        ));
240    }
241
242    #[test]
243    fn finds_urls_embedded_in_tool_input() {
244        let input = serde_json::json!({
245            "command": "curl https://allowed.example/a && curl http://127.0.0.1/x"
246        });
247        let config = EgressPolicyConfig {
248            mode: EgressPolicyMode::Block,
249            deny_hosts: vec!["127.0.0.1".into()],
250            block_private_ips: false,
251            ..Default::default()
252        };
253        let violations = evaluate_tool_input(&config, &input);
254        assert_eq!(violations.len(), 1);
255        assert_eq!(violations[0].0, "http://127.0.0.1/x");
256    }
257}