1use async_trait::async_trait;
2use bamboo_agent_core::{Tool, ToolClass, ToolCtx, ToolError, ToolOutcome, ToolResult};
3use futures::StreamExt;
4use regex::Regex;
5use serde::Deserialize;
6use serde_json::json;
7use std::net::{IpAddr, Ipv4Addr, SocketAddr};
8use std::sync::OnceLock;
9use std::time::Duration;
10
11const MAX_RESPONSE_BYTES: usize = 1_000_000;
12const MAX_REDIRECTS: usize = 10;
15
16static SCRIPT_RE: OnceLock<Regex> = OnceLock::new();
19static STYLE_RE: OnceLock<Regex> = OnceLock::new();
20static TAG_RE: OnceLock<Regex> = OnceLock::new();
21static WHITESPACE_RE: OnceLock<Regex> = OnceLock::new();
22
23#[derive(Debug, Deserialize)]
24struct WebFetchArgs {
25 url: String,
26 prompt: String,
27}
28
29pub struct WebFetchTool;
30
31impl WebFetchTool {
32 pub fn new() -> Self {
33 Self
34 }
35
36 fn strip_html(input: &str) -> Result<String, ToolError> {
37 let script_re = SCRIPT_RE.get_or_init(|| {
38 Regex::new(r"(?is)<script[^>]*>.*?</script>").expect("valid static regex")
39 });
40 let style_re = STYLE_RE.get_or_init(|| {
41 Regex::new(r"(?is)<style[^>]*>.*?</style>").expect("valid static regex")
42 });
43 let tag_re =
44 TAG_RE.get_or_init(|| Regex::new(r"(?is)<[^>]+>").expect("valid static regex"));
45 let whitespace_re =
46 WHITESPACE_RE.get_or_init(|| Regex::new(r"[ \t\n\r]+").expect("valid static regex"));
47
48 let without_scripts = script_re.replace_all(input, " ");
49 let without_styles = style_re.replace_all(&without_scripts, " ");
50 let without_tags = tag_re.replace_all(&without_styles, " ");
51 Ok(whitespace_re
52 .replace_all(&without_tags, " ")
53 .trim()
54 .to_string())
55 }
56
57 fn is_disallowed_ipv4(ipv4: Ipv4Addr) -> bool {
58 let octets = ipv4.octets();
59 let is_shared = octets[0] == 100 && (64..=127).contains(&octets[1]);
62 let is_this_network = octets[0] == 0;
65 ipv4.is_loopback()
66 || ipv4.is_private()
67 || ipv4.is_link_local()
68 || ipv4.is_multicast()
69 || ipv4.is_unspecified()
70 || is_shared
71 || is_this_network
72 }
73
74 fn embedded_ipv4(ipv6: std::net::Ipv6Addr) -> Option<Ipv4Addr> {
79 if let Some(mapped) = ipv6.to_ipv4_mapped() {
80 return Some(mapped);
81 }
82 let seg = ipv6.segments();
83 let low = Ipv4Addr::new(
84 (seg[6] >> 8) as u8,
85 (seg[6] & 0xff) as u8,
86 (seg[7] >> 8) as u8,
87 (seg[7] & 0xff) as u8,
88 );
89 if seg[0] == 0x0064 && seg[1] == 0xff9b && seg[2..6].iter().all(|s| *s == 0) {
91 return Some(low);
92 }
93 if seg[0..6].iter().all(|s| *s == 0) && !(seg[6] == 0 && (seg[7] == 0 || seg[7] == 1)) {
96 return Some(low);
97 }
98 None
99 }
100
101 fn is_disallowed_ip(ip: IpAddr) -> bool {
102 match ip {
103 IpAddr::V4(ipv4) => Self::is_disallowed_ipv4(ipv4),
104 IpAddr::V6(ipv6) => {
105 if let Some(v4) = Self::embedded_ipv4(ipv6) {
109 if Self::is_disallowed_ipv4(v4) {
110 return true;
111 }
112 }
113 let segments = ipv6.segments();
114 let first = segments[0];
115 let is_unique_local = (first & 0xfe00) == 0xfc00;
116 let is_unicast_link_local = (first & 0xffc0) == 0xfe80;
117 ipv6.is_loopback()
118 || ipv6.is_multicast()
119 || ipv6.is_unspecified()
120 || is_unique_local
121 || is_unicast_link_local
122 }
123 }
124 }
125
126 async fn validate_and_resolve(url: &url::Url) -> Result<(String, Vec<SocketAddr>), ToolError> {
132 let host = url
133 .host_str()
134 .ok_or_else(|| ToolError::InvalidArguments("URL must include a host".to_string()))?;
135 if Self::is_disallowed_host(host) {
136 return Err(ToolError::Execution(format!(
137 "Refusing to fetch restricted host: {}",
138 host
139 )));
140 }
141 let port = url.port_or_known_default().unwrap_or(80);
142
143 let addrs: Vec<SocketAddr> = if let Ok(ip) = host.parse::<IpAddr>() {
144 vec![SocketAddr::new(ip, port)]
145 } else {
146 tokio::net::lookup_host((host, port))
147 .await
148 .map_err(|e| {
149 ToolError::Execution(format!("Failed to resolve host '{}': {}", host, e))
150 })?
151 .collect()
152 };
153
154 if addrs.is_empty() {
155 return Err(ToolError::Execution(format!(
156 "Host '{}' resolved to no addresses",
157 host
158 )));
159 }
160 if Self::resolved_ips_include_disallowed(addrs.iter().map(|addr| addr.ip())) {
161 return Err(ToolError::Execution(format!(
162 "Refusing to fetch host '{}' because it resolved to a restricted IP",
163 host
164 )));
165 }
166
167 Ok((host.to_string(), addrs))
168 }
169
170 fn is_disallowed_host(host: &str) -> bool {
171 let host = host.trim().to_ascii_lowercase();
172 if host == "localhost" || host.ends_with(".localhost") || host.ends_with(".local") {
173 return true;
174 }
175
176 let Ok(ip) = host.parse::<IpAddr>() else {
177 return false;
178 };
179 Self::is_disallowed_ip(ip)
180 }
181
182 fn resolved_ips_include_disallowed<I>(ips: I) -> bool
183 where
184 I: IntoIterator<Item = IpAddr>,
185 {
186 ips.into_iter().any(Self::is_disallowed_ip)
187 }
188}
189
190impl Default for WebFetchTool {
191 fn default() -> Self {
192 Self::new()
193 }
194}
195
196#[async_trait]
197impl Tool for WebFetchTool {
198 fn name(&self) -> &str {
199 "WebFetch"
200 }
201
202 fn description(&self) -> &str {
203 "Fetch an HTTP(S) URL and return a cleaned text excerpt plus metadata. The `prompt` field is caller context only; this tool does not run an extra model."
204 }
205
206 fn classify(&self, _args: &serde_json::Value) -> ToolClass {
207 ToolClass::READONLY_PARALLEL.promotable()
208 }
209
210 fn parameters_schema(&self) -> serde_json::Value {
211 json!({
212 "type": "object",
213 "properties": {
214 "url": {
215 "type": "string",
216 "format": "uri",
217 "description": "The URL to fetch"
218 },
219 "prompt": {
220 "type": "string",
221 "description": "Caller-supplied extraction intent note; echoed in output for downstream processing"
222 }
223 },
224 "required": ["url", "prompt"],
225 "additionalProperties": false
226 })
227 }
228
229 async fn invoke(
230 &self,
231 args: serde_json::Value,
232 _ctx: ToolCtx,
233 ) -> Result<ToolOutcome, ToolError> {
234 let parsed: WebFetchArgs = serde_json::from_value(args)
235 .map_err(|e| ToolError::InvalidArguments(format!("Invalid WebFetch args: {}", e)))?;
236 let url = parsed.url.trim();
237 let mut current_url = url::Url::parse(url)
238 .map_err(|e| ToolError::InvalidArguments(format!("Invalid URL: {}", e)))?;
239
240 let fetch = async {
247 let mut hop_count = 0usize;
248 loop {
249 let scheme = current_url.scheme();
250 if scheme != "http" && scheme != "https" {
251 return Err(ToolError::InvalidArguments(
252 "Only http/https URLs are allowed".to_string(),
253 ));
254 }
255
256 let (host, addrs) = Self::validate_and_resolve(¤t_url).await?;
257
258 let client = reqwest::Client::builder()
259 .timeout(Duration::from_secs(30))
260 .redirect(reqwest::redirect::Policy::none())
262 .resolve_to_addrs(&host, &addrs)
265 .build()
266 .map_err(|e| {
267 ToolError::Execution(format!("Failed to build HTTP client: {}", e))
268 })?;
269
270 let resp = client
271 .get(current_url.clone())
272 .send()
273 .await
274 .map_err(|e| ToolError::Execution(format!("Failed to fetch URL: {}", e)))?;
275
276 if resp.status().is_redirection() {
277 let location = resp
278 .headers()
279 .get(reqwest::header::LOCATION)
280 .and_then(|value| value.to_str().ok());
281 if let Some(location) = location {
282 let next = current_url.join(location).map_err(|e| {
283 ToolError::Execution(format!("Invalid redirect Location: {}", e))
284 })?;
285 if current_url == next {
286 return Ok(resp); }
288 hop_count += 1;
289 if hop_count > MAX_REDIRECTS {
290 return Err(ToolError::Execution(format!(
291 "Too many redirects (>{})",
292 MAX_REDIRECTS
293 )));
294 }
295 current_url = next;
296 continue;
297 }
298 }
299 return Ok(resp);
300 }
301 };
302 let response = tokio::time::timeout(Duration::from_secs(30), fetch)
303 .await
304 .map_err(|_| ToolError::Execution("WebFetch timed out after 30s".to_string()))??;
305
306 let status = response.status().as_u16();
307 let mut stream = response.bytes_stream();
308 let mut bytes = Vec::with_capacity(64 * 1024);
309 let mut response_truncated = false;
310 while let Some(chunk_result) = stream.next().await {
311 let chunk = chunk_result.map_err(|e| {
312 ToolError::Execution(format!("Failed reading response body: {}", e))
313 })?;
314 if bytes.len() + chunk.len() > MAX_RESPONSE_BYTES {
315 let remaining = MAX_RESPONSE_BYTES.saturating_sub(bytes.len());
316 if remaining > 0 {
317 bytes.extend_from_slice(&chunk[..remaining]);
318 }
319 response_truncated = true;
320 break;
321 }
322 bytes.extend_from_slice(&chunk);
323 }
324
325 let body = String::from_utf8_lossy(&bytes).to_string();
326
327 let text = Self::strip_html(&body)?;
328 let excerpt: String = text.chars().take(20_000).collect();
329
330 Ok(ToolOutcome::Completed(ToolResult {
331 success: true,
332 result: json!({
333 "url": parsed.url,
334 "status": status,
335 "prompt": parsed.prompt,
336 "content": excerpt,
337 "response_truncated": response_truncated,
338 })
339 .to_string(),
340 display_preference: Some("Collapsible".to_string()),
341 images: Vec::new(),
342 }))
343 }
344}
345
346#[cfg(test)]
347mod tests {
348 use super::*;
349
350 #[test]
351 fn strip_html_strips_scripts_styles_tags_and_collapses_whitespace() {
352 let html = "<html><head><style>body{color:red}</style></head><body>\
355<script>alert(1)</script><h1>Title</h1><p>Hello world</p></body></html>";
356 assert_eq!(WebFetchTool::strip_html(html).unwrap(), "Title Hello world");
357
358 let html2 = "<div> <b>A</b> <i>B</i> </div>";
361 assert_eq!(WebFetchTool::strip_html(html2).unwrap(), "A B");
362 }
363
364 #[test]
365 fn disallowed_host_rejects_local_and_private_targets() {
366 assert!(WebFetchTool::is_disallowed_host("localhost"));
367 assert!(WebFetchTool::is_disallowed_host("api.localhost"));
368 assert!(WebFetchTool::is_disallowed_host("service.local"));
369 assert!(WebFetchTool::is_disallowed_host("127.0.0.1"));
370 assert!(WebFetchTool::is_disallowed_host("10.0.0.1"));
371 assert!(WebFetchTool::is_disallowed_host("192.168.1.1"));
372 assert!(WebFetchTool::is_disallowed_host("::1"));
373 assert!(!WebFetchTool::is_disallowed_host("example.com"));
374 assert!(!WebFetchTool::is_disallowed_host("8.8.8.8"));
375 }
376
377 #[test]
378 fn is_disallowed_ip_catches_ipv4_mapped_ipv6_and_cgnat() {
379 for mapped in [
382 "::ffff:127.0.0.1",
383 "::ffff:10.0.0.5",
384 "::ffff:192.168.1.1",
385 "::ffff:169.254.169.254", ] {
387 assert!(
388 WebFetchTool::is_disallowed_ip(mapped.parse().unwrap()),
389 "expected {mapped} to be disallowed"
390 );
391 }
392 for embedded in [
395 "::169.254.169.254",
396 "::127.0.0.1",
397 "64:ff9b::169.254.169.254",
398 "64:ff9b::7f00:1", ] {
400 assert!(
401 WebFetchTool::is_disallowed_ip(embedded.parse().unwrap()),
402 "expected {embedded} to be disallowed"
403 );
404 }
405
406 assert!(WebFetchTool::is_disallowed_ip(
408 "100.64.0.1".parse().unwrap()
409 ));
410 assert!(WebFetchTool::is_disallowed_ip(
411 "100.127.255.255".parse().unwrap()
412 ));
413
414 assert!(WebFetchTool::is_disallowed_ip("0.0.0.0".parse().unwrap()));
416 assert!(WebFetchTool::is_disallowed_ip("0.1.2.3".parse().unwrap()));
417
418 assert!(!WebFetchTool::is_disallowed_ip(
420 "100.63.255.255".parse().unwrap()
421 ));
422 assert!(!WebFetchTool::is_disallowed_ip(
423 "100.128.0.0".parse().unwrap()
424 ));
425 assert!(!WebFetchTool::is_disallowed_ip("8.8.8.8".parse().unwrap()));
426 assert!(!WebFetchTool::is_disallowed_ip(
427 "2606:4700:4700::1111".parse().unwrap()
428 ));
429 assert!(WebFetchTool::is_disallowed_host("::ffff:169.254.169.254"));
431 assert!(WebFetchTool::is_disallowed_host("100.64.0.1"));
432 }
433
434 #[test]
435 fn resolved_ips_include_disallowed_detects_any_private_or_loopback_ip() {
436 assert!(WebFetchTool::resolved_ips_include_disallowed(vec![
437 "8.8.8.8".parse::<IpAddr>().unwrap(),
438 "10.0.0.8".parse::<IpAddr>().unwrap(),
439 ]));
440 assert!(WebFetchTool::resolved_ips_include_disallowed(vec!["::1"
441 .parse::<IpAddr>()
442 .unwrap(),]));
443 assert!(!WebFetchTool::resolved_ips_include_disallowed(vec![
444 "1.1.1.1".parse::<IpAddr>().unwrap(),
445 "8.8.8.8".parse::<IpAddr>().unwrap(),
446 ]));
447 }
448
449 #[tokio::test]
450 async fn execute_rejects_non_http_schemes() {
451 let tool = WebFetchTool::new();
452 let err = tool
453 .invoke(
454 json!({
455 "url": "file:///etc/passwd",
456 "prompt": "read"
457 }),
458 ToolCtx::none("t"),
459 )
460 .await
461 .expect_err("non-http scheme should fail");
462
463 assert!(matches!(err, ToolError::InvalidArguments(msg) if msg.contains("http/https")));
464 }
465
466 #[tokio::test]
467 async fn execute_rejects_restricted_hosts_before_network_call() {
468 let tool = WebFetchTool::new();
469 let err = tool
470 .invoke(
471 json!({
472 "url": "http://localhost:8080",
473 "prompt": "read"
474 }),
475 ToolCtx::none("t"),
476 )
477 .await
478 .expect_err("localhost should be blocked");
479
480 assert!(matches!(err, ToolError::Execution(msg) if msg.contains("restricted host")));
481 }
482}