1use 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; const MAX_ALLOWED_BYTES: usize = 2_000_000; const MAX_ALLOWED_TIMEOUT_SECS: u64 = 120; const TEMP_SUBDIR: &str = "web_fetch";
37
38const 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
43pub(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#[derive(Clone)]
92pub struct WebFetchTool {
93 pub mode: vtcode_config::WebFetchMode,
95 pub blocked_domains: HashSet<String>,
97 pub blocked_patterns: Vec<String>,
99 pub allowed_domains: HashSet<String>,
101 pub defuddle: crate::tools::defuddle::DefuddleTool,
104 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 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
138async 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
147async 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 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
178pub 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 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 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 self.validate_url(url)?;
266
267 let default_headers = Self::default_headers();
268
269 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 if strict_https && !next_str.starts_with("https://") {
284 return attempt.stop();
285 }
286
287 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 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 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 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 self.validate_content_type(&content_type)?;
349
350 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 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 if self.strict_https_only && parsed.scheme() != "https" {
404 return Err(anyhow!("Only HTTPS URLs are allowed for security"));
405 }
406
407 let domain = extract_domain(url).map_err(|e| anyhow!("Failed to parse URL for security validation: {e}"))?;
410
411 if is_private_host(&domain) {
413 return Err(anyhow!("Access to local/private networks is blocked"));
414 }
415
416 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 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 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 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 let url_lower = url.to_lowercase();
470
471 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 self.validate_url_safety(&url_lower)?;
481
482 Ok(())
483 }
484
485 fn validate_url_safety(&self, url: &str) -> Result<()> {
486 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 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 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 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 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 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 let fetched = match self.fetch_url_content(&args.url, max_bytes, timeout_secs).await {
572 Ok(fetched) => fetched,
573 Err(e) => {
574 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 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 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 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 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 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 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
692fn 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
707pub(super) fn is_private_host(host: &str) -> bool {
717 let trimmed = host.strip_prefix('[').and_then(|s| s.strip_suffix(']')).unwrap_or(host);
718 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 if trimmed.eq_ignore_ascii_case("localhost") || trimmed.eq_ignore_ascii_case("localhost.localdomain") {
728 return true;
729 }
730
731 false
732}
733
734fn is_private_ipv4(v4: std::net::Ipv4Addr) -> bool {
737 let octets = v4.octets();
738 octets[0] == 127
740 || octets[0] == 10
742 || (octets[0] == 172 && (octets[1] & 0xf0) == 16)
744 || (octets[0] == 192 && octets[1] == 168)
746 || (octets[0] == 169 && octets[1] == 254)
748 || octets[0] == 0
751 || v4.is_broadcast()
753 || (octets[0] & 0xf0) == 224
755}
756
757fn is_private_ipv6(v6: std::net::Ipv6Addr) -> bool {
760 let segments = v6.segments();
761
762 if v6.is_loopback()
764 || v6.is_unspecified()
765 || (segments[0] & 0xfe00) == 0xfc00
767 || (segments[0] & 0xffc0) == 0xfe80
769 {
770 return true;
771 }
772
773 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 if let Some(suffix) = normalized_allowed.strip_prefix("*.") {
812 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 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 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
901fn 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
927fn classify_web_fetch_error(message: &str) -> (&'static str, Option<u16>, &'static str) {
931 let lower = message.to_lowercase();
934
935 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
999fn 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, );
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 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(), 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()], 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()], 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()], 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()], 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 let body = "界".repeat(500_000); let max_bytes = 1_000_000; 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 let exact = fetched_content_from_bytes("abcde".as_bytes(), 5).expect("utf-8");
1484 assert!(!exact.truncated_by_max_bytes);
1485
1486 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); assert!(MAX_ALLOWED_TIMEOUT_SECS <= 300); };
1511 }
1512
1513 #[test]
1514 fn ipv4_mapped_ipv6_loopback_is_private() {
1515 assert!(is_private_host("::ffff:127.0.0.1"));
1517 }
1518
1519 #[test]
1520 fn ipv4_mapped_ipv6_private_is_private() {
1521 assert!(is_private_host("::ffff:10.0.0.1"));
1523 assert!(is_private_host("::ffff:192.168.1.1"));
1525 assert!(is_private_host("::ffff:172.16.0.1"));
1527 }
1528
1529 #[test]
1530 fn ipv4_mapped_ipv6_link_local_is_private() {
1531 assert!(is_private_host("::ffff:169.254.0.1"));
1533 }
1534
1535 #[test]
1536 fn ipv4_compatible_ipv6_loopback_is_private() {
1537 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 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 #[test]
1583 fn default_restricted_mode_allows_common_dev_sites() {
1584 let tool = WebFetchTool::from_config(&vtcode_config::WebFetchConfig::default());
1585 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 #[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 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 assert!(!domain_matches_allowed("evilexample.com", "*.example.com"));
1631 assert!(!domain_matches_allowed("example.com.evil.tld", "*.example.com"));
1632 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 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 assert!(domain_matches_allowed("example.co.uk", "*.co.uk"));
1648 }
1649
1650 #[test]
1651 fn default_allowlist_includes_wildcard_categories() {
1652 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 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 #[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 #[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 #[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 #[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 #[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 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}