返回 CodeWhale
fetch_url.rs
根目录 / crates / tui / src / tools / fetch_url.rs
1 //! Direct-fetch HTTP tool. Complements `web_search` for cases where the user
2 //! already knows the URL — a known repo, a blog post, a spec page — and
3 //! search is overkill or actively unhelpful.
4 //!
5 //! Returns a structured `{url, status, content_type, content, truncated}`
6 //! payload. HTML responses are stripped to readable text by default
7 //! (`format = "markdown"`); pass `format = "raw"` to keep the bytes intact
8 //! when the model wants to do its own parsing.
9
10 use super::handle::query_jsonpath;
11 use super::pdf::PdfTextCommand;
12 use super::spec::{
13 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, optional_u64,
14 };
15 use super::web::extract::{DocumentKind, ExtractedDocument, decode_response_body};
16 use super::web::fetch::{
17 DEFAULT_MAX_BYTES, DEFAULT_TIMEOUT, FetchAttempt, FetchOptions, HARD_MAX_BYTES,
18 HARD_MAX_TIMEOUT, fetch_readable,
19 };
20 use super::web::overflow::bound_text as bound_web_text;
21 #[cfg(test)]
22 use super::web::overflow::inline_char_budget;
23 use async_trait::async_trait;
24 use serde::Serialize;
25 use serde_json::{Value, json};
26 use std::collections::BTreeMap;
27 use std::time::Duration;
28
29 const FETCH_ACCEPT: &str = "text/html,text/markdown,text/plain,application/json,application/pdf,image/*,audio/*,video/*,*/*;q=0.5";
30
31 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
32 enum Format {
33 Text,
34 Markdown,
35 Raw,
36 }
37
38 impl Format {
39 fn parse(value: Option<&str>) -> Result<Self, ToolError> {
40 match value
41 .unwrap_or("markdown")
42 .trim()
43 .to_ascii_lowercase()
44 .as_str()
45 {
46 "text" | "txt" | "plain" => Ok(Self::Text),
47 "markdown" | "md" => Ok(Self::Markdown),
48 "raw" | "html" | "bytes" => Ok(Self::Raw),
49 other => Err(ToolError::invalid_input(format!(
50 "unknown format `{other}` (allowed: text, markdown, raw)"
51 ))),
52 }
53 }
54 }
55
56 #[derive(Debug, Serialize)]
57 struct FetchResponse {
58 ref_id: String,
59 url: String,
60 status: u16,
61 headers: BTreeMap<String, String>,
62 content_type: String,
63 content: String,
64 truncated: bool,
65 receipt: FetchReceipt,
66 #[serde(skip_serializing_if = "Option::is_none")]
67 artifact: Option<String>,
68 #[serde(skip_serializing_if = "Option::is_none")]
69 fields: Option<BTreeMap<String, Vec<Value>>>,
70 }
71
72 #[derive(Debug, Serialize)]
73 struct FetchReceipt {
74 cache_hit: bool,
75 retries: usize,
76 redirects: usize,
77 /// Every request this fetch made, in order: session `cache_hit`, whether
78 /// the attempt bypassed caches, which one produced content, and the
79 /// response headers that explain the cache state (#5904).
80 attempts: Vec<FetchAttempt>,
81 }
82
83 #[derive(Debug)]
84 struct ArtifactWrite {
85 session_id: String,
86 absolute_path: std::path::PathBuf,
87 relative_path: std::path::PathBuf,
88 byte_size: u64,
89 preview: String,
90 }
91
92 pub struct FetchUrlTool;
93
94 #[async_trait]
95 impl ToolSpec for FetchUrlTool {
96 fn name(&self) -> &'static str {
97 "fetch_url"
98 }
99
100 fn model_visible(&self) -> bool {
101 false
102 }
103
104 fn description(&self) -> &'static str {
105 "Fetch a known URL directly (HTTP GET) and return its content with a session-scoped citation ref_id. Use this instead of `curl` in `exec_shell` — sandboxed, network-policy aware, and properly decoded. Plain-text endpoints (`.md`, `.txt`, `.json`, `.yaml`, `raw.githubusercontent.com`, public APIs) prefer this over the browser/automation stack. For unknown queries, use `web_search` first. If a login or authorization wall is returned, treat the wall as the result; do not claim the protected page was read."
106 }
107
108 fn input_schema(&self) -> Value {
109 json!({
110 "type": "object",
111 "properties": {
112 "url": {
113 "type": "string",
114 "description": "Absolute HTTP/HTTPS URL to fetch."
115 },
116 "format": {
117 "type": "string",
118 "enum": ["text", "markdown", "raw"],
119 "description": "Post-processing for the response body. `markdown` (default) uses readability extraction and real HTML-to-Markdown conversion; `text` returns readable plain text; `raw` preserves textual response bytes. Binary media is saved as a session artifact."
120 },
121 "max_bytes": {
122 "type": "integer",
123 "description": "Truncate response body after this many bytes (default 1,000,000; hard max 10,485,760)."
124 },
125 "timeout_ms": {
126 "type": "integer",
127 "description": "Request timeout in milliseconds (default 15,000; max 60,000)."
128 },
129 "fields": {
130 "type": "array",
131 "items": { "type": "string" },
132 "description": "Optional JSONPath projections for JSON responses. Supports $, .field, [index], [*], and ['field']; returns matches under `fields`."
133 }
134 },
135 "required": ["url"]
136 })
137 }
138
139 fn capabilities(&self) -> Vec<ToolCapability> {
140 vec![ToolCapability::ReadOnly, ToolCapability::Network]
141 }
142
143 fn approval_requirement(&self) -> ApprovalRequirement {
144 // Read-only HTTP can still disclose local data through a URL or query.
145 // Host allowlisting controls reachability, not approval of this payload.
146 ApprovalRequirement::Required
147 }
148
149 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
150 let url = input
151 .get("url")
152 .and_then(Value::as_str)
153 .ok_or_else(|| ToolError::invalid_input("`url` is required"))?
154 .trim()
155 .to_string();
156
157 if url.is_empty() {
158 return Err(ToolError::invalid_input("`url` cannot be empty"));
159 }
160 let scheme_ok = url.starts_with("http://") || url.starts_with("https://");
161 if !scheme_ok {
162 return Err(ToolError::invalid_input(
163 "only http:// and https:// URLs are supported",
164 ));
165 }
166
167 let format = Format::parse(input.get("format").and_then(Value::as_str))?;
168 let max_bytes =
169 usize::try_from(optional_u64(&input, "max_bytes", DEFAULT_MAX_BYTES as u64)?)
170 .unwrap_or(HARD_MAX_BYTES)
171 .clamp(1, HARD_MAX_BYTES);
172 let timeout_ms = optional_u64(&input, "timeout_ms", DEFAULT_TIMEOUT.as_millis() as u64)?
173 .clamp(1, HARD_MAX_TIMEOUT.as_millis() as u64);
174 let requested_fields = parse_fields(&input)?;
175 // Bound as a reference so the `Fn` extractor can run twice without
176 // moving the field list into its first future.
177 let requested_fields = &requested_fields;
178 // A 2xx that extracts to nothing is re-fetched once past every cache
179 // before it becomes an error; the receipt keeps both attempts (#5904).
180 let readable = fetch_readable(
181 &url,
182 &FetchOptions::new(Duration::from_millis(timeout_ms), max_bytes, FETCH_ACCEPT),
183 context,
184 "fetch_url",
185 |fetched: super::web::fetch::FetchedPayload| {
186 Box::pin(async move {
187 let is_success = (200..300).contains(&fetched.status);
188 let body_text = if requested_fields.is_empty() {
189 None
190 } else {
191 // JSON is never allowed to discover an encoding from body markup.
192 Some(decode_response_body(
193 &fetched.bytes,
194 Some(&fetched.content_type),
195 false,
196 )?)
197 };
198 let fields = match body_text.as_deref() {
199 Some(body) => {
200 project_json_fields(body, &fetched.content_type, requested_fields)?
201 }
202 None => None,
203 };
204 let extracted = extract_fetched_document(
205 format,
206 &fetched.url,
207 &fetched.content_type,
208 &fetched.bytes,
209 is_success,
210 body_text.as_deref(),
211 PdfTextCommand::system(context.cancel_token.as_ref()),
212 )
213 .await?;
214 Ok((extracted, fields))
215 })
216 },
217 )
218 .await?;
219 let super::web::fetch::ReadableFetch {
220 payload: fetched,
221 document: (extracted, fields),
222 attempts,
223 } = readable;
224 let is_success = (200..300).contains(&fetched.status);
225
226 let citation_title = extracted.title.clone();
227 let (processed, artifact_write) = render_extracted(
228 &fetched.url,
229 &fetched.content_type,
230 format,
231 extracted,
232 &fetched.bytes,
233 context,
234 )?;
235 let artifact = artifact_write
236 .as_ref()
237 .map(|write| crate::artifacts::format_artifact_relative_path(&write.relative_path));
238
239 let citation = super::web::citations::register(
240 &context.state_namespace,
241 &fetched.url,
242 citation_title.as_deref(),
243 )
244 .ok_or_else(|| ToolError::execution_failed("fetched URL could not be registered"))?;
245 let response = FetchResponse {
246 ref_id: citation.ref_id,
247 url: citation.url,
248 status: fetched.status,
249 headers: fetched.headers,
250 content_type: fetched.content_type,
251 content: processed,
252 truncated: fetched.truncated,
253 receipt: FetchReceipt {
254 cache_hit: fetched.cache_hit,
255 retries: fetched.retries,
256 redirects: fetched.redirects,
257 attempts,
258 },
259 artifact,
260 fields,
261 };
262
263 let content = serde_json::to_string_pretty(&response).map_err(|error| {
264 ToolError::execution_failed(format!("failed to serialize response: {error}"))
265 })?;
266 let metadata = artifact_write.map(artifact_metadata);
267
268 if !is_success {
269 // Don't `Err` on 4xx/5xx — the caller often wants to see the body
270 // (e.g. a JSON error envelope). Mark the result as a failure so the
271 // engine renders it as such.
272 return Ok(ToolResult {
273 content,
274 success: false,
275 metadata,
276 });
277 }
278
279 Ok(ToolResult {
280 content,
281 success: true,
282 metadata,
283 })
284 }
285 }
286
287 async fn extract_fetched_document(
288 format: Format,
289 url: &str,
290 content_type: &str,
291 bytes: &[u8],
292 is_success: bool,
293 decoded_body: Option<&str>,
294 pdf_command: PdfTextCommand<'_>,
295 ) -> Result<ExtractedDocument, ToolError> {
296 let extraction = if format == Format::Raw
297 && super::web::extract::validate_pdf_response(url, Some(content_type), bytes)?
298 {
299 Ok(ExtractedDocument {
300 kind: DocumentKind::Pdf,
301 title: Some("PDF Document".to_string()),
302 text: String::new(),
303 markdown: String::new(),
304 cleaned_html: None,
305 pdf_pages: None,
306 media_extension: None,
307 })
308 } else {
309 super::web::extract::extract_document_with_pdf_command(
310 url,
311 Some(content_type),
312 bytes,
313 pdf_command,
314 )
315 .await
316 };
317 match extraction {
318 Ok(document) => Ok(document),
319 Err(_error)
320 if (format == Format::Raw || !is_success) && is_declared_textual(content_type) =>
321 {
322 let body_text = match decoded_body {
323 Some(body_text) => body_text.to_string(),
324 None => {
325 decode_response_body(bytes, Some(content_type), is_declared_html(content_type))?
326 }
327 };
328 Ok(ExtractedDocument {
329 kind: DocumentKind::Text,
330 title: None,
331 text: body_text.clone(),
332 markdown: body_text,
333 cleaned_html: None,
334 pdf_pages: None,
335 media_extension: None,
336 })
337 }
338 Err(error) => Err(error),
339 }
340 }
341
342 fn is_declared_textual(content_type: &str) -> bool {
343 let content_type = content_type
344 .split(';')
345 .next()
346 .unwrap_or(content_type)
347 .trim()
348 .to_ascii_lowercase();
349 content_type.starts_with("text/")
350 || content_type.contains("html")
351 || content_type.contains("json")
352 || content_type.contains("xml")
353 || content_type.contains("yaml")
354 || content_type.contains("javascript")
355 }
356
357 fn is_declared_html(content_type: &str) -> bool {
358 matches!(
359 content_type
360 .split(';')
361 .next()
362 .unwrap_or(content_type)
363 .trim()
364 .to_ascii_lowercase()
365 .as_str(),
366 "text/html" | "application/xhtml+xml"
367 )
368 }
369
370 fn render_extracted(
371 url: &str,
372 content_type: &str,
373 format: Format,
374 document: ExtractedDocument,
375 bytes: &[u8],
376 context: &ToolContext,
377 ) -> Result<(String, Option<ArtifactWrite>), ToolError> {
378 if document.kind == DocumentKind::Pdf && format != Format::Raw {
379 let extracted = match format {
380 Format::Text => document.text,
381 Format::Markdown => document.markdown,
382 Format::Raw => unreachable!("raw PDF handled below"),
383 };
384 return bound_text(url, extracted, context);
385 }
386
387 if document.kind == DocumentKind::Media || document.kind == DocumentKind::Pdf {
388 let extension = document
389 .media_extension
390 .unwrap_or(if document.kind == DocumentKind::Pdf {
391 "pdf"
392 } else {
393 "bin"
394 });
395 let artifact = write_binary_artifact(url, extension, bytes, context)?;
396 let relative = crate::artifacts::format_artifact_relative_path(&artifact.relative_path);
397 let label = if document.kind == DocumentKind::Pdf {
398 "PDF"
399 } else {
400 "media"
401 };
402 let content =
403 format!("[{label} response saved to {relative}; content type: {content_type}.]");
404 return Ok((content, Some(artifact)));
405 }
406
407 let content = match format {
408 Format::Raw => decode_response_body(
409 bytes,
410 Some(content_type),
411 document.kind == DocumentKind::Html,
412 )?,
413 Format::Text => document.text,
414 Format::Markdown => document.markdown,
415 };
416 bound_text(url, content, context)
417 }
418
419 fn bound_text(
420 url: &str,
421 content: String,
422 context: &ToolContext,
423 ) -> Result<(String, Option<ArtifactWrite>), ToolError> {
424 let bounded = bound_web_text(
425 content,
426 context,
427 |body| fetch_artifact_id(url, body.as_bytes()),
428 "page",
429 )?;
430 let artifact = bounded.artifact.map(|artifact| ArtifactWrite {
431 session_id: artifact.session_id,
432 absolute_path: artifact.absolute_path,
433 relative_path: artifact.relative_path,
434 byte_size: artifact.byte_size,
435 preview: artifact.preview,
436 });
437 Ok((bounded.content, artifact))
438 }
439
440 fn write_binary_artifact(
441 url: &str,
442 extension: &str,
443 bytes: &[u8],
444 context: &ToolContext,
445 ) -> Result<ArtifactWrite, ToolError> {
446 let artifact_id = fetch_artifact_id(url, bytes);
447 let (absolute_path, relative_path) = crate::artifacts::write_session_artifact_bytes(
448 &context.state_namespace,
449 &artifact_id,
450 extension,
451 bytes,
452 )
453 .map_err(|error| {
454 ToolError::execution_failed(format!(
455 "failed to preserve fetched media artifact: {error}"
456 ))
457 })?;
458 Ok(ArtifactWrite {
459 session_id: context.state_namespace.clone(),
460 absolute_path,
461 relative_path,
462 byte_size: bytes.len() as u64,
463 preview: format!("Fetched {extension} artifact from {url}"),
464 })
465 }
466
467 fn fetch_artifact_id(url: &str, bytes: &[u8]) -> String {
468 let mut identity = Vec::with_capacity(url.len() + bytes.len());
469 identity.extend_from_slice(url.as_bytes());
470 identity.extend_from_slice(bytes);
471 let digest = crate::hashing::sha256_hex(&identity);
472 format!("fetch_{}", &digest[..16])
473 }
474
475 fn artifact_metadata(write: ArtifactWrite) -> Value {
476 json!({
477 "spillover_path": write.absolute_path.display().to_string(),
478 "artifact_session_id": write.session_id,
479 "artifact_relative_path": crate::artifacts::format_artifact_relative_path(&write.relative_path),
480 "artifact_byte_size": write.byte_size,
481 "artifact_preview": write.preview,
482 })
483 }
484
485 fn parse_fields(input: &Value) -> Result<Vec<String>, ToolError> {
486 let Some(values) = input.get("fields") else {
487 return Ok(Vec::new());
488 };
489 let Some(values) = values.as_array() else {
490 return Err(ToolError::invalid_input("`fields` must be an array"));
491 };
492 let mut fields = Vec::new();
493 for value in values {
494 let Some(field) = value.as_str() else {
495 return Err(ToolError::invalid_input(
496 "`fields` entries must be JSONPath strings",
497 ));
498 };
499 let field = field.trim();
500 if !field.is_empty() {
501 fields.push(field.to_string());
502 }
503 }
504 Ok(fields)
505 }
506
507 fn project_json_fields(
508 body_text: &str,
509 content_type: &str,
510 fields: &[String],
511 ) -> Result<Option<BTreeMap<String, Vec<Value>>>, ToolError> {
512 if fields.is_empty() {
513 return Ok(None);
514 }
515 if !content_type.to_ascii_lowercase().contains("json") {
516 return Err(ToolError::invalid_input(
517 "`fields` can only be used with JSON responses",
518 ));
519 }
520 let body_json: Value = serde_json::from_str(body_text).map_err(|e| {
521 ToolError::execution_failed(format!("response body is not valid JSON for `fields`: {e}"))
522 })?;
523 let mut out = BTreeMap::new();
524 for field in fields {
525 let matches = query_jsonpath(&body_json, field).map_err(|e| {
526 ToolError::invalid_input(format!("invalid JSONPath `{field}` in `fields`: {e}"))
527 })?;
528 out.insert(field.clone(), matches);
529 }
530 Ok(Some(out))
531 }
532
533 #[cfg(test)]
534 #[path = "fetch_url/tests.rs"]
535 mod pdf_tests;
536
537 #[cfg(test)]
538 mod tests {
539 use super::*;
540 use crate::tools::spec::ToolContext;
541 use std::path::PathBuf;
542
543 struct ArtifactRootRestore(Option<PathBuf>);
544
545 impl Drop for ArtifactRootRestore {
546 fn drop(&mut self) {
547 crate::artifacts::set_test_artifact_sessions_root(self.0.take());
548 }
549 }
550
551 fn ctx() -> ToolContext {
552 ToolContext::new(PathBuf::from("."))
553 }
554
555 #[test]
556 fn format_parse_accepts_aliases_and_rejects_unknown() {
557 assert_eq!(Format::parse(Some("markdown")).unwrap(), Format::Markdown);
558 assert_eq!(Format::parse(Some("MD")).unwrap(), Format::Markdown);
559 assert_eq!(Format::parse(Some("text")).unwrap(), Format::Text);
560 assert_eq!(Format::parse(Some("raw")).unwrap(), Format::Raw);
561 assert_eq!(Format::parse(None).unwrap(), Format::Markdown);
562 assert!(Format::parse(Some("yaml")).is_err());
563 }
564
565 #[test]
566 fn raw_text_uses_declared_charset_and_rejects_nul_binary() {
567 let document = || ExtractedDocument {
568 kind: DocumentKind::Text,
569 title: None,
570 text: String::new(),
571 markdown: String::new(),
572 cleaned_html: None,
573 pdf_pages: None,
574 media_extension: None,
575 };
576 let (bytes, _, _) = encoding_rs::WINDOWS_1252.encode("café");
577 let (content, artifact) = render_extracted(
578 "https://example.com/plain",
579 "text/plain; charset=windows-1252",
580 Format::Raw,
581 document(),
582 &bytes,
583 &ctx(),
584 )
585 .expect("decode raw text");
586 assert_eq!(content, "café");
587 assert!(artifact.is_none());
588
589 let error = render_extracted(
590 "https://example.com/not-text",
591 "text/plain",
592 Format::Raw,
593 document(),
594 b"binary\0payload",
595 &ctx(),
596 )
597 .expect_err("raw text must retain the binary guard");
598 assert!(error.to_string().contains("NUL bytes"));
599 }
600
601 #[test]
602 fn textual_and_html_fallback_classification_is_exact() {
603 assert!(is_declared_textual("Application/JSON; charset=utf-8"));
604 assert!(is_declared_html("TEXT/HTML; charset=gbk"));
605 assert!(!is_declared_html("text/plain; note=text/html"));
606 assert!(!is_declared_textual("application/octet-stream"));
607 }
608
609 #[test]
610 fn route_budget_overflow_round_trips_through_session_artifact() {
611 let _lock = crate::artifacts::TEST_ARTIFACT_SESSIONS_GUARD
612 .lock()
613 .unwrap_or_else(|error| error.into_inner());
614 let tmp = tempfile::tempdir().unwrap();
615 let prior =
616 crate::artifacts::set_test_artifact_sessions_root(Some(tmp.path().join("sessions")));
617 let _restore = ArtifactRootRestore(prior);
618 let context = ToolContext::new(".")
619 .with_state_namespace("fetch-overflow")
620 .with_route_context_window(10_000);
621 let full = "Whale content. ".repeat(200);
622
623 let (inline, artifact) =
624 bound_text("https://example.com/large", full.clone(), &context).unwrap();
625 let artifact = artifact.expect("overflow artifact");
626
627 assert!(inline.contains("retrieve_tool_result"));
628 assert!(inline.chars().count() <= inline_char_budget(&context));
629 assert_eq!(
630 std::fs::read_to_string(artifact.absolute_path).unwrap(),
631 full
632 );
633 }
634
635 #[test]
636 fn project_json_fields_returns_requested_jsonpath_matches() {
637 let fields = vec!["$.items[*].name".to_string(), "$.count".to_string()];
638 let projected = project_json_fields(
639 r#"{"items":[{"name":"alpha"},{"name":"beta"}],"count":2}"#,
640 "application/json",
641 &fields,
642 )
643 .expect("project")
644 .expect("some");
645
646 assert_eq!(
647 projected.get("$.items[*].name").unwrap(),
648 &vec![json!("alpha"), json!("beta")]
649 );
650 assert_eq!(projected.get("$.count").unwrap(), &vec![json!(2)]);
651 }
652
653 #[test]
654 fn project_json_fields_rejects_non_json_content_type() {
655 let fields = vec!["$.name".to_string()];
656 let err = project_json_fields("{}", "text/plain", &fields).expect_err("must reject");
657 assert!(format!("{err}").contains("JSON responses"));
658 }
659
660 #[tokio::test]
661 async fn rejects_non_http_schemes() {
662 let tool = FetchUrlTool;
663 let res = tool
664 .execute(json!({"url": "file:///etc/passwd"}), &ctx())
665 .await;
666 let err = res.unwrap_err();
667 assert!(format!("{err:?}").contains("http"));
668 }
669
670 #[tokio::test]
671 async fn rejects_empty_url() {
672 let tool = FetchUrlTool;
673 let res = tool.execute(json!({"url": " "}), &ctx()).await;
674 assert!(res.is_err());
675 }
676
677 #[tokio::test]
678 async fn rejects_missing_url() {
679 let tool = FetchUrlTool;
680 let res = tool.execute(json!({}), &ctx()).await;
681 assert!(res.is_err());
682 }
683
684 #[tokio::test]
685 async fn rejects_localhost_hostname() {
686 let tool = FetchUrlTool;
687 let res = tool
688 .execute(json!({"url": "http://localhost:8080/admin"}), &ctx())
689 .await;
690 let err = res.unwrap_err();
691 assert!(format!("{err}").contains("localhost"));
692 }
693
694 #[tokio::test]
695 async fn network_policy_denies_blocked_host() {
696 use crate::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider};
697 let policy = NetworkPolicy {
698 default: Decision::Deny.into(),
699 allow: vec!["api.deepseek.com".to_string()],
700 deny: vec![],
701 proxy: Vec::new(),
702 proxy_fake_ip_cidrs: Vec::new(),
703 audit: false,
704 };
705 let decider = NetworkPolicyDecider::new(policy, None);
706 let ctx = ToolContext::new(PathBuf::from(".")).with_network_policy(decider);
707 let tool = FetchUrlTool;
708 let res = tool
709 .execute(json!({"url": "https://example.com/foo"}), &ctx)
710 .await;
711 let err = res.expect_err("blocked host should fail");
712 assert!(format!("{err}").contains("blocked"));
713 }
714
715 #[tokio::test]
716 async fn proxy_opt_in_does_not_allow_restricted_ip_literal() {
717 use crate::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider};
718
719 let policy = NetworkPolicy {
720 default: Decision::Allow.into(),
721 allow: Vec::new(),
722 deny: Vec::new(),
723 proxy: vec!["198.18.0.1".to_string()],
724 proxy_fake_ip_cidrs: vec!["198.18.0.0/15".to_string()],
725 audit: false,
726 };
727 let decider = NetworkPolicyDecider::new(policy, None);
728 let ctx = ToolContext::new(PathBuf::from(".")).with_network_policy(decider);
729 let tool = FetchUrlTool;
730
731 let err = tool
732 .execute(json!({"url": "http://198.18.0.1/status"}), &ctx)
733 .await
734 .expect_err("literal restricted IP URLs must stay blocked");
735
736 assert!(format!("{err}").contains("IP 198.18.0.1 is a restricted address"));
737 }
738 }
739
739 lines RUST