1use std::net::{IpAddr, SocketAddr};
20use std::sync::Arc;
21use std::sync::atomic::{AtomicU64, Ordering};
22use std::time::{Duration, Instant};
23
24use schemars::JsonSchema;
25use serde::Deserialize;
26use url::Url;
27
28use zeph_common::ToolName;
29
30use zeph_sanitizer::IpiFilter;
31
32use crate::audit::{AuditEntry, AuditLogger, AuditResult, EgressEvent, chrono_now};
33use crate::config::{EgressConfig, ScrapeConfig};
34use crate::executor::{
35 ClaimSource, ToolCall, ToolError, ToolExecutor, ToolOutput, deserialize_params,
36};
37use crate::net::is_private_ip;
38
39fn redact_url_for_log(url: &str) -> String {
43 let Ok(mut parsed) = Url::parse(url) else {
44 return url.to_owned();
45 };
46 let _ = parsed.set_username("");
48 let _ = parsed.set_password(None);
49 let sensitive = [
51 "token", "key", "secret", "password", "auth", "sig", "api_key", "apikey",
52 ];
53 let filtered: Vec<(String, String)> = parsed
54 .query_pairs()
55 .filter(|(k, _)| {
56 let lower = k.to_lowercase();
57 !sensitive.iter().any(|s| lower.contains(s))
58 })
59 .map(|(k, v)| (k.into_owned(), v.into_owned()))
60 .collect();
61 if filtered.is_empty() {
62 parsed.set_query(None);
63 } else {
64 let q: String = filtered
65 .iter()
66 .map(|(k, v)| format!("{k}={v}"))
67 .collect::<Vec<_>>()
68 .join("&");
69 parsed.set_query(Some(&q));
70 }
71 parsed.to_string()
72}
73
74#[derive(Debug, Deserialize, JsonSchema)]
75struct FetchParams {
76 url: String,
78}
79
80#[derive(Debug, Deserialize, JsonSchema)]
81struct ScrapeInstruction {
82 url: String,
84 select: String,
86 #[serde(default = "default_extract")]
88 extract: String,
89 limit: Option<usize>,
91}
92
93fn default_extract() -> String {
94 "text".into()
95}
96
97#[derive(Debug)]
98enum ExtractMode {
99 Text,
100 Html,
101 Attr(String),
102}
103
104impl ExtractMode {
105 fn parse(s: &str) -> Self {
106 match s {
107 "text" => Self::Text,
108 "html" => Self::Html,
109 attr if attr.starts_with("attr:") => {
110 Self::Attr(attr.strip_prefix("attr:").unwrap_or(attr).to_owned())
111 }
112 _ => Self::Text,
113 }
114 }
115}
116
117#[derive(Debug)]
159pub struct WebScrapeExecutor {
160 timeout: Duration,
161 max_body_bytes: usize,
162 allowed_domains: Vec<String>,
163 denied_domains: Vec<String>,
164 audit_logger: Option<Arc<AuditLogger>>,
165 egress_config: EgressConfig,
166 egress_tx: Option<tokio::sync::mpsc::Sender<EgressEvent>>,
167 egress_dropped: Arc<AtomicU64>,
168 ipi_filter: IpiFilter,
170}
171
172impl WebScrapeExecutor {
173 #[must_use]
177 pub fn new(config: &ScrapeConfig) -> Self {
178 Self {
179 timeout: Duration::from_secs(config.timeout),
180 max_body_bytes: config.max_body_bytes,
181 allowed_domains: config.allowed_domains.clone(),
182 denied_domains: config.denied_domains.clone(),
183 audit_logger: None,
184 egress_config: EgressConfig::default(),
185 egress_tx: None,
186 egress_dropped: Arc::new(AtomicU64::new(0)),
187 ipi_filter: IpiFilter::new(config.ipi_filter_threshold),
188 }
189 }
190
191 #[must_use]
193 pub fn with_audit(mut self, logger: Arc<AuditLogger>) -> Self {
194 self.audit_logger = Some(logger);
195 self
196 }
197
198 #[must_use]
200 pub fn with_egress_config(mut self, config: EgressConfig) -> Self {
201 self.egress_config = config;
202 self
203 }
204
205 #[must_use]
210 pub fn with_egress_tx(
211 mut self,
212 tx: tokio::sync::mpsc::Sender<EgressEvent>,
213 dropped: Arc<AtomicU64>,
214 ) -> Self {
215 self.egress_tx = Some(tx);
216 self.egress_dropped = dropped;
217 self
218 }
219
220 #[must_use]
222 pub fn egress_dropped(&self) -> Arc<AtomicU64> {
223 Arc::clone(&self.egress_dropped)
224 }
225
226 fn build_client(&self, host: &str, addrs: &[SocketAddr]) -> reqwest::Client {
227 let mut builder = reqwest::Client::builder()
228 .timeout(self.timeout)
229 .redirect(reqwest::redirect::Policy::none());
230 builder = builder.resolve_to_addrs(host, addrs);
231 builder.build().unwrap_or_default()
232 }
233}
234
235impl ToolExecutor for WebScrapeExecutor {
236 fn tool_definitions(&self) -> Vec<crate::registry::ToolDef> {
237 use crate::registry::{InvocationHint, ToolDef};
238 vec![
239 ToolDef {
240 id: "web_scrape".into(),
241 description: "Extract structured data from a web page using CSS selectors.\n\nONLY call this tool when the user has explicitly provided a URL in their message, or when a prior tool call returned a URL to retrieve. NEVER construct, guess, or infer a URL from entity names, brand knowledge, or domain patterns.\n\nParameters: url (string, required) - HTTPS URL; select (string, required) - CSS selector; extract (string, optional) - \"text\", \"html\", or \"attr:<name>\"; limit (integer, optional) - max results\nReturns: extracted text/HTML/attribute values, one per line\nErrors: InvalidParams if URL is not HTTPS or selector is empty; Timeout after configured seconds; connection/DNS failures".into(),
242 schema: schemars::schema_for!(ScrapeInstruction),
243 invocation: InvocationHint::FencedBlock("scrape"),
244 output_schema: None,
245 server_id: None,
246 },
247 ToolDef {
248 id: "fetch".into(),
249 description: "Fetch a URL and return the response body as plain text.\n\nONLY call this tool when the user has explicitly provided a URL in their message, or when a prior tool call returned a URL to retrieve. NEVER construct, guess, or infer a URL from entity names, brand knowledge, or domain patterns. If no URL is present in the conversation, do not call this tool.\n\nParameters: url (string, required) - HTTPS URL to fetch\nReturns: response body as UTF-8 text, truncated if exceeding max body size\nErrors: InvalidParams if URL is not HTTPS; Timeout; SSRF-blocked private IPs; connection failures".into(),
250 schema: schemars::schema_for!(FetchParams),
251 invocation: InvocationHint::ToolCall,
252 output_schema: None,
253 server_id: None,
254 },
255 ]
256 }
257
258 async fn execute(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
259 let blocks = extract_scrape_blocks(response);
260 if blocks.is_empty() {
261 return Ok(None);
262 }
263
264 let mut outputs = Vec::with_capacity(blocks.len());
265 #[allow(clippy::cast_possible_truncation)]
266 let blocks_executed = blocks.len() as u32;
267
268 for block in &blocks {
269 let instruction: ScrapeInstruction = serde_json::from_str(block).map_err(|e| {
270 ToolError::Execution(std::io::Error::new(
271 std::io::ErrorKind::InvalidData,
272 e.to_string(),
273 ))
274 })?;
275 let correlation_id = EgressEvent::new_correlation_id();
276 let start = Instant::now();
277 let scrape_result = self
278 .scrape_instruction(&instruction, &correlation_id, None, None)
279 .await;
280 #[allow(clippy::cast_possible_truncation)]
281 let duration_ms = start.elapsed().as_millis() as u64;
282 match scrape_result {
283 Ok(output) => {
284 self.log_audit(
285 "web_scrape",
286 &redact_url_for_log(&instruction.url),
287 AuditResult::Success,
288 duration_ms,
289 None,
290 None,
291 None,
292 Some(correlation_id),
293 )
294 .await;
295 outputs.push(output);
296 }
297 Err(e) => {
298 let audit_result = tool_error_to_audit_result(&e);
299 self.log_audit(
300 "web_scrape",
301 &redact_url_for_log(&instruction.url),
302 audit_result,
303 duration_ms,
304 Some(&e),
305 None,
306 None,
307 Some(correlation_id),
308 )
309 .await;
310 return Err(e);
311 }
312 }
313 }
314
315 Ok(Some(ToolOutput {
316 tool_name: ToolName::new("web-scrape"),
317 summary: outputs.join("\n\n"),
318 blocks_executed,
319 filter_stats: None,
320 diff: None,
321 streamed: false,
322 terminal_id: None,
323 locations: None,
324 raw_response: None,
325 claim_source: Some(ClaimSource::WebScrape),
326 }))
327 }
328
329 #[cfg_attr(
330 feature = "profiling",
331 tracing::instrument(name = "tools.scrape.fetch", skip_all)
332 )]
333 async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
334 match call.tool_id.as_str() {
335 "web_scrape" => {
336 let instruction: ScrapeInstruction = deserialize_params(&call.params)?;
337 let correlation_id = EgressEvent::new_correlation_id();
338 let start = Instant::now();
339 let result = self
340 .scrape_instruction(
341 &instruction,
342 &correlation_id,
343 call.caller_id.clone(),
344 call.skill_name.clone(),
345 )
346 .await;
347 #[allow(clippy::cast_possible_truncation)]
348 let duration_ms = start.elapsed().as_millis() as u64;
349 self.run_with_audit(
350 "web_scrape",
351 "web-scrape",
352 &redact_url_for_log(&instruction.url),
353 call.caller_id.clone(),
354 call.skill_name.clone(),
355 correlation_id,
356 duration_ms,
357 result,
358 )
359 .await
360 }
361 "fetch" => {
362 let p: FetchParams = deserialize_params(&call.params)?;
363 let correlation_id = EgressEvent::new_correlation_id();
364 let start = Instant::now();
365 let result = self
366 .handle_fetch(
367 &p,
368 &correlation_id,
369 call.caller_id.clone(),
370 call.skill_name.clone(),
371 )
372 .await;
373 #[allow(clippy::cast_possible_truncation)]
374 let duration_ms = start.elapsed().as_millis() as u64;
375 self.run_with_audit(
376 "fetch",
377 "fetch",
378 &redact_url_for_log(&p.url),
379 call.caller_id.clone(),
380 call.skill_name.clone(),
381 correlation_id,
382 duration_ms,
383 result,
384 )
385 .await
386 }
387 _ => Ok(None),
388 }
389 }
390
391 fn is_tool_retryable(&self, tool_id: &str) -> bool {
392 matches!(tool_id, "web_scrape" | "fetch")
393 }
394}
395
396fn tool_error_to_audit_result(e: &ToolError) -> AuditResult {
397 match e {
398 ToolError::Blocked { command } => AuditResult::Blocked {
399 reason: command.clone(),
400 },
401 ToolError::Timeout { .. } => AuditResult::Timeout,
402 _ => AuditResult::Error {
403 message: e.to_string(),
404 },
405 }
406}
407
408impl WebScrapeExecutor {
409 #[allow(clippy::too_many_arguments)]
410 async fn run_with_audit(
411 &self,
412 audit_tool_name: &str,
413 public_tool_name: &str,
414 audit_command: &str,
415 caller_id: Option<String>,
416 skill_name: Option<Vec<String>>,
417 correlation_id: String,
418 duration_ms: u64,
419 result: Result<String, ToolError>,
420 ) -> Result<Option<ToolOutput>, ToolError> {
421 match result {
422 Ok(output) => {
423 self.log_audit(
424 audit_tool_name,
425 audit_command,
426 AuditResult::Success,
427 duration_ms,
428 None,
429 caller_id,
430 skill_name,
431 Some(correlation_id),
432 )
433 .await;
434 Ok(Some(ToolOutput {
435 tool_name: ToolName::new(public_tool_name),
436 summary: output,
437 blocks_executed: 1,
438 filter_stats: None,
439 diff: None,
440 streamed: false,
441 terminal_id: None,
442 locations: None,
443 raw_response: None,
444 claim_source: Some(ClaimSource::WebScrape),
445 }))
446 }
447 Err(e) => {
448 let audit_result = tool_error_to_audit_result(&e);
449 self.log_audit(
450 audit_tool_name,
451 audit_command,
452 audit_result,
453 duration_ms,
454 Some(&e),
455 caller_id,
456 skill_name,
457 Some(correlation_id),
458 )
459 .await;
460 Err(e)
461 }
462 }
463 }
464
465 #[allow(clippy::too_many_arguments)] async fn log_audit(
467 &self,
468 tool: &str,
469 command: &str,
470 result: AuditResult,
471 duration_ms: u64,
472 error: Option<&ToolError>,
473 caller_id: Option<String>,
474 skill_name: Option<Vec<String>>,
475 correlation_id: Option<String>,
476 ) {
477 if let Some(ref logger) = self.audit_logger {
478 let (error_category, error_domain, error_phase) =
479 error.map_or((None, None, None), |e| {
480 let cat = e.category();
481 (
482 Some(cat.label().to_owned()),
483 Some(cat.domain().label().to_owned()),
484 Some(cat.phase().label().to_owned()),
485 )
486 });
487 let entry = AuditEntry {
488 timestamp: chrono_now(),
489 tool: tool.into(),
490 command: command.into(),
491 result,
492 duration_ms,
493 error_category,
494 error_domain,
495 error_phase,
496 claim_source: Some(ClaimSource::WebScrape),
497 mcp_server_id: None,
498 injection_flagged: false,
499 embedding_anomalous: false,
500 cross_boundary_mcp_to_acp: false,
501 adversarial_policy_decision: None,
502 exit_code: None,
503 truncated: false,
504 caller_id,
505 skill_name,
506 policy_match: None,
507 correlation_id,
508 vigil_risk: None,
509 execution_env: None,
510 resolved_cwd: None,
511 scope_at_definition: None,
512 scope_at_dispatch: None,
513 };
514 logger.log(&entry).await;
515 }
516 }
517
518 fn send_egress_event(&self, event: EgressEvent) {
519 if let Some(ref tx) = self.egress_tx {
520 match tx.try_send(event) {
521 Ok(()) => {}
522 Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => {
523 self.egress_dropped.fetch_add(1, Ordering::Relaxed);
524 }
525 Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => {
526 tracing::debug!("egress channel closed; executor continuing without telemetry");
527 }
528 }
529 }
530 }
531
532 async fn log_egress_event(&self, event: &EgressEvent) {
533 if let Some(ref logger) = self.audit_logger {
534 logger.log_egress(event).await;
535 }
536 self.send_egress_event(event.clone());
537 }
538
539 async fn handle_fetch(
540 &self,
541 params: &FetchParams,
542 correlation_id: &str,
543 caller_id: Option<String>,
544 skill_name: Option<Vec<String>>,
545 ) -> Result<String, ToolError> {
546 let parsed = validate_url(¶ms.url);
547 let host_str = parsed
548 .as_ref()
549 .map(|u| u.host_str().unwrap_or("").to_owned())
550 .unwrap_or_default();
551
552 if let Err(ref _e) = parsed {
553 if self.egress_config.enabled && self.egress_config.log_blocked {
554 let event = Self::make_blocked_event(
555 "fetch",
556 ¶ms.url,
557 &host_str,
558 correlation_id,
559 caller_id.clone(),
560 skill_name.clone(),
561 "scheme",
562 );
563 self.log_egress_event(&event).await;
564 }
565 return Err(parsed.unwrap_err());
566 }
567 let parsed = parsed.unwrap();
568
569 if let Err(e) = check_domain_policy(
570 parsed.host_str().unwrap_or(""),
571 &self.allowed_domains,
572 &self.denied_domains,
573 ) {
574 if self.egress_config.enabled && self.egress_config.log_blocked {
575 let event = Self::make_blocked_event(
576 "fetch",
577 ¶ms.url,
578 parsed.host_str().unwrap_or(""),
579 correlation_id,
580 caller_id.clone(),
581 skill_name.clone(),
582 "blocklist",
583 );
584 self.log_egress_event(&event).await;
585 }
586 return Err(e);
587 }
588
589 let (host, addrs) = match resolve_and_validate(&parsed).await {
590 Ok(v) => v,
591 Err(e) => {
592 if self.egress_config.enabled && self.egress_config.log_blocked {
593 let event = Self::make_blocked_event(
594 "fetch",
595 ¶ms.url,
596 parsed.host_str().unwrap_or(""),
597 correlation_id,
598 caller_id.clone(),
599 skill_name.clone(),
600 "ssrf",
601 );
602 self.log_egress_event(&event).await;
603 }
604 return Err(e);
605 }
606 };
607
608 let body = self
609 .fetch_html(
610 ¶ms.url,
611 &host,
612 &addrs,
613 "fetch",
614 correlation_id,
615 caller_id,
616 skill_name,
617 )
618 .await?;
619 self.apply_ipi_filter(&body, ¶ms.url).await
620 }
621
622 async fn scrape_instruction(
623 &self,
624 instruction: &ScrapeInstruction,
625 correlation_id: &str,
626 caller_id: Option<String>,
627 skill_name: Option<Vec<String>>,
628 ) -> Result<String, ToolError> {
629 let parsed = validate_url(&instruction.url);
630 let host_str = parsed
631 .as_ref()
632 .map(|u| u.host_str().unwrap_or("").to_owned())
633 .unwrap_or_default();
634
635 if let Err(ref _e) = parsed {
636 if self.egress_config.enabled && self.egress_config.log_blocked {
637 let event = Self::make_blocked_event(
638 "web_scrape",
639 &instruction.url,
640 &host_str,
641 correlation_id,
642 caller_id.clone(),
643 skill_name.clone(),
644 "scheme",
645 );
646 self.log_egress_event(&event).await;
647 }
648 return Err(parsed.unwrap_err());
649 }
650 let parsed = parsed.unwrap();
651
652 if let Err(e) = check_domain_policy(
653 parsed.host_str().unwrap_or(""),
654 &self.allowed_domains,
655 &self.denied_domains,
656 ) {
657 if self.egress_config.enabled && self.egress_config.log_blocked {
658 let event = Self::make_blocked_event(
659 "web_scrape",
660 &instruction.url,
661 parsed.host_str().unwrap_or(""),
662 correlation_id,
663 caller_id.clone(),
664 skill_name.clone(),
665 "blocklist",
666 );
667 self.log_egress_event(&event).await;
668 }
669 return Err(e);
670 }
671
672 let (host, addrs) = match resolve_and_validate(&parsed).await {
673 Ok(v) => v,
674 Err(e) => {
675 if self.egress_config.enabled && self.egress_config.log_blocked {
676 let event = Self::make_blocked_event(
677 "web_scrape",
678 &instruction.url,
679 parsed.host_str().unwrap_or(""),
680 correlation_id,
681 caller_id.clone(),
682 skill_name.clone(),
683 "ssrf",
684 );
685 self.log_egress_event(&event).await;
686 }
687 return Err(e);
688 }
689 };
690
691 let html = self
692 .fetch_html(
693 &instruction.url,
694 &host,
695 &addrs,
696 "web_scrape",
697 correlation_id,
698 caller_id,
699 skill_name,
700 )
701 .await?;
702 let selector = instruction.select.clone();
703 let extract = ExtractMode::parse(&instruction.extract);
704 let limit = instruction.limit.unwrap_or(10);
705 let extracted = tokio::task::spawn_blocking(move || {
706 parse_and_extract(&html, &selector, &extract, limit)
707 })
708 .await
709 .map_err(|e| ToolError::Execution(std::io::Error::other(e.to_string())))??;
710 self.apply_ipi_filter(&extracted, &instruction.url).await
712 }
713
714 fn make_blocked_event(
715 tool: &str,
716 url: &str,
717 host: &str,
718 correlation_id: &str,
719 caller_id: Option<String>,
720 skill_name: Option<Vec<String>>,
721 block_reason: &'static str,
722 ) -> EgressEvent {
723 EgressEvent {
724 timestamp: chrono_now(),
725 kind: "egress",
726 correlation_id: correlation_id.to_owned(),
727 tool: tool.into(),
728 url: redact_url_for_log(url),
729 host: host.to_owned(),
730 method: "GET".to_owned(),
731 status: None,
732 duration_ms: 0,
733 response_bytes: 0,
734 blocked: true,
735 block_reason: Some(block_reason),
736 caller_id,
737 skill_name,
738 hop: 0,
739 }
740 }
741
742 #[allow(clippy::too_many_lines, clippy::too_many_arguments)]
753 async fn fetch_html(
754 &self,
755 url: &str,
756 host: &str,
757 addrs: &[SocketAddr],
758 tool: &str,
759 correlation_id: &str,
760 caller_id: Option<String>,
761 skill_name: Option<Vec<String>>,
762 ) -> Result<String, ToolError> {
763 const MAX_REDIRECTS: usize = 3;
764
765 let mut current_url = url.to_owned();
766 let mut current_host = host.to_owned();
767 let mut current_addrs = addrs.to_vec();
768
769 for hop in 0..=MAX_REDIRECTS {
770 let hop_start = Instant::now();
771 let client = self.build_client(¤t_host, ¤t_addrs);
773 let resp = client
774 .get(¤t_url)
775 .send()
776 .await
777 .map_err(|e| ToolError::Execution(std::io::Error::other(e.to_string())));
778
779 let resp = match resp {
780 Ok(r) => r,
781 Err(e) => {
782 if self.egress_config.enabled {
783 #[allow(clippy::cast_possible_truncation)]
784 let duration_ms = hop_start.elapsed().as_millis() as u64;
785 let event = EgressEvent {
786 timestamp: chrono_now(),
787 kind: "egress",
788 correlation_id: correlation_id.to_owned(),
789 tool: tool.into(),
790 url: redact_url_for_log(¤t_url),
791 host: current_host.clone(),
792 method: "GET".to_owned(),
793 status: None,
794 duration_ms,
795 response_bytes: 0,
796 blocked: false,
797 block_reason: None,
798 caller_id: caller_id.clone(),
799 skill_name: skill_name.clone(),
800 #[allow(clippy::cast_possible_truncation)]
801 hop: hop as u8,
802 };
803 self.log_egress_event(&event).await;
804 }
805 return Err(e);
806 }
807 };
808
809 let status = resp.status();
810
811 if status.is_redirection() {
812 if hop == MAX_REDIRECTS {
813 return Err(ToolError::Execution(std::io::Error::other(
814 "too many redirects",
815 )));
816 }
817
818 let location = resp
819 .headers()
820 .get(reqwest::header::LOCATION)
821 .and_then(|v| v.to_str().ok())
822 .ok_or_else(|| {
823 ToolError::Execution(std::io::Error::other("redirect with no Location"))
824 })?;
825
826 let base = Url::parse(¤t_url)
828 .map_err(|e| ToolError::Execution(std::io::Error::other(e.to_string())))?;
829 let next_url = base
830 .join(location)
831 .map_err(|e| ToolError::Execution(std::io::Error::other(e.to_string())))?;
832
833 let validated = validate_url(next_url.as_str());
834 if let Err(ref _e) = validated {
835 if self.egress_config.enabled && self.egress_config.log_blocked {
836 #[allow(clippy::cast_possible_truncation)]
837 let duration_ms = hop_start.elapsed().as_millis() as u64;
838 let next_host = next_url.host_str().unwrap_or("").to_owned();
839 let event = EgressEvent {
840 timestamp: chrono_now(),
841 kind: "egress",
842 correlation_id: correlation_id.to_owned(),
843 tool: tool.into(),
844 url: redact_url_for_log(next_url.as_str()),
845 host: next_host,
846 method: "GET".to_owned(),
847 status: None,
848 duration_ms,
849 response_bytes: 0,
850 blocked: true,
851 block_reason: Some("ssrf"),
852 caller_id: caller_id.clone(),
853 skill_name: skill_name.clone(),
854 #[allow(clippy::cast_possible_truncation)]
855 hop: (hop + 1) as u8,
856 };
857 self.log_egress_event(&event).await;
858 }
859 return Err(validated.unwrap_err());
860 }
861 let validated = validated.unwrap();
862 let resolve_result = resolve_and_validate(&validated).await;
863 if let Err(ref _e) = resolve_result {
864 if self.egress_config.enabled && self.egress_config.log_blocked {
865 #[allow(clippy::cast_possible_truncation)]
866 let duration_ms = hop_start.elapsed().as_millis() as u64;
867 let next_host = next_url.host_str().unwrap_or("").to_owned();
868 let event = EgressEvent {
869 timestamp: chrono_now(),
870 kind: "egress",
871 correlation_id: correlation_id.to_owned(),
872 tool: tool.into(),
873 url: redact_url_for_log(next_url.as_str()),
874 host: next_host,
875 method: "GET".to_owned(),
876 status: None,
877 duration_ms,
878 response_bytes: 0,
879 blocked: true,
880 block_reason: Some("ssrf"),
881 caller_id: caller_id.clone(),
882 skill_name: skill_name.clone(),
883 #[allow(clippy::cast_possible_truncation)]
884 hop: (hop + 1) as u8,
885 };
886 self.log_egress_event(&event).await;
887 }
888 return Err(resolve_result.unwrap_err());
889 }
890 let (next_host, next_addrs) = resolve_result.unwrap();
891
892 current_url = next_url.to_string();
893 current_host = next_host;
894 current_addrs = next_addrs;
895 continue;
896 }
897
898 if !status.is_success() {
899 if self.egress_config.enabled {
900 #[allow(clippy::cast_possible_truncation)]
901 let duration_ms = hop_start.elapsed().as_millis() as u64;
902 let event = EgressEvent {
903 timestamp: chrono_now(),
904 kind: "egress",
905 correlation_id: correlation_id.to_owned(),
906 tool: tool.into(),
907 url: redact_url_for_log(¤t_url),
908 host: current_host.clone(),
909 method: "GET".to_owned(),
910 status: Some(status.as_u16()),
911 duration_ms,
912 response_bytes: 0,
913 blocked: false,
914 block_reason: None,
915 caller_id: caller_id.clone(),
916 skill_name: skill_name.clone(),
917 #[allow(clippy::cast_possible_truncation)]
918 hop: hop as u8,
919 };
920 self.log_egress_event(&event).await;
921 }
922 return Err(ToolError::Http {
923 status: status.as_u16(),
924 message: status.canonical_reason().unwrap_or("unknown").to_owned(),
925 });
926 }
927
928 let bytes = resp
929 .bytes()
930 .await
931 .map_err(|e| ToolError::Execution(std::io::Error::other(e.to_string())))?;
932
933 if bytes.len() > self.max_body_bytes {
934 if self.egress_config.enabled {
935 #[allow(clippy::cast_possible_truncation)]
936 let duration_ms = hop_start.elapsed().as_millis() as u64;
937 let event = EgressEvent {
938 timestamp: chrono_now(),
939 kind: "egress",
940 correlation_id: correlation_id.to_owned(),
941 tool: tool.into(),
942 url: redact_url_for_log(¤t_url),
943 host: current_host.clone(),
944 method: "GET".to_owned(),
945 status: Some(status.as_u16()),
946 duration_ms,
947 response_bytes: bytes.len(),
948 blocked: false,
949 block_reason: None,
950 caller_id: caller_id.clone(),
951 skill_name: skill_name.clone(),
952 #[allow(clippy::cast_possible_truncation)]
953 hop: hop as u8,
954 };
955 self.log_egress_event(&event).await;
956 }
957 return Err(ToolError::Execution(std::io::Error::other(format!(
958 "response too large: {} bytes (max: {})",
959 bytes.len(),
960 self.max_body_bytes,
961 ))));
962 }
963
964 if self.egress_config.enabled {
966 #[allow(clippy::cast_possible_truncation)]
967 let duration_ms = hop_start.elapsed().as_millis() as u64;
968 let response_bytes = if self.egress_config.log_response_bytes {
969 bytes.len()
970 } else {
971 0
972 };
973 let event = EgressEvent {
974 timestamp: chrono_now(),
975 kind: "egress",
976 correlation_id: correlation_id.to_owned(),
977 tool: tool.into(),
978 url: redact_url_for_log(¤t_url),
979 host: current_host.clone(),
980 method: "GET".to_owned(),
981 status: Some(status.as_u16()),
982 duration_ms,
983 response_bytes,
984 blocked: false,
985 block_reason: None,
986 caller_id: caller_id.clone(),
987 skill_name: skill_name.clone(),
988 #[allow(clippy::cast_possible_truncation)]
989 hop: hop as u8,
990 };
991 self.log_egress_event(&event).await;
992 }
993
994 return String::from_utf8(bytes.to_vec())
995 .map_err(|e| ToolError::Execution(std::io::Error::other(e.to_string())));
996 }
997
998 Err(ToolError::Execution(std::io::Error::other(
999 "too many redirects",
1000 )))
1001 }
1002
1003 #[tracing::instrument(name = "tools.scrape.apply_ipi_filter", skip(self, body), fields(body_len = body.len()))]
1013 async fn apply_ipi_filter(&self, body: &str, url: &str) -> Result<String, ToolError> {
1014 let verdict = self
1015 .ipi_filter
1016 .filter_async(body.to_owned())
1017 .await
1018 .map_err(|e| ToolError::Execution(std::io::Error::other(e.to_string())))?;
1019 if !verdict.patterns_found.is_empty() {
1020 tracing::warn!(
1021 url = url,
1022 score = verdict.score,
1023 patterns = ?verdict.patterns_found,
1024 "IPI patterns detected in fetched content"
1025 );
1026 }
1027 if verdict.sanitized == body {
1029 Ok(verdict.sanitized)
1030 } else {
1031 Ok(format!(
1032 "[IPI WARNING: score={:.2}, patterns={}] {}",
1033 verdict.score,
1034 verdict.patterns_found.join(", "),
1035 verdict.sanitized,
1036 ))
1037 }
1038 }
1039}
1040
1041fn extract_scrape_blocks(text: &str) -> Vec<&str> {
1042 crate::executor::extract_fenced_blocks(text, "scrape")
1043}
1044
1045fn check_domain_policy(
1057 host: &str,
1058 allowed_domains: &[String],
1059 denied_domains: &[String],
1060) -> Result<(), ToolError> {
1061 if denied_domains.iter().any(|p| domain_matches(p, host)) {
1062 return Err(ToolError::Blocked {
1063 command: format!("domain blocked by denylist: {host}"),
1064 });
1065 }
1066 if !allowed_domains.is_empty() {
1067 let is_ip = host.parse::<std::net::IpAddr>().is_ok()
1069 || (host.starts_with('[') && host.ends_with(']'));
1070 if is_ip {
1071 return Err(ToolError::Blocked {
1072 command: format!(
1073 "bare IP address not allowed when domain allowlist is active: {host}"
1074 ),
1075 });
1076 }
1077 if !allowed_domains.iter().any(|p| domain_matches(p, host)) {
1078 return Err(ToolError::Blocked {
1079 command: format!("domain not in allowlist: {host}"),
1080 });
1081 }
1082 }
1083 Ok(())
1084}
1085
1086use crate::domain_match::domain_matches;
1088
1089fn validate_url(raw: &str) -> Result<Url, ToolError> {
1090 let parsed = Url::parse(raw).map_err(|_| ToolError::Blocked {
1091 command: format!("invalid URL: {raw}"),
1092 })?;
1093
1094 if parsed.scheme() != "https" {
1095 return Err(ToolError::Blocked {
1096 command: format!("scheme not allowed: {}", parsed.scheme()),
1097 });
1098 }
1099
1100 if let Some(host) = parsed.host()
1101 && is_private_host(&host)
1102 {
1103 return Err(ToolError::Blocked {
1104 command: format!(
1105 "private/local host blocked: {}",
1106 parsed.host_str().unwrap_or("")
1107 ),
1108 });
1109 }
1110
1111 Ok(parsed)
1112}
1113
1114fn is_private_host(host: &url::Host<&str>) -> bool {
1115 match host {
1116 url::Host::Domain(d) => {
1117 #[allow(clippy::case_sensitive_file_extension_comparisons)]
1120 {
1121 *d == "localhost"
1122 || d.ends_with(".localhost")
1123 || d.ends_with(".internal")
1124 || d.ends_with(".local")
1125 }
1126 }
1127 url::Host::Ipv4(v4) => is_private_ip(IpAddr::V4(*v4)),
1128 url::Host::Ipv6(v6) => is_private_ip(IpAddr::V6(*v6)),
1129 }
1130}
1131
1132#[tracing::instrument(name = "tools.scrape.dns.resolve", skip(url), fields(host = url.host_str().unwrap_or("")))]
1142async fn resolve_and_validate(url: &Url) -> Result<(String, Vec<SocketAddr>), ToolError> {
1143 let Some(host) = url.host_str() else {
1144 return Ok((String::new(), vec![]));
1145 };
1146 let port = url.port_or_known_default().unwrap_or(443);
1147 let addrs = zeph_common::net::resolve_and_validate(host, port)
1148 .await
1149 .map_err(|e| match e {
1150 zeph_common::net::ResolveError::Timeout(timeout) => ToolError::Timeout {
1151 timeout_secs: timeout.as_secs(),
1152 },
1153 zeph_common::net::ResolveError::Lookup(io_err) => ToolError::Blocked {
1154 command: format!("DNS resolution failed: {io_err}"),
1155 },
1156 zeph_common::net::ResolveError::PrivateAddress { host, addr } => ToolError::Blocked {
1157 command: format!("SSRF protection: private IP {addr} for host {host}"),
1158 },
1159 other => ToolError::Blocked {
1160 command: format!("DNS resolution failed: {other}"),
1161 },
1162 })?;
1163 Ok((host.to_owned(), addrs))
1164}
1165
1166fn parse_and_extract(
1167 html: &str,
1168 selector: &str,
1169 extract: &ExtractMode,
1170 limit: usize,
1171) -> Result<String, ToolError> {
1172 let soup = scrape_core::Soup::parse(html);
1173
1174 let tags = soup.find_all(selector).map_err(|e| {
1175 ToolError::Execution(std::io::Error::new(
1176 std::io::ErrorKind::InvalidData,
1177 format!("invalid selector: {e}"),
1178 ))
1179 })?;
1180
1181 let mut results = Vec::new();
1182
1183 for tag in tags.into_iter().take(limit) {
1184 let value = match extract {
1185 ExtractMode::Text => tag.text(),
1186 ExtractMode::Html => tag.inner_html(),
1187 ExtractMode::Attr(name) => tag.get(name).unwrap_or_default().to_owned(),
1188 };
1189 if !value.trim().is_empty() {
1190 results.push(value.trim().to_owned());
1191 }
1192 }
1193
1194 if results.is_empty() {
1195 Ok(format!("No results for selector: {selector}"))
1196 } else {
1197 Ok(results.join("\n"))
1198 }
1199}
1200
1201#[cfg(test)]
1202mod tests {
1203 use super::*;
1204 use std::assert_matches;
1205
1206 #[test]
1209 fn extract_single_block() {
1210 let text =
1211 "Here:\n```scrape\n{\"url\":\"https://example.com\",\"select\":\"h1\"}\n```\nDone.";
1212 let blocks = extract_scrape_blocks(text);
1213 assert_eq!(blocks.len(), 1);
1214 assert!(blocks[0].contains("example.com"));
1215 }
1216
1217 #[test]
1218 fn extract_multiple_blocks() {
1219 let text = "```scrape\n{\"url\":\"https://a.com\",\"select\":\"h1\"}\n```\ntext\n```scrape\n{\"url\":\"https://b.com\",\"select\":\"p\"}\n```";
1220 let blocks = extract_scrape_blocks(text);
1221 assert_eq!(blocks.len(), 2);
1222 }
1223
1224 #[test]
1225 fn no_blocks_returns_empty() {
1226 let blocks = extract_scrape_blocks("plain text, no code blocks");
1227 assert!(blocks.is_empty());
1228 }
1229
1230 #[test]
1231 fn unclosed_block_ignored() {
1232 let blocks = extract_scrape_blocks("```scrape\n{\"url\":\"https://x.com\"}");
1233 assert!(blocks.is_empty());
1234 }
1235
1236 #[test]
1237 fn non_scrape_block_ignored() {
1238 let text =
1239 "```bash\necho hi\n```\n```scrape\n{\"url\":\"https://x.com\",\"select\":\"h1\"}\n```";
1240 let blocks = extract_scrape_blocks(text);
1241 assert_eq!(blocks.len(), 1);
1242 assert!(blocks[0].contains("x.com"));
1243 }
1244
1245 #[test]
1246 fn multiline_json_block() {
1247 let text =
1248 "```scrape\n{\n \"url\": \"https://example.com\",\n \"select\": \"h1\"\n}\n```";
1249 let blocks = extract_scrape_blocks(text);
1250 assert_eq!(blocks.len(), 1);
1251 let instr: ScrapeInstruction = serde_json::from_str(blocks[0]).unwrap();
1252 assert_eq!(instr.url, "https://example.com");
1253 }
1254
1255 #[test]
1258 fn parse_valid_instruction() {
1259 let json = r#"{"url":"https://example.com","select":"h1","extract":"text","limit":5}"#;
1260 let instr: ScrapeInstruction = serde_json::from_str(json).unwrap();
1261 assert_eq!(instr.url, "https://example.com");
1262 assert_eq!(instr.select, "h1");
1263 assert_eq!(instr.extract, "text");
1264 assert_eq!(instr.limit, Some(5));
1265 }
1266
1267 #[test]
1268 fn parse_minimal_instruction() {
1269 let json = r#"{"url":"https://example.com","select":"p"}"#;
1270 let instr: ScrapeInstruction = serde_json::from_str(json).unwrap();
1271 assert_eq!(instr.extract, "text");
1272 assert!(instr.limit.is_none());
1273 }
1274
1275 #[test]
1276 fn parse_attr_extract() {
1277 let json = r#"{"url":"https://example.com","select":"a","extract":"attr:href"}"#;
1278 let instr: ScrapeInstruction = serde_json::from_str(json).unwrap();
1279 assert_eq!(instr.extract, "attr:href");
1280 }
1281
1282 #[test]
1283 fn parse_invalid_json_errors() {
1284 let result = serde_json::from_str::<ScrapeInstruction>("not json");
1285 assert!(result.is_err());
1286 }
1287
1288 #[test]
1291 fn extract_mode_text() {
1292 assert_matches!(ExtractMode::parse("text"), ExtractMode::Text);
1293 }
1294
1295 #[test]
1296 fn extract_mode_html() {
1297 assert_matches!(ExtractMode::parse("html"), ExtractMode::Html);
1298 }
1299
1300 #[test]
1301 fn extract_mode_attr() {
1302 let mode = ExtractMode::parse("attr:href");
1303 assert_matches!(mode, ExtractMode::Attr(ref s) if s == "href");
1304 }
1305
1306 #[test]
1307 fn extract_mode_unknown_defaults_to_text() {
1308 assert_matches!(ExtractMode::parse("unknown"), ExtractMode::Text);
1309 }
1310
1311 #[test]
1314 fn valid_https_url() {
1315 assert!(validate_url("https://example.com").is_ok());
1316 }
1317
1318 #[test]
1319 fn http_rejected() {
1320 let err = validate_url("http://example.com").unwrap_err();
1321 assert_matches!(err, ToolError::Blocked { .. });
1322 }
1323
1324 #[test]
1325 fn ftp_rejected() {
1326 let err = validate_url("ftp://files.example.com").unwrap_err();
1327 assert_matches!(err, ToolError::Blocked { .. });
1328 }
1329
1330 #[test]
1331 fn file_rejected() {
1332 let err = validate_url("file:///etc/passwd").unwrap_err();
1333 assert_matches!(err, ToolError::Blocked { .. });
1334 }
1335
1336 #[test]
1337 fn invalid_url_rejected() {
1338 let err = validate_url("not a url").unwrap_err();
1339 assert_matches!(err, ToolError::Blocked { .. });
1340 }
1341
1342 #[test]
1343 fn localhost_blocked() {
1344 let err = validate_url("https://localhost/path").unwrap_err();
1345 assert_matches!(err, ToolError::Blocked { .. });
1346 }
1347
1348 #[test]
1349 fn loopback_ip_blocked() {
1350 let err = validate_url("https://127.0.0.1/path").unwrap_err();
1351 assert_matches!(err, ToolError::Blocked { .. });
1352 }
1353
1354 #[test]
1355 fn private_10_blocked() {
1356 let err = validate_url("https://10.0.0.1/api").unwrap_err();
1357 assert_matches!(err, ToolError::Blocked { .. });
1358 }
1359
1360 #[test]
1361 fn private_172_blocked() {
1362 let err = validate_url("https://172.16.0.1/api").unwrap_err();
1363 assert_matches!(err, ToolError::Blocked { .. });
1364 }
1365
1366 #[test]
1367 fn private_192_blocked() {
1368 let err = validate_url("https://192.168.1.1/api").unwrap_err();
1369 assert_matches!(err, ToolError::Blocked { .. });
1370 }
1371
1372 #[test]
1373 fn ipv6_loopback_blocked() {
1374 let err = validate_url("https://[::1]/path").unwrap_err();
1375 assert_matches!(err, ToolError::Blocked { .. });
1376 }
1377
1378 #[test]
1379 fn public_ip_allowed() {
1380 assert!(validate_url("https://93.184.216.34/page").is_ok());
1381 }
1382
1383 #[test]
1386 fn extract_text_from_html() {
1387 let html = "<html><body><h1>Hello World</h1><p>Content</p></body></html>";
1388 let result = parse_and_extract(html, "h1", &ExtractMode::Text, 10).unwrap();
1389 assert_eq!(result, "Hello World");
1390 }
1391
1392 #[test]
1393 fn extract_multiple_elements() {
1394 let html = "<ul><li>A</li><li>B</li><li>C</li></ul>";
1395 let result = parse_and_extract(html, "li", &ExtractMode::Text, 10).unwrap();
1396 assert_eq!(result, "A\nB\nC");
1397 }
1398
1399 #[test]
1400 fn extract_with_limit() {
1401 let html = "<ul><li>A</li><li>B</li><li>C</li></ul>";
1402 let result = parse_and_extract(html, "li", &ExtractMode::Text, 2).unwrap();
1403 assert_eq!(result, "A\nB");
1404 }
1405
1406 #[test]
1407 fn extract_attr_href() {
1408 let html = r#"<a href="https://example.com">Link</a>"#;
1409 let result =
1410 parse_and_extract(html, "a", &ExtractMode::Attr("href".to_owned()), 10).unwrap();
1411 assert_eq!(result, "https://example.com");
1412 }
1413
1414 #[test]
1415 fn extract_inner_html() {
1416 let html = "<div><span>inner</span></div>";
1417 let result = parse_and_extract(html, "div", &ExtractMode::Html, 10).unwrap();
1418 assert!(result.contains("<span>inner</span>"));
1419 }
1420
1421 #[test]
1422 fn no_matches_returns_message() {
1423 let html = "<html><body><p>text</p></body></html>";
1424 let result = parse_and_extract(html, "h1", &ExtractMode::Text, 10).unwrap();
1425 assert!(result.starts_with("No results for selector:"));
1426 }
1427
1428 #[test]
1429 fn empty_text_skipped() {
1430 let html = "<ul><li> </li><li>A</li></ul>";
1431 let result = parse_and_extract(html, "li", &ExtractMode::Text, 10).unwrap();
1432 assert_eq!(result, "A");
1433 }
1434
1435 #[test]
1436 fn invalid_selector_errors() {
1437 let html = "<html><body></body></html>";
1438 let result = parse_and_extract(html, "[[[invalid", &ExtractMode::Text, 10);
1439 assert!(result.is_err());
1440 }
1441
1442 #[test]
1443 fn empty_html_returns_no_results() {
1444 let result = parse_and_extract("", "h1", &ExtractMode::Text, 10).unwrap();
1445 assert!(result.starts_with("No results for selector:"));
1446 }
1447
1448 #[test]
1449 fn nested_selector() {
1450 let html = "<div><span>inner</span></div><span>outer</span>";
1451 let result = parse_and_extract(html, "div > span", &ExtractMode::Text, 10).unwrap();
1452 assert_eq!(result, "inner");
1453 }
1454
1455 #[test]
1456 fn attr_missing_returns_empty() {
1457 let html = r"<a>No href</a>";
1458 let result =
1459 parse_and_extract(html, "a", &ExtractMode::Attr("href".to_owned()), 10).unwrap();
1460 assert!(result.starts_with("No results for selector:"));
1461 }
1462
1463 #[test]
1464 fn extract_html_mode() {
1465 let html = "<div><b>bold</b> text</div>";
1466 let result = parse_and_extract(html, "div", &ExtractMode::Html, 10).unwrap();
1467 assert!(result.contains("<b>bold</b>"));
1468 }
1469
1470 #[test]
1471 fn limit_zero_returns_no_results() {
1472 let html = "<ul><li>A</li><li>B</li></ul>";
1473 let result = parse_and_extract(html, "li", &ExtractMode::Text, 0).unwrap();
1474 assert!(result.starts_with("No results for selector:"));
1475 }
1476
1477 #[test]
1480 fn url_with_port_allowed() {
1481 assert!(validate_url("https://example.com:8443/path").is_ok());
1482 }
1483
1484 #[test]
1485 fn link_local_ip_blocked() {
1486 let err = validate_url("https://169.254.1.1/path").unwrap_err();
1487 assert_matches!(err, ToolError::Blocked { .. });
1488 }
1489
1490 #[test]
1491 fn url_no_scheme_rejected() {
1492 let err = validate_url("example.com/path").unwrap_err();
1493 assert_matches!(err, ToolError::Blocked { .. });
1494 }
1495
1496 #[test]
1497 fn unspecified_ipv4_blocked() {
1498 let err = validate_url("https://0.0.0.0/path").unwrap_err();
1499 assert_matches!(err, ToolError::Blocked { .. });
1500 }
1501
1502 #[test]
1503 fn broadcast_ipv4_blocked() {
1504 let err = validate_url("https://255.255.255.255/path").unwrap_err();
1505 assert_matches!(err, ToolError::Blocked { .. });
1506 }
1507
1508 #[test]
1509 fn ipv6_link_local_blocked() {
1510 let err = validate_url("https://[fe80::1]/path").unwrap_err();
1511 assert_matches!(err, ToolError::Blocked { .. });
1512 }
1513
1514 #[test]
1515 fn ipv6_unique_local_blocked() {
1516 let err = validate_url("https://[fd12::1]/path").unwrap_err();
1517 assert_matches!(err, ToolError::Blocked { .. });
1518 }
1519
1520 #[test]
1521 fn ipv4_mapped_ipv6_loopback_blocked() {
1522 let err = validate_url("https://[::ffff:127.0.0.1]/path").unwrap_err();
1523 assert_matches!(err, ToolError::Blocked { .. });
1524 }
1525
1526 #[test]
1527 fn ipv4_mapped_ipv6_private_blocked() {
1528 let err = validate_url("https://[::ffff:10.0.0.1]/path").unwrap_err();
1529 assert_matches!(err, ToolError::Blocked { .. });
1530 }
1531
1532 #[tokio::test]
1535 async fn executor_no_blocks_returns_none() {
1536 let config = ScrapeConfig::default();
1537 let executor = WebScrapeExecutor::new(&config);
1538 let result = executor.execute("plain text").await;
1539 assert!(result.unwrap().is_none());
1540 }
1541
1542 #[tokio::test]
1543 async fn executor_invalid_json_errors() {
1544 let config = ScrapeConfig::default();
1545 let executor = WebScrapeExecutor::new(&config);
1546 let response = "```scrape\nnot json\n```";
1547 let result = executor.execute(response).await;
1548 assert_matches!(result, Err(ToolError::Execution(_)));
1549 }
1550
1551 #[tokio::test]
1552 async fn executor_blocked_url_errors() {
1553 let config = ScrapeConfig::default();
1554 let executor = WebScrapeExecutor::new(&config);
1555 let response = "```scrape\n{\"url\":\"http://example.com\",\"select\":\"h1\"}\n```";
1556 let result = executor.execute(response).await;
1557 assert_matches!(result, Err(ToolError::Blocked { .. }));
1558 }
1559
1560 #[tokio::test]
1561 async fn executor_private_ip_blocked() {
1562 let config = ScrapeConfig::default();
1563 let executor = WebScrapeExecutor::new(&config);
1564 let response = "```scrape\n{\"url\":\"https://192.168.1.1/api\",\"select\":\"h1\"}\n```";
1565 let result = executor.execute(response).await;
1566 assert_matches!(result, Err(ToolError::Blocked { .. }));
1567 }
1568
1569 #[tokio::test]
1570 async fn executor_unreachable_host_returns_error() {
1571 let config = ScrapeConfig {
1572 timeout: 1,
1573 max_body_bytes: 1_048_576,
1574 ..Default::default()
1575 };
1576 let executor = WebScrapeExecutor::new(&config);
1577 let response = "```scrape\n{\"url\":\"https://192.0.2.1:1/page\",\"select\":\"h1\"}\n```";
1578 let result = executor.execute(response).await;
1579 assert_matches!(result, Err(ToolError::Execution(_)));
1580 }
1581
1582 #[tokio::test]
1583 async fn executor_localhost_url_blocked() {
1584 let config = ScrapeConfig::default();
1585 let executor = WebScrapeExecutor::new(&config);
1586 let response = "```scrape\n{\"url\":\"https://localhost:9999/api\",\"select\":\"h1\"}\n```";
1587 let result = executor.execute(response).await;
1588 assert_matches!(result, Err(ToolError::Blocked { .. }));
1589 }
1590
1591 #[tokio::test]
1592 async fn executor_empty_text_returns_none() {
1593 let config = ScrapeConfig::default();
1594 let executor = WebScrapeExecutor::new(&config);
1595 let result = executor.execute("").await;
1596 assert!(result.unwrap().is_none());
1597 }
1598
1599 #[tokio::test]
1600 async fn executor_multiple_blocks_first_blocked() {
1601 let config = ScrapeConfig::default();
1602 let executor = WebScrapeExecutor::new(&config);
1603 let response = "```scrape\n{\"url\":\"http://evil.com\",\"select\":\"h1\"}\n```\n\
1604 ```scrape\n{\"url\":\"https://ok.com\",\"select\":\"h1\"}\n```";
1605 let result = executor.execute(response).await;
1606 assert!(result.is_err());
1607 }
1608
1609 #[test]
1610 fn validate_url_empty_string() {
1611 let err = validate_url("").unwrap_err();
1612 assert_matches!(err, ToolError::Blocked { .. });
1613 }
1614
1615 #[test]
1616 fn validate_url_javascript_scheme_blocked() {
1617 let err = validate_url("javascript:alert(1)").unwrap_err();
1618 assert_matches!(err, ToolError::Blocked { .. });
1619 }
1620
1621 #[test]
1622 fn validate_url_data_scheme_blocked() {
1623 let err = validate_url("data:text/html,<h1>hi</h1>").unwrap_err();
1624 assert_matches!(err, ToolError::Blocked { .. });
1625 }
1626
1627 #[test]
1628 fn is_private_host_public_domain_is_false() {
1629 let host: url::Host<&str> = url::Host::Domain("example.com");
1630 assert!(!is_private_host(&host));
1631 }
1632
1633 #[test]
1634 fn is_private_host_localhost_is_true() {
1635 let host: url::Host<&str> = url::Host::Domain("localhost");
1636 assert!(is_private_host(&host));
1637 }
1638
1639 #[test]
1640 fn is_private_host_ipv6_unspecified_is_true() {
1641 let host = url::Host::Ipv6(std::net::Ipv6Addr::UNSPECIFIED);
1642 assert!(is_private_host(&host));
1643 }
1644
1645 #[test]
1646 fn is_private_host_public_ipv6_is_false() {
1647 let host = url::Host::Ipv6("2001:db8::1".parse().unwrap());
1648 assert!(!is_private_host(&host));
1649 }
1650
1651 async fn mock_server_executor() -> (WebScrapeExecutor, wiremock::MockServer) {
1662 let server = wiremock::MockServer::start().await;
1663 let executor = WebScrapeExecutor {
1664 timeout: Duration::from_secs(5),
1665 max_body_bytes: 1_048_576,
1666 allowed_domains: vec![],
1667 denied_domains: vec![],
1668 audit_logger: None,
1669 egress_config: EgressConfig::default(),
1670 egress_tx: None,
1671 egress_dropped: Arc::new(AtomicU64::new(0)),
1672 ipi_filter: IpiFilter::new(0.6),
1673 };
1674 (executor, server)
1675 }
1676
1677 fn server_host_and_addr(server: &wiremock::MockServer) -> (String, Vec<std::net::SocketAddr>) {
1679 let uri = server.uri();
1680 let url = Url::parse(&uri).unwrap();
1681 let host = url.host_str().unwrap_or("127.0.0.1").to_owned();
1682 let port = url.port().unwrap_or(80);
1683 let addr: std::net::SocketAddr = format!("{host}:{port}").parse().unwrap();
1684 (host, vec![addr])
1685 }
1686
1687 async fn follow_redirects_raw(
1691 executor: &WebScrapeExecutor,
1692 start_url: &str,
1693 host: &str,
1694 addrs: &[std::net::SocketAddr],
1695 ) -> Result<String, ToolError> {
1696 const MAX_REDIRECTS: usize = 3;
1697 let mut current_url = start_url.to_owned();
1698 let mut current_host = host.to_owned();
1699 let mut current_addrs = addrs.to_vec();
1700
1701 for hop in 0..=MAX_REDIRECTS {
1702 let client = executor.build_client(¤t_host, ¤t_addrs);
1703 let resp = client
1704 .get(¤t_url)
1705 .send()
1706 .await
1707 .map_err(|e| ToolError::Execution(std::io::Error::other(e.to_string())))?;
1708
1709 let status = resp.status();
1710
1711 if status.is_redirection() {
1712 if hop == MAX_REDIRECTS {
1713 return Err(ToolError::Execution(std::io::Error::other(
1714 "too many redirects",
1715 )));
1716 }
1717
1718 let location = resp
1719 .headers()
1720 .get(reqwest::header::LOCATION)
1721 .and_then(|v| v.to_str().ok())
1722 .ok_or_else(|| {
1723 ToolError::Execution(std::io::Error::other("redirect with no Location"))
1724 })?;
1725
1726 let base = Url::parse(¤t_url)
1727 .map_err(|e| ToolError::Execution(std::io::Error::other(e.to_string())))?;
1728 let next_url = base
1729 .join(location)
1730 .map_err(|e| ToolError::Execution(std::io::Error::other(e.to_string())))?;
1731
1732 current_url = next_url.to_string();
1734 let _ = &mut current_host;
1736 let _ = &mut current_addrs;
1737 continue;
1738 }
1739
1740 if !status.is_success() {
1741 return Err(ToolError::Execution(std::io::Error::other(format!(
1742 "HTTP {status}",
1743 ))));
1744 }
1745
1746 let bytes = resp
1747 .bytes()
1748 .await
1749 .map_err(|e| ToolError::Execution(std::io::Error::other(e.to_string())))?;
1750
1751 if bytes.len() > executor.max_body_bytes {
1752 return Err(ToolError::Execution(std::io::Error::other(format!(
1753 "response too large: {} bytes (max: {})",
1754 bytes.len(),
1755 executor.max_body_bytes,
1756 ))));
1757 }
1758
1759 return String::from_utf8(bytes.to_vec())
1760 .map_err(|e| ToolError::Execution(std::io::Error::other(e.to_string())));
1761 }
1762
1763 Err(ToolError::Execution(std::io::Error::other(
1764 "too many redirects",
1765 )))
1766 }
1767
1768 #[tokio::test]
1769 async fn fetch_html_success_returns_body() {
1770 use wiremock::matchers::{method, path};
1771 use wiremock::{Mock, ResponseTemplate};
1772
1773 let (executor, server) = mock_server_executor().await;
1774 Mock::given(method("GET"))
1775 .and(path("/page"))
1776 .respond_with(ResponseTemplate::new(200).set_body_string("<h1>OK</h1>"))
1777 .mount(&server)
1778 .await;
1779
1780 let (host, addrs) = server_host_and_addr(&server);
1781 let url = format!("{}/page", server.uri());
1782 let result = executor
1783 .fetch_html(&url, &host, &addrs, "fetch", "test-cid", None, None)
1784 .await;
1785 assert!(result.is_ok(), "expected Ok, got: {result:?}");
1786 assert_eq!(result.unwrap(), "<h1>OK</h1>");
1787 }
1788
1789 #[tokio::test]
1790 async fn fetch_html_non_2xx_returns_error() {
1791 use wiremock::matchers::{method, path};
1792 use wiremock::{Mock, ResponseTemplate};
1793
1794 let (executor, server) = mock_server_executor().await;
1795 Mock::given(method("GET"))
1796 .and(path("/forbidden"))
1797 .respond_with(ResponseTemplate::new(403))
1798 .mount(&server)
1799 .await;
1800
1801 let (host, addrs) = server_host_and_addr(&server);
1802 let url = format!("{}/forbidden", server.uri());
1803 let result = executor
1804 .fetch_html(&url, &host, &addrs, "fetch", "test-cid", None, None)
1805 .await;
1806 assert!(result.is_err());
1807 let msg = result.unwrap_err().to_string();
1808 assert!(msg.contains("403"), "expected 403 in error: {msg}");
1809 }
1810
1811 #[tokio::test]
1812 async fn fetch_html_404_returns_error() {
1813 use wiremock::matchers::{method, path};
1814 use wiremock::{Mock, ResponseTemplate};
1815
1816 let (executor, server) = mock_server_executor().await;
1817 Mock::given(method("GET"))
1818 .and(path("/missing"))
1819 .respond_with(ResponseTemplate::new(404))
1820 .mount(&server)
1821 .await;
1822
1823 let (host, addrs) = server_host_and_addr(&server);
1824 let url = format!("{}/missing", server.uri());
1825 let result = executor
1826 .fetch_html(&url, &host, &addrs, "fetch", "test-cid", None, None)
1827 .await;
1828 assert!(result.is_err());
1829 let msg = result.unwrap_err().to_string();
1830 assert!(msg.contains("404"), "expected 404 in error: {msg}");
1831 }
1832
1833 #[tokio::test]
1834 async fn fetch_html_redirect_no_location_returns_error() {
1835 use wiremock::matchers::{method, path};
1836 use wiremock::{Mock, ResponseTemplate};
1837
1838 let (executor, server) = mock_server_executor().await;
1839 Mock::given(method("GET"))
1841 .and(path("/redirect-no-loc"))
1842 .respond_with(ResponseTemplate::new(302))
1843 .mount(&server)
1844 .await;
1845
1846 let (host, addrs) = server_host_and_addr(&server);
1847 let url = format!("{}/redirect-no-loc", server.uri());
1848 let result = executor
1849 .fetch_html(&url, &host, &addrs, "fetch", "test-cid", None, None)
1850 .await;
1851 assert!(result.is_err());
1852 let msg = result.unwrap_err().to_string();
1853 assert!(
1854 msg.contains("Location") || msg.contains("location"),
1855 "expected Location-related error: {msg}"
1856 );
1857 }
1858
1859 #[tokio::test]
1860 async fn fetch_html_single_redirect_followed() {
1861 use wiremock::matchers::{method, path};
1862 use wiremock::{Mock, ResponseTemplate};
1863
1864 let (executor, server) = mock_server_executor().await;
1865 let final_url = format!("{}/final", server.uri());
1866
1867 Mock::given(method("GET"))
1868 .and(path("/start"))
1869 .respond_with(ResponseTemplate::new(302).insert_header("location", final_url.as_str()))
1870 .mount(&server)
1871 .await;
1872
1873 Mock::given(method("GET"))
1874 .and(path("/final"))
1875 .respond_with(ResponseTemplate::new(200).set_body_string("<p>final</p>"))
1876 .mount(&server)
1877 .await;
1878
1879 let (host, addrs) = server_host_and_addr(&server);
1880 let url = format!("{}/start", server.uri());
1881 let result = follow_redirects_raw(&executor, &url, &host, &addrs).await;
1882 assert!(result.is_ok(), "single redirect should succeed: {result:?}");
1883 assert_eq!(result.unwrap(), "<p>final</p>");
1884 }
1885
1886 #[tokio::test]
1887 async fn fetch_html_three_redirects_allowed() {
1888 use wiremock::matchers::{method, path};
1889 use wiremock::{Mock, ResponseTemplate};
1890
1891 let (executor, server) = mock_server_executor().await;
1892 let hop2 = format!("{}/hop2", server.uri());
1893 let hop3 = format!("{}/hop3", server.uri());
1894 let final_dest = format!("{}/done", server.uri());
1895
1896 Mock::given(method("GET"))
1897 .and(path("/hop1"))
1898 .respond_with(ResponseTemplate::new(301).insert_header("location", hop2.as_str()))
1899 .mount(&server)
1900 .await;
1901 Mock::given(method("GET"))
1902 .and(path("/hop2"))
1903 .respond_with(ResponseTemplate::new(301).insert_header("location", hop3.as_str()))
1904 .mount(&server)
1905 .await;
1906 Mock::given(method("GET"))
1907 .and(path("/hop3"))
1908 .respond_with(ResponseTemplate::new(301).insert_header("location", final_dest.as_str()))
1909 .mount(&server)
1910 .await;
1911 Mock::given(method("GET"))
1912 .and(path("/done"))
1913 .respond_with(ResponseTemplate::new(200).set_body_string("<p>done</p>"))
1914 .mount(&server)
1915 .await;
1916
1917 let (host, addrs) = server_host_and_addr(&server);
1918 let url = format!("{}/hop1", server.uri());
1919 let result = follow_redirects_raw(&executor, &url, &host, &addrs).await;
1920 assert!(result.is_ok(), "3 redirects should succeed: {result:?}");
1921 assert_eq!(result.unwrap(), "<p>done</p>");
1922 }
1923
1924 #[tokio::test]
1925 async fn fetch_html_four_redirects_rejected() {
1926 use wiremock::matchers::{method, path};
1927 use wiremock::{Mock, ResponseTemplate};
1928
1929 let (executor, server) = mock_server_executor().await;
1930 let hop2 = format!("{}/r2", server.uri());
1931 let hop3 = format!("{}/r3", server.uri());
1932 let hop4 = format!("{}/r4", server.uri());
1933 let hop5 = format!("{}/r5", server.uri());
1934
1935 for (from, to) in [
1936 ("/r1", &hop2),
1937 ("/r2", &hop3),
1938 ("/r3", &hop4),
1939 ("/r4", &hop5),
1940 ] {
1941 Mock::given(method("GET"))
1942 .and(path(from))
1943 .respond_with(ResponseTemplate::new(301).insert_header("location", to.as_str()))
1944 .mount(&server)
1945 .await;
1946 }
1947
1948 let (host, addrs) = server_host_and_addr(&server);
1949 let url = format!("{}/r1", server.uri());
1950 let result = follow_redirects_raw(&executor, &url, &host, &addrs).await;
1951 assert!(result.is_err(), "4 redirects should be rejected");
1952 let msg = result.unwrap_err().to_string();
1953 assert!(
1954 msg.contains("redirect"),
1955 "expected redirect-related error: {msg}"
1956 );
1957 }
1958
1959 #[tokio::test]
1960 async fn fetch_html_body_too_large_returns_error() {
1961 use wiremock::matchers::{method, path};
1962 use wiremock::{Mock, ResponseTemplate};
1963
1964 let small_limit_executor = WebScrapeExecutor {
1965 timeout: Duration::from_secs(5),
1966 max_body_bytes: 10,
1967 allowed_domains: vec![],
1968 denied_domains: vec![],
1969 audit_logger: None,
1970 egress_config: EgressConfig::default(),
1971 egress_tx: None,
1972 egress_dropped: Arc::new(AtomicU64::new(0)),
1973 ipi_filter: IpiFilter::new(0.6),
1974 };
1975 let server = wiremock::MockServer::start().await;
1976 Mock::given(method("GET"))
1977 .and(path("/big"))
1978 .respond_with(
1979 ResponseTemplate::new(200)
1980 .set_body_string("this body is definitely longer than ten bytes"),
1981 )
1982 .mount(&server)
1983 .await;
1984
1985 let (host, addrs) = server_host_and_addr(&server);
1986 let url = format!("{}/big", server.uri());
1987 let result = small_limit_executor
1988 .fetch_html(&url, &host, &addrs, "fetch", "test-cid", None, None)
1989 .await;
1990 assert!(result.is_err());
1991 let msg = result.unwrap_err().to_string();
1992 assert!(msg.contains("too large"), "expected too-large error: {msg}");
1993 }
1994
1995 #[test]
1996 fn extract_scrape_blocks_empty_block_content() {
1997 let text = "```scrape\n\n```";
1998 let blocks = extract_scrape_blocks(text);
1999 assert_eq!(blocks.len(), 1);
2000 assert!(blocks[0].is_empty());
2001 }
2002
2003 #[test]
2004 fn extract_scrape_blocks_whitespace_only() {
2005 let text = "```scrape\n \n```";
2006 let blocks = extract_scrape_blocks(text);
2007 assert_eq!(blocks.len(), 1);
2008 }
2009
2010 #[test]
2011 fn parse_and_extract_multiple_selectors() {
2012 let html = "<div><h1>Title</h1><p>Para</p></div>";
2013 let result = parse_and_extract(html, "h1, p", &ExtractMode::Text, 10).unwrap();
2014 assert!(result.contains("Title"));
2015 assert!(result.contains("Para"));
2016 }
2017
2018 #[test]
2019 fn webscrape_executor_new_with_custom_config() {
2020 let config = ScrapeConfig {
2021 timeout: 60,
2022 max_body_bytes: 512,
2023 ..Default::default()
2024 };
2025 let executor = WebScrapeExecutor::new(&config);
2026 assert_eq!(executor.max_body_bytes, 512);
2027 }
2028
2029 #[test]
2030 fn webscrape_executor_debug() {
2031 let config = ScrapeConfig::default();
2032 let executor = WebScrapeExecutor::new(&config);
2033 let dbg = format!("{executor:?}");
2034 assert!(dbg.contains("WebScrapeExecutor"));
2035 }
2036
2037 #[test]
2038 fn extract_mode_attr_empty_name() {
2039 let mode = ExtractMode::parse("attr:");
2040 assert_matches!(mode, ExtractMode::Attr(ref s) if s.is_empty());
2041 }
2042
2043 #[test]
2044 fn default_extract_returns_text() {
2045 assert_eq!(default_extract(), "text");
2046 }
2047
2048 #[test]
2049 fn scrape_instruction_debug() {
2050 let json = r#"{"url":"https://example.com","select":"h1"}"#;
2051 let instr: ScrapeInstruction = serde_json::from_str(json).unwrap();
2052 let dbg = format!("{instr:?}");
2053 assert!(dbg.contains("ScrapeInstruction"));
2054 }
2055
2056 #[test]
2057 fn extract_mode_debug() {
2058 let mode = ExtractMode::Text;
2059 let dbg = format!("{mode:?}");
2060 assert!(dbg.contains("Text"));
2061 }
2062
2063 #[test]
2068 fn max_redirects_constant_is_three() {
2069 const MAX_REDIRECTS: usize = 3;
2073 assert_eq!(MAX_REDIRECTS, 3, "fetch_html allows exactly 3 redirects");
2074 }
2075
2076 #[test]
2079 fn redirect_no_location_error_message() {
2080 let err = std::io::Error::other("redirect with no Location");
2081 assert!(err.to_string().contains("redirect with no Location"));
2082 }
2083
2084 #[test]
2086 fn too_many_redirects_error_message() {
2087 let err = std::io::Error::other("too many redirects");
2088 assert!(err.to_string().contains("too many redirects"));
2089 }
2090
2091 #[test]
2093 fn non_2xx_status_error_format() {
2094 let status = reqwest::StatusCode::FORBIDDEN;
2095 let msg = format!("HTTP {status}");
2096 assert!(msg.contains("403"));
2097 }
2098
2099 #[test]
2101 fn not_found_status_error_format() {
2102 let status = reqwest::StatusCode::NOT_FOUND;
2103 let msg = format!("HTTP {status}");
2104 assert!(msg.contains("404"));
2105 }
2106
2107 #[test]
2109 fn relative_redirect_same_host_path() {
2110 let base = Url::parse("https://example.com/current").unwrap();
2111 let resolved = base.join("/other").unwrap();
2112 assert_eq!(resolved.as_str(), "https://example.com/other");
2113 }
2114
2115 #[test]
2117 fn relative_redirect_relative_path() {
2118 let base = Url::parse("https://example.com/a/b").unwrap();
2119 let resolved = base.join("c").unwrap();
2120 assert_eq!(resolved.as_str(), "https://example.com/a/c");
2121 }
2122
2123 #[test]
2125 fn absolute_redirect_overrides_base() {
2126 let base = Url::parse("https://example.com/page").unwrap();
2127 let resolved = base.join("https://other.com/target").unwrap();
2128 assert_eq!(resolved.as_str(), "https://other.com/target");
2129 }
2130
2131 #[test]
2133 fn redirect_http_downgrade_rejected() {
2134 let location = "http://example.com/page";
2135 let base = Url::parse("https://example.com/start").unwrap();
2136 let next = base.join(location).unwrap();
2137 let err = validate_url(next.as_str()).unwrap_err();
2138 assert_matches!(err, ToolError::Blocked { .. });
2139 }
2140
2141 #[test]
2143 fn redirect_location_private_ip_blocked() {
2144 let location = "https://192.168.100.1/admin";
2145 let base = Url::parse("https://example.com/start").unwrap();
2146 let next = base.join(location).unwrap();
2147 let err = validate_url(next.as_str()).unwrap_err();
2148 assert_matches!(err, ToolError::Blocked { .. });
2149 let ToolError::Blocked { command: cmd } = err else {
2150 panic!("expected Blocked");
2151 };
2152 assert!(
2153 cmd.contains("private") || cmd.contains("scheme"),
2154 "error message should describe the block reason: {cmd}"
2155 );
2156 }
2157
2158 #[test]
2160 fn redirect_location_internal_domain_blocked() {
2161 let location = "https://metadata.internal/latest/meta-data/";
2162 let base = Url::parse("https://example.com/start").unwrap();
2163 let next = base.join(location).unwrap();
2164 let err = validate_url(next.as_str()).unwrap_err();
2165 assert_matches!(err, ToolError::Blocked { .. });
2166 }
2167
2168 #[test]
2170 fn redirect_chain_three_hops_all_public() {
2171 let hops = [
2172 "https://redirect1.example.com/hop1",
2173 "https://redirect2.example.com/hop2",
2174 "https://destination.example.com/final",
2175 ];
2176 for hop in hops {
2177 assert!(validate_url(hop).is_ok(), "expected ok for {hop}");
2178 }
2179 }
2180
2181 #[test]
2186 fn redirect_to_private_ip_rejected_by_validate_url() {
2187 let private_targets = [
2189 "https://127.0.0.1/secret",
2190 "https://10.0.0.1/internal",
2191 "https://192.168.1.1/admin",
2192 "https://172.16.0.1/data",
2193 "https://[::1]/path",
2194 "https://[fe80::1]/path",
2195 "https://localhost/path",
2196 "https://service.internal/api",
2197 ];
2198 for target in private_targets {
2199 let result = validate_url(target);
2200 assert!(result.is_err(), "expected error for {target}");
2201 assert!(
2202 matches!(result.unwrap_err(), ToolError::Blocked { .. }),
2203 "expected Blocked for {target}"
2204 );
2205 }
2206 }
2207
2208 #[test]
2210 fn redirect_relative_url_resolves_correctly() {
2211 let base = Url::parse("https://example.com/page").unwrap();
2212 let relative = "/other";
2213 let resolved = base.join(relative).unwrap();
2214 assert_eq!(resolved.as_str(), "https://example.com/other");
2215 }
2216
2217 #[test]
2219 fn redirect_to_http_rejected() {
2220 let err = validate_url("http://example.com/page").unwrap_err();
2221 assert_matches!(err, ToolError::Blocked { .. });
2222 }
2223
2224 #[test]
2225 fn ipv4_mapped_ipv6_link_local_blocked() {
2226 let err = validate_url("https://[::ffff:169.254.0.1]/path").unwrap_err();
2227 assert_matches!(err, ToolError::Blocked { .. });
2228 }
2229
2230 #[test]
2231 fn ipv4_mapped_ipv6_public_allowed() {
2232 assert!(validate_url("https://[::ffff:93.184.216.34]/path").is_ok());
2233 }
2234
2235 #[tokio::test]
2238 async fn fetch_http_scheme_blocked() {
2239 let config = ScrapeConfig::default();
2240 let executor = WebScrapeExecutor::new(&config);
2241 let call = crate::executor::ToolCall {
2242 tool_id: ToolName::new("fetch"),
2243 params: {
2244 let mut m = serde_json::Map::new();
2245 m.insert("url".to_owned(), serde_json::json!("http://example.com"));
2246 m
2247 },
2248 caller_id: None,
2249 context: None,
2250
2251 tool_call_id: String::new(),
2252 skill_name: None,
2253 };
2254 let result = executor.execute_tool_call(&call).await;
2255 assert_matches!(result, Err(ToolError::Blocked { .. }));
2256 }
2257
2258 #[tokio::test]
2259 async fn fetch_private_ip_blocked() {
2260 let config = ScrapeConfig::default();
2261 let executor = WebScrapeExecutor::new(&config);
2262 let call = crate::executor::ToolCall {
2263 tool_id: ToolName::new("fetch"),
2264 params: {
2265 let mut m = serde_json::Map::new();
2266 m.insert(
2267 "url".to_owned(),
2268 serde_json::json!("https://192.168.1.1/secret"),
2269 );
2270 m
2271 },
2272 caller_id: None,
2273 context: None,
2274
2275 tool_call_id: String::new(),
2276 skill_name: None,
2277 };
2278 let result = executor.execute_tool_call(&call).await;
2279 assert_matches!(result, Err(ToolError::Blocked { .. }));
2280 }
2281
2282 #[tokio::test]
2283 async fn fetch_localhost_blocked() {
2284 let config = ScrapeConfig::default();
2285 let executor = WebScrapeExecutor::new(&config);
2286 let call = crate::executor::ToolCall {
2287 tool_id: ToolName::new("fetch"),
2288 params: {
2289 let mut m = serde_json::Map::new();
2290 m.insert(
2291 "url".to_owned(),
2292 serde_json::json!("https://localhost/page"),
2293 );
2294 m
2295 },
2296 caller_id: None,
2297 context: None,
2298
2299 tool_call_id: String::new(),
2300 skill_name: None,
2301 };
2302 let result = executor.execute_tool_call(&call).await;
2303 assert_matches!(result, Err(ToolError::Blocked { .. }));
2304 }
2305
2306 #[tokio::test]
2307 async fn fetch_unknown_tool_returns_none() {
2308 let config = ScrapeConfig::default();
2309 let executor = WebScrapeExecutor::new(&config);
2310 let call = crate::executor::ToolCall {
2311 tool_id: ToolName::new("unknown_tool"),
2312 params: serde_json::Map::new(),
2313 caller_id: None,
2314 context: None,
2315
2316 tool_call_id: String::new(),
2317 skill_name: None,
2318 };
2319 let result = executor.execute_tool_call(&call).await;
2320 assert!(result.unwrap().is_none());
2321 }
2322
2323 #[tokio::test]
2324 async fn fetch_returns_body_via_mock() {
2325 use wiremock::matchers::{method, path};
2326 use wiremock::{Mock, ResponseTemplate};
2327
2328 let (executor, server) = mock_server_executor().await;
2329 Mock::given(method("GET"))
2330 .and(path("/content"))
2331 .respond_with(ResponseTemplate::new(200).set_body_string("plain text content"))
2332 .mount(&server)
2333 .await;
2334
2335 let (host, addrs) = server_host_and_addr(&server);
2336 let url = format!("{}/content", server.uri());
2337 let result = executor
2338 .fetch_html(&url, &host, &addrs, "fetch", "test-cid", None, None)
2339 .await;
2340 assert!(result.is_ok());
2341 assert_eq!(result.unwrap(), "plain text content");
2342 }
2343
2344 #[test]
2345 fn tool_definitions_returns_web_scrape_and_fetch() {
2346 let config = ScrapeConfig::default();
2347 let executor = WebScrapeExecutor::new(&config);
2348 let defs = executor.tool_definitions();
2349 assert_eq!(defs.len(), 2);
2350 assert_eq!(defs[0].id, "web_scrape");
2351 assert_eq!(
2352 defs[0].invocation,
2353 crate::registry::InvocationHint::FencedBlock("scrape")
2354 );
2355 assert_eq!(defs[1].id, "fetch");
2356 assert_eq!(
2357 defs[1].invocation,
2358 crate::registry::InvocationHint::ToolCall
2359 );
2360 }
2361
2362 #[test]
2363 fn tool_definitions_schema_has_all_params() {
2364 let config = ScrapeConfig::default();
2365 let executor = WebScrapeExecutor::new(&config);
2366 let defs = executor.tool_definitions();
2367 let obj = defs[0].schema.as_object().unwrap();
2368 let props = obj["properties"].as_object().unwrap();
2369 assert!(props.contains_key("url"));
2370 assert!(props.contains_key("select"));
2371 assert!(props.contains_key("extract"));
2372 assert!(props.contains_key("limit"));
2373 let req = obj["required"].as_array().unwrap();
2374 assert!(req.iter().any(|v| v.as_str() == Some("url")));
2375 assert!(req.iter().any(|v| v.as_str() == Some("select")));
2376 assert!(!req.iter().any(|v| v.as_str() == Some("extract")));
2377 }
2378
2379 #[test]
2382 fn subdomain_localhost_blocked() {
2383 let host: url::Host<&str> = url::Host::Domain("foo.localhost");
2384 assert!(is_private_host(&host));
2385 }
2386
2387 #[test]
2388 fn internal_tld_blocked() {
2389 let host: url::Host<&str> = url::Host::Domain("service.internal");
2390 assert!(is_private_host(&host));
2391 }
2392
2393 #[test]
2394 fn local_tld_blocked() {
2395 let host: url::Host<&str> = url::Host::Domain("printer.local");
2396 assert!(is_private_host(&host));
2397 }
2398
2399 #[test]
2400 fn public_domain_not_blocked() {
2401 let host: url::Host<&str> = url::Host::Domain("example.com");
2402 assert!(!is_private_host(&host));
2403 }
2404
2405 #[tokio::test]
2408 async fn resolve_loopback_rejected() {
2409 let url = url::Url::parse("https://127.0.0.1/path").unwrap();
2411 let result = resolve_and_validate(&url).await;
2413 assert!(
2414 result.is_err(),
2415 "loopback IP must be rejected by resolve_and_validate"
2416 );
2417 let err = result.unwrap_err();
2418 assert_matches!(err, crate::executor::ToolError::Blocked { .. });
2419 }
2420
2421 #[tokio::test]
2422 async fn resolve_private_10_rejected() {
2423 let url = url::Url::parse("https://10.0.0.1/path").unwrap();
2424 let result = resolve_and_validate(&url).await;
2425 assert!(result.is_err());
2426 assert_matches!(
2427 result.unwrap_err(),
2428 crate::executor::ToolError::Blocked { .. }
2429 );
2430 }
2431
2432 #[tokio::test]
2433 async fn resolve_private_192_rejected() {
2434 let url = url::Url::parse("https://192.168.1.1/path").unwrap();
2435 let result = resolve_and_validate(&url).await;
2436 assert!(result.is_err());
2437 assert_matches!(
2438 result.unwrap_err(),
2439 crate::executor::ToolError::Blocked { .. }
2440 );
2441 }
2442
2443 #[tokio::test]
2444 async fn resolve_ipv6_loopback_rejected() {
2445 let url = url::Url::parse("https://[::1]/path").unwrap();
2446 let result = resolve_and_validate(&url).await;
2447 assert!(result.is_err());
2448 assert_matches!(
2449 result.unwrap_err(),
2450 crate::executor::ToolError::Blocked { .. }
2451 );
2452 }
2453
2454 #[tokio::test]
2455 async fn resolve_no_host_returns_ok() {
2456 let url = url::Url::parse("https://example.com/path").unwrap();
2458 let url_no_host = url::Url::parse("data:text/plain,hello").unwrap();
2460 let result = resolve_and_validate(&url_no_host).await;
2462 assert!(result.is_ok());
2463 let (host, addrs) = result.unwrap();
2464 assert!(host.is_empty());
2465 assert!(addrs.is_empty());
2466 drop(url);
2467 drop(url_no_host);
2468 }
2469
2470 async fn make_file_audit_logger(
2474 dir: &tempfile::TempDir,
2475 ) -> (
2476 std::sync::Arc<crate::audit::AuditLogger>,
2477 std::path::PathBuf,
2478 ) {
2479 use crate::audit::AuditLogger;
2480 use crate::config::AuditConfig;
2481 let path = dir.path().join("audit.log");
2482 let config = AuditConfig {
2483 enabled: true,
2484 destination: crate::config::AuditDestination::File(path.clone()),
2485 ..Default::default()
2486 };
2487 let logger = std::sync::Arc::new(AuditLogger::from_config(&config, false).await.unwrap());
2488 (logger, path)
2489 }
2490
2491 #[tokio::test]
2492 async fn with_audit_sets_logger() {
2493 let config = ScrapeConfig::default();
2494 let executor = WebScrapeExecutor::new(&config);
2495 assert!(executor.audit_logger.is_none());
2496
2497 let dir = tempfile::tempdir().unwrap();
2498 let (logger, _path) = make_file_audit_logger(&dir).await;
2499 let executor = executor.with_audit(logger);
2500 assert!(executor.audit_logger.is_some());
2501 }
2502
2503 #[test]
2504 fn tool_error_to_audit_result_blocked_maps_correctly() {
2505 let err = ToolError::Blocked {
2506 command: "scheme not allowed: http".into(),
2507 };
2508 let result = tool_error_to_audit_result(&err);
2509 assert!(
2510 matches!(result, AuditResult::Blocked { reason } if reason == "scheme not allowed: http")
2511 );
2512 }
2513
2514 #[test]
2515 fn tool_error_to_audit_result_timeout_maps_correctly() {
2516 let err = ToolError::Timeout { timeout_secs: 15 };
2517 let result = tool_error_to_audit_result(&err);
2518 assert_matches!(result, AuditResult::Timeout);
2519 }
2520
2521 #[test]
2522 fn tool_error_to_audit_result_execution_error_maps_correctly() {
2523 let err = ToolError::Execution(std::io::Error::other("connection refused"));
2524 let result = tool_error_to_audit_result(&err);
2525 assert!(
2526 matches!(result, AuditResult::Error { message } if message.contains("connection refused"))
2527 );
2528 }
2529
2530 #[tokio::test]
2531 async fn fetch_audit_blocked_url_logged() {
2532 let dir = tempfile::tempdir().unwrap();
2533 let (logger, log_path) = make_file_audit_logger(&dir).await;
2534
2535 let config = ScrapeConfig::default();
2536 let executor = WebScrapeExecutor::new(&config).with_audit(logger);
2537
2538 let call = crate::executor::ToolCall {
2539 tool_id: ToolName::new("fetch"),
2540 params: {
2541 let mut m = serde_json::Map::new();
2542 m.insert("url".to_owned(), serde_json::json!("http://example.com"));
2543 m
2544 },
2545 caller_id: None,
2546 context: None,
2547
2548 tool_call_id: String::new(),
2549 skill_name: None,
2550 };
2551 let result = executor.execute_tool_call(&call).await;
2552 assert_matches!(result, Err(ToolError::Blocked { .. }));
2553
2554 let content = tokio::fs::read_to_string(&log_path).await.unwrap();
2555 assert!(
2556 content.contains("\"tool\":\"fetch\""),
2557 "expected tool=fetch in audit: {content}"
2558 );
2559 assert!(
2560 content.contains("\"type\":\"blocked\""),
2561 "expected type=blocked in audit: {content}"
2562 );
2563 assert!(
2564 content.contains("http://example.com"),
2565 "expected URL in audit command field: {content}"
2566 );
2567 }
2568
2569 #[tokio::test]
2570 async fn log_audit_success_writes_to_file() {
2571 let dir = tempfile::tempdir().unwrap();
2572 let (logger, log_path) = make_file_audit_logger(&dir).await;
2573
2574 let config = ScrapeConfig::default();
2575 let executor = WebScrapeExecutor::new(&config).with_audit(logger);
2576
2577 executor
2578 .log_audit(
2579 "fetch",
2580 "https://example.com/page",
2581 AuditResult::Success,
2582 42,
2583 None,
2584 None,
2585 None,
2586 None,
2587 )
2588 .await;
2589
2590 let content = tokio::fs::read_to_string(&log_path).await.unwrap();
2591 assert!(
2592 content.contains("\"tool\":\"fetch\""),
2593 "expected tool=fetch in audit: {content}"
2594 );
2595 assert!(
2596 content.contains("\"type\":\"success\""),
2597 "expected type=success in audit: {content}"
2598 );
2599 assert!(
2600 content.contains("\"command\":\"https://example.com/page\""),
2601 "expected command URL in audit: {content}"
2602 );
2603 assert!(
2604 content.contains("\"duration_ms\":42"),
2605 "expected duration_ms in audit: {content}"
2606 );
2607 }
2608
2609 #[tokio::test]
2610 async fn log_audit_blocked_writes_to_file() {
2611 let dir = tempfile::tempdir().unwrap();
2612 let (logger, log_path) = make_file_audit_logger(&dir).await;
2613
2614 let config = ScrapeConfig::default();
2615 let executor = WebScrapeExecutor::new(&config).with_audit(logger);
2616
2617 executor
2618 .log_audit(
2619 "web_scrape",
2620 "http://evil.com/page",
2621 AuditResult::Blocked {
2622 reason: "scheme not allowed: http".into(),
2623 },
2624 0,
2625 None,
2626 None,
2627 None,
2628 None,
2629 )
2630 .await;
2631
2632 let content = tokio::fs::read_to_string(&log_path).await.unwrap();
2633 assert!(
2634 content.contains("\"tool\":\"web_scrape\""),
2635 "expected tool=web_scrape in audit: {content}"
2636 );
2637 assert!(
2638 content.contains("\"type\":\"blocked\""),
2639 "expected type=blocked in audit: {content}"
2640 );
2641 assert!(
2642 content.contains("scheme not allowed"),
2643 "expected block reason in audit: {content}"
2644 );
2645 }
2646
2647 #[tokio::test]
2648 async fn web_scrape_audit_blocked_url_logged() {
2649 let dir = tempfile::tempdir().unwrap();
2650 let (logger, log_path) = make_file_audit_logger(&dir).await;
2651
2652 let config = ScrapeConfig::default();
2653 let executor = WebScrapeExecutor::new(&config).with_audit(logger);
2654
2655 let call = crate::executor::ToolCall {
2656 tool_id: ToolName::new("web_scrape"),
2657 params: {
2658 let mut m = serde_json::Map::new();
2659 m.insert("url".to_owned(), serde_json::json!("http://example.com"));
2660 m.insert("select".to_owned(), serde_json::json!("h1"));
2661 m
2662 },
2663 caller_id: None,
2664 context: None,
2665
2666 tool_call_id: String::new(),
2667 skill_name: None,
2668 };
2669 let result = executor.execute_tool_call(&call).await;
2670 assert_matches!(result, Err(ToolError::Blocked { .. }));
2671
2672 let content = tokio::fs::read_to_string(&log_path).await.unwrap();
2673 assert!(
2674 content.contains("\"tool\":\"web_scrape\""),
2675 "expected tool=web_scrape in audit: {content}"
2676 );
2677 assert!(
2678 content.contains("\"type\":\"blocked\""),
2679 "expected type=blocked in audit: {content}"
2680 );
2681 }
2682
2683 #[tokio::test]
2684 async fn no_audit_logger_does_not_panic_on_blocked_fetch() {
2685 let config = ScrapeConfig::default();
2686 let executor = WebScrapeExecutor::new(&config);
2687 assert!(executor.audit_logger.is_none());
2688
2689 let call = crate::executor::ToolCall {
2690 tool_id: ToolName::new("fetch"),
2691 params: {
2692 let mut m = serde_json::Map::new();
2693 m.insert("url".to_owned(), serde_json::json!("http://example.com"));
2694 m
2695 },
2696 caller_id: None,
2697 context: None,
2698
2699 tool_call_id: String::new(),
2700 skill_name: None,
2701 };
2702 let result = executor.execute_tool_call(&call).await;
2704 assert_matches!(result, Err(ToolError::Blocked { .. }));
2705 }
2706
2707 #[tokio::test]
2709 async fn fetch_execute_tool_call_end_to_end() {
2710 use wiremock::matchers::{method, path};
2711 use wiremock::{Mock, ResponseTemplate};
2712
2713 let (executor, server) = mock_server_executor().await;
2714 Mock::given(method("GET"))
2715 .and(path("/e2e"))
2716 .respond_with(ResponseTemplate::new(200).set_body_string("<h1>end-to-end</h1>"))
2717 .mount(&server)
2718 .await;
2719
2720 let (host, addrs) = server_host_and_addr(&server);
2721 let result = executor
2723 .fetch_html(
2724 &format!("{}/e2e", server.uri()),
2725 &host,
2726 &addrs,
2727 "fetch",
2728 "test-cid",
2729 None,
2730 None,
2731 )
2732 .await;
2733 assert!(result.is_ok());
2734 assert!(result.unwrap().contains("end-to-end"));
2735 }
2736
2737 #[test]
2740 fn domain_matches_exact() {
2741 assert!(domain_matches("example.com", "example.com"));
2742 assert!(!domain_matches("example.com", "other.com"));
2743 assert!(!domain_matches("example.com", "sub.example.com"));
2744 }
2745
2746 #[test]
2747 fn domain_matches_wildcard_single_subdomain() {
2748 assert!(domain_matches("*.example.com", "sub.example.com"));
2749 assert!(!domain_matches("*.example.com", "example.com"));
2750 assert!(!domain_matches("*.example.com", "sub.sub.example.com"));
2751 }
2752
2753 #[test]
2754 fn domain_matches_wildcard_does_not_match_empty_label() {
2755 assert!(!domain_matches("*.example.com", ".example.com"));
2757 }
2758
2759 #[test]
2760 fn domain_matches_multi_wildcard_treated_as_exact() {
2761 assert!(!domain_matches("*.*.example.com", "a.b.example.com"));
2763 }
2764
2765 #[test]
2768 fn check_domain_policy_empty_lists_allow_all() {
2769 assert!(check_domain_policy("example.com", &[], &[]).is_ok());
2770 assert!(check_domain_policy("evil.com", &[], &[]).is_ok());
2771 }
2772
2773 #[test]
2774 fn check_domain_policy_denylist_blocks() {
2775 let denied = vec!["evil.com".to_string()];
2776 let err = check_domain_policy("evil.com", &[], &denied).unwrap_err();
2777 assert_matches!(err, ToolError::Blocked { .. });
2778 }
2779
2780 #[test]
2781 fn check_domain_policy_denylist_does_not_block_other_domains() {
2782 let denied = vec!["evil.com".to_string()];
2783 assert!(check_domain_policy("good.com", &[], &denied).is_ok());
2784 }
2785
2786 #[test]
2787 fn check_domain_policy_allowlist_permits_matching() {
2788 let allowed = vec!["docs.rs".to_string(), "*.rust-lang.org".to_string()];
2789 assert!(check_domain_policy("docs.rs", &allowed, &[]).is_ok());
2790 assert!(check_domain_policy("blog.rust-lang.org", &allowed, &[]).is_ok());
2791 }
2792
2793 #[test]
2794 fn check_domain_policy_allowlist_blocks_unknown() {
2795 let allowed = vec!["docs.rs".to_string()];
2796 let err = check_domain_policy("other.com", &allowed, &[]).unwrap_err();
2797 assert_matches!(err, ToolError::Blocked { .. });
2798 }
2799
2800 #[test]
2801 fn check_domain_policy_deny_overrides_allow() {
2802 let allowed = vec!["example.com".to_string()];
2803 let denied = vec!["example.com".to_string()];
2804 let err = check_domain_policy("example.com", &allowed, &denied).unwrap_err();
2805 assert_matches!(err, ToolError::Blocked { .. });
2806 }
2807
2808 #[test]
2809 fn check_domain_policy_wildcard_in_denylist() {
2810 let denied = vec!["*.evil.com".to_string()];
2811 let err = check_domain_policy("sub.evil.com", &[], &denied).unwrap_err();
2812 assert_matches!(err, ToolError::Blocked { .. });
2813 assert!(check_domain_policy("evil.com", &[], &denied).is_ok());
2815 }
2816}