Skip to main content

agentzero_tools/
web_fetch.rs

1use 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}