use async_trait::async_trait;
use bamboo_agent_core::{Tool, ToolClass, ToolCtx, ToolError, ToolOutcome, ToolResult};
use futures::StreamExt;
use regex::Regex;
use serde::Deserialize;
use serde_json::json;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::sync::OnceLock;
use std::time::Duration;
const MAX_RESPONSE_BYTES: usize = 1_000_000;
const MAX_REDIRECTS: usize = 10;
static SCRIPT_RE: OnceLock<Regex> = OnceLock::new();
static STYLE_RE: OnceLock<Regex> = OnceLock::new();
static TAG_RE: OnceLock<Regex> = OnceLock::new();
static WHITESPACE_RE: OnceLock<Regex> = OnceLock::new();
#[derive(Debug, Deserialize)]
struct WebFetchArgs {
url: String,
prompt: String,
}
pub struct WebFetchTool;
impl WebFetchTool {
pub fn new() -> Self {
Self
}
fn strip_html(input: &str) -> Result<String, ToolError> {
let script_re = SCRIPT_RE.get_or_init(|| {
Regex::new(r"(?is)<script[^>]*>.*?</script>").expect("valid static regex")
});
let style_re = STYLE_RE.get_or_init(|| {
Regex::new(r"(?is)<style[^>]*>.*?</style>").expect("valid static regex")
});
let tag_re =
TAG_RE.get_or_init(|| Regex::new(r"(?is)<[^>]+>").expect("valid static regex"));
let whitespace_re =
WHITESPACE_RE.get_or_init(|| Regex::new(r"[ \t\n\r]+").expect("valid static regex"));
let without_scripts = script_re.replace_all(input, " ");
let without_styles = style_re.replace_all(&without_scripts, " ");
let without_tags = tag_re.replace_all(&without_styles, " ");
Ok(whitespace_re
.replace_all(&without_tags, " ")
.trim()
.to_string())
}
fn is_disallowed_ipv4(ipv4: Ipv4Addr) -> bool {
let octets = ipv4.octets();
let is_shared = octets[0] == 100 && (64..=127).contains(&octets[1]);
let is_this_network = octets[0] == 0;
ipv4.is_loopback()
|| ipv4.is_private()
|| ipv4.is_link_local()
|| ipv4.is_multicast()
|| ipv4.is_unspecified()
|| is_shared
|| is_this_network
}
fn embedded_ipv4(ipv6: std::net::Ipv6Addr) -> Option<Ipv4Addr> {
if let Some(mapped) = ipv6.to_ipv4_mapped() {
return Some(mapped);
}
let seg = ipv6.segments();
let low = Ipv4Addr::new(
(seg[6] >> 8) as u8,
(seg[6] & 0xff) as u8,
(seg[7] >> 8) as u8,
(seg[7] & 0xff) as u8,
);
if seg[0] == 0x0064 && seg[1] == 0xff9b && seg[2..6].iter().all(|s| *s == 0) {
return Some(low);
}
if seg[0..6].iter().all(|s| *s == 0) && !(seg[6] == 0 && (seg[7] == 0 || seg[7] == 1)) {
return Some(low);
}
None
}
fn is_disallowed_ip(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(ipv4) => Self::is_disallowed_ipv4(ipv4),
IpAddr::V6(ipv6) => {
if let Some(v4) = Self::embedded_ipv4(ipv6) {
if Self::is_disallowed_ipv4(v4) {
return true;
}
}
let segments = ipv6.segments();
let first = segments[0];
let is_unique_local = (first & 0xfe00) == 0xfc00;
let is_unicast_link_local = (first & 0xffc0) == 0xfe80;
ipv6.is_loopback()
|| ipv6.is_multicast()
|| ipv6.is_unspecified()
|| is_unique_local
|| is_unicast_link_local
}
}
}
async fn validate_and_resolve(url: &url::Url) -> Result<(String, Vec<SocketAddr>), ToolError> {
let host = url
.host_str()
.ok_or_else(|| ToolError::InvalidArguments("URL must include a host".to_string()))?;
if Self::is_disallowed_host(host) {
return Err(ToolError::Execution(format!(
"Refusing to fetch restricted host: {}",
host
)));
}
let port = url.port_or_known_default().unwrap_or(80);
let addrs: Vec<SocketAddr> = if let Ok(ip) = host.parse::<IpAddr>() {
vec![SocketAddr::new(ip, port)]
} else {
tokio::net::lookup_host((host, port))
.await
.map_err(|e| {
ToolError::Execution(format!("Failed to resolve host '{}': {}", host, e))
})?
.collect()
};
if addrs.is_empty() {
return Err(ToolError::Execution(format!(
"Host '{}' resolved to no addresses",
host
)));
}
if Self::resolved_ips_include_disallowed(addrs.iter().map(|addr| addr.ip())) {
return Err(ToolError::Execution(format!(
"Refusing to fetch host '{}' because it resolved to a restricted IP",
host
)));
}
Ok((host.to_string(), addrs))
}
fn is_disallowed_host(host: &str) -> bool {
let host = host.trim().to_ascii_lowercase();
if host == "localhost" || host.ends_with(".localhost") || host.ends_with(".local") {
return true;
}
let Ok(ip) = host.parse::<IpAddr>() else {
return false;
};
Self::is_disallowed_ip(ip)
}
fn resolved_ips_include_disallowed<I>(ips: I) -> bool
where
I: IntoIterator<Item = IpAddr>,
{
ips.into_iter().any(Self::is_disallowed_ip)
}
}
impl Default for WebFetchTool {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl Tool for WebFetchTool {
fn name(&self) -> &str {
"WebFetch"
}
fn description(&self) -> &str {
"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."
}
fn classify(&self, _args: &serde_json::Value) -> ToolClass {
ToolClass::READONLY_PARALLEL.promotable()
}
fn parameters_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"url": {
"type": "string",
"format": "uri",
"description": "The URL to fetch"
},
"prompt": {
"type": "string",
"description": "Caller-supplied extraction intent note; echoed in output for downstream processing"
}
},
"required": ["url", "prompt"],
"additionalProperties": false
})
}
async fn invoke(
&self,
args: serde_json::Value,
_ctx: ToolCtx,
) -> Result<ToolOutcome, ToolError> {
let parsed: WebFetchArgs = serde_json::from_value(args)
.map_err(|e| ToolError::InvalidArguments(format!("Invalid WebFetch args: {}", e)))?;
let url = parsed.url.trim();
let mut current_url = url::Url::parse(url)
.map_err(|e| ToolError::InvalidArguments(format!("Invalid URL: {}", e)))?;
let fetch = async {
let mut hop_count = 0usize;
loop {
let scheme = current_url.scheme();
if scheme != "http" && scheme != "https" {
return Err(ToolError::InvalidArguments(
"Only http/https URLs are allowed".to_string(),
));
}
let (host, addrs) = Self::validate_and_resolve(¤t_url).await?;
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.redirect(reqwest::redirect::Policy::none())
.resolve_to_addrs(&host, &addrs)
.build()
.map_err(|e| {
ToolError::Execution(format!("Failed to build HTTP client: {}", e))
})?;
let resp = client
.get(current_url.clone())
.send()
.await
.map_err(|e| ToolError::Execution(format!("Failed to fetch URL: {}", e)))?;
if resp.status().is_redirection() {
let location = resp
.headers()
.get(reqwest::header::LOCATION)
.and_then(|value| value.to_str().ok());
if let Some(location) = location {
let next = current_url.join(location).map_err(|e| {
ToolError::Execution(format!("Invalid redirect Location: {}", e))
})?;
if current_url == next {
return Ok(resp); }
hop_count += 1;
if hop_count > MAX_REDIRECTS {
return Err(ToolError::Execution(format!(
"Too many redirects (>{})",
MAX_REDIRECTS
)));
}
current_url = next;
continue;
}
}
return Ok(resp);
}
};
let response = tokio::time::timeout(Duration::from_secs(30), fetch)
.await
.map_err(|_| ToolError::Execution("WebFetch timed out after 30s".to_string()))??;
let status = response.status().as_u16();
let mut stream = response.bytes_stream();
let mut bytes = Vec::with_capacity(64 * 1024);
let mut response_truncated = false;
while let Some(chunk_result) = stream.next().await {
let chunk = chunk_result.map_err(|e| {
ToolError::Execution(format!("Failed reading response body: {}", e))
})?;
if bytes.len() + chunk.len() > MAX_RESPONSE_BYTES {
let remaining = MAX_RESPONSE_BYTES.saturating_sub(bytes.len());
if remaining > 0 {
bytes.extend_from_slice(&chunk[..remaining]);
}
response_truncated = true;
break;
}
bytes.extend_from_slice(&chunk);
}
let body = String::from_utf8_lossy(&bytes).to_string();
let text = Self::strip_html(&body)?;
let excerpt: String = text.chars().take(20_000).collect();
Ok(ToolOutcome::Completed(ToolResult {
success: true,
result: json!({
"url": parsed.url,
"status": status,
"prompt": parsed.prompt,
"content": excerpt,
"response_truncated": response_truncated,
})
.to_string(),
display_preference: Some("Collapsible".to_string()),
images: Vec::new(),
}))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strip_html_strips_scripts_styles_tags_and_collapses_whitespace() {
let html = "<html><head><style>body{color:red}</style></head><body>\
<script>alert(1)</script><h1>Title</h1><p>Hello world</p></body></html>";
assert_eq!(WebFetchTool::strip_html(html).unwrap(), "Title Hello world");
let html2 = "<div> <b>A</b> <i>B</i> </div>";
assert_eq!(WebFetchTool::strip_html(html2).unwrap(), "A B");
}
#[test]
fn disallowed_host_rejects_local_and_private_targets() {
assert!(WebFetchTool::is_disallowed_host("localhost"));
assert!(WebFetchTool::is_disallowed_host("api.localhost"));
assert!(WebFetchTool::is_disallowed_host("service.local"));
assert!(WebFetchTool::is_disallowed_host("127.0.0.1"));
assert!(WebFetchTool::is_disallowed_host("10.0.0.1"));
assert!(WebFetchTool::is_disallowed_host("192.168.1.1"));
assert!(WebFetchTool::is_disallowed_host("::1"));
assert!(!WebFetchTool::is_disallowed_host("example.com"));
assert!(!WebFetchTool::is_disallowed_host("8.8.8.8"));
}
#[test]
fn is_disallowed_ip_catches_ipv4_mapped_ipv6_and_cgnat() {
for mapped in [
"::ffff:127.0.0.1",
"::ffff:10.0.0.5",
"::ffff:192.168.1.1",
"::ffff:169.254.169.254", ] {
assert!(
WebFetchTool::is_disallowed_ip(mapped.parse().unwrap()),
"expected {mapped} to be disallowed"
);
}
for embedded in [
"::169.254.169.254",
"::127.0.0.1",
"64:ff9b::169.254.169.254",
"64:ff9b::7f00:1", ] {
assert!(
WebFetchTool::is_disallowed_ip(embedded.parse().unwrap()),
"expected {embedded} to be disallowed"
);
}
assert!(WebFetchTool::is_disallowed_ip(
"100.64.0.1".parse().unwrap()
));
assert!(WebFetchTool::is_disallowed_ip(
"100.127.255.255".parse().unwrap()
));
assert!(WebFetchTool::is_disallowed_ip("0.0.0.0".parse().unwrap()));
assert!(WebFetchTool::is_disallowed_ip("0.1.2.3".parse().unwrap()));
assert!(!WebFetchTool::is_disallowed_ip(
"100.63.255.255".parse().unwrap()
));
assert!(!WebFetchTool::is_disallowed_ip(
"100.128.0.0".parse().unwrap()
));
assert!(!WebFetchTool::is_disallowed_ip("8.8.8.8".parse().unwrap()));
assert!(!WebFetchTool::is_disallowed_ip(
"2606:4700:4700::1111".parse().unwrap()
));
assert!(WebFetchTool::is_disallowed_host("::ffff:169.254.169.254"));
assert!(WebFetchTool::is_disallowed_host("100.64.0.1"));
}
#[test]
fn resolved_ips_include_disallowed_detects_any_private_or_loopback_ip() {
assert!(WebFetchTool::resolved_ips_include_disallowed(vec![
"8.8.8.8".parse::<IpAddr>().unwrap(),
"10.0.0.8".parse::<IpAddr>().unwrap(),
]));
assert!(WebFetchTool::resolved_ips_include_disallowed(vec!["::1"
.parse::<IpAddr>()
.unwrap(),]));
assert!(!WebFetchTool::resolved_ips_include_disallowed(vec![
"1.1.1.1".parse::<IpAddr>().unwrap(),
"8.8.8.8".parse::<IpAddr>().unwrap(),
]));
}
#[tokio::test]
async fn execute_rejects_non_http_schemes() {
let tool = WebFetchTool::new();
let err = tool
.invoke(
json!({
"url": "file:///etc/passwd",
"prompt": "read"
}),
ToolCtx::none("t"),
)
.await
.expect_err("non-http scheme should fail");
assert!(matches!(err, ToolError::InvalidArguments(msg) if msg.contains("http/https")));
}
#[tokio::test]
async fn execute_rejects_restricted_hosts_before_network_call() {
let tool = WebFetchTool::new();
let err = tool
.invoke(
json!({
"url": "http://localhost:8080",
"prompt": "read"
}),
ToolCtx::none("t"),
)
.await
.expect_err("localhost should be blocked");
assert!(matches!(err, ToolError::Execution(msg) if msg.contains("restricted host")));
}
}