microclaw_engine/internal/tool_runtime/
egress.rs1use 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 #[serde(default)]
28 pub allow_hosts: Vec<String>,
29 #[serde(default)]
30 pub deny_hosts: Vec<String>,
31 #[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 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}