1use std::net::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::{check_domain_policy, validate_url};
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
1049#[tracing::instrument(name = "tools.scrape.dns.resolve", skip(url), fields(host = url.host_str().unwrap_or("")))]
1059async fn resolve_and_validate(url: &Url) -> Result<(String, Vec<SocketAddr>), ToolError> {
1060 let Some(host) = url.host_str() else {
1061 return Ok((String::new(), vec![]));
1062 };
1063 let port = url.port_or_known_default().unwrap_or(443);
1064 let addrs = zeph_common::net::resolve_and_validate(host, port)
1065 .await
1066 .map_err(|e| match e {
1067 zeph_common::net::ResolveError::Timeout(timeout) => ToolError::Timeout {
1068 timeout_secs: timeout.as_secs(),
1069 },
1070 zeph_common::net::ResolveError::Lookup(io_err) => ToolError::Blocked {
1071 command: format!("DNS resolution failed: {io_err}"),
1072 },
1073 zeph_common::net::ResolveError::PrivateAddress { host, addr } => ToolError::Blocked {
1074 command: format!("SSRF protection: private IP {addr} for host {host}"),
1075 },
1076 other => ToolError::Blocked {
1077 command: format!("DNS resolution failed: {other}"),
1078 },
1079 })?;
1080 Ok((host.to_owned(), addrs))
1081}
1082
1083fn parse_and_extract(
1084 html: &str,
1085 selector: &str,
1086 extract: &ExtractMode,
1087 limit: usize,
1088) -> Result<String, ToolError> {
1089 let soup = scrape_core::Soup::parse(html);
1090
1091 let tags = soup.find_all(selector).map_err(|e| {
1092 ToolError::Execution(std::io::Error::new(
1093 std::io::ErrorKind::InvalidData,
1094 format!("invalid selector: {e}"),
1095 ))
1096 })?;
1097
1098 let mut results = Vec::new();
1099
1100 for tag in tags.into_iter().take(limit) {
1101 let value = match extract {
1102 ExtractMode::Text => tag.text(),
1103 ExtractMode::Html => tag.inner_html(),
1104 ExtractMode::Attr(name) => tag.get(name).unwrap_or_default().to_owned(),
1105 };
1106 if !value.trim().is_empty() {
1107 results.push(value.trim().to_owned());
1108 }
1109 }
1110
1111 if results.is_empty() {
1112 Ok(format!("No results for selector: {selector}"))
1113 } else {
1114 Ok(results.join("\n"))
1115 }
1116}
1117
1118#[cfg(test)]
1119mod tests {
1120 use super::*;
1121 use crate::domain_match::domain_matches;
1122 use crate::net::is_private_host;
1123 use std::assert_matches;
1124
1125 #[test]
1128 fn extract_single_block() {
1129 let text =
1130 "Here:\n```scrape\n{\"url\":\"https://example.com\",\"select\":\"h1\"}\n```\nDone.";
1131 let blocks = extract_scrape_blocks(text);
1132 assert_eq!(blocks.len(), 1);
1133 assert!(blocks[0].contains("example.com"));
1134 }
1135
1136 #[test]
1137 fn extract_multiple_blocks() {
1138 let text = "```scrape\n{\"url\":\"https://a.com\",\"select\":\"h1\"}\n```\ntext\n```scrape\n{\"url\":\"https://b.com\",\"select\":\"p\"}\n```";
1139 let blocks = extract_scrape_blocks(text);
1140 assert_eq!(blocks.len(), 2);
1141 }
1142
1143 #[test]
1144 fn no_blocks_returns_empty() {
1145 let blocks = extract_scrape_blocks("plain text, no code blocks");
1146 assert!(blocks.is_empty());
1147 }
1148
1149 #[test]
1150 fn unclosed_block_ignored() {
1151 let blocks = extract_scrape_blocks("```scrape\n{\"url\":\"https://x.com\"}");
1152 assert!(blocks.is_empty());
1153 }
1154
1155 #[test]
1156 fn non_scrape_block_ignored() {
1157 let text =
1158 "```bash\necho hi\n```\n```scrape\n{\"url\":\"https://x.com\",\"select\":\"h1\"}\n```";
1159 let blocks = extract_scrape_blocks(text);
1160 assert_eq!(blocks.len(), 1);
1161 assert!(blocks[0].contains("x.com"));
1162 }
1163
1164 #[test]
1165 fn multiline_json_block() {
1166 let text =
1167 "```scrape\n{\n \"url\": \"https://example.com\",\n \"select\": \"h1\"\n}\n```";
1168 let blocks = extract_scrape_blocks(text);
1169 assert_eq!(blocks.len(), 1);
1170 let instr: ScrapeInstruction = serde_json::from_str(blocks[0]).unwrap();
1171 assert_eq!(instr.url, "https://example.com");
1172 }
1173
1174 #[test]
1177 fn parse_valid_instruction() {
1178 let json = r#"{"url":"https://example.com","select":"h1","extract":"text","limit":5}"#;
1179 let instr: ScrapeInstruction = serde_json::from_str(json).unwrap();
1180 assert_eq!(instr.url, "https://example.com");
1181 assert_eq!(instr.select, "h1");
1182 assert_eq!(instr.extract, "text");
1183 assert_eq!(instr.limit, Some(5));
1184 }
1185
1186 #[test]
1187 fn parse_minimal_instruction() {
1188 let json = r#"{"url":"https://example.com","select":"p"}"#;
1189 let instr: ScrapeInstruction = serde_json::from_str(json).unwrap();
1190 assert_eq!(instr.extract, "text");
1191 assert!(instr.limit.is_none());
1192 }
1193
1194 #[test]
1195 fn parse_attr_extract() {
1196 let json = r#"{"url":"https://example.com","select":"a","extract":"attr:href"}"#;
1197 let instr: ScrapeInstruction = serde_json::from_str(json).unwrap();
1198 assert_eq!(instr.extract, "attr:href");
1199 }
1200
1201 #[test]
1202 fn parse_invalid_json_errors() {
1203 let result = serde_json::from_str::<ScrapeInstruction>("not json");
1204 assert!(result.is_err());
1205 }
1206
1207 #[test]
1210 fn extract_mode_text() {
1211 assert_matches!(ExtractMode::parse("text"), ExtractMode::Text);
1212 }
1213
1214 #[test]
1215 fn extract_mode_html() {
1216 assert_matches!(ExtractMode::parse("html"), ExtractMode::Html);
1217 }
1218
1219 #[test]
1220 fn extract_mode_attr() {
1221 let mode = ExtractMode::parse("attr:href");
1222 assert_matches!(mode, ExtractMode::Attr(ref s) if s == "href");
1223 }
1224
1225 #[test]
1226 fn extract_mode_unknown_defaults_to_text() {
1227 assert_matches!(ExtractMode::parse("unknown"), ExtractMode::Text);
1228 }
1229
1230 #[test]
1236 fn extract_text_from_html() {
1237 let html = "<html><body><h1>Hello World</h1><p>Content</p></body></html>";
1238 let result = parse_and_extract(html, "h1", &ExtractMode::Text, 10).unwrap();
1239 assert_eq!(result, "Hello World");
1240 }
1241
1242 #[test]
1243 fn extract_multiple_elements() {
1244 let html = "<ul><li>A</li><li>B</li><li>C</li></ul>";
1245 let result = parse_and_extract(html, "li", &ExtractMode::Text, 10).unwrap();
1246 assert_eq!(result, "A\nB\nC");
1247 }
1248
1249 #[test]
1250 fn extract_with_limit() {
1251 let html = "<ul><li>A</li><li>B</li><li>C</li></ul>";
1252 let result = parse_and_extract(html, "li", &ExtractMode::Text, 2).unwrap();
1253 assert_eq!(result, "A\nB");
1254 }
1255
1256 #[test]
1257 fn extract_attr_href() {
1258 let html = r#"<a href="https://example.com">Link</a>"#;
1259 let result =
1260 parse_and_extract(html, "a", &ExtractMode::Attr("href".to_owned()), 10).unwrap();
1261 assert_eq!(result, "https://example.com");
1262 }
1263
1264 #[test]
1265 fn extract_inner_html() {
1266 let html = "<div><span>inner</span></div>";
1267 let result = parse_and_extract(html, "div", &ExtractMode::Html, 10).unwrap();
1268 assert!(result.contains("<span>inner</span>"));
1269 }
1270
1271 #[test]
1272 fn no_matches_returns_message() {
1273 let html = "<html><body><p>text</p></body></html>";
1274 let result = parse_and_extract(html, "h1", &ExtractMode::Text, 10).unwrap();
1275 assert!(result.starts_with("No results for selector:"));
1276 }
1277
1278 #[test]
1279 fn empty_text_skipped() {
1280 let html = "<ul><li> </li><li>A</li></ul>";
1281 let result = parse_and_extract(html, "li", &ExtractMode::Text, 10).unwrap();
1282 assert_eq!(result, "A");
1283 }
1284
1285 #[test]
1286 fn invalid_selector_errors() {
1287 let html = "<html><body></body></html>";
1288 let result = parse_and_extract(html, "[[[invalid", &ExtractMode::Text, 10);
1289 assert!(result.is_err());
1290 }
1291
1292 #[test]
1293 fn empty_html_returns_no_results() {
1294 let result = parse_and_extract("", "h1", &ExtractMode::Text, 10).unwrap();
1295 assert!(result.starts_with("No results for selector:"));
1296 }
1297
1298 #[test]
1299 fn nested_selector() {
1300 let html = "<div><span>inner</span></div><span>outer</span>";
1301 let result = parse_and_extract(html, "div > span", &ExtractMode::Text, 10).unwrap();
1302 assert_eq!(result, "inner");
1303 }
1304
1305 #[test]
1306 fn attr_missing_returns_empty() {
1307 let html = r"<a>No href</a>";
1308 let result =
1309 parse_and_extract(html, "a", &ExtractMode::Attr("href".to_owned()), 10).unwrap();
1310 assert!(result.starts_with("No results for selector:"));
1311 }
1312
1313 #[test]
1314 fn extract_html_mode() {
1315 let html = "<div><b>bold</b> text</div>";
1316 let result = parse_and_extract(html, "div", &ExtractMode::Html, 10).unwrap();
1317 assert!(result.contains("<b>bold</b>"));
1318 }
1319
1320 #[test]
1321 fn limit_zero_returns_no_results() {
1322 let html = "<ul><li>A</li><li>B</li></ul>";
1323 let result = parse_and_extract(html, "li", &ExtractMode::Text, 0).unwrap();
1324 assert!(result.starts_with("No results for selector:"));
1325 }
1326
1327 #[tokio::test]
1330 async fn executor_no_blocks_returns_none() {
1331 let config = ScrapeConfig::default();
1332 let executor = WebScrapeExecutor::new(&config);
1333 let result = executor.execute("plain text").await;
1334 assert!(result.unwrap().is_none());
1335 }
1336
1337 #[tokio::test]
1338 async fn executor_invalid_json_errors() {
1339 let config = ScrapeConfig::default();
1340 let executor = WebScrapeExecutor::new(&config);
1341 let response = "```scrape\nnot json\n```";
1342 let result = executor.execute(response).await;
1343 assert_matches!(result, Err(ToolError::Execution(_)));
1344 }
1345
1346 #[tokio::test]
1347 async fn executor_blocked_url_errors() {
1348 let config = ScrapeConfig::default();
1349 let executor = WebScrapeExecutor::new(&config);
1350 let response = "```scrape\n{\"url\":\"http://example.com\",\"select\":\"h1\"}\n```";
1351 let result = executor.execute(response).await;
1352 assert_matches!(result, Err(ToolError::Blocked { .. }));
1353 }
1354
1355 #[tokio::test]
1356 async fn executor_private_ip_blocked() {
1357 let config = ScrapeConfig::default();
1358 let executor = WebScrapeExecutor::new(&config);
1359 let response = "```scrape\n{\"url\":\"https://192.168.1.1/api\",\"select\":\"h1\"}\n```";
1360 let result = executor.execute(response).await;
1361 assert_matches!(result, Err(ToolError::Blocked { .. }));
1362 }
1363
1364 #[tokio::test]
1365 async fn executor_unreachable_host_returns_error() {
1366 let config = ScrapeConfig {
1367 timeout: 1,
1368 max_body_bytes: 1_048_576,
1369 ..Default::default()
1370 };
1371 let executor = WebScrapeExecutor::new(&config);
1372 let response = "```scrape\n{\"url\":\"https://192.0.2.1:1/page\",\"select\":\"h1\"}\n```";
1373 let result = executor.execute(response).await;
1374 assert_matches!(result, Err(ToolError::Execution(_)));
1375 }
1376
1377 #[tokio::test]
1378 async fn executor_localhost_url_blocked() {
1379 let config = ScrapeConfig::default();
1380 let executor = WebScrapeExecutor::new(&config);
1381 let response = "```scrape\n{\"url\":\"https://localhost:9999/api\",\"select\":\"h1\"}\n```";
1382 let result = executor.execute(response).await;
1383 assert_matches!(result, Err(ToolError::Blocked { .. }));
1384 }
1385
1386 #[tokio::test]
1387 async fn executor_empty_text_returns_none() {
1388 let config = ScrapeConfig::default();
1389 let executor = WebScrapeExecutor::new(&config);
1390 let result = executor.execute("").await;
1391 assert!(result.unwrap().is_none());
1392 }
1393
1394 #[tokio::test]
1395 async fn executor_multiple_blocks_first_blocked() {
1396 let config = ScrapeConfig::default();
1397 let executor = WebScrapeExecutor::new(&config);
1398 let response = "```scrape\n{\"url\":\"http://evil.com\",\"select\":\"h1\"}\n```\n\
1399 ```scrape\n{\"url\":\"https://ok.com\",\"select\":\"h1\"}\n```";
1400 let result = executor.execute(response).await;
1401 assert!(result.is_err());
1402 }
1403
1404 #[test]
1405 fn validate_url_empty_string() {
1406 let err = validate_url("").unwrap_err();
1407 assert_matches!(err, ToolError::Blocked { .. });
1408 }
1409
1410 #[test]
1411 fn validate_url_javascript_scheme_blocked() {
1412 let err = validate_url("javascript:alert(1)").unwrap_err();
1413 assert_matches!(err, ToolError::Blocked { .. });
1414 }
1415
1416 #[test]
1417 fn validate_url_data_scheme_blocked() {
1418 let err = validate_url("data:text/html,<h1>hi</h1>").unwrap_err();
1419 assert_matches!(err, ToolError::Blocked { .. });
1420 }
1421
1422 #[test]
1423 fn is_private_host_public_domain_is_false() {
1424 let host: url::Host<&str> = url::Host::Domain("example.com");
1425 assert!(!is_private_host(&host));
1426 }
1427
1428 #[test]
1429 fn is_private_host_localhost_is_true() {
1430 let host: url::Host<&str> = url::Host::Domain("localhost");
1431 assert!(is_private_host(&host));
1432 }
1433
1434 #[test]
1435 fn is_private_host_ipv6_unspecified_is_true() {
1436 let host = url::Host::Ipv6(std::net::Ipv6Addr::UNSPECIFIED);
1437 assert!(is_private_host(&host));
1438 }
1439
1440 #[test]
1441 fn is_private_host_public_ipv6_is_false() {
1442 let host = url::Host::Ipv6("2001:db8::1".parse().unwrap());
1443 assert!(!is_private_host(&host));
1444 }
1445
1446 async fn mock_server_executor() -> (WebScrapeExecutor, wiremock::MockServer) {
1457 let server = wiremock::MockServer::start().await;
1458 let executor = WebScrapeExecutor {
1459 timeout: Duration::from_secs(5),
1460 max_body_bytes: 1_048_576,
1461 allowed_domains: vec![],
1462 denied_domains: vec![],
1463 audit_logger: None,
1464 egress_config: EgressConfig::default(),
1465 egress_tx: None,
1466 egress_dropped: Arc::new(AtomicU64::new(0)),
1467 ipi_filter: IpiFilter::new(0.6),
1468 };
1469 (executor, server)
1470 }
1471
1472 fn server_host_and_addr(server: &wiremock::MockServer) -> (String, Vec<std::net::SocketAddr>) {
1474 let uri = server.uri();
1475 let url = Url::parse(&uri).unwrap();
1476 let host = url.host_str().unwrap_or("127.0.0.1").to_owned();
1477 let port = url.port().unwrap_or(80);
1478 let addr: std::net::SocketAddr = format!("{host}:{port}").parse().unwrap();
1479 (host, vec![addr])
1480 }
1481
1482 async fn follow_redirects_raw(
1486 executor: &WebScrapeExecutor,
1487 start_url: &str,
1488 host: &str,
1489 addrs: &[std::net::SocketAddr],
1490 ) -> Result<String, ToolError> {
1491 const MAX_REDIRECTS: usize = 3;
1492 let mut current_url = start_url.to_owned();
1493 let mut current_host = host.to_owned();
1494 let mut current_addrs = addrs.to_vec();
1495
1496 for hop in 0..=MAX_REDIRECTS {
1497 let client = executor.build_client(¤t_host, ¤t_addrs);
1498 let resp = client
1499 .get(¤t_url)
1500 .send()
1501 .await
1502 .map_err(|e| ToolError::Execution(std::io::Error::other(e.to_string())))?;
1503
1504 let status = resp.status();
1505
1506 if status.is_redirection() {
1507 if hop == MAX_REDIRECTS {
1508 return Err(ToolError::Execution(std::io::Error::other(
1509 "too many redirects",
1510 )));
1511 }
1512
1513 let location = resp
1514 .headers()
1515 .get(reqwest::header::LOCATION)
1516 .and_then(|v| v.to_str().ok())
1517 .ok_or_else(|| {
1518 ToolError::Execution(std::io::Error::other("redirect with no Location"))
1519 })?;
1520
1521 let base = Url::parse(¤t_url)
1522 .map_err(|e| ToolError::Execution(std::io::Error::other(e.to_string())))?;
1523 let next_url = base
1524 .join(location)
1525 .map_err(|e| ToolError::Execution(std::io::Error::other(e.to_string())))?;
1526
1527 current_url = next_url.to_string();
1529 let _ = &mut current_host;
1531 let _ = &mut current_addrs;
1532 continue;
1533 }
1534
1535 if !status.is_success() {
1536 return Err(ToolError::Execution(std::io::Error::other(format!(
1537 "HTTP {status}",
1538 ))));
1539 }
1540
1541 let bytes = resp
1542 .bytes()
1543 .await
1544 .map_err(|e| ToolError::Execution(std::io::Error::other(e.to_string())))?;
1545
1546 if bytes.len() > executor.max_body_bytes {
1547 return Err(ToolError::Execution(std::io::Error::other(format!(
1548 "response too large: {} bytes (max: {})",
1549 bytes.len(),
1550 executor.max_body_bytes,
1551 ))));
1552 }
1553
1554 return String::from_utf8(bytes.to_vec())
1555 .map_err(|e| ToolError::Execution(std::io::Error::other(e.to_string())));
1556 }
1557
1558 Err(ToolError::Execution(std::io::Error::other(
1559 "too many redirects",
1560 )))
1561 }
1562
1563 #[tokio::test]
1564 async fn fetch_html_success_returns_body() {
1565 use wiremock::matchers::{method, path};
1566 use wiremock::{Mock, ResponseTemplate};
1567
1568 let (executor, server) = mock_server_executor().await;
1569 Mock::given(method("GET"))
1570 .and(path("/page"))
1571 .respond_with(ResponseTemplate::new(200).set_body_string("<h1>OK</h1>"))
1572 .mount(&server)
1573 .await;
1574
1575 let (host, addrs) = server_host_and_addr(&server);
1576 let url = format!("{}/page", server.uri());
1577 let result = executor
1578 .fetch_html(&url, &host, &addrs, "fetch", "test-cid", None, None)
1579 .await;
1580 assert!(result.is_ok(), "expected Ok, got: {result:?}");
1581 assert_eq!(result.unwrap(), "<h1>OK</h1>");
1582 }
1583
1584 #[tokio::test]
1585 async fn fetch_html_non_2xx_returns_error() {
1586 use wiremock::matchers::{method, path};
1587 use wiremock::{Mock, ResponseTemplate};
1588
1589 let (executor, server) = mock_server_executor().await;
1590 Mock::given(method("GET"))
1591 .and(path("/forbidden"))
1592 .respond_with(ResponseTemplate::new(403))
1593 .mount(&server)
1594 .await;
1595
1596 let (host, addrs) = server_host_and_addr(&server);
1597 let url = format!("{}/forbidden", server.uri());
1598 let result = executor
1599 .fetch_html(&url, &host, &addrs, "fetch", "test-cid", None, None)
1600 .await;
1601 assert!(result.is_err());
1602 let msg = result.unwrap_err().to_string();
1603 assert!(msg.contains("403"), "expected 403 in error: {msg}");
1604 }
1605
1606 #[tokio::test]
1607 async fn fetch_html_404_returns_error() {
1608 use wiremock::matchers::{method, path};
1609 use wiremock::{Mock, ResponseTemplate};
1610
1611 let (executor, server) = mock_server_executor().await;
1612 Mock::given(method("GET"))
1613 .and(path("/missing"))
1614 .respond_with(ResponseTemplate::new(404))
1615 .mount(&server)
1616 .await;
1617
1618 let (host, addrs) = server_host_and_addr(&server);
1619 let url = format!("{}/missing", server.uri());
1620 let result = executor
1621 .fetch_html(&url, &host, &addrs, "fetch", "test-cid", None, None)
1622 .await;
1623 assert!(result.is_err());
1624 let msg = result.unwrap_err().to_string();
1625 assert!(msg.contains("404"), "expected 404 in error: {msg}");
1626 }
1627
1628 #[tokio::test]
1629 async fn fetch_html_redirect_no_location_returns_error() {
1630 use wiremock::matchers::{method, path};
1631 use wiremock::{Mock, ResponseTemplate};
1632
1633 let (executor, server) = mock_server_executor().await;
1634 Mock::given(method("GET"))
1636 .and(path("/redirect-no-loc"))
1637 .respond_with(ResponseTemplate::new(302))
1638 .mount(&server)
1639 .await;
1640
1641 let (host, addrs) = server_host_and_addr(&server);
1642 let url = format!("{}/redirect-no-loc", server.uri());
1643 let result = executor
1644 .fetch_html(&url, &host, &addrs, "fetch", "test-cid", None, None)
1645 .await;
1646 assert!(result.is_err());
1647 let msg = result.unwrap_err().to_string();
1648 assert!(
1649 msg.contains("Location") || msg.contains("location"),
1650 "expected Location-related error: {msg}"
1651 );
1652 }
1653
1654 #[tokio::test]
1655 async fn fetch_html_single_redirect_followed() {
1656 use wiremock::matchers::{method, path};
1657 use wiremock::{Mock, ResponseTemplate};
1658
1659 let (executor, server) = mock_server_executor().await;
1660 let final_url = format!("{}/final", server.uri());
1661
1662 Mock::given(method("GET"))
1663 .and(path("/start"))
1664 .respond_with(ResponseTemplate::new(302).insert_header("location", final_url.as_str()))
1665 .mount(&server)
1666 .await;
1667
1668 Mock::given(method("GET"))
1669 .and(path("/final"))
1670 .respond_with(ResponseTemplate::new(200).set_body_string("<p>final</p>"))
1671 .mount(&server)
1672 .await;
1673
1674 let (host, addrs) = server_host_and_addr(&server);
1675 let url = format!("{}/start", server.uri());
1676 let result = follow_redirects_raw(&executor, &url, &host, &addrs).await;
1677 assert!(result.is_ok(), "single redirect should succeed: {result:?}");
1678 assert_eq!(result.unwrap(), "<p>final</p>");
1679 }
1680
1681 #[tokio::test]
1682 async fn fetch_html_three_redirects_allowed() {
1683 use wiremock::matchers::{method, path};
1684 use wiremock::{Mock, ResponseTemplate};
1685
1686 let (executor, server) = mock_server_executor().await;
1687 let hop2 = format!("{}/hop2", server.uri());
1688 let hop3 = format!("{}/hop3", server.uri());
1689 let final_dest = format!("{}/done", server.uri());
1690
1691 Mock::given(method("GET"))
1692 .and(path("/hop1"))
1693 .respond_with(ResponseTemplate::new(301).insert_header("location", hop2.as_str()))
1694 .mount(&server)
1695 .await;
1696 Mock::given(method("GET"))
1697 .and(path("/hop2"))
1698 .respond_with(ResponseTemplate::new(301).insert_header("location", hop3.as_str()))
1699 .mount(&server)
1700 .await;
1701 Mock::given(method("GET"))
1702 .and(path("/hop3"))
1703 .respond_with(ResponseTemplate::new(301).insert_header("location", final_dest.as_str()))
1704 .mount(&server)
1705 .await;
1706 Mock::given(method("GET"))
1707 .and(path("/done"))
1708 .respond_with(ResponseTemplate::new(200).set_body_string("<p>done</p>"))
1709 .mount(&server)
1710 .await;
1711
1712 let (host, addrs) = server_host_and_addr(&server);
1713 let url = format!("{}/hop1", server.uri());
1714 let result = follow_redirects_raw(&executor, &url, &host, &addrs).await;
1715 assert!(result.is_ok(), "3 redirects should succeed: {result:?}");
1716 assert_eq!(result.unwrap(), "<p>done</p>");
1717 }
1718
1719 #[tokio::test]
1720 async fn fetch_html_four_redirects_rejected() {
1721 use wiremock::matchers::{method, path};
1722 use wiremock::{Mock, ResponseTemplate};
1723
1724 let (executor, server) = mock_server_executor().await;
1725 let hop2 = format!("{}/r2", server.uri());
1726 let hop3 = format!("{}/r3", server.uri());
1727 let hop4 = format!("{}/r4", server.uri());
1728 let hop5 = format!("{}/r5", server.uri());
1729
1730 for (from, to) in [
1731 ("/r1", &hop2),
1732 ("/r2", &hop3),
1733 ("/r3", &hop4),
1734 ("/r4", &hop5),
1735 ] {
1736 Mock::given(method("GET"))
1737 .and(path(from))
1738 .respond_with(ResponseTemplate::new(301).insert_header("location", to.as_str()))
1739 .mount(&server)
1740 .await;
1741 }
1742
1743 let (host, addrs) = server_host_and_addr(&server);
1744 let url = format!("{}/r1", server.uri());
1745 let result = follow_redirects_raw(&executor, &url, &host, &addrs).await;
1746 assert!(result.is_err(), "4 redirects should be rejected");
1747 let msg = result.unwrap_err().to_string();
1748 assert!(
1749 msg.contains("redirect"),
1750 "expected redirect-related error: {msg}"
1751 );
1752 }
1753
1754 #[tokio::test]
1755 async fn fetch_html_body_too_large_returns_error() {
1756 use wiremock::matchers::{method, path};
1757 use wiremock::{Mock, ResponseTemplate};
1758
1759 let small_limit_executor = WebScrapeExecutor {
1760 timeout: Duration::from_secs(5),
1761 max_body_bytes: 10,
1762 allowed_domains: vec![],
1763 denied_domains: vec![],
1764 audit_logger: None,
1765 egress_config: EgressConfig::default(),
1766 egress_tx: None,
1767 egress_dropped: Arc::new(AtomicU64::new(0)),
1768 ipi_filter: IpiFilter::new(0.6),
1769 };
1770 let server = wiremock::MockServer::start().await;
1771 Mock::given(method("GET"))
1772 .and(path("/big"))
1773 .respond_with(
1774 ResponseTemplate::new(200)
1775 .set_body_string("this body is definitely longer than ten bytes"),
1776 )
1777 .mount(&server)
1778 .await;
1779
1780 let (host, addrs) = server_host_and_addr(&server);
1781 let url = format!("{}/big", server.uri());
1782 let result = small_limit_executor
1783 .fetch_html(&url, &host, &addrs, "fetch", "test-cid", None, None)
1784 .await;
1785 assert!(result.is_err());
1786 let msg = result.unwrap_err().to_string();
1787 assert!(msg.contains("too large"), "expected too-large error: {msg}");
1788 }
1789
1790 #[test]
1791 fn extract_scrape_blocks_empty_block_content() {
1792 let text = "```scrape\n\n```";
1793 let blocks = extract_scrape_blocks(text);
1794 assert_eq!(blocks.len(), 1);
1795 assert!(blocks[0].is_empty());
1796 }
1797
1798 #[test]
1799 fn extract_scrape_blocks_whitespace_only() {
1800 let text = "```scrape\n \n```";
1801 let blocks = extract_scrape_blocks(text);
1802 assert_eq!(blocks.len(), 1);
1803 }
1804
1805 #[test]
1806 fn parse_and_extract_multiple_selectors() {
1807 let html = "<div><h1>Title</h1><p>Para</p></div>";
1808 let result = parse_and_extract(html, "h1, p", &ExtractMode::Text, 10).unwrap();
1809 assert!(result.contains("Title"));
1810 assert!(result.contains("Para"));
1811 }
1812
1813 #[test]
1814 fn webscrape_executor_new_with_custom_config() {
1815 let config = ScrapeConfig {
1816 timeout: 60,
1817 max_body_bytes: 512,
1818 ..Default::default()
1819 };
1820 let executor = WebScrapeExecutor::new(&config);
1821 assert_eq!(executor.max_body_bytes, 512);
1822 }
1823
1824 #[test]
1825 fn webscrape_executor_debug() {
1826 let config = ScrapeConfig::default();
1827 let executor = WebScrapeExecutor::new(&config);
1828 let dbg = format!("{executor:?}");
1829 assert!(dbg.contains("WebScrapeExecutor"));
1830 }
1831
1832 #[test]
1833 fn extract_mode_attr_empty_name() {
1834 let mode = ExtractMode::parse("attr:");
1835 assert_matches!(mode, ExtractMode::Attr(ref s) if s.is_empty());
1836 }
1837
1838 #[test]
1839 fn default_extract_returns_text() {
1840 assert_eq!(default_extract(), "text");
1841 }
1842
1843 #[test]
1844 fn scrape_instruction_debug() {
1845 let json = r#"{"url":"https://example.com","select":"h1"}"#;
1846 let instr: ScrapeInstruction = serde_json::from_str(json).unwrap();
1847 let dbg = format!("{instr:?}");
1848 assert!(dbg.contains("ScrapeInstruction"));
1849 }
1850
1851 #[test]
1852 fn extract_mode_debug() {
1853 let mode = ExtractMode::Text;
1854 let dbg = format!("{mode:?}");
1855 assert!(dbg.contains("Text"));
1856 }
1857
1858 #[test]
1863 fn max_redirects_constant_is_three() {
1864 const MAX_REDIRECTS: usize = 3;
1868 assert_eq!(MAX_REDIRECTS, 3, "fetch_html allows exactly 3 redirects");
1869 }
1870
1871 #[test]
1874 fn redirect_no_location_error_message() {
1875 let err = std::io::Error::other("redirect with no Location");
1876 assert!(err.to_string().contains("redirect with no Location"));
1877 }
1878
1879 #[test]
1881 fn too_many_redirects_error_message() {
1882 let err = std::io::Error::other("too many redirects");
1883 assert!(err.to_string().contains("too many redirects"));
1884 }
1885
1886 #[test]
1888 fn non_2xx_status_error_format() {
1889 let status = reqwest::StatusCode::FORBIDDEN;
1890 let msg = format!("HTTP {status}");
1891 assert!(msg.contains("403"));
1892 }
1893
1894 #[test]
1896 fn not_found_status_error_format() {
1897 let status = reqwest::StatusCode::NOT_FOUND;
1898 let msg = format!("HTTP {status}");
1899 assert!(msg.contains("404"));
1900 }
1901
1902 #[test]
1904 fn relative_redirect_same_host_path() {
1905 let base = Url::parse("https://example.com/current").unwrap();
1906 let resolved = base.join("/other").unwrap();
1907 assert_eq!(resolved.as_str(), "https://example.com/other");
1908 }
1909
1910 #[test]
1912 fn relative_redirect_relative_path() {
1913 let base = Url::parse("https://example.com/a/b").unwrap();
1914 let resolved = base.join("c").unwrap();
1915 assert_eq!(resolved.as_str(), "https://example.com/a/c");
1916 }
1917
1918 #[test]
1920 fn absolute_redirect_overrides_base() {
1921 let base = Url::parse("https://example.com/page").unwrap();
1922 let resolved = base.join("https://other.com/target").unwrap();
1923 assert_eq!(resolved.as_str(), "https://other.com/target");
1924 }
1925
1926 #[test]
1928 fn redirect_http_downgrade_rejected() {
1929 let location = "http://example.com/page";
1930 let base = Url::parse("https://example.com/start").unwrap();
1931 let next = base.join(location).unwrap();
1932 let err = validate_url(next.as_str()).unwrap_err();
1933 assert_matches!(err, ToolError::Blocked { .. });
1934 }
1935
1936 #[test]
1938 fn redirect_location_private_ip_blocked() {
1939 let location = "https://192.168.100.1/admin";
1940 let base = Url::parse("https://example.com/start").unwrap();
1941 let next = base.join(location).unwrap();
1942 let err = validate_url(next.as_str()).unwrap_err();
1943 assert_matches!(err, ToolError::Blocked { .. });
1944 let ToolError::Blocked { command: cmd } = err else {
1945 panic!("expected Blocked");
1946 };
1947 assert!(
1948 cmd.contains("private") || cmd.contains("scheme"),
1949 "error message should describe the block reason: {cmd}"
1950 );
1951 }
1952
1953 #[test]
1955 fn redirect_location_internal_domain_blocked() {
1956 let location = "https://metadata.internal/latest/meta-data/";
1957 let base = Url::parse("https://example.com/start").unwrap();
1958 let next = base.join(location).unwrap();
1959 let err = validate_url(next.as_str()).unwrap_err();
1960 assert_matches!(err, ToolError::Blocked { .. });
1961 }
1962
1963 #[test]
1965 fn redirect_chain_three_hops_all_public() {
1966 let hops = [
1967 "https://redirect1.example.com/hop1",
1968 "https://redirect2.example.com/hop2",
1969 "https://destination.example.com/final",
1970 ];
1971 for hop in hops {
1972 assert!(validate_url(hop).is_ok(), "expected ok for {hop}");
1973 }
1974 }
1975
1976 #[test]
1981 fn redirect_to_private_ip_rejected_by_validate_url() {
1982 let private_targets = [
1984 "https://127.0.0.1/secret",
1985 "https://10.0.0.1/internal",
1986 "https://192.168.1.1/admin",
1987 "https://172.16.0.1/data",
1988 "https://[::1]/path",
1989 "https://[fe80::1]/path",
1990 "https://localhost/path",
1991 "https://service.internal/api",
1992 ];
1993 for target in private_targets {
1994 let result = validate_url(target);
1995 assert!(result.is_err(), "expected error for {target}");
1996 assert!(
1997 matches!(result.unwrap_err(), ToolError::Blocked { .. }),
1998 "expected Blocked for {target}"
1999 );
2000 }
2001 }
2002
2003 #[test]
2005 fn redirect_relative_url_resolves_correctly() {
2006 let base = Url::parse("https://example.com/page").unwrap();
2007 let relative = "/other";
2008 let resolved = base.join(relative).unwrap();
2009 assert_eq!(resolved.as_str(), "https://example.com/other");
2010 }
2011
2012 #[test]
2014 fn redirect_to_http_rejected() {
2015 let err = validate_url("http://example.com/page").unwrap_err();
2016 assert_matches!(err, ToolError::Blocked { .. });
2017 }
2018
2019 #[test]
2020 fn ipv4_mapped_ipv6_link_local_blocked() {
2021 let err = validate_url("https://[::ffff:169.254.0.1]/path").unwrap_err();
2022 assert_matches!(err, ToolError::Blocked { .. });
2023 }
2024
2025 #[test]
2026 fn ipv4_mapped_ipv6_public_allowed() {
2027 assert!(validate_url("https://[::ffff:93.184.216.34]/path").is_ok());
2028 }
2029
2030 #[tokio::test]
2033 async fn fetch_http_scheme_blocked() {
2034 let config = ScrapeConfig::default();
2035 let executor = WebScrapeExecutor::new(&config);
2036 let call = crate::executor::ToolCall {
2037 tool_id: ToolName::new("fetch"),
2038 params: {
2039 let mut m = serde_json::Map::new();
2040 m.insert("url".to_owned(), serde_json::json!("http://example.com"));
2041 m
2042 },
2043 caller_id: None,
2044 context: None,
2045
2046 tool_call_id: String::new(),
2047 skill_name: None,
2048 };
2049 let result = executor.execute_tool_call(&call).await;
2050 assert_matches!(result, Err(ToolError::Blocked { .. }));
2051 }
2052
2053 #[tokio::test]
2054 async fn fetch_private_ip_blocked() {
2055 let config = ScrapeConfig::default();
2056 let executor = WebScrapeExecutor::new(&config);
2057 let call = crate::executor::ToolCall {
2058 tool_id: ToolName::new("fetch"),
2059 params: {
2060 let mut m = serde_json::Map::new();
2061 m.insert(
2062 "url".to_owned(),
2063 serde_json::json!("https://192.168.1.1/secret"),
2064 );
2065 m
2066 },
2067 caller_id: None,
2068 context: None,
2069
2070 tool_call_id: String::new(),
2071 skill_name: None,
2072 };
2073 let result = executor.execute_tool_call(&call).await;
2074 assert_matches!(result, Err(ToolError::Blocked { .. }));
2075 }
2076
2077 #[tokio::test]
2078 async fn fetch_localhost_blocked() {
2079 let config = ScrapeConfig::default();
2080 let executor = WebScrapeExecutor::new(&config);
2081 let call = crate::executor::ToolCall {
2082 tool_id: ToolName::new("fetch"),
2083 params: {
2084 let mut m = serde_json::Map::new();
2085 m.insert(
2086 "url".to_owned(),
2087 serde_json::json!("https://localhost/page"),
2088 );
2089 m
2090 },
2091 caller_id: None,
2092 context: None,
2093
2094 tool_call_id: String::new(),
2095 skill_name: None,
2096 };
2097 let result = executor.execute_tool_call(&call).await;
2098 assert_matches!(result, Err(ToolError::Blocked { .. }));
2099 }
2100
2101 #[tokio::test]
2102 async fn fetch_unknown_tool_returns_none() {
2103 let config = ScrapeConfig::default();
2104 let executor = WebScrapeExecutor::new(&config);
2105 let call = crate::executor::ToolCall {
2106 tool_id: ToolName::new("unknown_tool"),
2107 params: serde_json::Map::new(),
2108 caller_id: None,
2109 context: None,
2110
2111 tool_call_id: String::new(),
2112 skill_name: None,
2113 };
2114 let result = executor.execute_tool_call(&call).await;
2115 assert!(result.unwrap().is_none());
2116 }
2117
2118 #[tokio::test]
2119 async fn fetch_returns_body_via_mock() {
2120 use wiremock::matchers::{method, path};
2121 use wiremock::{Mock, ResponseTemplate};
2122
2123 let (executor, server) = mock_server_executor().await;
2124 Mock::given(method("GET"))
2125 .and(path("/content"))
2126 .respond_with(ResponseTemplate::new(200).set_body_string("plain text content"))
2127 .mount(&server)
2128 .await;
2129
2130 let (host, addrs) = server_host_and_addr(&server);
2131 let url = format!("{}/content", server.uri());
2132 let result = executor
2133 .fetch_html(&url, &host, &addrs, "fetch", "test-cid", None, None)
2134 .await;
2135 assert!(result.is_ok());
2136 assert_eq!(result.unwrap(), "plain text content");
2137 }
2138
2139 #[test]
2140 fn tool_definitions_returns_web_scrape_and_fetch() {
2141 let config = ScrapeConfig::default();
2142 let executor = WebScrapeExecutor::new(&config);
2143 let defs = executor.tool_definitions();
2144 assert_eq!(defs.len(), 2);
2145 assert_eq!(defs[0].id, "web_scrape");
2146 assert_eq!(
2147 defs[0].invocation,
2148 crate::registry::InvocationHint::FencedBlock("scrape")
2149 );
2150 assert_eq!(defs[1].id, "fetch");
2151 assert_eq!(
2152 defs[1].invocation,
2153 crate::registry::InvocationHint::ToolCall
2154 );
2155 }
2156
2157 #[test]
2158 fn tool_definitions_schema_has_all_params() {
2159 let config = ScrapeConfig::default();
2160 let executor = WebScrapeExecutor::new(&config);
2161 let defs = executor.tool_definitions();
2162 let obj = defs[0].schema.as_object().unwrap();
2163 let props = obj["properties"].as_object().unwrap();
2164 assert!(props.contains_key("url"));
2165 assert!(props.contains_key("select"));
2166 assert!(props.contains_key("extract"));
2167 assert!(props.contains_key("limit"));
2168 let req = obj["required"].as_array().unwrap();
2169 assert!(req.iter().any(|v| v.as_str() == Some("url")));
2170 assert!(req.iter().any(|v| v.as_str() == Some("select")));
2171 assert!(!req.iter().any(|v| v.as_str() == Some("extract")));
2172 }
2173
2174 #[test]
2177 fn subdomain_localhost_blocked() {
2178 let host: url::Host<&str> = url::Host::Domain("foo.localhost");
2179 assert!(is_private_host(&host));
2180 }
2181
2182 #[test]
2183 fn internal_tld_blocked() {
2184 let host: url::Host<&str> = url::Host::Domain("service.internal");
2185 assert!(is_private_host(&host));
2186 }
2187
2188 #[test]
2189 fn local_tld_blocked() {
2190 let host: url::Host<&str> = url::Host::Domain("printer.local");
2191 assert!(is_private_host(&host));
2192 }
2193
2194 #[test]
2195 fn public_domain_not_blocked() {
2196 let host: url::Host<&str> = url::Host::Domain("example.com");
2197 assert!(!is_private_host(&host));
2198 }
2199
2200 #[tokio::test]
2203 async fn resolve_loopback_rejected() {
2204 let url = url::Url::parse("https://127.0.0.1/path").unwrap();
2206 let result = resolve_and_validate(&url).await;
2208 assert!(
2209 result.is_err(),
2210 "loopback IP must be rejected by resolve_and_validate"
2211 );
2212 let err = result.unwrap_err();
2213 assert_matches!(err, crate::executor::ToolError::Blocked { .. });
2214 }
2215
2216 #[tokio::test]
2217 async fn resolve_private_10_rejected() {
2218 let url = url::Url::parse("https://10.0.0.1/path").unwrap();
2219 let result = resolve_and_validate(&url).await;
2220 assert!(result.is_err());
2221 assert_matches!(
2222 result.unwrap_err(),
2223 crate::executor::ToolError::Blocked { .. }
2224 );
2225 }
2226
2227 #[tokio::test]
2228 async fn resolve_private_192_rejected() {
2229 let url = url::Url::parse("https://192.168.1.1/path").unwrap();
2230 let result = resolve_and_validate(&url).await;
2231 assert!(result.is_err());
2232 assert_matches!(
2233 result.unwrap_err(),
2234 crate::executor::ToolError::Blocked { .. }
2235 );
2236 }
2237
2238 #[tokio::test]
2239 async fn resolve_ipv6_loopback_rejected() {
2240 let url = url::Url::parse("https://[::1]/path").unwrap();
2241 let result = resolve_and_validate(&url).await;
2242 assert!(result.is_err());
2243 assert_matches!(
2244 result.unwrap_err(),
2245 crate::executor::ToolError::Blocked { .. }
2246 );
2247 }
2248
2249 #[tokio::test]
2250 async fn resolve_no_host_returns_ok() {
2251 let url = url::Url::parse("https://example.com/path").unwrap();
2253 let url_no_host = url::Url::parse("data:text/plain,hello").unwrap();
2255 let result = resolve_and_validate(&url_no_host).await;
2257 assert!(result.is_ok());
2258 let (host, addrs) = result.unwrap();
2259 assert!(host.is_empty());
2260 assert!(addrs.is_empty());
2261 drop(url);
2262 drop(url_no_host);
2263 }
2264
2265 async fn make_file_audit_logger(
2269 dir: &tempfile::TempDir,
2270 ) -> (
2271 std::sync::Arc<crate::audit::AuditLogger>,
2272 std::path::PathBuf,
2273 ) {
2274 use crate::audit::AuditLogger;
2275 use crate::config::AuditConfig;
2276 let path = dir.path().join("audit.log");
2277 let config = AuditConfig {
2278 enabled: true,
2279 destination: crate::config::AuditDestination::File(path.clone()),
2280 ..Default::default()
2281 };
2282 let logger = std::sync::Arc::new(AuditLogger::from_config(&config, false).await.unwrap());
2283 (logger, path)
2284 }
2285
2286 #[tokio::test]
2287 async fn with_audit_sets_logger() {
2288 let config = ScrapeConfig::default();
2289 let executor = WebScrapeExecutor::new(&config);
2290 assert!(executor.audit_logger.is_none());
2291
2292 let dir = tempfile::tempdir().unwrap();
2293 let (logger, _path) = make_file_audit_logger(&dir).await;
2294 let executor = executor.with_audit(logger);
2295 assert!(executor.audit_logger.is_some());
2296 }
2297
2298 #[test]
2299 fn tool_error_to_audit_result_blocked_maps_correctly() {
2300 let err = ToolError::Blocked {
2301 command: "scheme not allowed: http".into(),
2302 };
2303 let result = tool_error_to_audit_result(&err);
2304 assert!(
2305 matches!(result, AuditResult::Blocked { reason } if reason == "scheme not allowed: http")
2306 );
2307 }
2308
2309 #[test]
2310 fn tool_error_to_audit_result_timeout_maps_correctly() {
2311 let err = ToolError::Timeout { timeout_secs: 15 };
2312 let result = tool_error_to_audit_result(&err);
2313 assert_matches!(result, AuditResult::Timeout);
2314 }
2315
2316 #[test]
2317 fn tool_error_to_audit_result_execution_error_maps_correctly() {
2318 let err = ToolError::Execution(std::io::Error::other("connection refused"));
2319 let result = tool_error_to_audit_result(&err);
2320 assert!(
2321 matches!(result, AuditResult::Error { message } if message.contains("connection refused"))
2322 );
2323 }
2324
2325 #[tokio::test]
2326 async fn fetch_audit_blocked_url_logged() {
2327 let dir = tempfile::tempdir().unwrap();
2328 let (logger, log_path) = make_file_audit_logger(&dir).await;
2329
2330 let config = ScrapeConfig::default();
2331 let executor = WebScrapeExecutor::new(&config).with_audit(logger);
2332
2333 let call = crate::executor::ToolCall {
2334 tool_id: ToolName::new("fetch"),
2335 params: {
2336 let mut m = serde_json::Map::new();
2337 m.insert("url".to_owned(), serde_json::json!("http://example.com"));
2338 m
2339 },
2340 caller_id: None,
2341 context: None,
2342
2343 tool_call_id: String::new(),
2344 skill_name: None,
2345 };
2346 let result = executor.execute_tool_call(&call).await;
2347 assert_matches!(result, Err(ToolError::Blocked { .. }));
2348
2349 let content = tokio::fs::read_to_string(&log_path).await.unwrap();
2350 assert!(
2351 content.contains("\"tool\":\"fetch\""),
2352 "expected tool=fetch in audit: {content}"
2353 );
2354 assert!(
2355 content.contains("\"type\":\"blocked\""),
2356 "expected type=blocked in audit: {content}"
2357 );
2358 assert!(
2359 content.contains("http://example.com"),
2360 "expected URL in audit command field: {content}"
2361 );
2362 }
2363
2364 #[tokio::test]
2365 async fn log_audit_success_writes_to_file() {
2366 let dir = tempfile::tempdir().unwrap();
2367 let (logger, log_path) = make_file_audit_logger(&dir).await;
2368
2369 let config = ScrapeConfig::default();
2370 let executor = WebScrapeExecutor::new(&config).with_audit(logger);
2371
2372 executor
2373 .log_audit(
2374 "fetch",
2375 "https://example.com/page",
2376 AuditResult::Success,
2377 42,
2378 None,
2379 None,
2380 None,
2381 None,
2382 )
2383 .await;
2384
2385 let content = tokio::fs::read_to_string(&log_path).await.unwrap();
2386 assert!(
2387 content.contains("\"tool\":\"fetch\""),
2388 "expected tool=fetch in audit: {content}"
2389 );
2390 assert!(
2391 content.contains("\"type\":\"success\""),
2392 "expected type=success in audit: {content}"
2393 );
2394 assert!(
2395 content.contains("\"command\":\"https://example.com/page\""),
2396 "expected command URL in audit: {content}"
2397 );
2398 assert!(
2399 content.contains("\"duration_ms\":42"),
2400 "expected duration_ms in audit: {content}"
2401 );
2402 }
2403
2404 #[tokio::test]
2405 async fn log_audit_blocked_writes_to_file() {
2406 let dir = tempfile::tempdir().unwrap();
2407 let (logger, log_path) = make_file_audit_logger(&dir).await;
2408
2409 let config = ScrapeConfig::default();
2410 let executor = WebScrapeExecutor::new(&config).with_audit(logger);
2411
2412 executor
2413 .log_audit(
2414 "web_scrape",
2415 "http://evil.com/page",
2416 AuditResult::Blocked {
2417 reason: "scheme not allowed: http".into(),
2418 },
2419 0,
2420 None,
2421 None,
2422 None,
2423 None,
2424 )
2425 .await;
2426
2427 let content = tokio::fs::read_to_string(&log_path).await.unwrap();
2428 assert!(
2429 content.contains("\"tool\":\"web_scrape\""),
2430 "expected tool=web_scrape in audit: {content}"
2431 );
2432 assert!(
2433 content.contains("\"type\":\"blocked\""),
2434 "expected type=blocked in audit: {content}"
2435 );
2436 assert!(
2437 content.contains("scheme not allowed"),
2438 "expected block reason in audit: {content}"
2439 );
2440 }
2441
2442 #[tokio::test]
2443 async fn web_scrape_audit_blocked_url_logged() {
2444 let dir = tempfile::tempdir().unwrap();
2445 let (logger, log_path) = make_file_audit_logger(&dir).await;
2446
2447 let config = ScrapeConfig::default();
2448 let executor = WebScrapeExecutor::new(&config).with_audit(logger);
2449
2450 let call = crate::executor::ToolCall {
2451 tool_id: ToolName::new("web_scrape"),
2452 params: {
2453 let mut m = serde_json::Map::new();
2454 m.insert("url".to_owned(), serde_json::json!("http://example.com"));
2455 m.insert("select".to_owned(), serde_json::json!("h1"));
2456 m
2457 },
2458 caller_id: None,
2459 context: None,
2460
2461 tool_call_id: String::new(),
2462 skill_name: None,
2463 };
2464 let result = executor.execute_tool_call(&call).await;
2465 assert_matches!(result, Err(ToolError::Blocked { .. }));
2466
2467 let content = tokio::fs::read_to_string(&log_path).await.unwrap();
2468 assert!(
2469 content.contains("\"tool\":\"web_scrape\""),
2470 "expected tool=web_scrape in audit: {content}"
2471 );
2472 assert!(
2473 content.contains("\"type\":\"blocked\""),
2474 "expected type=blocked in audit: {content}"
2475 );
2476 }
2477
2478 #[tokio::test]
2479 async fn no_audit_logger_does_not_panic_on_blocked_fetch() {
2480 let config = ScrapeConfig::default();
2481 let executor = WebScrapeExecutor::new(&config);
2482 assert!(executor.audit_logger.is_none());
2483
2484 let call = crate::executor::ToolCall {
2485 tool_id: ToolName::new("fetch"),
2486 params: {
2487 let mut m = serde_json::Map::new();
2488 m.insert("url".to_owned(), serde_json::json!("http://example.com"));
2489 m
2490 },
2491 caller_id: None,
2492 context: None,
2493
2494 tool_call_id: String::new(),
2495 skill_name: None,
2496 };
2497 let result = executor.execute_tool_call(&call).await;
2499 assert_matches!(result, Err(ToolError::Blocked { .. }));
2500 }
2501
2502 #[tokio::test]
2504 async fn fetch_execute_tool_call_end_to_end() {
2505 use wiremock::matchers::{method, path};
2506 use wiremock::{Mock, ResponseTemplate};
2507
2508 let (executor, server) = mock_server_executor().await;
2509 Mock::given(method("GET"))
2510 .and(path("/e2e"))
2511 .respond_with(ResponseTemplate::new(200).set_body_string("<h1>end-to-end</h1>"))
2512 .mount(&server)
2513 .await;
2514
2515 let (host, addrs) = server_host_and_addr(&server);
2516 let result = executor
2518 .fetch_html(
2519 &format!("{}/e2e", server.uri()),
2520 &host,
2521 &addrs,
2522 "fetch",
2523 "test-cid",
2524 None,
2525 None,
2526 )
2527 .await;
2528 assert!(result.is_ok());
2529 assert!(result.unwrap().contains("end-to-end"));
2530 }
2531
2532 #[test]
2535 fn domain_matches_exact() {
2536 assert!(domain_matches("example.com", "example.com"));
2537 assert!(!domain_matches("example.com", "other.com"));
2538 assert!(!domain_matches("example.com", "sub.example.com"));
2539 }
2540
2541 #[test]
2542 fn domain_matches_wildcard_single_subdomain() {
2543 assert!(domain_matches("*.example.com", "sub.example.com"));
2544 assert!(!domain_matches("*.example.com", "example.com"));
2545 assert!(!domain_matches("*.example.com", "sub.sub.example.com"));
2546 }
2547
2548 #[test]
2549 fn domain_matches_wildcard_does_not_match_empty_label() {
2550 assert!(!domain_matches("*.example.com", ".example.com"));
2552 }
2553
2554 #[test]
2555 fn domain_matches_multi_wildcard_treated_as_exact() {
2556 assert!(!domain_matches("*.*.example.com", "a.b.example.com"));
2558 }
2559
2560 #[test]
2563 fn check_domain_policy_empty_lists_allow_all() {
2564 assert!(check_domain_policy("example.com", &[], &[]).is_ok());
2565 assert!(check_domain_policy("evil.com", &[], &[]).is_ok());
2566 }
2567
2568 #[test]
2569 fn check_domain_policy_denylist_blocks() {
2570 let denied = vec!["evil.com".to_string()];
2571 let err = check_domain_policy("evil.com", &[], &denied).unwrap_err();
2572 assert_matches!(err, ToolError::Blocked { .. });
2573 }
2574
2575 #[test]
2576 fn check_domain_policy_denylist_does_not_block_other_domains() {
2577 let denied = vec!["evil.com".to_string()];
2578 assert!(check_domain_policy("good.com", &[], &denied).is_ok());
2579 }
2580
2581 #[test]
2582 fn check_domain_policy_allowlist_permits_matching() {
2583 let allowed = vec!["docs.rs".to_string(), "*.rust-lang.org".to_string()];
2584 assert!(check_domain_policy("docs.rs", &allowed, &[]).is_ok());
2585 assert!(check_domain_policy("blog.rust-lang.org", &allowed, &[]).is_ok());
2586 }
2587
2588 #[test]
2589 fn check_domain_policy_allowlist_blocks_unknown() {
2590 let allowed = vec!["docs.rs".to_string()];
2591 let err = check_domain_policy("other.com", &allowed, &[]).unwrap_err();
2592 assert_matches!(err, ToolError::Blocked { .. });
2593 }
2594
2595 #[test]
2596 fn check_domain_policy_deny_overrides_allow() {
2597 let allowed = vec!["example.com".to_string()];
2598 let denied = vec!["example.com".to_string()];
2599 let err = check_domain_policy("example.com", &allowed, &denied).unwrap_err();
2600 assert_matches!(err, ToolError::Blocked { .. });
2601 }
2602
2603 #[test]
2604 fn check_domain_policy_wildcard_in_denylist() {
2605 let denied = vec!["*.evil.com".to_string()];
2606 let err = check_domain_policy("sub.evil.com", &[], &denied).unwrap_err();
2607 assert_matches!(err, ToolError::Blocked { .. });
2608 assert!(check_domain_policy("evil.com", &[], &denied).is_ok());
2610 }
2611}