Skip to main content

vtcode_core/tools/web_fetch/
mod.rs

1//! WebFetch tool for fetching and analyzing web content using AI
2//!
3//! Supports both restricted (blocklist) and whitelist (allowlist) modes
4//! with dynamic configuration loading from vtcode.toml
5//!
6//! Fetched content is written to ephemeral cache files under the user cache
7//! directory's `web-fetch/` path.
8//! and cleaned up periodically. Content is never persisted to the workspace.
9
10use super::traits::Tool;
11use crate::config::constants::tools;
12use anyhow::{Context, Result, anyhow, bail};
13use async_trait::async_trait;
14use futures::stream::StreamExt;
15use hashbrown::HashSet;
16use reqwest::header::{ACCEPT, HeaderMap, HeaderValue, USER_AGENT};
17use serde::Deserialize;
18use serde_json::{Value, json};
19use std::net::IpAddr;
20use std::path::PathBuf;
21use url::Url;
22use vtcode_commons::VtCodePaths;
23
24pub mod classify_helpers;
25pub mod domains;
26mod html_preview;
27pub use classify_helpers::extract_http_status;
28pub use domains::{BUILTIN_BLOCKED_DOMAINS, BUILTIN_BLOCKED_PATTERNS, MALICIOUS_PATTERNS};
29
30const DEFAULT_TIMEOUT_SECS: u64 = 30;
31const MAX_CONTENT_SIZE: usize = 500_000; // 500KB max content size
32const MAX_ALLOWED_BYTES: usize = 2_000_000; // 2MB hard cap
33const MAX_ALLOWED_TIMEOUT_SECS: u64 = 120; // 2 minutes hard cap
34
35/// Subdirectory under the user cache directory for ephemeral web-fetch artifacts.
36const TEMP_SUBDIR: &str = "web_fetch";
37
38/// Max age in seconds before temp files are cleaned up (1 hour).
39const TEMP_MAX_AGE_SECS: u64 = 3600;
40
41pub(crate) const WEB_FETCH_DESCRIPTION: &str = "Fetch a remote URL and return the start of the page inline. The result contains `preview` (readable text for HTML pages, else the first 8000 bytes of the body), `preview_kind` (`text` or `raw`), `content_length`, and a `temp_file` path holding the full fetched body; read `temp_file` only when the preview is not enough. The tool does not analyze the page: `prompt` is returned with the result to guide your own reading. Set format=markdown to get cleaned markdown inline from the defuddle.md extraction service instead; that mode allows 1 call per session. For docs domains, try /llms.txt first: for 'abc.com', fetch https://abc.com/llms.txt before the homepage, then traverse linked URLs for relevant Markdown sources. Prefer llms.txt over llms-full.txt, which can be multi-megabyte. A body larger than max_bytes is cut off and the result reports truncated_by_max_bytes and source_size_bytes, so you can retry with a larger max_bytes. Temp files are ephemeral and may be cleaned up after about an hour.";
42
43/// Parameter schema for `web_fetch`, shared by the distributed builtin
44/// registration and the web tool pack so the two surfaces cannot drift.
45///
46/// `format` must be declared here: argument validation rejects unknown
47/// properties (`additionalProperties: false`), so an undeclared `format`
48/// would make the markdown route in [`WebFetchTool::execute`] unreachable.
49pub(crate) fn web_fetch_parameter_schema() -> Value {
50    json!({
51        "type": "object",
52        "properties": {
53            "url": {
54                "type": "string",
55                "description": "URL to fetch (HTTPS required by default)"
56            },
57            "prompt": {
58                "type": "string",
59                "description": "Question or instruction describing what to look for in the fetched content. It is returned with the result to guide your analysis; omit it to get a default summarize instruction."
60            },
61            "format": {
62                "type": "string",
63                "enum": ["summary", "markdown"],
64                "description": "Output mode (default: summary). summary fetches the page directly and returns a preview plus a temp_file path. markdown returns the page as cleaned markdown inline via the defuddle.md extraction service; it allows 1 call per session, accepts only remote http(s) URLs, caps max_bytes at 262144, and ignores prompt and timeout_secs."
65            },
66            "max_bytes": {
67                "type": "integer",
68                "description": "Maximum response body size in bytes (default: 500000, max: 2000000). The default fits most pages, including llms.txt, so set this only to raise the limit after a truncated_by_max_bytes result or to cap a very large page."
69            },
70            "timeout_secs": {
71                "type": "integer",
72                "description": "Request timeout in seconds (default: 30, max: 120)"
73            }
74        },
75        "required": ["url"],
76        "additionalProperties": false
77    })
78}
79
80#[derive(Debug, Deserialize)]
81struct WebFetchArgs {
82    url: String,
83    prompt: String,
84    #[serde(default)]
85    max_bytes: Option<usize>,
86    #[serde(default)]
87    timeout_secs: Option<u64>,
88}
89
90/// WebFetch tool that fetches URL content and processes it with AI
91#[derive(Clone)]
92pub struct WebFetchTool {
93    /// Security mode: restricted (blocklist) or whitelist (allowlist)
94    pub mode: vtcode_config::WebFetchMode,
95    /// Additional blocked domains (merged with builtin)
96    pub blocked_domains: HashSet<String>,
97    /// Additional blocked patterns (merged with builtin)
98    pub blocked_patterns: Vec<String>,
99    /// Allowed domains (for exemptions in restricted mode or primary list in whitelist mode)
100    pub allowed_domains: HashSet<String>,
101    /// Defuddle markdown-extraction backend (used when `format: "markdown"`).
102    /// Shared session-cap state survives clones (Arc counter inside).
103    pub defuddle: crate::tools::defuddle::DefuddleTool,
104    /// Strict HTTPS-only mode
105    pub strict_https_only: bool,
106}
107
108struct FetchedWebContent {
109    content: String,
110    truncated_by_max_bytes: bool,
111    source_size_bytes: usize,
112}
113
114fn fetched_content_from_bytes(bytes: &[u8], max_bytes: usize) -> Result<FetchedWebContent> {
115    let source_size_bytes = bytes.len();
116    let truncated_by_max_bytes = source_size_bytes > max_bytes;
117    if !truncated_by_max_bytes {
118        return Ok(FetchedWebContent {
119            content: String::from_utf8(bytes.to_vec()).context("Response body is not valid UTF-8")?,
120            truncated_by_max_bytes,
121            source_size_bytes,
122        });
123    }
124
125    // Truncate to `max_bytes`, then back off to the nearest valid UTF-8
126    // boundary in a single O(n) pass via `valid_up_to`. The previous per-byte
127    // `end -= 1` loop was O(n^2) on adversarial bodies (e.g. a multi-MB
128    // document whose first max_bytes bytes are not UTF-8-aligned).
129    let valid_end = match std::str::from_utf8(&bytes[..max_bytes]) {
130        Ok(_) => max_bytes,
131        Err(err) => err.valid_up_to(),
132    };
133
134    let content = String::from_utf8(bytes[..valid_end].to_vec()).context("Response body is not valid UTF-8")?;
135    Ok(FetchedWebContent { content, truncated_by_max_bytes, source_size_bytes })
136}
137
138/// Returns the path to the ephemeral temp directory for web_fetch artifacts.
139/// Creates the directory if it doesn't exist.
140async fn web_fetch_temp_dir() -> Result<PathBuf> {
141    VtCodePaths::resolve()
142        .context("Could not resolve VT Code cache directory")?
143        .ensure_cache_child_dir(TEMP_SUBDIR)
144        .context("Failed to create web-fetch cache directory")
145}
146
147/// Write content to an ephemeral temp file and return its path.
148/// Files are named with a timestamp for easy age-based cleanup.
149async fn write_to_temp_file(content: &str, url: &str) -> Result<PathBuf> {
150    let temp_dir = web_fetch_temp_dir().await?;
151    let timestamp = std::time::SystemTime::now()
152        .duration_since(std::time::UNIX_EPOCH)
153        .unwrap_or_default()
154        .as_micros();
155
156    // Create a safe filename from the URL
157    let url_hash = {
158        use std::collections::hash_map::DefaultHasher;
159        use std::hash::{Hash, Hasher};
160        let mut hasher = DefaultHasher::new();
161        url.hash(&mut hasher);
162        format!("{:016x}", hasher.finish())
163    };
164
165    let filename = format!("{url_hash}_{timestamp}.txt");
166    let file_path = temp_dir.join(&filename);
167
168    let bytes = content.as_bytes().to_vec();
169    let write_path = file_path.clone();
170    tokio::task::spawn_blocking(move || VtCodePaths::write_private_file_atomic(&write_path, &bytes))
171        .await
172        .context("web-fetch cache writer task panicked")?
173        .with_context(|| format!("Failed to write temp file: {}", file_path.display()))?;
174
175    Ok(file_path)
176}
177
178/// Clean up old web_fetch temp files.
179pub async fn cleanup_old_web_fetch_temps(max_age_secs: u64) -> Result<usize> {
180    let temp_dir = match web_fetch_temp_dir().await {
181        Ok(d) => d,
182        Err(_) => return Ok(0),
183    };
184
185    if tokio::fs::metadata(&temp_dir).await.is_err() {
186        return Ok(0);
187    }
188
189    let cutoff = std::time::SystemTime::now()
190        .checked_sub(std::time::Duration::from_secs(max_age_secs))
191        .unwrap_or(std::time::UNIX_EPOCH);
192
193    let mut removed = 0;
194    let mut entries = match tokio::fs::read_dir(&temp_dir).await {
195        Ok(e) => e,
196        Err(_) => return Ok(0),
197    };
198
199    while let Some(entry) = entries.next_entry().await.unwrap_or(None) {
200        let path = entry.path();
201        if !path.is_file() {
202            continue;
203        }
204        if let Ok(metadata) = entry.metadata().await
205            && let Ok(modified) = metadata.modified()
206            && modified <= cutoff
207            && tokio::fs::remove_file(&path).await.is_ok()
208        {
209            removed += 1;
210        }
211    }
212
213    if removed > 0 {
214        tracing::info!(count = removed, "Cleaned up old web_fetch temp files");
215    }
216
217    Ok(removed)
218}
219
220impl WebFetchTool {
221    pub fn new() -> Self {
222        Self {
223            mode: vtcode_config::WebFetchMode::Restricted,
224            blocked_domains: HashSet::new(),
225            blocked_patterns: Vec::new(),
226            allowed_domains: HashSet::new(),
227            defuddle: crate::tools::defuddle::DefuddleTool::new(),
228            strict_https_only: true,
229        }
230    }
231
232    /// Create a WebFetchTool with custom configuration
233    pub fn with_config(
234        mode: vtcode_config::WebFetchMode,
235        blocked_domains: Vec<String>,
236        blocked_patterns: Vec<String>,
237        allowed_domains: Vec<String>,
238        strict_https_only: bool,
239    ) -> Self {
240        Self {
241            mode,
242            blocked_domains: blocked_domains.into_iter().collect(),
243            blocked_patterns,
244            allowed_domains: allowed_domains.into_iter().collect(),
245            defuddle: crate::tools::defuddle::DefuddleTool::new(),
246            strict_https_only,
247        }
248    }
249
250    /// Build a `WebFetchTool` from a `WebFetchConfig` value-object. This is
251    /// the entry point the tool registry uses so user-configured
252    /// allow/block lists and HTTPS settings actually take effect.
253    pub fn from_config(config: &vtcode_config::WebFetchConfig) -> Self {
254        Self::with_config(
255            config.mode,
256            config.blocked_domains.clone(),
257            config.blocked_patterns.clone(),
258            config.allowed_domains.clone(),
259            config.strict_https_only,
260        )
261    }
262
263    async fn fetch_url_content(&self, url: &str, max_bytes: usize, timeout_secs: u64) -> Result<FetchedWebContent> {
264        // Validate URL
265        self.validate_url(url)?;
266
267        let default_headers = Self::default_headers();
268
269        // Build a redirect policy that validates each redirect target against SSRF rules.
270        // Limits to 5 redirects to bound the chain and re-checks security on each hop.
271        let blocked_domains = self.blocked_domains.clone();
272        let blocked_patterns = self.blocked_patterns.clone();
273        let allowed_domains = self.allowed_domains.clone();
274        let strict_https = self.strict_https_only;
275        let redirect_policy = reqwest::redirect::Policy::custom(move |attempt| {
276            if attempt.previous().len() >= 5 {
277                return attempt.stop();
278            }
279            let next_url = attempt.url();
280            let next_str = next_url.as_str();
281
282            // Re-validate HTTPS if strict mode is on
283            if strict_https && !next_str.starts_with("https://") {
284                return attempt.stop();
285            }
286
287            // Re-validate the redirect target's domain against the same security checks
288            if let Ok(domain) = extract_domain(next_str) {
289                if is_private_host(&domain) {
290                    return attempt.stop();
291                }
292                let domain_lower = domain.to_ascii_lowercase();
293                if domain_lower.ends_with(".local")
294                    || domain_lower.ends_with(".internal")
295                    || domain_lower.ends_with(".localhost")
296                    || domain_lower.ends_with(".test")
297                    || domain_lower.ends_with(".invalid")
298                    || domain_lower.ends_with(".home.arpa")
299                {
300                    return attempt.stop();
301                }
302                // Check blocked domains
303                let mut all_blocked = BUILTIN_BLOCKED_DOMAINS.to_vec();
304                all_blocked.extend(blocked_domains.iter().map(|s| s.as_str()));
305                for blocked in &all_blocked {
306                    if next_str.to_lowercase().contains(blocked) {
307                        return attempt.stop();
308                    }
309                }
310                // Check blocked patterns
311                let mut all_patterns = BUILTIN_BLOCKED_PATTERNS.to_vec();
312                all_patterns.extend(blocked_patterns.iter().map(|s| s.as_str()));
313                for pattern in &all_patterns {
314                    if next_str.to_lowercase().contains(pattern) {
315                        return attempt.stop();
316                    }
317                }
318                // Check allowed domains (exemptions)
319                for allowed in &allowed_domains {
320                    if domain_matches_allowed(&domain, allowed) {
321                        return attempt.follow();
322                    }
323                }
324            }
325            attempt.follow()
326        });
327
328        let client = reqwest::Client::builder()
329            .default_headers(default_headers)
330            .timeout(std::time::Duration::from_secs(timeout_secs))
331            .redirect(redirect_policy)
332            .build()?;
333
334        let response = client.get(url).send().await?;
335
336        if !response.status().is_success() {
337            return Err(anyhow!("HTTP request failed with status: {}", response.status()));
338        }
339
340        let content_type = response
341            .headers()
342            .get("content-type")
343            .and_then(|h| h.to_str().ok())
344            .unwrap_or("")
345            .to_string();
346
347        // Validate content type
348        self.validate_content_type(&content_type)?;
349
350        // Limit response body to max_bytes while still returning useful partial
351        // text. Oversized documentation pages are common, and a truncated fetch
352        // gives the agent more signal than an error-only result.
353        //
354        // Reject up-front when Content-Length already exceeds the cap, and
355        // stream `chunk()`s capped at max_bytes instead of buffering the entire
356        // body with `response.bytes()`. This bounds peak memory even for
357        // hostile servers that lie about or omit Content-Length.
358        if let Some(content_length) = response.content_length()
359            && content_length > max_bytes as u64
360        {
361            return Err(anyhow!(
362                "Response Content-Length {} exceeds max_bytes {}; refusing to buffer the full body",
363                content_length,
364                max_bytes
365            ));
366        }
367
368        let mut bytes = Vec::with_capacity(max_bytes.min(64 * 1024));
369        let mut stream = response.bytes_stream();
370        let mut max_reached = false;
371        while let Some(chunk) = stream.next().await {
372            let chunk = chunk.context("Failed to read response body")?;
373            if bytes.len() >= max_bytes {
374                max_reached = true;
375                break;
376            }
377            let take = chunk.len().min(max_bytes - bytes.len());
378            bytes.extend_from_slice(&chunk[..take]);
379        }
380
381        let mut fetched = fetched_content_from_bytes(&bytes, max_bytes)?;
382        // The stream stopped exactly at the cap, so the true body is at least
383        // max_bytes. `fetched_content_from_bytes` compares against `bytes.len()`
384        // and would report "not truncated" for a body that ends precisely at the
385        // cap — override so callers still see the truncation metadata.
386        if max_reached || bytes.len() >= max_bytes {
387            fetched.truncated_by_max_bytes = true;
388        }
389        Ok(fetched)
390    }
391
392    fn validate_url(&self, url: &str) -> Result<()> {
393        let parsed = Url::parse(url).map_err(|error| {
394            anyhow!(
395                "web_fetch only accepts remote HTTP(S) URLs; use read_file or unified_file to read local files: {error}"
396            )
397        })?;
398        if !matches!(parsed.scheme(), "http" | "https") {
399            bail!("web_fetch only accepts remote HTTP(S) URLs; use read_file or unified_file to read local files");
400        }
401
402        // HTTPS enforcement (can be disabled only for testing)
403        if self.strict_https_only && parsed.scheme() != "https" {
404            return Err(anyhow!("Only HTTPS URLs are allowed for security"));
405        }
406
407        // Parse the URL to extract the real host, which correctly separates
408        // userinfo credentials (http://evil@127.0.0.1/) from the host.
409        let domain = extract_domain(url).map_err(|e| anyhow!("Failed to parse URL for security validation: {e}"))?;
410
411        // Reject private, loopback, link-local, and reserved IPs.
412        if is_private_host(&domain) {
413            return Err(anyhow!("Access to local/private networks is blocked"));
414        }
415
416        // Block special-use TLDs per RFC 6761 and mDNS/split-DNS conventions:
417        // - .local (mDNS)
418        // - .internal (private split-DNS)
419        // - .localhost (loopback)
420        // - .test (testing, RFC 6761)
421        // - .invalid (always fails, RFC 6761)
422        // - .home.arpa (home networking, RFC 7788)
423        let domain_lower = domain.to_ascii_lowercase();
424        if domain_lower.ends_with(".local")
425            || domain_lower.ends_with(".internal")
426            || domain_lower.ends_with(".localhost")
427            || domain_lower.ends_with(".test")
428            || domain_lower.ends_with(".invalid")
429            || domain_lower.ends_with(".home.arpa")
430        {
431            return Err(anyhow!("Access to local/private networks is blocked"));
432        }
433
434        let url_lower = url.to_lowercase();
435
436        // Apply security policy based on mode
437        match self.mode {
438            vtcode_config::WebFetchMode::Whitelist => self.validate_whitelist_mode(&url_lower)?,
439            vtcode_config::WebFetchMode::Restricted => self.validate_restricted_mode(&url_lower)?,
440        }
441
442        Ok(())
443    }
444
445    fn validate_whitelist_mode(&self, url: &str) -> Result<()> {
446        // In whitelist mode, only explicitly allowed domains are permitted
447        let domain = extract_domain(url)?;
448
449        if self.allowed_domains.is_empty() {
450            return Err(anyhow!(
451                "Whitelist mode enabled but no domains are whitelisted. Configure allowed_domains in web_fetch settings."
452            ));
453        }
454
455        // Check if domain matches any whitelisted domain or pattern
456        for allowed in &self.allowed_domains {
457            if domain_matches_allowed(&domain, allowed) {
458                return Ok(());
459            }
460        }
461
462        Err(anyhow!(
463            "Domain '{domain}' is not in the whitelist. Only explicitly allowed domains are permitted in whitelist mode."
464        ))
465    }
466
467    fn validate_restricted_mode(&self, url: &str) -> Result<()> {
468        // In restricted mode, use a blocklist of known dangerous/sensitive domains
469        let url_lower = url.to_lowercase();
470
471        // Check against allowed exemptions first (exemptions override blocklist)
472        let domain = extract_domain(url)?;
473        for allowed in &self.allowed_domains {
474            if domain_matches_allowed(&domain, allowed) {
475                return Ok(());
476            }
477        }
478
479        // Check for malicious and sensitive URL patterns
480        self.validate_url_safety(&url_lower)?;
481
482        Ok(())
483    }
484
485    fn validate_url_safety(&self, url: &str) -> Result<()> {
486        // Combine built-in and custom blocked domains
487        let mut all_blocked_domains = BUILTIN_BLOCKED_DOMAINS.to_vec();
488        all_blocked_domains.extend(self.blocked_domains.iter().map(|s| s.as_str()));
489
490        // Combine built-in and custom blocked patterns
491        let mut all_blocked_patterns = BUILTIN_BLOCKED_PATTERNS.to_vec();
492        all_blocked_patterns.extend(self.blocked_patterns.iter().map(|s| s.as_str()));
493
494        // Check blocked domains
495        for domain in &all_blocked_domains {
496            if url.contains(domain) {
497                return Err(anyhow!(
498                    "Access to sensitive domain '{domain}' is blocked for privacy and security reasons"
499                ));
500            }
501        }
502
503        // Check for sensitive patterns in URL
504        for pattern in &all_blocked_patterns {
505            if url.contains(pattern) {
506                return Err(anyhow!(
507                    "URL contains sensitive pattern '{pattern}'. Fetching URLs with credentials or sensitive data is blocked"
508                ));
509            }
510        }
511
512        // Check for common malware/phishing indicators
513        self.check_malicious_indicators(url)?;
514
515        Ok(())
516    }
517
518    fn check_malicious_indicators(&self, url: &str) -> Result<()> {
519        for pattern in MALICIOUS_PATTERNS {
520            if url.contains(pattern) {
521                return Err(anyhow!("URL contains potentially malicious pattern. Access blocked for safety"));
522            }
523        }
524
525        Ok(())
526    }
527
528    fn validate_content_type(&self, content_type: &str) -> Result<()> {
529        if content_type.is_empty() {
530            return Ok(());
531        }
532
533        let allowed_types = [
534            "text/html",
535            "text/plain",
536            "text/markdown",
537            "application/json",
538            "application/xml",
539            "text/xml",
540            "application/javascript",
541            "text/css",
542            "text/javascript",
543            "application/xhtml+xml",
544        ];
545
546        // Content-Type headers may include parameters (e.g., "text/html; charset=utf-8").
547        // Extract just the media type (everything before the first ';') for matching.
548        let content_type_lower = content_type.to_lowercase();
549        let media_type = content_type_lower.split(';').next().unwrap_or(&content_type_lower).trim();
550
551        if allowed_types.contains(&media_type) {
552            Ok(())
553        } else {
554            Err(anyhow!(
555                "Content type '{content_type}' is not supported. Only text-based content types are allowed."
556            ))
557        }
558    }
559
560    async fn run(&self, raw_args: Value) -> Result<Value> {
561        let args: WebFetchArgs = serde_json::from_value(raw_args)
562            .context("Invalid arguments for web_fetch tool. Provide 'url' (and optionally 'prompt').")?;
563
564        let max_bytes = args.max_bytes.map(|v| v.min(MAX_ALLOWED_BYTES)).unwrap_or(MAX_CONTENT_SIZE);
565        let timeout_secs = args
566            .timeout_secs
567            .map(|v| v.min(MAX_ALLOWED_TIMEOUT_SECS))
568            .unwrap_or(DEFAULT_TIMEOUT_SECS);
569
570        // Fetch the URL content with detailed error handling
571        let fetched = match self.fetch_url_content(&args.url, max_bytes, timeout_secs).await {
572            Ok(fetched) => fetched,
573            Err(e) => {
574                // Categorize the error so the agent loop can react. We used to
575                // surface a flat string here, which led to the agent in
576                // turn_578 reporting "github.com, npmjs.com, and crates.io are
577                // all blocked" when the actual errors were 403/404 from the
578                // upstream services.
579                return Ok(web_fetch_error_response(&args.url, max_bytes, timeout_secs, &e));
580            }
581        };
582        let content = fetched.content;
583
584        let content_length = content.len();
585
586        if content_length == 0 {
587            return Ok(json!({
588                "error": format!(
589                    "web_fetch: no content fetched from '{}'. The URL may be unreachable, returned empty content, or used an unsupported content-type.",
590                    args.url
591                ),
592                "url": args.url,
593                "error_type": "empty_content",
594                "next_action": "The host returned an empty body. This may be a bot block, a JS-only page, or a real empty document. Try web_search to confirm the page exists; if it does, the content is probably JavaScript-rendered and you need a different tool."
595            }));
596        }
597
598        // Write content to ephemeral temp file instead of keeping it in the response.
599        // The temp file is auto-cleaned by age-based cleanup.
600        let temp_path = write_to_temp_file(&content, &args.url).await?;
601        let temp_path_str = temp_path.to_string_lossy().to_string();
602
603        // Preview prefers extracted readable text for HTML pages (scripts,
604        // styles, and head metadata removed); other bodies preview raw.
605        // The full body always lands in `temp_file` untouched.
606        let preview_limit = 8000;
607        let extracted_text = if html_preview::looks_like_html(&content) {
608            let text = html_preview::extract_text_preview(&content);
609            (!text.is_empty()).then_some(text)
610        } else {
611            None
612        };
613        let (preview, truncated, preview_kind, preview_basis_len) = match extracted_text {
614            Some(text) => {
615                let basis_len = text.len();
616                let truncated = basis_len > preview_limit;
617                let preview = if truncated {
618                    vtcode_commons::formatting::truncate_byte_budget(&text, preview_limit, "...")
619                } else {
620                    text
621                };
622                (preview, truncated, "text", basis_len)
623            }
624            None => {
625                let truncated = content_length > preview_limit;
626                let preview = if truncated {
627                    vtcode_commons::formatting::truncate_byte_budget(&content, preview_limit, "...")
628                } else {
629                    content.clone()
630                };
631                (preview, truncated, "raw", content_length)
632            }
633        };
634
635        // Cleanup: periodically remove old temp files (every ~50 calls)
636        if let Err(e) = cleanup_old_web_fetch_temps(TEMP_MAX_AGE_SECS).await {
637            tracing::debug!(error = %e, "Periodic web_fetch temp cleanup failed");
638        }
639
640        // Canonical response shape:
641        // - `temp_file`: path to ephemeral temp file containing full fetched body
642        // - `preview`: truncated snippet (8KB by default) for display
643        // - `prompt`: what the user/model wants to know
644        // - `next_action_hint`: explicit instruction so the agent continues the loop correctly
645        // - `no_spool: true` prevents the output spooler from persisting fetched content to disk
646        let mut response = json!({
647            "url": args.url,
648            "prompt": args.prompt,
649            "temp_file": temp_path_str,
650            "preview": preview,
651            "preview_kind": preview_kind,
652            "content_length": content_length,
653            "truncated": truncated,
654            "no_spool": true,
655            "next_action_hint": if preview_kind == "text" {
656                "Analyze the inline `preview` (readable text extracted from the page; scripts and styles removed) using `prompt` and answer the user directly. Only read `temp_file` (raw HTML) if you need content beyond the preview; it is ephemeral and may already be cleaned up."
657            } else {
658                "Analyze the inline `preview` (the start of the page) using `prompt` and answer the user directly. Only read `temp_file` if you need content beyond the preview; it is ephemeral and may already be cleaned up."
659            }
660        });
661
662        // Add overflow indicator if preview was truncated
663        if truncated {
664            response["overflow"] = json!(format!("[+{} more characters]", preview_basis_len - preview_limit));
665        }
666
667        if fetched.truncated_by_max_bytes {
668            response["truncated_by_max_bytes"] = json!(true);
669            response["max_bytes"] = json!(max_bytes);
670            response["source_size_bytes"] = json!(fetched.source_size_bytes);
671            response["next_action_hint"] = json!(
672                "Read `temp_file` to get the fetched content. Analyze it using `prompt`. If it does not contain enough detail, retry web_fetch with a larger max_bytes or a more specific URL."
673            );
674        }
675
676        Ok(response)
677    }
678}
679
680impl WebFetchTool {
681    /// Returns default headers used by the WebFetch client. This keeps Accept set to
682    /// prefer 'text/markdown' so documentation sites can provide token-efficient markdown
683    /// content as a preference.
684    fn default_headers() -> HeaderMap {
685        let mut headers = HeaderMap::new();
686        headers.insert(ACCEPT, HeaderValue::from_static("text/markdown, */*"));
687        headers.insert(USER_AGENT, HeaderValue::from_static("VT Code/1.0 (compatible; web-fetch tool)"));
688        headers
689    }
690}
691
692/// Helper function to extract domain from URL
693///
694/// Uses proper URL parsing to correctly handle:
695/// - User credentials in URLs (`http://user@host/`) — the host is properly
696///   separated from the userinfo, preventing SSRF bypass
697/// - Port numbers, paths, and query strings
698fn extract_domain(url: &str) -> Result<String> {
699    let parsed = Url::parse(url).with_context(|| format!("Failed to parse URL: {url}"))?;
700    let host = parsed.host_str().ok_or_else(|| anyhow!("URL has no host: {url}"))?;
701    if host.is_empty() {
702        bail!("URL has empty host: {url}");
703    }
704    Ok(host.to_string())
705}
706
707/// Returns `true` when `host` is a private, loopback, or link-local IP address.
708/// Return `true` when `host` is a loopback, private, link-local,
709/// broadcast, or multicast address. Used by the SSRF guard in
710/// `web_fetch::validate_url` and re-used by `defuddle` so a private host
711/// can never sneak through the defuddle relay.
712///
713/// Accepts the host either bare or bracket-wrapped (the URL parser
714/// returns IPv6 hosts like `[::1]`; we strip the brackets before
715/// parsing).
716pub(super) fn is_private_host(host: &str) -> bool {
717    let trimmed = host.strip_prefix('[').and_then(|s| s.strip_suffix(']')).unwrap_or(host);
718    // Try IPv4 / IPv6 parsing first.
719    if let Ok(ip) = trimmed.parse::<IpAddr>() {
720        return match ip {
721            IpAddr::V4(v4) => is_private_ipv4(v4),
722            IpAddr::V6(v6) => is_private_ipv6(v6),
723        };
724    }
725
726    // DNS names like "localhost" that will resolve to loopback.
727    if trimmed.eq_ignore_ascii_case("localhost") || trimmed.eq_ignore_ascii_case("localhost.localdomain") {
728        return true;
729    }
730
731    false
732}
733
734/// Check if an IPv4 address is private, loopback, link-local, broadcast,
735/// or multicast.
736fn is_private_ipv4(v4: std::net::Ipv4Addr) -> bool {
737    let octets = v4.octets();
738    // 127.0.0.0/8 — loopback (is_loopback only matches 127.0.0.1)
739    octets[0] == 127
740        // 10.0.0.0/8 — class A private
741        || octets[0] == 10
742        // 172.16.0.0/12 — class B private
743        || (octets[0] == 172 && (octets[1] & 0xf0) == 16)
744        // 192.168.0.0/16 — class C private
745        || (octets[0] == 192 && octets[1] == 168)
746        // 169.254.0.0/16 — link-local
747        || (octets[0] == 169 && octets[1] == 254)
748        // 0.0.0.0/8 — "this network" (covers 0.0.0.0 itself; is_unspecified
749        // would only match 0.0.0.0 strictly)
750        || octets[0] == 0
751        // 255.255.255.255 — broadcast
752        || v4.is_broadcast()
753        // 224.0.0.0/4 — multicast
754        || (octets[0] & 0xf0) == 224
755}
756
757/// Check if an IPv6 address is private, loopback, link-local, or an
758/// IPv4-mapped/compatible address pointing to a private IPv4 range.
759fn is_private_ipv6(v6: std::net::Ipv6Addr) -> bool {
760    let segments = v6.segments();
761
762    // Direct IPv6 checks
763    if v6.is_loopback()
764        || v6.is_unspecified()
765        // fc00::/7 — unique local unicast
766        || (segments[0] & 0xfe00) == 0xfc00
767        // fe80::/10 — link-local unicast
768        || (segments[0] & 0xffc0) == 0xfe80
769    {
770        return true;
771    }
772
773    // Check for IPv4-mapped (::ffff:a.b.c.d) and IPv4-compatible (::a.b.c.d) addresses.
774    // These embed an IPv4 address in the low 32 bits; the embedded IPv4 must also be checked.
775    // Format: segments[0..4] are 0x0000 or 0xffff, segments[4..6] are 0x0000,
776    // then segments[6..8] hold the IPv4 address.
777    let is_ipv4_mapped = segments[0] == 0
778        && segments[1] == 0
779        && segments[2] == 0
780        && segments[3] == 0
781        && segments[4] == 0
782        && segments[5] == 0xffff;
783    let is_ipv4_compat = segments[0] == 0
784        && segments[1] == 0
785        && segments[2] == 0
786        && segments[3] == 0
787        && segments[4] == 0
788        && segments[5] == 0;
789
790    if is_ipv4_mapped || is_ipv4_compat {
791        let embedded = std::net::Ipv4Addr::new(
792            (segments[6] >> 8) as u8,
793            (segments[6] & 0xff) as u8,
794            (segments[7] >> 8) as u8,
795            (segments[7] & 0xff) as u8,
796        );
797        return is_private_ipv4(embedded);
798    }
799
800    false
801}
802
803fn domain_matches_allowed(domain: &str, allowed: &str) -> bool {
804    let normalized_domain = domain.trim_end_matches('.').to_ascii_lowercase();
805    let normalized_allowed = allowed.trim_start_matches('.').trim_end_matches('.').to_ascii_lowercase();
806
807    // Wildcard entries like `*.example.com` match the apex and any
808    // subdomain. The `*` must be a complete leading label, not a partial
809    // match (so `*.example.com` matches `api.example.com` but not
810    // `evilexample.com`).
811    if let Some(suffix) = normalized_allowed.strip_prefix("*.") {
812        // Reject single-label wildcards like `*.com` — that would match
813        // every `.com` host, which is almost certainly a misconfiguration
814        // and a serious over-grant. The suffix must contain at least one
815        // dot, e.g. `*.example.com` or `*.co.uk`.
816        if !suffix.contains('.') {
817            return false;
818        }
819        return normalized_domain == suffix || normalized_domain.ends_with(&format!(".{suffix}"));
820    }
821
822    normalized_domain == normalized_allowed || normalized_domain.ends_with(&format!(".{normalized_allowed}"))
823}
824
825impl Default for WebFetchTool {
826    fn default() -> Self {
827        Self::new()
828    }
829}
830
831#[async_trait]
832impl Tool for WebFetchTool {
833    async fn execute(&self, mut args: Value) -> Result<Value> {
834        // Validate before dispatching to either backend. The markdown path is
835        // a convenience route through Defuddle, but it must have the same
836        // remote-only and SSRF policy as the normal fetch path.
837        if let Some(url) = args.get("url").and_then(Value::as_str) {
838            let max_bytes = args
839                .get("max_bytes")
840                .and_then(Value::as_u64)
841                .map(|value| value as usize)
842                .unwrap_or(MAX_CONTENT_SIZE);
843            let timeout_secs = args.get("timeout_secs").and_then(Value::as_u64).unwrap_or(DEFAULT_TIMEOUT_SECS);
844            if let Err(error) = self.validate_url(url) {
845                return Ok(web_fetch_error_response(url, max_bytes, timeout_secs, &error));
846            }
847        }
848
849        // Backwards-compatible argument normalization:
850        // - If called with only { "url": "..." } (no prompt), interpret as:
851        //   "Fetch this URL and return a concise natural language summary."
852        //
853        // This ensures:
854        // - Simple "fetch https://..." style calls are handled natively by VT Code.
855        // - We do not force upstream agents or MCP tools to construct a full prompt.
856        // - MCP tools like `get_current_time` remain unaffected (they are separate).
857        // `format: "markdown"` routes to the defuddle.md extraction backend
858        // (absorbs the former standalone `defuddle_fetch` tool). Session cap
859        // and URL validation are enforced by the backend itself.
860        if let Some(obj) = args.as_object_mut() {
861            let wants_markdown = obj
862                .get("format")
863                .and_then(Value::as_str)
864                .is_some_and(|f| f.eq_ignore_ascii_case("markdown"));
865            if wants_markdown {
866                let mut defuddle_args = serde_json::Map::new();
867                if let Some(url) = obj.get("url").cloned() {
868                    defuddle_args.insert("url".to_string(), url);
869                }
870                if let Some(max_bytes) = obj.get("max_bytes").cloned() {
871                    defuddle_args.insert("max_bytes".to_string(), max_bytes);
872                }
873                return self.defuddle.execute(Value::Object(defuddle_args)).await;
874            }
875        }
876
877        if let Some(obj) = args.as_object_mut() {
878            let has_url = obj.get("url").is_some_and(Value::is_string);
879            let has_prompt = obj.get("prompt").is_some_and(Value::is_string);
880
881            if has_url && !has_prompt {
882                obj.insert(
883                    "prompt".to_string(),
884                    json!("Summarize this page concisely. Read the temp_file to get the full content, then focus on the primary purpose, key information, and any actionable details."),
885                );
886            }
887        }
888
889        self.run(args).await
890    }
891
892    fn name(&self) -> &str {
893        tools::WEB_FETCH
894    }
895
896    fn description(&self) -> &str {
897        WEB_FETCH_DESCRIPTION
898    }
899}
900
901/// Build a structured error response for a failed `web_fetch` call.
902///
903/// The error category (`http_error`, `network_error`, `policy_blocked`,
904/// `empty_content`, etc.) plus a `next_action` hint is much more useful to
905/// the agent loop than a flat string. The hint is what unblocks the loop
906/// in cases like turn_578, where the agent was getting real HTTP 4xx
907/// responses but reported the hosts as "blocked" because the error format
908/// only carried a free-form message.
909fn web_fetch_error_response(url: &str, max_bytes: usize, timeout_secs: u64, err: &anyhow::Error) -> Value {
910    let message = err.to_string();
911    let (category, http_status, next_action) = classify_web_fetch_error(&message);
912
913    let mut payload = json!({
914        "error": format!("web_fetch: failed to fetch URL '{}': {}", url, message),
915        "url": url,
916        "max_bytes": max_bytes,
917        "timeout_secs": timeout_secs,
918        "error_type": category,
919        "next_action": next_action,
920    });
921    if let Some(status) = http_status {
922        payload["http_status"] = json!(status);
923    }
924    payload
925}
926
927/// Map a `web_fetch` error string into a stable (category, status,
928/// next_action) tuple. The agent loop reads `next_action` to decide whether
929/// to retry, fall back to a different tool, or give up.
930fn classify_web_fetch_error(message: &str) -> (&'static str, Option<u16>, &'static str) {
931    // The reqwest error string is "<METHOD> <URL> error: <chain>". We only
932    // look at the error portion because the URL is already in the response.
933    let lower = message.to_lowercase();
934
935    // Check timeout / network-class failures BEFORE HTTP status: a slow
936    // upstream that returns a 5xx AFTER the body stream stalls can
937    // produce a string like "request timed out (last status: 503)".
938    // Status-based hints are not the right next-action for that case —
939    // the cause is timeout, not server outage.
940    if lower.contains("timeout") || lower.contains("timed out") {
941        return (
942            "network_error",
943            None,
944            "The request timed out. The host may be slow or unreachable. Try web_search to look up the page title first, or retry with a larger timeout_secs.",
945        );
946    }
947    if lower.contains("only accepts remote http") || lower.contains("local files") {
948        return (
949            "local_resource",
950            None,
951            "web_fetch is for remote HTTP(S) URLs only. Use read_file or unified_file to read a local workspace file; do not retry web_fetch with a file:// URL.",
952        );
953    }
954    if lower.contains("dns") || lower.contains("name resolution") || lower.contains("connection refused") {
955        return (
956            "network_error",
957            None,
958            "The host could not be reached. Try web_search to look up the page title first; if the host is down, retry later.",
959        );
960    }
961    if let Some(status) = extract_http_status(&lower) {
962        return http_status_to_category(status);
963    }
964    if lower.contains("ssl") || lower.contains("certificate") || lower.contains("tls") {
965        return (
966            "tls_error",
967            None,
968            "TLS handshake failed. The host may have an invalid or self-signed certificate. Try web_search as a fallback.",
969        );
970    }
971    if lower.contains("redirect") {
972        return (
973            "redirect_error",
974            None,
975            "Redirect chain failed validation. The host may redirect to a blocked domain or loop. Try web_search to find the canonical URL.",
976        );
977    }
978    if lower.contains("unsupported content type") || lower.contains("not supported") {
979        return (
980            "content_type_error",
981            None,
982            "The server returned a non-text content type (e.g., image or PDF). The web_fetch tool only handles text/HTML/JSON. Try web_search instead, or use a different tool to read the resource.",
983        );
984    }
985    if lower.contains("blocked") || lower.contains("sensitive") {
986        return (
987            "policy_blocked",
988            None,
989            "The URL is on the blocklist for this tool. Use web_search to find an alternative source, or ask the user to whitelist the host via [web_fetch] allowed_domains in vtcode.toml.",
990        );
991    }
992    (
993        "unknown_error",
994        None,
995        "An unexpected error occurred. The error message above is the upstream cause; if it repeats, surface it to the user rather than retrying.",
996    )
997}
998
999/// Re-export of `classify_helpers::extract_http_status`. Pulled into
1000/// this module so existing call sites can keep using
1001/// `extract_http_status(message)` without the extra namespace.
1002fn http_status_to_category(status: u16) -> (&'static str, Option<u16>, &'static str) {
1003    match status {
1004        401 | 407 => (
1005            "http_error",
1006            Some(status),
1007            "The host requires authentication that web_fetch cannot provide. Try web_search for a cached version, or ask the user for credentials.",
1008        ),
1009        403 => (
1010            "http_error",
1011            Some(status),
1012            "The host explicitly blocked this request (often anti-bot). Try web_search, or wait a few seconds and retry with a normal browser User-Agent.",
1013        ),
1014        404 => (
1015            "http_error",
1016            Some(status),
1017            "The host returned 404: the page or resource does not exist. Verify the URL, or try web_search to find the correct one.",
1018        ),
1019        410 => (
1020            "http_error",
1021            Some(status),
1022            "The host returned 410: the resource is permanently gone. Try web_search for an alternative.",
1023        ),
1024        429 => (
1025            "http_error",
1026            Some(status),
1027            "The host rate-limited this client. Wait a few seconds and retry, or use web_search instead.",
1028        ),
1029        500..=599 => (
1030            "http_error",
1031            Some(status),
1032            "The host returned a server error. Retry after a short delay, or use web_search as a fallback.",
1033        ),
1034        _ => (
1035            "http_error",
1036            Some(status),
1037            "The host returned an unexpected status. The error message above is the upstream response; treat it as terminal unless the user asks for a retry.",
1038        ),
1039    }
1040}
1041
1042#[cfg(test)]
1043mod tests {
1044    use super::*;
1045    use serde_json::{Value, json};
1046
1047    async fn execute_json(tool: &WebFetchTool, args: Value) -> Value {
1048        tool.execute(args)
1049            .await
1050            .expect("web_fetch should return structured JSON output")
1051    }
1052
1053    fn error_text(result: &Value) -> Option<&str> {
1054        result.get("error").and_then(Value::as_str)
1055    }
1056
1057    #[tokio::test]
1058    async fn rejects_non_https_urls() {
1059        let tool = WebFetchTool::new();
1060        let result = execute_json(
1061            &tool,
1062            json!({
1063                "url": "http://example.com",
1064                "prompt": "Extract the main content"
1065            }),
1066        )
1067        .await;
1068        let error = error_text(&result).unwrap_or("");
1069        assert!(error.contains("Only HTTPS URLs are allowed"));
1070    }
1071
1072    #[tokio::test]
1073    async fn rejects_local_file_urls_with_read_file_guidance() {
1074        let tool = WebFetchTool::new();
1075        let result = execute_json(
1076            &tool,
1077            json!({
1078                "url": "file:///tmp/checkpoint.json",
1079                "prompt": "Read the file"
1080            }),
1081        )
1082        .await;
1083        let error = error_text(&result).unwrap_or("");
1084        assert!(error.contains("remote HTTP(S) URLs"));
1085        assert_eq!(result["error_type"], "local_resource");
1086        assert!(result["next_action"].as_str().unwrap_or("").contains("read_file"));
1087    }
1088
1089    #[tokio::test]
1090    async fn rejects_local_file_urls_before_markdown_backend_routing() {
1091        let tool = WebFetchTool::new();
1092        let result = execute_json(
1093            &tool,
1094            json!({
1095                "url": "file:///tmp/checkpoint.json",
1096                "format": "markdown",
1097                "prompt": "Read the file"
1098            }),
1099        )
1100        .await;
1101
1102        assert_eq!(result["error_type"], "local_resource");
1103        assert!(error_text(&result).unwrap_or("").contains("remote HTTP(S) URLs"));
1104        assert!(result["next_action"].as_str().unwrap_or("").contains("read_file"));
1105    }
1106
1107    #[tokio::test]
1108    async fn rejects_relative_urls_before_markdown_backend_routing() {
1109        let tool = WebFetchTool::new();
1110        let result = execute_json(
1111            &tool,
1112            json!({
1113                "url": "README.md",
1114                "format": "markdown",
1115                "prompt": "Read the file"
1116            }),
1117        )
1118        .await;
1119
1120        assert_eq!(result["error_type"], "local_resource");
1121        assert!(error_text(&result).unwrap_or("").contains("remote HTTP(S) URLs"));
1122        assert!(result["next_action"].as_str().unwrap_or("").contains("unified_file"));
1123    }
1124
1125    #[tokio::test]
1126    async fn rejects_relative_urls_before_normal_fetch_routing() {
1127        let tool = WebFetchTool::new();
1128        let result = execute_json(
1129            &tool,
1130            json!({
1131                "url": "README.md",
1132                "prompt": "Read the file"
1133            }),
1134        )
1135        .await;
1136
1137        assert_eq!(result["error_type"], "local_resource");
1138        assert!(error_text(&result).unwrap_or("").contains("remote HTTP(S) URLs"));
1139        assert!(result["next_action"].as_str().unwrap_or("").contains("unified_file"));
1140    }
1141
1142    #[tokio::test]
1143    async fn allows_http_when_https_disabled() {
1144        let tool = WebFetchTool::with_config(
1145            vtcode_config::WebFetchMode::Restricted,
1146            Vec::new(),
1147            Vec::new(),
1148            Vec::new(),
1149            false, // strict_https_only = false
1150        );
1151        let result = execute_json(
1152            &tool,
1153            json!({
1154                "url": "http://example.com",
1155                "prompt": "Extract the main content"
1156            }),
1157        )
1158        .await;
1159        if let Some(error) = error_text(&result) {
1160            assert!(!error.contains("Only HTTPS URLs are allowed"));
1161        }
1162    }
1163
1164    #[tokio::test]
1165    async fn rejects_localhost_urls() {
1166        let tool = WebFetchTool::new();
1167        let result = execute_json(
1168            &tool,
1169            json!({
1170                "url": "https://localhost:8080",
1171                "prompt": "Extract the main content"
1172            }),
1173        )
1174        .await;
1175        let error = error_text(&result).unwrap_or("");
1176        assert!(error.contains("local/private networks"));
1177    }
1178
1179    #[tokio::test]
1180    async fn requires_both_url_and_prompt() {
1181        let tool = WebFetchTool::new();
1182        let result = execute_json(
1183            &tool,
1184            json!({
1185                "url": "http://example.com"
1186            }),
1187        )
1188        .await;
1189        // Prompt should be auto-filled; URL then fails HTTPS policy.
1190        let error = error_text(&result).unwrap_or("");
1191        assert!(error.contains("Only HTTPS URLs are allowed"));
1192    }
1193
1194    #[tokio::test]
1195    async fn rejects_sensitive_banking_domains() {
1196        let tool = WebFetchTool::new();
1197        let result = execute_json(
1198            &tool,
1199            json!({
1200                "url": "https://paypal.com/login",
1201                "prompt": "Extract the main content"
1202            }),
1203        )
1204        .await;
1205        let error = error_text(&result).unwrap_or("");
1206        assert!(error.contains("blocked for privacy and security reasons"));
1207    }
1208
1209    #[tokio::test]
1210    async fn rejects_sensitive_auth_domains() {
1211        let tool = WebFetchTool::new();
1212        let result = execute_json(
1213            &tool,
1214            json!({
1215                "url": "https://accounts.google.com",
1216                "prompt": "Extract the main content"
1217            }),
1218        )
1219        .await;
1220        let error = error_text(&result).unwrap_or("");
1221        assert!(error.contains("blocked for privacy and security reasons"));
1222    }
1223
1224    #[tokio::test]
1225    async fn rejects_urls_with_credentials() {
1226        let tool = WebFetchTool::new();
1227        let result = execute_json(
1228            &tool,
1229            json!({
1230                "url": "https://example.com?password=secret123",
1231                "prompt": "Extract the main content"
1232            }),
1233        )
1234        .await;
1235        let error = error_text(&result).unwrap_or("");
1236        assert!(error.contains("sensitive pattern"));
1237    }
1238
1239    #[tokio::test]
1240    async fn rejects_urls_with_api_keys() {
1241        let tool = WebFetchTool::new();
1242        let result = execute_json(
1243            &tool,
1244            json!({
1245                "url": "https://api.example.com?api_key=sk_live_123456",
1246                "prompt": "Extract the main content"
1247            }),
1248        )
1249        .await;
1250        let error = error_text(&result).unwrap_or("");
1251        assert!(error.contains("sensitive pattern"));
1252    }
1253
1254    #[tokio::test]
1255    async fn rejects_urls_with_tokens() {
1256        let tool = WebFetchTool::new();
1257        let result = execute_json(
1258            &tool,
1259            json!({
1260                "url": "https://example.com?token=xyz123",
1261                "prompt": "Extract the main content"
1262            }),
1263        )
1264        .await;
1265        let error = error_text(&result).unwrap_or("");
1266        assert!(error.contains("sensitive pattern"));
1267    }
1268
1269    #[tokio::test]
1270    async fn rejects_malicious_url_patterns() {
1271        let tool = WebFetchTool::new();
1272        let result = execute_json(
1273            &tool,
1274            json!({
1275                "url": "https://example.com/malware.exe\"",
1276                "prompt": "Extract the main content"
1277            }),
1278        )
1279        .await;
1280        let error = error_text(&result).unwrap_or("");
1281        assert!(error.contains("potentially malicious pattern"));
1282    }
1283
1284    #[tokio::test]
1285    async fn rejects_typosquatting_domains() {
1286        let tool = WebFetchTool::new();
1287        let result = execute_json(
1288            &tool,
1289            json!({
1290                "url": "https://g00gle.com",
1291                "prompt": "Extract the main content"
1292            }),
1293        )
1294        .await;
1295        let error = error_text(&result).unwrap_or("");
1296        assert!(error.contains("potentially malicious pattern"));
1297    }
1298
1299    #[tokio::test]
1300    async fn rejects_url_shorteners() {
1301        let tool = WebFetchTool::new();
1302        let result = execute_json(
1303            &tool,
1304            json!({
1305                "url": "https://bit.ly/xyz123",
1306                "prompt": "Extract the main content"
1307            }),
1308        )
1309        .await;
1310        let error = error_text(&result).unwrap_or("");
1311        assert!(error.contains("potentially malicious pattern"));
1312    }
1313
1314    #[tokio::test]
1315    async fn whitelist_mode_requires_allowed_domains() {
1316        let tool = WebFetchTool::with_config(
1317            vtcode_config::WebFetchMode::Whitelist,
1318            Vec::new(),
1319            Vec::new(),
1320            Vec::new(), // No allowed domains
1321            true,
1322        );
1323        let result = execute_json(
1324            &tool,
1325            json!({
1326                "url": "https://example.com",
1327                "prompt": "Extract the main content"
1328            }),
1329        )
1330        .await;
1331        let error = error_text(&result).unwrap_or("").to_string();
1332        assert!(error.contains("whitelist") || error.contains("whitelisted"));
1333    }
1334
1335    #[tokio::test]
1336    async fn whitelist_mode_allows_whitelisted_domains() {
1337        let tool = WebFetchTool::with_config(
1338            vtcode_config::WebFetchMode::Whitelist,
1339            Vec::new(),
1340            Vec::new(),
1341            vec!["example.com".to_string()], // Only example.com allowed
1342            true,
1343        );
1344        let result = execute_json(
1345            &tool,
1346            json!({
1347                "url": "https://example.com/path",
1348                "prompt": "Extract the main content"
1349            }),
1350        )
1351        .await;
1352        if let Some(error) = error_text(&result) {
1353            assert!(!error.contains("not in the whitelist"));
1354        }
1355    }
1356
1357    #[tokio::test]
1358    async fn whitelist_mode_rejects_non_whitelisted_domains() {
1359        let tool = WebFetchTool::with_config(
1360            vtcode_config::WebFetchMode::Whitelist,
1361            Vec::new(),
1362            Vec::new(),
1363            vec!["allowed.com".to_string()],
1364            true,
1365        );
1366        let result = execute_json(
1367            &tool,
1368            json!({
1369                "url": "https://notallowed.com",
1370                "prompt": "Extract the main content"
1371            }),
1372        )
1373        .await;
1374        let error = error_text(&result).unwrap_or("").to_string();
1375        assert!(error.contains("not in the whitelist"));
1376    }
1377
1378    #[tokio::test]
1379    async fn restricted_mode_allows_exemptions() {
1380        let tool = WebFetchTool::with_config(
1381            vtcode_config::WebFetchMode::Restricted,
1382            Vec::new(),
1383            Vec::new(),
1384            vec!["paypal.com".to_string()], // Exempt from blocklist
1385            true,
1386        );
1387        let result = execute_json(
1388            &tool,
1389            json!({
1390                "url": "https://paypal.com/login",
1391                "prompt": "Extract the main content"
1392            }),
1393        )
1394        .await;
1395        if let Some(error) = error_text(&result) {
1396            assert!(!error.contains("blocked for privacy"));
1397        }
1398    }
1399
1400    #[tokio::test]
1401    async fn custom_blocked_domains_work() {
1402        let tool = WebFetchTool::with_config(
1403            vtcode_config::WebFetchMode::Restricted,
1404            vec!["custom-blocked.com".to_string()], // Custom blocked domain
1405            Vec::new(),
1406            Vec::new(),
1407            true,
1408        );
1409        let result = execute_json(
1410            &tool,
1411            json!({
1412                "url": "https://custom-blocked.com/page",
1413                "prompt": "Extract the main content"
1414            }),
1415        )
1416        .await;
1417        let error = error_text(&result).unwrap_or("").to_string();
1418        assert!(error.contains("blocked for privacy and security reasons"));
1419    }
1420
1421    #[tokio::test]
1422    async fn custom_blocked_patterns_work() {
1423        let tool = WebFetchTool::with_config(
1424            vtcode_config::WebFetchMode::Restricted,
1425            Vec::new(),
1426            vec!["custom_secret=".to_string()], // Custom pattern
1427            Vec::new(),
1428            true,
1429        );
1430        let result = execute_json(
1431            &tool,
1432            json!({
1433                "url": "https://example.com?custom_secret=abc123",
1434                "prompt": "Extract the main content"
1435            }),
1436        )
1437        .await;
1438        let error = error_text(&result).unwrap_or("").to_string();
1439        assert!(error.contains("sensitive pattern"));
1440    }
1441
1442    #[test]
1443    fn default_headers_contain_text_markdown_accept() {
1444        let headers = WebFetchTool::default_headers();
1445        assert!(headers.contains_key(ACCEPT));
1446        let val = headers.get(ACCEPT).unwrap().to_str().unwrap();
1447        assert!(val.contains("text/markdown"));
1448    }
1449
1450    #[test]
1451    fn oversized_body_is_truncated_instead_of_rejected() {
1452        let fetched = fetched_content_from_bytes("αβγ".as_bytes(), 3).expect("valid utf-8 prefix should be returned");
1453
1454        assert_eq!(fetched.content, "α");
1455        assert!(fetched.truncated_by_max_bytes);
1456        assert_eq!(fetched.source_size_bytes, "αβγ".len());
1457    }
1458
1459    #[test]
1460    fn truncation_backs_off_to_utf8_boundary_in_one_pass() {
1461        // Regression: the old implementation decremented `end` byte-by-byte,
1462        // which is O(n^2) on bodies whose prefix ends mid-multibyte-char. A 1MB
1463        // body of multibyte chars must truncate via `valid_up_to()` without
1464        // producing invalid UTF-8.
1465        let body = "界".repeat(500_000); // 3 bytes per char, 1.5MB total
1466        let max_bytes = 1_000_000; // cuts mid-char (not multiple of 3)
1467        let fetched = fetched_content_from_bytes(body.as_bytes(), max_bytes).expect("valid utf-8 prefix");
1468        assert!(fetched.truncated_by_max_bytes);
1469        assert!(fetched.content.len() <= max_bytes);
1470        assert!(
1471            fetched.content.len().is_multiple_of(3),
1472            "must end on a char boundary, got {}",
1473            fetched.content.len()
1474        );
1475    }
1476
1477    #[test]
1478    fn body_ending_exactly_at_cap_is_still_truncated() {
1479        // A body that is exactly `max_bytes` long is NOT truncated; one that
1480        // ends precisely at the cap after streaming must not be reported as
1481        // untruncated by the stream path. `fetched_content_from_bytes` alone
1482        // sees bytes.len() == max_bytes, so callers override the flag.
1483        let exact = fetched_content_from_bytes("abcde".as_bytes(), 5).expect("utf-8");
1484        assert!(!exact.truncated_by_max_bytes);
1485
1486        // Multi-char exact fit that lands on a boundary is also not truncated.
1487        let fit = fetched_content_from_bytes("αβγ".as_bytes(), 6).expect("utf-8");
1488        assert!(!fit.truncated_by_max_bytes);
1489        assert_eq!(fit.content, "αβγ");
1490    }
1491
1492    #[test]
1493    fn description_guides_agents_to_try_llms_txt_first() {
1494        let tool = WebFetchTool::new();
1495        let description = tool.description();
1496
1497        assert!(description.contains("/llms.txt"));
1498        assert!(description.contains("abc.com"));
1499        assert!(description.contains("https://abc.com/llms.txt"));
1500        assert!(description.contains("traverse"));
1501    }
1502
1503    #[test]
1504    fn max_bytes_and_timeout_are_clamped_to_hard_caps() {
1505        const _: () = {
1506            assert!(MAX_ALLOWED_BYTES >= MAX_CONTENT_SIZE);
1507            assert!(MAX_ALLOWED_TIMEOUT_SECS >= DEFAULT_TIMEOUT_SECS);
1508            assert!(MAX_ALLOWED_BYTES <= 10_000_000); // sanity: under 10MB
1509            assert!(MAX_ALLOWED_TIMEOUT_SECS <= 300); // sanity: under 5 minutes
1510        };
1511    }
1512
1513    #[test]
1514    fn ipv4_mapped_ipv6_loopback_is_private() {
1515        // ::ffff:127.0.0.1 should be caught as loopback
1516        assert!(is_private_host("::ffff:127.0.0.1"));
1517    }
1518
1519    #[test]
1520    fn ipv4_mapped_ipv6_private_is_private() {
1521        // ::ffff:10.0.0.1 should be caught as private (class A)
1522        assert!(is_private_host("::ffff:10.0.0.1"));
1523        // ::ffff:192.168.1.1 should be caught as private (class C)
1524        assert!(is_private_host("::ffff:192.168.1.1"));
1525        // ::ffff:172.16.0.1 should be caught as private (class B)
1526        assert!(is_private_host("::ffff:172.16.0.1"));
1527    }
1528
1529    #[test]
1530    fn ipv4_mapped_ipv6_link_local_is_private() {
1531        // ::ffff:169.254.0.1 should be caught as link-local
1532        assert!(is_private_host("::ffff:169.254.0.1"));
1533    }
1534
1535    #[test]
1536    fn ipv4_compatible_ipv6_loopback_is_private() {
1537        // ::127.0.0.1 (IPv4-compatible) should be caught
1538        assert!(is_private_host("::127.0.0.1"));
1539    }
1540
1541    #[test]
1542    fn ipv6_loopback_is_private() {
1543        assert!(is_private_host("::1"));
1544    }
1545
1546    #[test]
1547    fn ipv6_unique_local_is_private() {
1548        assert!(is_private_host("fd00::1"));
1549    }
1550
1551    #[test]
1552    fn ipv6_link_local_is_private() {
1553        assert!(is_private_host("fe80::1"));
1554    }
1555
1556    #[test]
1557    fn ipv4_mapped_ipv6_public_is_not_private() {
1558        // ::ffff:8.8.8.8 (Google DNS) should NOT be blocked
1559        assert!(!is_private_host("::ffff:8.8.8.8"));
1560    }
1561
1562    #[tokio::test]
1563    async fn rejects_localhost_tld() {
1564        let tool = WebFetchTool::new();
1565        let result = execute_json(
1566            &tool,
1567            json!({
1568                "url": "https://myapp.localhost/api",
1569                "prompt": "Extract content"
1570            }),
1571        )
1572        .await;
1573        let error = error_text(&result).unwrap_or("");
1574        assert!(error.contains("local/private networks"));
1575    }
1576
1577    /// Regression test for the "who is vinhnx?" case. The default
1578    /// `WebFetchConfig` seeds `allowed_domains` with common developer sites
1579    /// (github.com, npmjs.com, crates.io, etc.). The default `Restricted`
1580    /// mode honors those exemptions, so URLs on those hosts must pass
1581    /// `validate_url` without touching the network.
1582    #[test]
1583    fn default_restricted_mode_allows_common_dev_sites() {
1584        let tool = WebFetchTool::from_config(&vtcode_config::WebFetchConfig::default());
1585        // After H1: the defaults come from the TOML allowlist filtered
1586        // to web-fetch-relevant categories. `docs.rs` and `www.npmjs.com`
1587        // are not in the TOML and are no longer in defaults; replace
1588        // them with registry hosts that ARE in the TOML.
1589        for url in [
1590            "https://github.com/vinhnx",
1591            "https://github.com/vinhnx?tab=repositories",
1592            "https://api.github.com/users/vinhnx",
1593            "https://api.github.com/users/vinhnx/repos?sort=stars&per_page=20",
1594            "https://registry.npmjs.org/vinhnx",
1595            "https://crates.io/users/vinhnx",
1596            "https://raw.githubusercontent.com/rust-lang/rust/master/README.md",
1597            "https://en.wikipedia.org/wiki/Rust_(programming_language)",
1598            "https://pypi.org/project/requests/",
1599            "https://r.jina.ai/https://example.com",
1600        ] {
1601            tool.validate_url(url)
1602                .unwrap_or_else(|e| panic!("default restricted mode should allow {url}: {e}"));
1603        }
1604    }
1605
1606    /// Whitelist mode is a strict allowlist: only the user's explicit
1607    /// `allowed_domains` apply, even if the defaults include the host.
1608    /// This test constructs a tool with `Whitelist` mode and an empty
1609    /// user allow list to assert the strict contract.
1610    #[test]
1611    fn whitelist_mode_is_strict_when_user_allow_list_is_empty() {
1612        let tool = WebFetchTool::from_config(&vtcode_config::WebFetchConfig {
1613            mode: vtcode_config::WebFetchMode::Whitelist,
1614            allowed_domains: Vec::new(),
1615            ..vtcode_config::WebFetchConfig::default()
1616        });
1617        let err = tool
1618            .validate_url("https://github.com/vinhnx")
1619            .expect_err("whitelist mode must reject domains not in the user allow list");
1620        assert!(err.to_string().contains("whitelist"), "unexpected error: {err}");
1621    }
1622
1623    #[test]
1624    fn domain_matches_allowed_handles_wildcards() {
1625        // Apex + subdomains under a wildcard.
1626        assert!(domain_matches_allowed("example.com", "*.example.com"));
1627        assert!(domain_matches_allowed("api.example.com", "*.example.com"));
1628        assert!(domain_matches_allowed("deep.nested.api.example.com", "*.example.com"));
1629        // Negative matches — wildcard must not be a substring match.
1630        assert!(!domain_matches_allowed("evilexample.com", "*.example.com"));
1631        assert!(!domain_matches_allowed("example.com.evil.tld", "*.example.com"));
1632        // Apex match is also accepted.
1633        assert!(domain_matches_allowed("example.com", "example.com"));
1634        assert!(domain_matches_allowed("api.example.com", "example.com"));
1635    }
1636
1637    #[test]
1638    fn domain_matches_allowed_rejects_single_label_wildcards() {
1639        // L10 (review): a wildcard like `*.com` would match every `.com`
1640        // host. That's almost certainly a misconfiguration; refuse it
1641        // outright so an editor who fat-fingers the allowlist doesn't
1642        // accidentally open the whole TLD.
1643        assert!(!domain_matches_allowed("example.com", "*.com"));
1644        assert!(!domain_matches_allowed("api.example.com", "*.com"));
1645        assert!(!domain_matches_allowed("co.uk", "*.uk"));
1646        // A multi-label suffix is still allowed.
1647        assert!(domain_matches_allowed("example.co.uk", "*.co.uk"));
1648    }
1649
1650    #[test]
1651    fn default_allowlist_includes_wildcard_categories() {
1652        // After H1: the web_fetch defaults are filtered to web-relevant
1653        // categories. The TOML currently has no `*.foo` wildcards in
1654        // those categories, so the defaults should not contain any.
1655        // The matcher itself is still tested for wildcards via
1656        // `domain_matches_allowed_handles_wildcards` above.
1657        let config = vtcode_config::WebFetchConfig::default();
1658        let wildcards: Vec<&str> = config
1659            .allowed_domains
1660            .iter()
1661            .map(|s| s.as_str())
1662            .filter(|s| s.starts_with("*."))
1663            .collect();
1664        assert!(
1665            wildcards.is_empty(),
1666            "expected no wildcards in default web_fetch allowlist (TOML has them only in auth/dev_infra which are excluded); got {wildcards:?}"
1667        );
1668    }
1669
1670    #[test]
1671    fn default_restricted_mode_lets_through_wildcard_hosts() {
1672        // Regression for the broader TOML allowlist: a URL on a wildcard
1673        // host should pass `validate_url` under the default `Restricted`
1674        // mode without any per-session config. The TOML only has wildcards
1675        // in `auth` / `dev_infra` (excluded by H1), so we exercise the
1676        // matcher with a user-supplied wildcard instead.
1677        let tool = WebFetchTool::from_config(&vtcode_config::WebFetchConfig {
1678            allowed_domains: vec!["*.example.com".to_string()],
1679            ..vtcode_config::WebFetchConfig::default()
1680        });
1681        for url in [
1682            "https://acme.example.com/authorize",
1683            "https://deep.nested.api.example.com/api",
1684        ] {
1685            tool.validate_url(url)
1686                .unwrap_or_else(|e| panic!("wildcard allow should accept {url}: {e}"));
1687        }
1688    }
1689
1690    #[tokio::test]
1691    async fn rejects_test_tld() {
1692        let tool = WebFetchTool::new();
1693        let result = execute_json(
1694            &tool,
1695            json!({
1696                "url": "https://example.test/page",
1697                "prompt": "Extract content"
1698            }),
1699        )
1700        .await;
1701        let error = error_text(&result).unwrap_or("");
1702        assert!(error.contains("local/private networks"));
1703    }
1704
1705    #[tokio::test]
1706    async fn rejects_invalid_tld() {
1707        let tool = WebFetchTool::new();
1708        let result = execute_json(
1709            &tool,
1710            json!({
1711                "url": "https://example.invalid/page",
1712                "prompt": "Extract content"
1713            }),
1714        )
1715        .await;
1716        let error = error_text(&result).unwrap_or("");
1717        assert!(error.contains("local/private networks"));
1718    }
1719
1720    #[tokio::test]
1721    async fn rejects_home_arpa_tld() {
1722        let tool = WebFetchTool::new();
1723        let result = execute_json(
1724            &tool,
1725            json!({
1726                "url": "https://myhost.home.arpa/page",
1727                "prompt": "Extract content"
1728            }),
1729        )
1730        .await;
1731        let error = error_text(&result).unwrap_or("");
1732        assert!(error.contains("local/private networks"));
1733    }
1734
1735    /// Regression test for turn_578: a real HTTP 403 from npmjs.com
1736    /// (server-side bot block) must come back as `error_type=http_error`
1737    /// with `http_status=403`, NOT as a "policy_blocked" or "PermissionDenied"
1738    /// response. The agent in turn_578 reported the host as "blocked by the
1739    /// sandbox" because the error format only carried a flat string.
1740    #[test]
1741    fn classifies_http_403_as_server_block_not_policy() {
1742        let err = anyhow::anyhow!("HTTP request failed with status: 403 Forbidden");
1743        let response = web_fetch_error_response("https://www.npmjs.com/~vinhnx", 262144, 30, &err);
1744        assert_eq!(response["error_type"], "http_error");
1745        assert_eq!(response["http_status"], 403);
1746        let action = response["next_action"].as_str().unwrap_or("");
1747        assert!(
1748            action.contains("anti-bot") || action.contains("rate-limited"),
1749            "next_action should hint at anti-bot / rate-limit, got: {action}"
1750        );
1751    }
1752
1753    /// Regression test for turn_578: a real HTTP 404 from crates.io (the
1754    /// user does not exist) must come back as `error_type=http_error`
1755    /// with `http_status=404` and a "the resource does not exist" hint.
1756    #[test]
1757    fn classifies_http_404_with_verify_url_hint() {
1758        let err = anyhow::anyhow!("HTTP request failed with status: 404 Not Found");
1759        let response = web_fetch_error_response("https://crates.io/users/vinhnx", 262144, 30, &err);
1760        assert_eq!(response["error_type"], "http_error");
1761        assert_eq!(response["http_status"], 404);
1762        let action = response["next_action"].as_str().unwrap_or("");
1763        assert!(action.contains("does not exist"), "next_action was: {action}");
1764    }
1765
1766    /// 5xx responses are transient — the hint should nudge the agent to
1767    /// either retry briefly or use web_search as a fallback.
1768    #[test]
1769    fn classifies_http_5xx_as_transient() {
1770        let err = anyhow::anyhow!("HTTP request failed with status: 503 Service Unavailable");
1771        let response = web_fetch_error_response("https://example.com", 262144, 30, &err);
1772        assert_eq!(response["error_type"], "http_error");
1773        assert_eq!(response["http_status"], 503);
1774        let action = response["next_action"].as_str().unwrap_or("");
1775        assert!(action.contains("retry") || action.contains("search"), "got: {action}");
1776    }
1777
1778    /// Network-level errors (timeout, DNS, TLS) should be classified
1779    /// distinctly from HTTP errors so the agent can pick the right
1780    /// fallback.
1781    #[test]
1782    fn classifies_timeout_as_network_error() {
1783        let err = anyhow::anyhow!("request timed out");
1784        let response = web_fetch_error_response("https://example.com", 262144, 30, &err);
1785        assert_eq!(response["error_type"], "network_error");
1786        assert!(response.get("http_status").is_none() || response["http_status"].is_null());
1787    }
1788
1789    #[test]
1790    fn classifies_tls_error_separately() {
1791        let err = anyhow::anyhow!("TLS handshake failed: certificate verify failed");
1792        let response = web_fetch_error_response("https://example.com", 262144, 30, &err);
1793        assert_eq!(response["error_type"], "tls_error");
1794    }
1795
1796    /// The error response must always carry a `next_action` so the agent
1797    /// loop can decide what to do without re-parsing the free-form message.
1798    #[test]
1799    fn every_error_response_has_next_action() {
1800        let samples = [
1801            anyhow::anyhow!("HTTP request failed with status: 418 I'm a teapot"),
1802            anyhow::anyhow!("dns error: no such host"),
1803            anyhow::anyhow!("something completely unexpected"),
1804        ];
1805        for err in &samples {
1806            let response = web_fetch_error_response("https://example.com", 262144, 30, err);
1807            let action = response["next_action"].as_str().unwrap_or("");
1808            assert!(!action.is_empty(), "next_action must be non-empty; got response {response}");
1809        }
1810    }
1811
1812    #[test]
1813    fn extract_http_status_delegates_to_classify_helpers() {
1814        // The local function delegates to `classify_helpers::extract_http_status`,
1815        // which is the canonical parser. These cases mirror the helpers'
1816        // own tests so a regression in the delegate is caught here too.
1817        assert_eq!(extract_http_status("status: 403 Forbidden"), Some(403));
1818        assert_eq!(
1819            extract_http_status("HTTP status server error (503 Service Unavailable) for url (https://example.com/)"),
1820            Some(503)
1821        );
1822        assert_eq!(extract_http_status("status:  500 Internal"), Some(500));
1823        assert_eq!(extract_http_status("no status here"), None);
1824    }
1825}