1use 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
37static MARKDOWN_IMAGE_RE: LazyLock<Regex> = LazyLock::new(|| {
47 Regex::new(r"!\[([^\]]*)\]\((https?://[^)]+)\)").expect("valid MARKDOWN_IMAGE_RE")
48});
49
50static REFERENCE_DEF_RE: LazyLock<Regex> = LazyLock::new(|| {
53 Regex::new(r"(?m)^\[([^\]]+)\]:\s*(https?://\S+)").expect("valid REFERENCE_DEF_RE")
54});
55
56static REFERENCE_USAGE_RE: LazyLock<Regex> =
58 LazyLock::new(|| Regex::new(r"!\[([^\]]*)\]\[([^\]]+)\]").expect("valid REFERENCE_USAGE_RE"));
59
60static URL_EXTRACT_RE: LazyLock<Regex> =
62 LazyLock::new(|| Regex::new(r#"https?://[^\s"'<>]+"#).expect("valid URL_EXTRACT_RE"));
63
64static 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
73static UNICODE_BYPASS_RE: LazyLock<Regex> =
84 LazyLock::new(|| Regex::new(r"!(?:[\p{Cf}\x{034F}])+\[").expect("valid UNICODE_BYPASS_RE"));
85
86#[non_exhaustive]
107#[derive(Debug, Clone, PartialEq)]
108pub enum ExfiltrationEvent {
109 MarkdownImageBlocked { url: String },
111 HtmlImageBlocked { url: String },
113 SuspiciousToolUrl { url: String, tool_name: ToolName },
115 MemoryWriteGuarded { reason: String },
117}
118
119#[derive(Debug, Clone)]
150pub struct ExfiltrationGuard {
151 config: ExfiltrationGuardConfig,
152}
153
154impl ExfiltrationGuard {
155 #[must_use]
166 pub fn new(config: ExfiltrationGuardConfig) -> Self {
167 Self { config }
168 }
169
170 #[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 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 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 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 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 if !text.ends_with('\n') && def_cleaned.ends_with('\n') {
272 def_cleaned.pop();
273 }
274 result = def_cleaned;
275 }
276
277 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 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 #[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 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 #[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 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#[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
421fn 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 #[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
458fn 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#[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 #[test]
500 fn strips_external_inline_image() {
501 let (cleaned, events) =
502 guard().scan_output("Before  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:  — 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: ";
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 = " text ";
532 let (cleaned, events) = guard().scan_output(text);
533 assert!(
535 !cleaned.contains(",
536 "first image syntax must be removed: {cleaned}"
537 );
538 assert!(
539 !cleaned.contains(",
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 = "";
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 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 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 let text = "";
594 let (cleaned, _events) = guard().scan_output(text);
595 assert_eq!(
597 cleaned, text,
598 "percent-encoded scheme not detected by inline regex"
599 );
600
601 let normal = "";
603 let (normal_cleaned, normal_events) = guard().scan_output(normal);
604 assert!(
605 !normal_cleaned.contains(",
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 = "";
614 let (cleaned, events) = guard().scan_output(text);
615 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 let input = "!\u{200B}[alt](https://evil.com/track)";
685 let (cleaned, _events) = guard.scan_output(input);
686 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 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 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 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 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 let (cleaned, events) = guard.scan_output("");
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(",
874 "clean image must be stripped by Pass 1: {cleaned}"
875 );
876 }
877
878 #[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 let mut flagged = HashSet::new();
932 flagged.insert("https://evil.com/path".to_owned());
933 let args = r#"{"url": "https:\/\/evil.com\/path"}"#;
935 let parsed: serde_json::Value = serde_json::from_str(args).unwrap();
936 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 #[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 #[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 #[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}