agentzero_tools/
web_fetch.rs1use agentzero_core::common::url_policy::UrlAccessPolicy;
2use agentzero_core::common::util::parse_http_url_with_policy;
3use agentzero_core::{Tool, ToolContext, ToolResult};
4use anyhow::{anyhow, Context};
5use async_trait::async_trait;
6
7const DEFAULT_MAX_BYTES: usize = 64 * 1024;
8
9pub struct WebFetchTool {
10 client: reqwest::Client,
11 max_bytes: usize,
12 url_policy: UrlAccessPolicy,
13}
14
15impl Default for WebFetchTool {
16 fn default() -> Self {
17 Self {
18 client: reqwest::Client::new(),
19 max_bytes: DEFAULT_MAX_BYTES,
20 url_policy: UrlAccessPolicy::default(),
21 }
22 }
23}
24
25impl WebFetchTool {
26 pub fn with_url_policy(mut self, policy: UrlAccessPolicy) -> Self {
27 self.url_policy = policy;
28 self
29 }
30}
31
32#[async_trait]
33impl Tool for WebFetchTool {
34 fn name(&self) -> &'static str {
35 "web_fetch"
36 }
37
38 fn description(&self) -> &'static str {
39 "Fetch a URL and return its content as text (HTML converted to plain text)."
40 }
41
42 fn input_schema(&self) -> Option<serde_json::Value> {
43 Some(serde_json::json!({
44 "type": "object",
45 "properties": {
46 "url": { "type": "string", "description": "The URL to fetch" }
47 },
48 "required": ["url"]
49 }))
50 }
51
52 async fn execute(&self, input: &str, _ctx: &ToolContext) -> anyhow::Result<ToolResult> {
53 let url = input.trim();
54 if url.is_empty() {
55 return Err(anyhow!("url is required"));
56 }
57
58 let parsed = parse_http_url_with_policy(url, &self.url_policy)?;
59
60 let response = self
61 .client
62 .get(parsed)
63 .send()
64 .await
65 .context("web fetch request failed")?;
66
67 let status = response.status().as_u16();
68 let body = response.text().await.context("failed reading response")?;
69 let output = if body.len() > self.max_bytes {
70 format!(
71 "status={status}\n{}\n<truncated at {} bytes>",
72 &body[..self.max_bytes],
73 self.max_bytes
74 )
75 } else {
76 format!("status={status}\n{body}")
77 };
78
79 Ok(ToolResult { output })
80 }
81}
82
83#[cfg(test)]
84mod tests {
85 use super::WebFetchTool;
86 use agentzero_core::{Tool, ToolContext};
87
88 #[tokio::test]
89 async fn web_fetch_rejects_missing_url_negative_path() {
90 let tool = WebFetchTool::default();
91 let err = tool
92 .execute(" ", &ToolContext::new(".".to_string()))
93 .await
94 .expect_err("missing url should fail");
95 assert!(err.to_string().contains("url is required"));
96 }
97
98 #[tokio::test]
99 async fn web_fetch_rejects_invalid_url_negative_path() {
100 let tool = WebFetchTool::default();
101 let err = tool
102 .execute("not-a-url", &ToolContext::new(".".to_string()))
103 .await
104 .expect_err("invalid url should fail");
105 assert!(err.to_string().contains("invalid url"));
106 }
107
108 #[tokio::test]
109 async fn web_fetch_blocks_private_ip_negative_path() {
110 let tool = WebFetchTool::default();
111 let err = tool
112 .execute(
113 "http://10.0.0.1/internal",
114 &ToolContext::new(".".to_string()),
115 )
116 .await
117 .expect_err("private IP should be blocked");
118 assert!(err.to_string().contains("URL access denied"));
119 }
120
121 #[tokio::test]
122 async fn web_fetch_blocks_blocklisted_domain_negative_path() {
123 use agentzero_core::common::url_policy::UrlAccessPolicy;
124 let tool = WebFetchTool::default().with_url_policy(UrlAccessPolicy {
125 domain_blocklist: vec!["evil.example".to_string()],
126 ..Default::default()
127 });
128 let err = tool
129 .execute(
130 "https://evil.example/phish",
131 &ToolContext::new(".".to_string()),
132 )
133 .await
134 .expect_err("blocklisted domain should be blocked");
135 assert!(err.to_string().contains("URL access denied"));
136 }
137}