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