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.
46static MARKDOWN_IMAGE_RE: LazyLock<Regex> = LazyLock::new(|| {
47    Regex::new(r"!\[([^\]]*)\]\((https?://[^)]+)\)").expect("valid MARKDOWN_IMAGE_RE")
48});
49
50/// Matches reference-style markdown image declarations: `[ref]: https://example.com/img`
51/// Used in conjunction with `REFERENCE_LABEL_RE` to detect two-part reference images.
52static REFERENCE_DEF_RE: LazyLock<Regex> = LazyLock::new(|| {
53    Regex::new(r"(?m)^\[([^\]]+)\]:\s*(https?://\S+)").expect("valid REFERENCE_DEF_RE")
54});
55
56/// Matches reference-style image usages: `![alt][ref]`
57static REFERENCE_USAGE_RE: LazyLock<Regex> =
58    LazyLock::new(|| Regex::new(r"!\[([^\]]*)\]\[([^\]]+)\]").expect("valid REFERENCE_USAGE_RE"));
59
60/// Extracts http/https URLs from arbitrary text (used for tool argument scanning).
61static URL_EXTRACT_RE: LazyLock<Regex> =
62    LazyLock::new(|| Regex::new(r#"https?://[^\s"'<>]+"#).expect("valid URL_EXTRACT_RE"));
63
64/// Matches HTML `<img>` tags with external http/https `src` attributes.
65///
66/// Both single-quoted and double-quoted `src` values are matched. The captured group 1 contains
67/// the URL. The full tag (`<img … >`) is replaced with `[image removed: <url>]`.
68static HTML_IMG_RE: LazyLock<Regex> = LazyLock::new(|| {
69    Regex::new(r#"(?i)<img\b[^>]*\bsrc\s*=\s*["'](https?://[^"']+)["'][^>]*>"#)
70        .expect("valid HTML_IMG_RE")
71});
72
73/// Detects invisible Unicode characters between `!` and `[` used to bypass markdown regex.
74///
75/// Adversaries insert invisible formatting or combining characters between `!` and `[` to prevent
76/// standard regex matchers from recognising the markdown image syntax. This pattern covers:
77///
78/// - `\p{Cf}` (Unicode Format category): zero-width joiners/non-joiners, BIDI overrides and
79///   isolates (U+202A–202E, U+2066–2069), deprecated format chars (U+206A–206F), soft hyphen
80///   (U+00AD), Mongolian vowel separator (U+180E), and the entire TAGS block (U+E0000–E007F).
81/// - U+034F (COMBINING GRAPHEME JOINER, category Mn): invisible combining mark; not in `\p{Cf}`,
82///   added explicitly.
83static UNICODE_BYPASS_RE: LazyLock<Regex> =
84    LazyLock::new(|| Regex::new(r"!(?:[\p{Cf}\x{034F}])+\[").expect("valid UNICODE_BYPASS_RE"));
85
86// ---------------------------------------------------------------------------
87// Event types
88// ---------------------------------------------------------------------------
89
90/// An exfiltration event detected by [`ExfiltrationGuard`].
91///
92/// Events are advisory: they are logged, counted, and returned to the caller for
93/// further action. The guard itself never panics or blocks the agent loop.
94///
95/// # Examples
96///
97/// ```rust
98/// use zeph_sanitizer::exfiltration::{ExfiltrationGuard, ExfiltrationEvent};
99/// use zeph_config::ExfiltrationGuardConfig;
100///
101/// let guard = ExfiltrationGuard::new(ExfiltrationGuardConfig::default());
102/// let (cleaned, events) = guard.scan_output("![t](https://evil.com/pixel.gif)");
103/// assert_eq!(events.len(), 1);
104/// assert!(matches!(&events[0], ExfiltrationEvent::MarkdownImageBlocked { url } if url.contains("evil.com")));
105/// ```
106#[non_exhaustive]
107#[derive(Debug, Clone, PartialEq)]
108pub enum ExfiltrationEvent {
109    /// A markdown image with an external URL was stripped from LLM output.
110    MarkdownImageBlocked { url: String },
111    /// An HTML `<img src="…">` tag with an external URL was stripped from LLM output.
112    HtmlImageBlocked { url: String },
113    /// A tool call argument contained a URL that appeared in untrusted flagged content.
114    SuspiciousToolUrl { url: String, tool_name: ToolName },
115    /// A memory write was intercepted because the content had injection flags.
116    MemoryWriteGuarded { reason: String },
117}
118
119// ---------------------------------------------------------------------------
120// Guard
121// ---------------------------------------------------------------------------
122
123/// Stateless exfiltration guard covering three outbound leak vectors.
124///
125/// Construct once from [`ExfiltrationGuardConfig`] and store on the agent. Cheap to clone.
126/// All three scanners ([`scan_output`](Self::scan_output),
127/// [`validate_tool_call`](Self::validate_tool_call),
128/// [`should_guard_memory_write`](Self::should_guard_memory_write)) are independently
129/// toggled via the config flags `block_markdown_images`, `validate_tool_urls`, and
130/// `guard_memory_writes`.
131///
132/// # Examples
133///
134/// ```rust
135/// use zeph_sanitizer::exfiltration::ExfiltrationGuard;
136/// use zeph_config::ExfiltrationGuardConfig;
137///
138/// let guard = ExfiltrationGuard::new(ExfiltrationGuardConfig::default());
139///
140/// // Strips external tracking pixels from LLM output.
141/// let (cleaned, events) = guard.scan_output("text ![track](https://evil.com/p.gif) end");
142/// assert!(events.len() == 1);
143/// assert!(!cleaned.contains("![track]"));
144///
145/// // Memory write is guarded when injection flags are present.
146/// let event = guard.should_guard_memory_write(true);
147/// assert!(event.is_some());
148/// ```
149#[derive(Debug, Clone)]
150pub struct ExfiltrationGuard {
151    config: ExfiltrationGuardConfig,
152}
153
154impl ExfiltrationGuard {
155    /// Create a new guard from the given configuration.
156    ///
157    /// # Examples
158    ///
159    /// ```rust
160    /// use zeph_sanitizer::exfiltration::ExfiltrationGuard;
161    /// use zeph_config::ExfiltrationGuardConfig;
162    ///
163    /// let guard = ExfiltrationGuard::new(ExfiltrationGuardConfig::default());
164    /// ```
165    #[must_use]
166    pub fn new(config: ExfiltrationGuardConfig) -> Self {
167        Self { config }
168    }
169
170    /// Scan LLM output text and strip external markdown images.
171    ///
172    /// Returns the cleaned text and a list of [`ExfiltrationEvent::MarkdownImageBlocked`]
173    /// for each image that was removed.
174    ///
175    /// When `block_markdown_images` is `false`, returns the input unchanged.
176    ///
177    /// # Scanning coverage
178    /// - Inline images: `![alt](https://evil.com/track.gif)`
179    /// - Reference-style images: `![alt][ref]` + `[ref]: https://evil.com/img`
180    /// - Percent-encoded URLs inside already-captured groups: decoded before `is_external_url()`
181    ///
182    /// # Not covered (tracked in #1195)
183    /// - Percent-encoded scheme bypass: `%68ttps://evil.com` — the regex requires literal
184    ///   `https?://`, so a percent-encoded scheme is never captured. Fix requires pre-decoding
185    ///   the full input text before regex matching.
186    /// - Reference definitions inside fenced code blocks (false positive risk)
187    ///
188    /// # Panics
189    ///
190    /// Panics if the compiled regex does not produce expected capture groups (compile-time
191    /// guarantee — the regex patterns are validated via `expect` in `LazyLock` initializers).
192    #[must_use]
193    pub fn scan_output(&self, text: &str) -> (String, Vec<ExfiltrationEvent>) {
194        if !self.config.block_markdown_images {
195            return (text.to_owned(), vec![]);
196        }
197
198        let mut events = Vec::new();
199        let mut result = text.to_owned();
200
201        // --- Pass 1: inline images ---
202        let mut replacement = String::new();
203        let mut last_end = 0usize;
204        for cap in MARKDOWN_IMAGE_RE.captures_iter(text) {
205            let m = cap.get(0).expect("full match");
206            let raw_url = cap.get(2).expect("url group").as_str();
207            let url = percent_decode_url(raw_url);
208
209            if is_external_url(&url) {
210                replacement.push_str(&text[last_end..m.start()]);
211                let _ = write!(replacement, "[image removed: {url}]");
212                last_end = m.end();
213                events.push(ExfiltrationEvent::MarkdownImageBlocked { url });
214            }
215        }
216        if !events.is_empty() || last_end > 0 {
217            replacement.push_str(&text[last_end..]);
218            result = replacement;
219        }
220
221        // --- Pass 2: reference-style images ---
222        // Collect reference definitions from the (already partially cleaned) result.
223        let mut ref_defs: std::collections::HashMap<String, String> =
224            std::collections::HashMap::new();
225        for cap in REFERENCE_DEF_RE.captures_iter(&result) {
226            let label = cap.get(1).expect("label").as_str().to_lowercase();
227            let raw_url = cap.get(2).expect("url").as_str();
228            let url = percent_decode_url(raw_url);
229            if is_external_url(&url) {
230                ref_defs.insert(label, url);
231            }
232        }
233
234        if !ref_defs.is_empty() {
235            // Remove reference usages that point to external defs.
236            let mut cleaned = String::with_capacity(result.len());
237            let mut last_end = 0usize;
238            for cap in REFERENCE_USAGE_RE.captures_iter(&result) {
239                let m = cap.get(0).expect("full match");
240                let label = cap.get(2).expect("label").as_str().to_lowercase();
241                if let Some(url) = ref_defs.get(&label) {
242                    cleaned.push_str(&result[last_end..m.start()]);
243                    let _ = write!(cleaned, "[image removed: {url}]");
244                    last_end = m.end();
245                    events.push(ExfiltrationEvent::MarkdownImageBlocked { url: url.clone() });
246                }
247            }
248            cleaned.push_str(&result[last_end..]);
249            result = cleaned;
250
251            // Remove the reference definition lines for blocked refs.
252            // Use split('\n') (not .lines()) to preserve \r in CRLF line endings —
253            // .lines() strips \r, and reconstruction with push('\n') would silently
254            // convert all CRLF to LF throughout the entire text.
255            let mut def_cleaned = String::with_capacity(result.len());
256            for line in result.split('\n') {
257                let mut keep = true;
258                for cap in REFERENCE_DEF_RE.captures_iter(line) {
259                    let label = cap.get(1).expect("label").as_str().to_lowercase();
260                    if ref_defs.contains_key(&label) {
261                        keep = false;
262                        break;
263                    }
264                }
265                if keep {
266                    def_cleaned.push_str(line);
267                    def_cleaned.push('\n');
268                }
269            }
270            // Preserve trailing newline behaviour of the original.
271            if !text.ends_with('\n') && def_cleaned.ends_with('\n') {
272                def_cleaned.pop();
273            }
274            result = def_cleaned;
275        }
276
277        // --- Pass 3: HTML img tags with external URLs ---
278        let mut html_result = String::with_capacity(result.len());
279        let mut html_last_end = 0usize;
280        for cap in HTML_IMG_RE.captures_iter(&result) {
281            let m = cap.get(0).expect("full match");
282            let url = cap.get(1).expect("src url group").as_str().to_owned();
283            tracing::warn!(url = %url, "HTML img tag with external URL stripped from LLM output");
284            html_result.push_str(&result[html_last_end..m.start()]);
285            let _ = write!(html_result, "[image removed: {url}]");
286            html_last_end = m.end();
287            events.push(ExfiltrationEvent::HtmlImageBlocked { url });
288        }
289        if html_last_end > 0 {
290            html_result.push_str(&result[html_last_end..]);
291            result = html_result;
292        }
293
294        // --- Pass 4: Unicode zero-width bypass sequences ---
295        // Adversaries insert zero-width chars between `!` and `[` to defeat markdown regexes.
296        // Strip the entire `!<zwc+>[` sequence to defuse the payload.
297        if UNICODE_BYPASS_RE.is_match(&result) {
298            tracing::warn!("Unicode zero-width bypass attempt detected in LLM output; stripping");
299            result = UNICODE_BYPASS_RE
300                .replace_all(&result, "[blocked]")
301                .into_owned();
302        }
303
304        (result, events)
305    }
306
307    /// Validate tool call arguments against a set of URLs flagged in untrusted content.
308    ///
309    /// Parses `args_json` as a JSON value and extracts all string leaves recursively to
310    /// avoid JSON-encoding bypasses (escaped slashes, unicode escapes, etc.).
311    ///
312    /// Returns one [`ExfiltrationEvent::SuspiciousToolUrl`] per matching URL.
313    /// When `validate_tool_urls` is `false`, always returns an empty vec.
314    ///
315    /// # Flag-only approach
316    /// Matching URLs are logged and counted but tool execution is NOT blocked. Blocking
317    /// would break legitimate workflows where the same URL appears in both a search result
318    /// and a subsequent fetch call. See design decision D1 in the architect handoff.
319    #[must_use]
320    pub fn validate_tool_call(
321        &self,
322        tool_name: &str,
323        args_json: &str,
324        flagged_urls: &HashSet<String>,
325    ) -> Vec<ExfiltrationEvent> {
326        if !self.config.validate_tool_urls || flagged_urls.is_empty() {
327            return vec![];
328        }
329
330        let parsed: serde_json::Value = match serde_json::from_str(args_json) {
331            Ok(v) => v,
332            Err(_) => {
333                // Fall back to raw regex scan if JSON is malformed.
334                return Self::scan_raw_args(tool_name, args_json, flagged_urls);
335            }
336        };
337
338        let mut events = Vec::new();
339        let mut strings = Vec::new();
340        collect_strings(&parsed, &mut strings);
341
342        for s in &strings {
343            for url_match in URL_EXTRACT_RE.find_iter(s) {
344                let url = url_match.as_str();
345                if flagged_urls.contains(url) {
346                    events.push(ExfiltrationEvent::SuspiciousToolUrl {
347                        url: url.to_owned(),
348                        tool_name: tool_name.into(),
349                    });
350                }
351            }
352        }
353
354        events
355    }
356
357    /// Check whether a memory write should skip Qdrant embedding.
358    ///
359    /// Returns `Some(MemoryWriteGuarded)` when `has_injection_flags` is `true` and
360    /// `guard_memory_writes` is enabled. The caller should still save to `SQLite` for
361    /// conversation continuity but omit the Qdrant embedding to prevent poisoned content
362    /// from polluting semantic search results.
363    ///
364    /// See design decision D2 in the architect handoff.
365    #[must_use]
366    pub fn should_guard_memory_write(
367        &self,
368        has_injection_flags: bool,
369    ) -> Option<ExfiltrationEvent> {
370        if !self.config.guard_memory_writes || !has_injection_flags {
371            return None;
372        }
373        Some(ExfiltrationEvent::MemoryWriteGuarded {
374            reason: "content contained injection patterns flagged by ContentSanitizer".to_owned(),
375        })
376    }
377
378    /// Extract URLs from untrusted tool output for use in subsequent `validate_tool_call` checks.
379    ///
380    fn scan_raw_args(
381        tool_name: &str,
382        args: &str,
383        flagged_urls: &HashSet<String>,
384    ) -> Vec<ExfiltrationEvent> {
385        URL_EXTRACT_RE
386            .find_iter(args)
387            .filter(|m| flagged_urls.contains(m.as_str()))
388            .map(|m| ExfiltrationEvent::SuspiciousToolUrl {
389                url: m.as_str().to_owned(),
390                tool_name: tool_name.into(),
391            })
392            .collect()
393    }
394}
395
396/// Extract all `http`/`https` URLs from `content` into a `HashSet` for later URL validation.
397///
398/// Call this after sanitizing untrusted tool output with `ContentSanitizer` when injection
399/// flags are present. Pass the returned set into the agent's `flagged_urls` field. Pass that
400/// set to [`ExfiltrationGuard::validate_tool_call`] on each subsequent tool call. Clear
401/// `flagged_urls` at the start of each `process_response` call (per-turn clearing strategy).
402///
403/// # Examples
404///
405/// ```rust
406/// use zeph_sanitizer::exfiltration::extract_flagged_urls;
407///
408/// let urls = extract_flagged_urls("visit https://evil.com/x and https://other.com/y");
409/// assert!(urls.contains("https://evil.com/x"));
410/// assert!(urls.contains("https://other.com/y"));
411/// assert_eq!(urls.len(), 2);
412/// ```
413#[must_use]
414pub fn extract_flagged_urls(content: &str) -> HashSet<String> {
415    URL_EXTRACT_RE
416        .find_iter(content)
417        .map(|m| m.as_str().to_owned())
418        .collect()
419}
420
421// ---------------------------------------------------------------------------
422// Helpers
423// ---------------------------------------------------------------------------
424
425/// Decode percent-encoded URL characters before exfiltration matching.
426///
427/// Converts `%68ttps://` → `https://` so simple percent-encoding bypasses are caught.
428/// Non-UTF-8 sequences are left as-is (they won't match `is_external_url`).
429fn percent_decode_url(raw: &str) -> String {
430    let mut out = String::with_capacity(raw.len());
431    let bytes = raw.as_bytes();
432    let mut i = 0;
433    while i < bytes.len() {
434        if bytes[i] == b'%'
435            && i + 2 < bytes.len()
436            && let (Some(hi), Some(lo)) = (
437                (bytes[i + 1] as char).to_digit(16),
438                (bytes[i + 2] as char).to_digit(16),
439            )
440        {
441            // hi and lo are 0-15; combined value is at most 0xFF, fits in u8.
442            #[allow(clippy::cast_possible_truncation)]
443            let byte = ((hi << 4) | lo) as u8;
444            out.push(byte as char);
445            i += 3;
446            continue;
447        }
448        out.push(bytes[i] as char);
449        i += 1;
450    }
451    out
452}
453
454fn is_external_url(url: &str) -> bool {
455    url.starts_with("http://") || url.starts_with("https://")
456}
457
458/// Recursively collect all string leaves from a JSON value.
459fn collect_strings<'a>(value: &'a serde_json::Value, out: &mut Vec<&'a str>) {
460    match value {
461        serde_json::Value::String(s) => out.push(s.as_str()),
462        serde_json::Value::Array(arr) => {
463            for v in arr {
464                collect_strings(v, out);
465            }
466        }
467        serde_json::Value::Object(map) => {
468            for v in map.values() {
469                collect_strings(v, out);
470            }
471        }
472        _ => {}
473    }
474}
475
476// ---------------------------------------------------------------------------
477// Tests
478// ---------------------------------------------------------------------------
479
480#[cfg(test)]
481mod tests {
482    use super::*;
483    use std::assert_matches;
484
485    fn guard() -> ExfiltrationGuard {
486        ExfiltrationGuard::new(ExfiltrationGuardConfig::default())
487    }
488
489    fn guard_disabled() -> ExfiltrationGuard {
490        ExfiltrationGuard::new(ExfiltrationGuardConfig {
491            block_markdown_images: false,
492            validate_tool_urls: false,
493            guard_memory_writes: false,
494        })
495    }
496
497    // --- scan_output ---
498
499    #[test]
500    fn strips_external_inline_image() {
501        let (cleaned, events) =
502            guard().scan_output("Before ![track](https://evil.com/p.gif) after");
503        assert_eq!(
504            cleaned,
505            "Before [image removed: https://evil.com/p.gif] after"
506        );
507        assert_eq!(events.len(), 1);
508        assert!(
509            matches!(&events[0], ExfiltrationEvent::MarkdownImageBlocked { url } if url == "https://evil.com/p.gif")
510        );
511    }
512
513    #[test]
514    fn preserves_local_image() {
515        let text = "Look: ![diagram](./diagram.png) — local";
516        let (cleaned, events) = guard().scan_output(text);
517        assert_eq!(cleaned, text);
518        assert!(events.is_empty());
519    }
520
521    #[test]
522    fn preserves_data_uri() {
523        let text = "Inline: ![icon](data:image/png;base64,abc123)";
524        let (cleaned, events) = guard().scan_output(text);
525        assert_eq!(cleaned, text);
526        assert!(events.is_empty());
527    }
528
529    #[test]
530    fn strips_multiple_external_images() {
531        let text = "![a](https://a.com/1.gif) text ![b](https://b.com/2.gif)";
532        let (cleaned, events) = guard().scan_output(text);
533        // Markdown image syntax must be removed; replacement label may contain URLs.
534        assert!(
535            !cleaned.contains("![a]("),
536            "first image syntax must be removed: {cleaned}"
537        );
538        assert!(
539            !cleaned.contains("![b]("),
540            "second image syntax must be removed: {cleaned}"
541        );
542        assert_eq!(events.len(), 2);
543    }
544
545    #[test]
546    fn scan_output_noop_when_disabled() {
547        let text = "![track](https://evil.com/p.gif)";
548        let (cleaned, events) = guard_disabled().scan_output(text);
549        assert_eq!(cleaned, text);
550        assert!(events.is_empty());
551    }
552
553    #[test]
554    fn strips_reference_style_image() {
555        let text = "Here is the image: ![alt][ref]\n[ref]: https://evil.com/track.gif\nend";
556        let (cleaned, events) = guard().scan_output(text);
557        // The markdown image syntax and definition line must be removed.
558        assert!(
559            !cleaned.contains("![alt][ref]"),
560            "image usage syntax must be removed: {cleaned}"
561        );
562        assert!(
563            !cleaned.contains("[ref]:"),
564            "reference definition must be removed: {cleaned}"
565        );
566        assert!(
567            cleaned.contains("[image removed:"),
568            "replacement label must be present: {cleaned}"
569        );
570        assert!(!events.is_empty(), "must generate event");
571    }
572
573    #[test]
574    fn preserves_local_reference_image() {
575        // Reference pointing to a local path — must not be stripped.
576        let text = "![alt][ref]\n[ref]: ./local.png\n";
577        let (cleaned, events) = guard().scan_output(text);
578        assert_eq!(cleaned, text);
579        assert!(events.is_empty());
580    }
581
582    #[test]
583    fn decodes_percent_encoded_url_in_inline_image() {
584        // %68 = 'h', so %68ttps:// decodes to https://.
585        // The MARKDOWN_IMAGE_RE pattern requires a literal `https?://` prefix, so
586        // `%68ttps://` is NOT matched by the regex and passes through unchanged.
587        // percent_decode_url() is called on the URL *after* the regex captures it —
588        // so percent-encoded schemes bypass inline detection.
589        //
590        // Known bypass — tracked for Phase 5 (#1195): the fix requires pre-decoding the
591        // full text before regex matching (or a multi-pass decode+scan approach). The LLM
592        // context wrapper already limits what arrives here, reducing practical risk.
593        let text = "![t](%68ttps://evil.com/track.gif)";
594        let (cleaned, _events) = guard().scan_output(text);
595        // The text passes through unchanged because the regex didn't match.
596        assert_eq!(
597            cleaned, text,
598            "percent-encoded scheme not detected by inline regex"
599        );
600
601        // A normal https:// URL IS detected.
602        let normal = "![t](https://evil.com/track.gif)";
603        let (normal_cleaned, normal_events) = guard().scan_output(normal);
604        assert!(
605            !normal_cleaned.contains("![t](https://"),
606            "normal URL must be removed"
607        );
608        assert_eq!(normal_events.len(), 1);
609    }
610
611    #[test]
612    fn empty_alt_text_still_blocked() {
613        let text = "![](https://evil.com/p.gif)";
614        let (cleaned, events) = guard().scan_output(text);
615        // The original markdown image syntax must be removed; the replacement label may contain the URL.
616        assert!(
617            !cleaned.contains("![]("),
618            "markdown image syntax must be removed: {cleaned}"
619        );
620        assert!(
621            cleaned.contains("[image removed:"),
622            "replacement label must be present: {cleaned}"
623        );
624        assert_eq!(events.len(), 1);
625    }
626
627    #[test]
628    fn html_img_tag_blocked() {
629        let guard = ExfiltrationGuard::new(ExfiltrationGuardConfig {
630            block_markdown_images: true,
631            ..ExfiltrationGuardConfig::default()
632        });
633        let (cleaned, events) = guard.scan_output(r#"text <img src="https://evil.com/p.gif"> end"#);
634        assert!(
635            events
636                .iter()
637                .any(|e| matches!(e, ExfiltrationEvent::HtmlImageBlocked { .. })),
638            "expected HtmlImageBlocked event"
639        );
640        assert!(
641            !cleaned.contains("<img"),
642            "img tag must be removed: {cleaned}"
643        );
644        assert!(
645            cleaned.contains("[image removed:"),
646            "replacement label must be present: {cleaned}"
647        );
648    }
649
650    #[test]
651    fn html_img_tag_single_quote_blocked() {
652        let guard = ExfiltrationGuard::new(ExfiltrationGuardConfig {
653            block_markdown_images: true,
654            ..ExfiltrationGuardConfig::default()
655        });
656        let (cleaned, events) = guard.scan_output("text <img src='https://evil.com/p.gif'> end");
657        assert!(
658            events
659                .iter()
660                .any(|e| matches!(e, ExfiltrationEvent::HtmlImageBlocked { .. })),
661            "expected HtmlImageBlocked event for single-quoted src"
662        );
663        assert!(
664            !cleaned.contains("<img"),
665            "img tag must be removed: {cleaned}"
666        );
667    }
668
669    #[test]
670    fn html_img_tag_noop_when_disabled() {
671        let input = r#"text <img src="https://evil.com/p.gif"> end"#;
672        let (cleaned, events) = guard_disabled().scan_output(input);
673        assert_eq!(cleaned, input);
674        assert!(events.is_empty());
675    }
676
677    #[test]
678    fn unicode_zwj_bypass_blocked() {
679        let guard = ExfiltrationGuard::new(ExfiltrationGuardConfig {
680            block_markdown_images: true,
681            ..ExfiltrationGuardConfig::default()
682        });
683        // Insert U+200B (ZWSP) between ! and [ to try to evade markdown regex.
684        let input = "!\u{200B}[alt](https://evil.com/track)";
685        let (cleaned, _events) = guard.scan_output(input);
686        // The bypass sequence `!\u{200B}[` is replaced with `[blocked]`, defusing
687        // the markdown image syntax — the `!` prefix that triggers image rendering is gone.
688        assert!(
689            !cleaned.contains('\u{200B}'),
690            "zero-width char must be stripped: {cleaned}"
691        );
692        assert!(
693            !cleaned.starts_with('!'),
694            "image trigger `!` must be removed: {cleaned}"
695        );
696    }
697
698    #[test]
699    fn unicode_word_joiner_bypass_blocked() {
700        let guard = ExfiltrationGuard::new(ExfiltrationGuardConfig {
701            block_markdown_images: true,
702            ..ExfiltrationGuardConfig::default()
703        });
704        // U+2060 (WORD JOINER) inserted between ! and [ to evade markdown regex.
705        let input = "!\u{2060}[alt](https://evil.com/track)";
706        let (cleaned, _events) = guard.scan_output(input);
707        assert!(
708            !cleaned.contains('\u{2060}'),
709            "U+2060 word joiner must be stripped: {cleaned}"
710        );
711        assert!(
712            !cleaned.starts_with('!'),
713            "image trigger `!` must be removed: {cleaned}"
714        );
715    }
716
717    #[test]
718    fn unicode_bypass_noop_when_disabled() {
719        let input = "!\u{200B}[alt](https://evil.com/track)";
720        let (cleaned, events) = guard_disabled().scan_output(input);
721        assert_eq!(cleaned, input);
722        assert!(events.is_empty());
723    }
724
725    #[test]
726    fn unicode_bidi_override_bypass_blocked() {
727        let guard = ExfiltrationGuard::new(ExfiltrationGuardConfig {
728            block_markdown_images: true,
729            ..ExfiltrationGuardConfig::default()
730        });
731        let input = "!\u{202E}[alt](https://evil.com/track)";
732        let (cleaned, _events) = guard.scan_output(input);
733        assert!(
734            !cleaned.contains('\u{202E}'),
735            "U+202E BIDI override must be stripped: {cleaned}"
736        );
737        assert!(
738            !cleaned.starts_with('!'),
739            "image trigger `!` must be removed: {cleaned}"
740        );
741    }
742
743    #[test]
744    fn unicode_bidi_isolate_bypass_blocked() {
745        let guard = ExfiltrationGuard::new(ExfiltrationGuardConfig {
746            block_markdown_images: true,
747            ..ExfiltrationGuardConfig::default()
748        });
749        let input = "!\u{2066}[alt](https://evil.com/track)";
750        let (cleaned, _events) = guard.scan_output(input);
751        assert!(
752            !cleaned.contains('\u{2066}'),
753            "U+2066 BIDI isolate must be stripped: {cleaned}"
754        );
755        assert!(
756            !cleaned.starts_with('!'),
757            "image trigger `!` must be removed: {cleaned}"
758        );
759    }
760
761    #[test]
762    fn unicode_soft_hyphen_bypass_blocked() {
763        let guard = ExfiltrationGuard::new(ExfiltrationGuardConfig {
764            block_markdown_images: true,
765            ..ExfiltrationGuardConfig::default()
766        });
767        let input = "!\u{00AD}[alt](https://evil.com/track)";
768        let (cleaned, _events) = guard.scan_output(input);
769        assert!(
770            !cleaned.contains('\u{00AD}'),
771            "U+00AD soft hyphen must be stripped: {cleaned}"
772        );
773        assert!(
774            !cleaned.starts_with('!'),
775            "image trigger `!` must be removed: {cleaned}"
776        );
777    }
778
779    #[test]
780    fn unicode_tags_block_bypass_blocked() {
781        let guard = ExfiltrationGuard::new(ExfiltrationGuardConfig {
782            block_markdown_images: true,
783            ..ExfiltrationGuardConfig::default()
784        });
785        let input = "!\u{E0041}[alt](https://evil.com/track)";
786        let (cleaned, _events) = guard.scan_output(input);
787        assert!(
788            !cleaned.contains('\u{E0041}'),
789            "U+E0041 TAGS char must be stripped: {cleaned}"
790        );
791        assert!(
792            !cleaned.starts_with('!'),
793            "image trigger `!` must be removed: {cleaned}"
794        );
795    }
796
797    #[test]
798    fn unicode_cgj_bypass_blocked() {
799        let guard = ExfiltrationGuard::new(ExfiltrationGuardConfig {
800            block_markdown_images: true,
801            ..ExfiltrationGuardConfig::default()
802        });
803        // U+034F (CGJ) is category Mn, not Cf — must be covered by explicit addition.
804        let input = "!\u{034F}[alt](https://evil.com/track)";
805        let (cleaned, _events) = guard.scan_output(input);
806        assert!(
807            !cleaned.contains('\u{034F}'),
808            "U+034F CGJ must be stripped: {cleaned}"
809        );
810        assert!(
811            !cleaned.starts_with('!'),
812            "image trigger `!` must be removed: {cleaned}"
813        );
814    }
815
816    #[test]
817    fn unicode_heterogeneous_run_bypass_blocked() {
818        let guard = ExfiltrationGuard::new(ExfiltrationGuardConfig {
819            block_markdown_images: true,
820            ..ExfiltrationGuardConfig::default()
821        });
822        // Mixed run: ZWSP + BIDI override + TAGS char — the `+` quantifier must consume all.
823        let input = "!\u{200B}\u{202E}\u{E0001}[alt](https://evil.com/track)";
824        let (cleaned, _events) = guard.scan_output(input);
825        assert!(
826            !cleaned.contains('\u{200B}'),
827            "U+200B must be stripped in mixed run: {cleaned}"
828        );
829        assert!(
830            !cleaned.contains('\u{202E}'),
831            "U+202E must be stripped in mixed run: {cleaned}"
832        );
833        assert!(
834            !cleaned.contains('\u{E0001}'),
835            "U+E0001 must be stripped in mixed run: {cleaned}"
836        );
837        assert!(
838            !cleaned.starts_with('!'),
839            "image trigger `!` must be removed: {cleaned}"
840        );
841    }
842
843    #[test]
844    fn unicode_bypass_no_false_positive_on_space() {
845        let guard = ExfiltrationGuard::new(ExfiltrationGuardConfig {
846            block_markdown_images: true,
847            ..ExfiltrationGuardConfig::default()
848        });
849        // Literal space between `!` and `[` is NOT an invisible bypass char — must not be matched.
850        let input = "! [text](https://example.com/)";
851        let (cleaned, _events) = guard.scan_output(input);
852        assert_eq!(
853            cleaned, input,
854            "literal space between ! and [ must not trigger bypass detection"
855        );
856    }
857
858    #[test]
859    fn unicode_bypass_no_false_positive_on_clean_image() {
860        let guard = ExfiltrationGuard::new(ExfiltrationGuardConfig {
861            block_markdown_images: true,
862            ..ExfiltrationGuardConfig::default()
863        });
864        // Legitimate inline image is handled by Pass 1, not double-processed by Pass 4.
865        let (cleaned, events) = guard.scan_output("![alt](https://evil.com/track.gif)");
866        assert!(
867            events
868                .iter()
869                .any(|e| matches!(e, ExfiltrationEvent::MarkdownImageBlocked { .. })),
870            "should produce MarkdownImageBlocked event, not bypass event"
871        );
872        assert!(
873            !cleaned.contains("![alt]("),
874            "clean image must be stripped by Pass 1: {cleaned}"
875        );
876    }
877
878    // --- validate_tool_call ---
879
880    #[test]
881    fn detects_flagged_url_in_json_string() {
882        let mut flagged = HashSet::new();
883        flagged.insert("https://evil.com/payload".to_owned());
884        let args = r#"{"url": "https://evil.com/payload"}"#;
885        let events = guard().validate_tool_call("fetch", args, &flagged);
886        assert_eq!(events.len(), 1);
887        assert!(
888            matches!(&events[0], ExfiltrationEvent::SuspiciousToolUrl { url, tool_name }
889            if url == "https://evil.com/payload" && tool_name == "fetch")
890        );
891    }
892
893    #[test]
894    fn no_event_when_url_not_flagged() {
895        let mut flagged = HashSet::new();
896        flagged.insert("https://other.com/benign".to_owned());
897        let args = r#"{"url": "https://legitimate.com/page"}"#;
898        let events = guard().validate_tool_call("fetch", args, &flagged);
899        assert!(events.is_empty());
900    }
901
902    #[test]
903    fn validate_tool_call_noop_when_disabled() {
904        let mut flagged = HashSet::new();
905        flagged.insert("https://evil.com/x".to_owned());
906        let args = r#"{"url": "https://evil.com/x"}"#;
907        let events = guard_disabled().validate_tool_call("fetch", args, &flagged);
908        assert!(events.is_empty());
909    }
910
911    #[test]
912    fn validate_tool_call_noop_with_empty_flagged() {
913        let args = r#"{"url": "https://evil.com/x"}"#;
914        let events = guard().validate_tool_call("fetch", args, &HashSet::new());
915        assert!(events.is_empty());
916    }
917
918    #[test]
919    fn extracts_urls_from_nested_json() {
920        let mut flagged = HashSet::new();
921        flagged.insert("https://evil.com/deep".to_owned());
922        let args = r#"{"nested": {"inner": ["https://evil.com/deep"]}}"#;
923        let events = guard().validate_tool_call("tool", args, &flagged);
924        assert_eq!(events.len(), 1);
925    }
926
927    #[test]
928    fn handles_escaped_slashes_in_json() {
929        // JSON-encoded URL with escaped forward slashes should still be detected
930        // after serde_json parsing (which unescapes the string value).
931        let mut flagged = HashSet::new();
932        flagged.insert("https://evil.com/path".to_owned());
933        // serde_json will unescape \/ → /
934        let args = r#"{"url": "https:\/\/evil.com\/path"}"#;
935        let parsed: serde_json::Value = serde_json::from_str(args).unwrap();
936        // Confirm serde_json unescapes it.
937        assert_eq!(parsed["url"], "https://evil.com/path");
938        let events = guard().validate_tool_call("fetch", args, &flagged);
939        assert_eq!(events.len(), 1, "JSON-escaped URL must be caught");
940    }
941
942    // --- should_guard_memory_write ---
943
944    #[test]
945    fn guards_when_injection_flags_set() {
946        let event = guard().should_guard_memory_write(true);
947        assert!(event.is_some());
948        assert_matches!(event.unwrap(), ExfiltrationEvent::MemoryWriteGuarded { .. });
949    }
950
951    #[test]
952    fn passes_when_no_injection_flags() {
953        let event = guard().should_guard_memory_write(false);
954        assert!(event.is_none());
955    }
956
957    #[test]
958    fn guard_memory_write_noop_when_disabled() {
959        let event = guard_disabled().should_guard_memory_write(true);
960        assert!(event.is_none());
961    }
962
963    // --- percent_decode_url ---
964
965    #[test]
966    fn percent_decode_roundtrip() {
967        assert_eq!(
968            percent_decode_url("https://example.com"),
969            "https://example.com"
970        );
971        assert_eq!(
972            percent_decode_url("%68ttps://example.com"),
973            "https://example.com"
974        );
975        assert_eq!(percent_decode_url("hello%20world"), "hello world");
976    }
977
978    // --- extract_flagged_urls ---
979
980    #[test]
981    fn extracts_urls_from_plain_text() {
982        let content = "check https://evil.com/x and https://other.com/y for details";
983        let urls = extract_flagged_urls(content);
984        assert!(urls.contains("https://evil.com/x"));
985        assert!(urls.contains("https://other.com/y"));
986    }
987}