Skip to main content

zeph_tools/
scrape.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Web scraping executor with SSRF protection and domain policy enforcement.
5//!
6//! Exposes two tools to the LLM:
7//!
8//! - **`web_scrape`** — fetches a URL and extracts elements matching a CSS selector.
9//! - **`fetch`** — fetches a URL and returns the raw response body as UTF-8 text.
10//!
11//! Both tools enforce:
12//!
13//! - HTTPS-only URLs (HTTP and other schemes are rejected).
14//! - DNS resolution followed by a private-IP check to prevent SSRF.
15//! - Optional domain allowlist and denylist from [`ScrapeConfig`].
16//! - Configurable timeout and maximum response body size.
17//! - Redirect following is disabled to prevent open-redirect SSRF bypasses.
18
19use 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
39/// Strips userinfo (`user:pass@`) and sensitive query params from a URL for safe logging.
40///
41/// Returns the sanitized URL string; falls back to the original if parsing fails.
42fn redact_url_for_log(url: &str) -> String {
43    let Ok(mut parsed) = Url::parse(url) else {
44        return url.to_owned();
45    };
46    // Remove userinfo.
47    let _ = parsed.set_username("");
48    let _ = parsed.set_password(None);
49    // Strip query params whose names suggest secrets (token, key, secret, password, auth, sig).
50    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    /// HTTPS URL to fetch
77    url: String,
78}
79
80#[derive(Debug, Deserialize, JsonSchema)]
81struct ScrapeInstruction {
82    /// HTTPS URL to scrape
83    url: String,
84    /// CSS selector
85    select: String,
86    /// Extract mode: text, html, or attr:<name>
87    #[serde(default = "default_extract")]
88    extract: String,
89    /// Max results to return
90    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/// Extracts data from web pages via CSS selectors.
118///
119/// Handles two invocation paths:
120///
121/// 1. **Legacy fenced blocks** — detects ` ```scrape ` blocks in the LLM response, each
122///    containing a JSON scrape instruction object. Dispatched via [`ToolExecutor::execute`].
123/// 2. **Structured tool calls** — dispatched via [`ToolExecutor::execute_tool_call`] for
124///    tool IDs `"web_scrape"` and `"fetch"`.
125///
126/// # Security
127///
128/// - Only HTTPS URLs are accepted. HTTP and other schemes return [`ToolError::InvalidParams`].
129/// - DNS is resolved synchronously and each resolved address is checked against
130///   [`crate::net::is_private_ip`]. Private addresses are rejected to prevent SSRF.
131/// - HTTP redirects are disabled (`Policy::none()`) to prevent open-redirect bypasses.
132/// - Domain allowlists and denylists from config are enforced before DNS resolution.
133///
134/// # Example
135///
136/// ```rust,no_run
137/// use zeph_tools::{WebScrapeExecutor, ToolExecutor, ToolCall, ScrapeConfig};
138/// use zeph_common::ToolName;
139///
140/// # async fn example() {
141/// let executor = WebScrapeExecutor::new(&ScrapeConfig::default());
142///
143/// let call = ToolCall {
144///     tool_id: ToolName::new("fetch"),
145///     params: {
146///         let mut m = serde_json::Map::new();
147///         m.insert("url".to_owned(), serde_json::json!("https://example.com"));
148///         m
149///     },
150///     caller_id: None,
151///     context: None,
152///     tool_call_id: String::new(),
153///     skill_name: None,
154/// };
155/// let _ = executor.execute_tool_call(&call).await;
156/// # }
157/// ```
158#[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 applied to every fetched response body before returning to callers.
169    ipi_filter: IpiFilter,
170}
171
172impl WebScrapeExecutor {
173    /// Create a new `WebScrapeExecutor` from configuration.
174    ///
175    /// No network connections are made at construction time.
176    #[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    /// Attach an audit logger. Each tool invocation will emit an [`AuditEntry`].
192    #[must_use]
193    pub fn with_audit(mut self, logger: Arc<AuditLogger>) -> Self {
194        self.audit_logger = Some(logger);
195        self
196    }
197
198    /// Configure egress event logging.
199    #[must_use]
200    pub fn with_egress_config(mut self, config: EgressConfig) -> Self {
201        self.egress_config = config;
202        self
203    }
204
205    /// Attach the egress telemetry channel sender and drop counter.
206    ///
207    /// Events are sent via [`tokio::sync::mpsc::Sender::try_send`] — the executor
208    /// never blocks waiting for capacity.
209    #[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    /// Returns a clone of the egress drop counter, for use in the drain task.
221    #[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)] // function with many required inputs; a *Params struct would be more verbose without simplifying the call site
470    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(&params.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                    &params.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                    &params.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                        &params.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                &params.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, &params.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        // apply_ipi_filter runs on plain extracted text, not raw HTML
715        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    /// Fetches the HTML at `url`, manually following up to 3 redirects.
747    ///
748    /// Each redirect target is validated with `validate_url` and `resolve_and_validate`
749    /// before following, preventing SSRF via redirect chains. When egress logging is
750    /// enabled, one [`EgressEvent`] is emitted per hop.
751    ///
752    /// # Errors
753    ///
754    /// Returns `ToolError::Blocked` if any redirect target resolves to a private IP.
755    /// Returns `ToolError::Execution` on HTTP errors, too-large bodies, or too many redirects.
756    #[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            // Build a per-hop client pinned to the current hop's validated addresses.
776            let client = self.build_client(&current_host, &current_addrs);
777            let resp = client
778                .get(&current_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(&current_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                // Resolve relative redirect URLs against the current URL.
831                let base = Url::parse(&current_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(&current_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(&current_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            // Success — emit egress event.
969            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(&current_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    /// Apply IPI filter to a fetched response body.
1008    ///
1009    /// Runs regex scanning on a blocking thread via `spawn_blocking` to avoid
1010    /// stalling the tokio executor on large inputs. When score >= threshold,
1011    /// prepends a warning header and emits a `tracing::warn!` log.
1012    ///
1013    /// # Errors
1014    ///
1015    /// Returns a [`ToolError`] if the blocking scan task panics.
1016    #[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        // verdict.sanitized == body only when score < threshold (no redaction)
1032        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/// Resolves DNS for the URL host, validates all resolved IPs against private ranges,
1050/// and returns the hostname and validated socket addresses.
1051///
1052/// Returning the addresses allows the caller to pin the HTTP client to these exact
1053/// addresses, eliminating TOCTOU between DNS validation and the actual connection.
1054///
1055/// Delegates the actual lookup+validation loop to the shared
1056/// [`zeph_common::net::resolve_and_validate`] helper (also used by `zeph-a2a`'s client)
1057/// and maps its neutral error into this crate's [`ToolError`].
1058#[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    // --- extract_scrape_blocks ---
1126
1127    #[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    // --- ScrapeInstruction parsing ---
1175
1176    #[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    // --- ExtractMode ---
1208
1209    #[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    // validate_url is now shared in `crate::net` — see net.rs tests for scheme/private-host
1231    // coverage. `ftp_rejected`/`file_rejected`/`ipv6_loopback_blocked` etc. moved there.
1232
1233    // --- parse_and_extract ---
1234
1235    #[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    // --- WebScrapeExecutor (no-network) ---
1328
1329    #[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    // --- fetch_html redirect logic: wiremock HTTP server tests ---
1447    //
1448    // These tests use a local wiremock server to exercise the redirect-following logic
1449    // in `fetch_html` without requiring an external HTTPS connection. The server binds to
1450    // 127.0.0.1, and tests call `fetch_html` directly (bypassing `validate_url`) to avoid
1451    // the SSRF guard that would otherwise block loopback connections.
1452
1453    /// Helper: returns executor + (`server_url`, `server_addr`) from a running wiremock mock server.
1454    /// The server address is passed to `fetch_html` via `resolve_to_addrs` so the client
1455    /// connects to the mock instead of doing a real DNS lookup.
1456    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    /// Parses the mock server's URI into (`host_str`, `socket_addr`) for use with `build_client`.
1473    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    /// Test-only redirect follower that mimics `fetch_html`'s loop but skips `validate_url` /
1483    /// `resolve_and_validate`. This lets us exercise the redirect-counting and
1484    /// missing-Location logic against a plain HTTP wiremock server.
1485    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(&current_host, &current_addrs);
1498            let resp = client
1499                .get(&current_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(&current_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                // Re-use same host/addrs (mock server is always the same endpoint).
1528                current_url = next_url.to_string();
1529                // Preserve host/addrs as-is since the mock server doesn't change.
1530                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        // 302 with no Location header
1635        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    // --- fetch_html redirect logic: constant and validation unit tests ---
1859
1860    /// `MAX_REDIRECTS` is 3; the 4th redirect attempt must be rejected.
1861    /// Verify the boundary is correct by inspecting the constant value.
1862    #[test]
1863    fn max_redirects_constant_is_three() {
1864        // fetch_html uses `for hop in 0..=MAX_REDIRECTS` and returns error when hop == MAX_REDIRECTS
1865        // while still in a redirect. That means hops 0,1,2 can redirect; hop 3 triggers the error.
1866        // This test documents the expected limit.
1867        const MAX_REDIRECTS: usize = 3;
1868        assert_eq!(MAX_REDIRECTS, 3, "fetch_html allows exactly 3 redirects");
1869    }
1870
1871    /// Verifies that a Location-less redirect would produce an error string containing the
1872    /// expected message, matching the error path in `fetch_html`.
1873    #[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    /// Verifies that a too-many-redirects condition produces the expected error string.
1880    #[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    /// Verifies that a non-2xx HTTP status produces an error message with the status code.
1887    #[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    /// Verifies that a 404 response status code formats into the expected error message.
1895    #[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    /// Verifies relative redirect resolution for same-host paths (simulates Location: /other).
1903    #[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    /// Verifies relative redirect resolution preserves scheme and host.
1911    #[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    /// Verifies that an absolute redirect URL overrides base URL completely.
1919    #[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    /// Verifies that a redirect Location of http:// (downgrade) is rejected.
1927    #[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    /// Verifies that a redirect to a private IP literal is blocked.
1937    #[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    /// Verifies that a redirect to a .internal domain is blocked.
1954    #[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    /// Verifies that a chain of 3 valid public redirects passes `validate_url` at every hop.
1964    #[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    // --- SSRF redirect chain defense ---
1977
1978    /// Verifies that a redirect Location pointing to a private IP is rejected by `validate_url`
1979    /// before any connection attempt — simulating the validation step inside `fetch_html`.
1980    #[test]
1981    fn redirect_to_private_ip_rejected_by_validate_url() {
1982        // These would appear as Location headers in a redirect response.
1983        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    /// Verifies that relative redirect URLs are resolved correctly before validation.
2004    #[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    /// Verifies that a protocol-relative redirect to http:// is rejected (scheme check).
2013    #[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    // --- fetch tool ---
2031
2032    #[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    // --- is_private_host: new domain checks (AUD-02) ---
2175
2176    #[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    // --- resolve_and_validate: private IP rejection ---
2201
2202    #[tokio::test]
2203    async fn resolve_loopback_rejected() {
2204        // 127.0.0.1 resolves directly (literal IP in DNS query)
2205        let url = url::Url::parse("https://127.0.0.1/path").unwrap();
2206        // validate_url catches this before resolve_and_validate, but test directly
2207        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        // URL without a resolvable host — should pass through
2252        let url = url::Url::parse("https://example.com/path").unwrap();
2253        // We can't do a live DNS test, but we can verify a URL with no host
2254        let url_no_host = url::Url::parse("data:text/plain,hello").unwrap();
2255        // data: URLs have no host; resolve_and_validate should return Ok with empty addrs
2256        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    // --- audit logging ---
2266
2267    /// Helper: build an `AuditLogger` writing to a temp file, and return the logger + path.
2268    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        // Must not panic even without an audit logger
2498        let result = executor.execute_tool_call(&call).await;
2499        assert_matches!(result, Err(ToolError::Blocked { .. }));
2500    }
2501
2502    // CR-10: fetch end-to-end via execute_tool_call -> handle_fetch -> fetch_html
2503    #[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        // Call fetch_html directly (bypassing SSRF guard for loopback mock server)
2517        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    // --- domain_matches ---
2533
2534    #[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        // Pattern "*.example.com" requires a non-empty label before ".example.com"
2551        assert!(!domain_matches("*.example.com", ".example.com"));
2552    }
2553
2554    #[test]
2555    fn domain_matches_multi_wildcard_treated_as_exact() {
2556        // Multiple wildcards are unsupported — treated as literal pattern
2557        assert!(!domain_matches("*.*.example.com", "a.b.example.com"));
2558    }
2559
2560    // --- check_domain_policy ---
2561
2562    #[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        // parent domain not blocked
2609        assert!(check_domain_policy("evil.com", &[], &denied).is_ok());
2610    }
2611}