1use std::collections::HashSet;
26use std::sync::{Arc, LazyLock};
27
28use parking_lot::RwLock;
29
30use regex::Regex;
31use unicode_normalization::UnicodeNormalization as _;
32
33use zeph_config::tools::{
34 DestructiveVerifierConfig, FirewallVerifierConfig, InjectionVerifierConfig,
35 UrlGroundingVerifierConfig,
36};
37
38#[non_exhaustive]
39#[must_use]
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum VerificationResult {
43 Allow,
45 Block { reason: String },
47 Warn { message: String },
50}
51
52pub trait PreExecutionVerifier: Send + Sync + std::fmt::Debug {
58 fn verify(&self, tool_name: &str, args: &serde_json::Value) -> VerificationResult;
60
61 fn name(&self) -> &'static str;
63}
64
65static DESTRUCTIVE_PATTERNS: &[&str] = &[
74 "rm -rf /",
75 "rm -rf ~",
76 "rm -r /",
77 "dd if=",
78 "mkfs",
79 "fdisk",
80 "shred",
81 "wipefs",
82 ":(){ :|:& };:",
83 ":(){:|:&};:",
84 "chmod -r 777 /",
85 "chown -r",
86];
87
88#[derive(Debug)]
96pub struct DestructiveCommandVerifier {
97 shell_tools: Vec<String>,
98 allowed_paths: Vec<String>,
99 extra_patterns: Vec<String>,
100}
101
102impl DestructiveCommandVerifier {
103 #[must_use]
104 pub fn new(config: &DestructiveVerifierConfig) -> Self {
105 Self {
106 shell_tools: config
107 .shell_tools
108 .iter()
109 .map(|s| s.to_lowercase())
110 .collect(),
111 allowed_paths: config
112 .allowed_paths
113 .iter()
114 .map(|s| s.to_lowercase())
115 .collect(),
116 extra_patterns: config
117 .extra_patterns
118 .iter()
119 .map(|s| s.to_lowercase())
120 .collect(),
121 }
122 }
123
124 fn is_shell_tool(&self, tool_name: &str) -> bool {
125 let lower = tool_name.to_lowercase();
126 self.shell_tools.iter().any(|t| t == &lower)
127 }
128
129 fn extract_command(args: &serde_json::Value) -> Option<String> {
139 let raw = match args.get("command") {
140 Some(serde_json::Value::String(s)) => s.clone(),
141 Some(serde_json::Value::Array(arr)) => arr
142 .iter()
143 .filter_map(|v| v.as_str())
144 .collect::<Vec<_>>()
145 .join(" "),
146 _ => return None,
147 };
148 let mut current: String = raw.nfkc().collect::<String>().to_lowercase();
150 for _ in 0..8 {
153 let trimmed = current.trim().to_owned();
154 let after_env = Self::strip_env_prefix(&trimmed);
156 let after_exec = after_env.strip_prefix("exec ").map_or(after_env, str::trim);
158 let mut unwrapped = false;
160 for interp in &["bash -c ", "sh -c ", "zsh -c "] {
161 if let Some(rest) = after_exec.strip_prefix(interp) {
162 let script = rest.trim().trim_matches(|c: char| c == '\'' || c == '"');
163 current.clone_from(&script.to_owned());
164 unwrapped = true;
165 break;
166 }
167 }
168 if !unwrapped {
169 return Some(after_exec.to_owned());
170 }
171 }
172 Some(current)
173 }
174
175 fn strip_env_prefix(cmd: &str) -> &str {
178 let mut rest = cmd;
179 if let Some(after_env) = rest.strip_prefix("env ") {
181 rest = after_env.trim_start();
182 }
183 loop {
185 let mut chars = rest.chars();
187 let key_end = chars
188 .by_ref()
189 .take_while(|c| c.is_alphanumeric() || *c == '_')
190 .count();
191 if key_end == 0 {
192 break;
193 }
194 let remainder = &rest[key_end..];
195 if let Some(after_eq) = remainder.strip_prefix('=') {
196 let val_end = after_eq.find(' ').unwrap_or(after_eq.len());
198 rest = after_eq[val_end..].trim_start();
199 } else {
200 break;
201 }
202 }
203 rest
204 }
205
206 fn is_allowed_path(&self, command: &str) -> bool {
212 if self.allowed_paths.is_empty() {
213 return false;
214 }
215 let tokens: Vec<&str> = command.split_whitespace().collect();
216 for token in &tokens {
217 let t = token.trim_matches(|c| c == '\'' || c == '"');
218 if t.starts_with('/') || t.starts_with('~') || t.starts_with('.') {
219 let normalized = Self::lexical_normalize(std::path::Path::new(t));
220 let n_lower = normalized
223 .to_string_lossy()
224 .replace('\\', "/")
225 .to_lowercase();
226 if self
227 .allowed_paths
228 .iter()
229 .any(|p| n_lower.starts_with(p.replace('\\', "/").to_lowercase().as_str()))
230 {
231 return true;
232 }
233 }
234 }
235 false
236 }
237
238 fn lexical_normalize(p: &std::path::Path) -> std::path::PathBuf {
241 let mut out = std::path::PathBuf::new();
242 for component in p.components() {
243 match component {
244 std::path::Component::ParentDir => {
245 out.pop();
246 }
247 std::path::Component::CurDir => {}
248 other => out.push(other),
249 }
250 }
251 out
252 }
253
254 fn check_patterns(command: &str) -> Option<&'static str> {
255 DESTRUCTIVE_PATTERNS
256 .iter()
257 .find(|&pat| command.contains(pat))
258 .copied()
259 }
260
261 fn check_extra_patterns(&self, command: &str) -> Option<String> {
262 self.extra_patterns
263 .iter()
264 .find(|pat| command.contains(pat.as_str()))
265 .cloned()
266 }
267}
268
269impl PreExecutionVerifier for DestructiveCommandVerifier {
270 fn name(&self) -> &'static str {
271 "DestructiveCommandVerifier"
272 }
273
274 fn verify(&self, tool_name: &str, args: &serde_json::Value) -> VerificationResult {
275 if !self.is_shell_tool(tool_name) {
276 return VerificationResult::Allow;
277 }
278
279 let Some(command) = Self::extract_command(args) else {
280 return VerificationResult::Allow;
281 };
282
283 if let Some(pat) = Self::check_patterns(&command) {
284 if self.is_allowed_path(&command) {
285 return VerificationResult::Allow;
286 }
287 return VerificationResult::Block {
288 reason: format!("[{}] destructive pattern '{}' detected", self.name(), pat),
289 };
290 }
291
292 if let Some(pat) = self.check_extra_patterns(&command) {
293 if self.is_allowed_path(&command) {
294 return VerificationResult::Allow;
295 }
296 return VerificationResult::Block {
297 reason: format!(
298 "[{}] extra destructive pattern '{}' detected",
299 self.name(),
300 pat
301 ),
302 };
303 }
304
305 VerificationResult::Allow
306 }
307}
308
309static INJECTION_BLOCK_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
319 [
320 r"(?i)'\s*OR\s*'1'\s*=\s*'1",
322 r"(?i)'\s*OR\s*1\s*=\s*1",
323 r"(?i);\s*DROP\s+TABLE",
324 r"(?i)UNION\s+SELECT",
325 r"(?i)'\s*;\s*SELECT",
326 r";\s*rm\s+",
328 r"\|\s*rm\s+",
329 r"&&\s*rm\s+",
330 r";\s*curl\s+",
331 r"\|\s*curl\s+",
332 r"&&\s*curl\s+",
333 r";\s*wget\s+",
334 r"\.\./\.\./\.\./etc/passwd",
336 r"\.\./\.\./\.\./etc/shadow",
337 r"\.\./\.\./\.\./windows/",
338 r"\.\.[/\\]\.\.[/\\]\.\.[/\\]",
339 ]
340 .iter()
341 .map(|s| Regex::new(s).expect("static pattern must compile"))
342 .collect()
343});
344
345static SSRF_HOST_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
350 [
351 r"^localhost$",
353 r"^localhost:",
354 r"^127\.0\.0\.1$",
356 r"^127\.0\.0\.1:",
357 r"^\[::1\]$",
359 r"^\[::1\]:",
360 r"^169\.254\.169\.254$",
362 r"^169\.254\.169\.254:",
363 r"^10\.\d+\.\d+\.\d+$",
365 r"^10\.\d+\.\d+\.\d+:",
366 r"^172\.(1[6-9]|2\d|3[01])\.\d+\.\d+$",
367 r"^172\.(1[6-9]|2\d|3[01])\.\d+\.\d+:",
368 r"^192\.168\.\d+\.\d+$",
369 r"^192\.168\.\d+\.\d+:",
370 ]
371 .iter()
372 .map(|s| Regex::new(s).expect("static pattern must compile"))
373 .collect()
374});
375
376fn extract_url_host(url: &str) -> Option<&str> {
380 let after_scheme = url.split_once("://")?.1;
381 let host_end = after_scheme
382 .find(['/', '?', '#'])
383 .unwrap_or(after_scheme.len());
384 Some(&after_scheme[..host_end])
385}
386
387static URL_FIELD_NAMES: &[&str] = &["url", "endpoint", "uri", "href", "src", "host", "base_url"];
389
390static SAFE_QUERY_FIELDS: &[&str] = &["query", "q", "search", "text", "message", "content"];
394
395#[derive(Debug)]
416pub struct InjectionPatternVerifier {
417 extra_patterns: Vec<Regex>,
418 allowlisted_urls: Vec<String>,
419}
420
421impl InjectionPatternVerifier {
422 #[must_use]
423 pub fn new(config: &InjectionVerifierConfig) -> Self {
424 let extra_patterns = config
425 .extra_patterns
426 .iter()
427 .filter_map(|s| match Regex::new(s) {
428 Ok(re) => Some(re),
429 Err(e) => {
430 tracing::warn!(
431 pattern = %s,
432 error = %e,
433 "InjectionPatternVerifier: invalid extra_pattern, skipping"
434 );
435 None
436 }
437 })
438 .collect();
439
440 Self {
441 extra_patterns,
442 allowlisted_urls: config
443 .allowlisted_urls
444 .iter()
445 .map(|s| s.to_lowercase())
446 .collect(),
447 }
448 }
449
450 fn is_allowlisted(&self, text: &str) -> bool {
451 let lower = text.to_lowercase();
452 self.allowlisted_urls
453 .iter()
454 .any(|u| lower.contains(u.as_str()))
455 }
456
457 fn is_url_field(field: &str) -> bool {
458 let lower = field.to_lowercase();
459 URL_FIELD_NAMES.iter().any(|&f| f == lower)
460 }
461
462 fn is_safe_query_field(field: &str) -> bool {
463 let lower = field.to_lowercase();
464 SAFE_QUERY_FIELDS.iter().any(|&f| f == lower)
465 }
466
467 fn check_field_value(&self, field: &str, value: &str) -> VerificationResult {
469 let is_url = Self::is_url_field(field);
470 let is_safe_query = Self::is_safe_query_field(field);
471
472 if !is_safe_query {
474 for pat in INJECTION_BLOCK_PATTERNS.iter() {
475 if pat.is_match(value) {
476 return VerificationResult::Block {
477 reason: format!(
478 "[{}] injection pattern detected in field '{}': {}",
479 "InjectionPatternVerifier",
480 field,
481 pat.as_str()
482 ),
483 };
484 }
485 }
486 for pat in &self.extra_patterns {
487 if pat.is_match(value) {
488 return VerificationResult::Block {
489 reason: format!(
490 "[{}] extra injection pattern detected in field '{}': {}",
491 "InjectionPatternVerifier",
492 field,
493 pat.as_str()
494 ),
495 };
496 }
497 }
498 }
499
500 if is_url && let Some(host) = extract_url_host(value) {
504 for pat in SSRF_HOST_PATTERNS.iter() {
505 if pat.is_match(host) {
506 if self.is_allowlisted(value) {
507 return VerificationResult::Allow;
508 }
509 return VerificationResult::Warn {
510 message: format!(
511 "[{}] possible SSRF in field '{}': host '{}' matches pattern (not blocked)",
512 "InjectionPatternVerifier", field, host,
513 ),
514 };
515 }
516 }
517 }
518
519 VerificationResult::Allow
520 }
521
522 fn check_object(&self, obj: &serde_json::Map<String, serde_json::Value>) -> VerificationResult {
524 for (key, val) in obj {
525 let result = self.check_value(key, val);
526 if !matches!(result, VerificationResult::Allow) {
527 return result;
528 }
529 }
530 VerificationResult::Allow
531 }
532
533 fn check_value(&self, field: &str, val: &serde_json::Value) -> VerificationResult {
534 match val {
535 serde_json::Value::String(s) => self.check_field_value(field, s),
536 serde_json::Value::Array(arr) => {
537 for item in arr {
538 let r = self.check_value(field, item);
539 if !matches!(r, VerificationResult::Allow) {
540 return r;
541 }
542 }
543 VerificationResult::Allow
544 }
545 serde_json::Value::Object(obj) => self.check_object(obj),
546 _ => VerificationResult::Allow,
548 }
549 }
550}
551
552impl PreExecutionVerifier for InjectionPatternVerifier {
553 fn name(&self) -> &'static str {
554 "InjectionPatternVerifier"
555 }
556
557 fn verify(&self, _tool_name: &str, args: &serde_json::Value) -> VerificationResult {
558 match args {
559 serde_json::Value::Object(obj) => self.check_object(obj),
560 serde_json::Value::String(s) => self.check_field_value("_args", s),
562 _ => VerificationResult::Allow,
563 }
564 }
565}
566
567#[derive(Debug, Clone)]
586pub struct UrlGroundingVerifier {
587 guarded_tools: Vec<String>,
588 user_provided_urls: Arc<RwLock<HashSet<String>>>,
589}
590
591impl UrlGroundingVerifier {
592 #[must_use]
593 pub fn new(
594 config: &UrlGroundingVerifierConfig,
595 user_provided_urls: Arc<RwLock<HashSet<String>>>,
596 ) -> Self {
597 Self {
598 guarded_tools: config
599 .guarded_tools
600 .iter()
601 .map(|s| s.to_lowercase())
602 .collect(),
603 user_provided_urls,
604 }
605 }
606
607 fn is_guarded(&self, tool_name: &str) -> bool {
608 let lower = tool_name.to_lowercase();
609 self.guarded_tools.iter().any(|t| t == &lower) || lower.ends_with("_fetch")
610 }
611
612 fn is_grounded(url: &str, user_provided_urls: &HashSet<String>) -> bool {
615 let lower = url.to_lowercase();
616 user_provided_urls
617 .iter()
618 .any(|u| lower.starts_with(u.as_str()) || u.starts_with(lower.as_str()))
619 }
620}
621
622impl PreExecutionVerifier for UrlGroundingVerifier {
623 fn name(&self) -> &'static str {
624 "UrlGroundingVerifier"
625 }
626
627 fn verify(&self, tool_name: &str, args: &serde_json::Value) -> VerificationResult {
628 if !self.is_guarded(tool_name) {
629 return VerificationResult::Allow;
630 }
631
632 let Some(url) = args.get("url").and_then(|v| v.as_str()) else {
633 return VerificationResult::Allow;
634 };
635
636 let urls = self.user_provided_urls.read();
637
638 if Self::is_grounded(url, &urls) {
639 return VerificationResult::Allow;
640 }
641
642 VerificationResult::Block {
643 reason: format!(
644 "[UrlGroundingVerifier] fetch rejected: URL '{url}' was not provided by the user",
645 ),
646 }
647 }
648}
649
650#[derive(Debug)]
667pub struct FirewallVerifier {
668 blocked_path_globs: Vec<glob::Pattern>,
669 blocked_env_vars: HashSet<String>,
670 exempt_tools: HashSet<String>,
671}
672
673static SENSITIVE_PATH_PATTERNS: LazyLock<Vec<glob::Pattern>> = LazyLock::new(|| {
675 let raw = [
676 "/etc/passwd",
677 "/etc/shadow",
678 "/etc/sudoers",
679 "~/.ssh/*",
680 "~/.aws/*",
681 "~/.gnupg/*",
682 "**/*.pem",
683 "**/*.key",
684 "**/id_rsa",
685 "**/id_ed25519",
686 "**/.env",
687 "**/credentials",
688 ];
689 raw.iter()
690 .filter_map(|p| {
691 glob::Pattern::new(p)
692 .map_err(|e| {
693 tracing::error!(pattern = p, error = %e, "failed to compile built-in firewall path pattern");
694 e
695 })
696 .ok()
697 })
698 .collect()
699});
700
701static SENSITIVE_ENV_PREFIXES: &[&str] =
703 &["$AWS_", "$ZEPH_", "${AWS_", "${ZEPH_", "%AWS_", "%ZEPH_"];
704
705static INSPECTED_FIELDS: &[&str] = &[
707 "command",
708 "file_path",
709 "path",
710 "url",
711 "query",
712 "uri",
713 "input",
714 "args",
715];
716
717impl FirewallVerifier {
718 #[must_use]
722 pub fn new(config: &FirewallVerifierConfig) -> Self {
723 let blocked_path_globs = config
724 .blocked_paths
725 .iter()
726 .filter_map(|p| {
727 glob::Pattern::new(p)
728 .map_err(|e| {
729 tracing::warn!(pattern = p, error = %e, "invalid glob pattern in firewall blocked_paths, skipping");
730 e
731 })
732 .ok()
733 })
734 .collect();
735
736 let blocked_env_vars = config
737 .blocked_env_vars
738 .iter()
739 .map(|s| s.to_uppercase())
740 .collect();
741
742 let exempt_tools = config
743 .exempt_tools
744 .iter()
745 .map(|s| s.to_lowercase())
746 .collect();
747
748 Self {
749 blocked_path_globs,
750 blocked_env_vars,
751 exempt_tools,
752 }
753 }
754
755 fn collect_args(args: &serde_json::Value) -> Vec<String> {
757 let mut out = Vec::new();
758 match args {
759 serde_json::Value::Object(map) => {
760 for field in INSPECTED_FIELDS {
761 if let Some(val) = map.get(*field) {
762 Self::collect_strings(val, &mut out);
763 }
764 }
765 }
766 serde_json::Value::String(s) => out.push(s.clone()),
767 _ => {}
768 }
769 out
770 }
771
772 fn collect_strings(val: &serde_json::Value, out: &mut Vec<String>) {
773 match val {
774 serde_json::Value::String(s) => out.push(s.clone()),
775 serde_json::Value::Array(arr) => {
776 for item in arr {
777 Self::collect_strings(item, out);
778 }
779 }
780 _ => {}
781 }
782 }
783
784 fn scan_arg(&self, arg: &str) -> Option<VerificationResult> {
785 let normalized: String = arg.nfkc().collect();
787 let lower = normalized.to_lowercase();
788
789 if lower.contains("../") || lower.contains("..\\") {
791 return Some(VerificationResult::Block {
792 reason: format!(
793 "[FirewallVerifier] path traversal pattern detected in argument: {arg}"
794 ),
795 });
796 }
797
798 for pattern in SENSITIVE_PATH_PATTERNS.iter() {
800 if pattern.matches(&normalized) || pattern.matches(&lower) {
801 return Some(VerificationResult::Block {
802 reason: format!(
803 "[FirewallVerifier] sensitive path pattern '{pattern}' matched in argument: {arg}"
804 ),
805 });
806 }
807 }
808
809 for pattern in &self.blocked_path_globs {
811 if pattern.matches(&normalized) || pattern.matches(&lower) {
812 return Some(VerificationResult::Block {
813 reason: format!(
814 "[FirewallVerifier] blocked path pattern '{pattern}' matched in argument: {arg}"
815 ),
816 });
817 }
818 }
819
820 let upper = normalized.to_uppercase();
822 for prefix in SENSITIVE_ENV_PREFIXES {
823 if upper.contains(*prefix) {
824 return Some(VerificationResult::Block {
825 reason: format!(
826 "[FirewallVerifier] env var exfiltration pattern '{prefix}' detected in argument: {arg}"
827 ),
828 });
829 }
830 }
831
832 for var in &self.blocked_env_vars {
834 let dollar_form = format!("${var}");
835 let brace_form = format!("${{{var}}}");
836 let percent_form = format!("%{var}%");
837 if upper.contains(&dollar_form)
838 || upper.contains(&brace_form)
839 || upper.contains(&percent_form)
840 {
841 return Some(VerificationResult::Block {
842 reason: format!(
843 "[FirewallVerifier] blocked env var '{var}' detected in argument: {arg}"
844 ),
845 });
846 }
847 }
848
849 None
850 }
851}
852
853impl PreExecutionVerifier for FirewallVerifier {
854 fn name(&self) -> &'static str {
855 "FirewallVerifier"
856 }
857
858 fn verify(&self, tool_name: &str, args: &serde_json::Value) -> VerificationResult {
859 if self.exempt_tools.contains(&tool_name.to_lowercase()) {
860 return VerificationResult::Allow;
861 }
862
863 for arg in Self::collect_args(args) {
864 if let Some(result) = self.scan_arg(&arg) {
865 return result;
866 }
867 }
868
869 VerificationResult::Allow
870 }
871}
872
873#[cfg(test)]
878mod tests {
879 use serde_json::json;
880 use std::assert_matches;
881
882 use super::*;
883
884 fn dcv() -> DestructiveCommandVerifier {
887 DestructiveCommandVerifier::new(&DestructiveVerifierConfig::default())
888 }
889
890 #[test]
891 fn allow_normal_command() {
892 let v = dcv();
893 assert_eq!(
894 v.verify("bash", &json!({"command": "ls -la /tmp"})),
895 VerificationResult::Allow
896 );
897 }
898
899 #[test]
900 fn block_rm_rf_root() {
901 let v = dcv();
902 let result = v.verify("bash", &json!({"command": "rm -rf /"}));
903 assert_matches!(result, VerificationResult::Block { .. });
904 }
905
906 #[test]
907 fn block_dd_dev_zero() {
908 let v = dcv();
909 let result = v.verify("bash", &json!({"command": "dd if=/dev/zero of=/dev/sda"}));
910 assert_matches!(result, VerificationResult::Block { .. });
911 }
912
913 #[test]
914 fn block_mkfs() {
915 let v = dcv();
916 let result = v.verify("bash", &json!({"command": "mkfs.ext4 /dev/sda1"}));
917 assert_matches!(result, VerificationResult::Block { .. });
918 }
919
920 #[test]
921 fn allow_rm_rf_in_allowed_path() {
922 let config = DestructiveVerifierConfig {
923 allowed_paths: vec!["/tmp/build".to_string()],
924 ..Default::default()
925 };
926 let v = DestructiveCommandVerifier::new(&config);
927 assert_eq!(
928 v.verify("bash", &json!({"command": "rm -rf /tmp/build/artifacts"})),
929 VerificationResult::Allow
930 );
931 }
932
933 #[test]
934 fn block_rm_rf_when_not_in_allowed_path() {
935 let config = DestructiveVerifierConfig {
936 allowed_paths: vec!["/tmp/build".to_string()],
937 ..Default::default()
938 };
939 let v = DestructiveCommandVerifier::new(&config);
940 let result = v.verify("bash", &json!({"command": "rm -rf /home/user"}));
941 assert_matches!(result, VerificationResult::Block { .. });
942 }
943
944 #[test]
945 fn allow_non_shell_tool() {
946 let v = dcv();
947 assert_eq!(
948 v.verify("read_file", &json!({"path": "rm -rf /"})),
949 VerificationResult::Allow
950 );
951 }
952
953 #[test]
954 fn block_extra_pattern() {
955 let config = DestructiveVerifierConfig {
956 extra_patterns: vec!["format c:".to_string()],
957 ..Default::default()
958 };
959 let v = DestructiveCommandVerifier::new(&config);
960 let result = v.verify("bash", &json!({"command": "format c:"}));
961 assert_matches!(result, VerificationResult::Block { .. });
962 }
963
964 #[test]
965 fn array_args_normalization() {
966 let v = dcv();
967 let result = v.verify("bash", &json!({"command": ["rm", "-rf", "/"]}));
968 assert_matches!(result, VerificationResult::Block { .. });
969 }
970
971 #[test]
972 fn sh_c_wrapping_normalization() {
973 let v = dcv();
974 let result = v.verify("bash", &json!({"command": "bash -c 'rm -rf /'"}));
975 assert_matches!(result, VerificationResult::Block { .. });
976 }
977
978 #[test]
979 fn fork_bomb_blocked() {
980 let v = dcv();
981 let result = v.verify("bash", &json!({"command": ":(){ :|:& };:"}));
982 assert_matches!(result, VerificationResult::Block { .. });
983 }
984
985 #[test]
986 fn custom_shell_tool_name_blocked() {
987 let config = DestructiveVerifierConfig {
988 shell_tools: vec!["execute".to_string(), "run_command".to_string()],
989 ..Default::default()
990 };
991 let v = DestructiveCommandVerifier::new(&config);
992 let result = v.verify("execute", &json!({"command": "rm -rf /"}));
993 assert_matches!(result, VerificationResult::Block { .. });
994 }
995
996 #[test]
997 fn terminal_tool_name_blocked_by_default() {
998 let v = dcv();
999 let result = v.verify("terminal", &json!({"command": "rm -rf /"}));
1000 assert_matches!(result, VerificationResult::Block { .. });
1001 }
1002
1003 #[test]
1004 fn default_shell_tools_contains_bash_shell_terminal() {
1005 let config = DestructiveVerifierConfig::default();
1006 let lower: Vec<String> = config
1007 .shell_tools
1008 .iter()
1009 .map(|s| s.to_lowercase())
1010 .collect();
1011 assert!(lower.contains(&"bash".to_string()));
1012 assert!(lower.contains(&"shell".to_string()));
1013 assert!(lower.contains(&"terminal".to_string()));
1014 }
1015
1016 fn ipv() -> InjectionPatternVerifier {
1019 InjectionPatternVerifier::new(&InjectionVerifierConfig::default())
1020 }
1021
1022 #[test]
1023 fn allow_clean_args() {
1024 let v = ipv();
1025 assert_eq!(
1026 v.verify("search", &json!({"query": "rust async traits"})),
1027 VerificationResult::Allow
1028 );
1029 }
1030
1031 #[test]
1032 fn allow_sql_discussion_in_query_field() {
1033 let v = ipv();
1035 assert_eq!(
1036 v.verify(
1037 "memory_search",
1038 &json!({"query": "explain SQL UNION SELECT vs JOIN"})
1039 ),
1040 VerificationResult::Allow
1041 );
1042 }
1043
1044 #[test]
1045 fn allow_sql_or_pattern_in_query_field() {
1046 let v = ipv();
1048 assert_eq!(
1049 v.verify("memory_search", &json!({"query": "' OR '1'='1"})),
1050 VerificationResult::Allow
1051 );
1052 }
1053
1054 #[test]
1055 fn block_sql_injection_in_non_query_field() {
1056 let v = ipv();
1057 let result = v.verify("db_query", &json!({"sql": "' OR '1'='1"}));
1058 assert_matches!(result, VerificationResult::Block { .. });
1059 }
1060
1061 #[test]
1062 fn block_drop_table() {
1063 let v = ipv();
1064 let result = v.verify("db_query", &json!({"input": "name'; DROP TABLE users"}));
1065 assert_matches!(result, VerificationResult::Block { .. });
1066 }
1067
1068 #[test]
1069 fn block_path_traversal() {
1070 let v = ipv();
1071 let result = v.verify("read_file", &json!({"path": "../../../etc/passwd"}));
1072 assert_matches!(result, VerificationResult::Block { .. });
1073 }
1074
1075 #[test]
1076 fn warn_on_localhost_url_field() {
1077 let v = ipv();
1079 let result = v.verify("http_get", &json!({"url": "http://localhost:8080/api"}));
1080 assert_matches!(result, VerificationResult::Warn { .. });
1081 }
1082
1083 #[test]
1084 fn allow_localhost_in_non_url_field() {
1085 let v = ipv();
1087 assert_eq!(
1088 v.verify(
1089 "memory_search",
1090 &json!({"query": "connect to http://localhost:8080"})
1091 ),
1092 VerificationResult::Allow
1093 );
1094 }
1095
1096 #[test]
1097 fn warn_on_private_ip_url_field() {
1098 let v = ipv();
1099 let result = v.verify("fetch", &json!({"url": "http://192.168.1.1/admin"}));
1100 assert_matches!(result, VerificationResult::Warn { .. });
1101 }
1102
1103 #[test]
1104 fn allow_localhost_when_allowlisted() {
1105 let config = InjectionVerifierConfig {
1106 allowlisted_urls: vec!["http://localhost:3000".to_string()],
1107 ..Default::default()
1108 };
1109 let v = InjectionPatternVerifier::new(&config);
1110 assert_eq!(
1111 v.verify("http_get", &json!({"url": "http://localhost:3000/api"})),
1112 VerificationResult::Allow
1113 );
1114 }
1115
1116 #[test]
1117 fn block_union_select_in_non_query_field() {
1118 let v = ipv();
1119 let result = v.verify(
1120 "db_query",
1121 &json!({"input": "id=1 UNION SELECT password FROM users"}),
1122 );
1123 assert_matches!(result, VerificationResult::Block { .. });
1124 }
1125
1126 #[test]
1127 fn allow_union_select_in_query_field() {
1128 let v = ipv();
1130 assert_eq!(
1131 v.verify(
1132 "memory_search",
1133 &json!({"query": "id=1 UNION SELECT password FROM users"})
1134 ),
1135 VerificationResult::Allow
1136 );
1137 }
1138
1139 #[test]
1142 fn block_rm_rf_unicode_homoglyph() {
1143 let v = dcv();
1145 let result = v.verify("bash", &json!({"command": "rm -rf \u{FF0F}"}));
1147 assert_matches!(result, VerificationResult::Block { .. });
1148 }
1149
1150 #[test]
1153 fn path_traversal_not_allowed_via_dotdot() {
1154 let config = DestructiveVerifierConfig {
1156 allowed_paths: vec!["/tmp/build".to_string()],
1157 ..Default::default()
1158 };
1159 let v = DestructiveCommandVerifier::new(&config);
1160 let result = v.verify("bash", &json!({"command": "rm -rf /tmp/build/../../etc"}));
1162 assert_matches!(result, VerificationResult::Block { .. });
1163 }
1164
1165 #[test]
1166 fn allowed_path_with_dotdot_stays_in_allowed() {
1167 let config = DestructiveVerifierConfig {
1169 allowed_paths: vec!["/tmp/build".to_string()],
1170 ..Default::default()
1171 };
1172 let v = DestructiveCommandVerifier::new(&config);
1173 assert_eq!(
1174 v.verify(
1175 "bash",
1176 &json!({"command": "rm -rf /tmp/build/sub/../artifacts"}),
1177 ),
1178 VerificationResult::Allow,
1179 );
1180 }
1181
1182 #[test]
1185 fn double_nested_bash_c_blocked() {
1186 let v = dcv();
1187 let result = v.verify(
1188 "bash",
1189 &json!({"command": "bash -c \"bash -c 'rm -rf /'\""}),
1190 );
1191 assert_matches!(result, VerificationResult::Block { .. });
1192 }
1193
1194 #[test]
1195 fn env_prefix_stripping_blocked() {
1196 let v = dcv();
1197 let result = v.verify(
1198 "bash",
1199 &json!({"command": "env FOO=bar bash -c 'rm -rf /'"}),
1200 );
1201 assert_matches!(result, VerificationResult::Block { .. });
1202 }
1203
1204 #[test]
1205 fn exec_prefix_stripping_blocked() {
1206 let v = dcv();
1207 let result = v.verify("bash", &json!({"command": "exec bash -c 'rm -rf /'"}));
1208 assert_matches!(result, VerificationResult::Block { .. });
1209 }
1210
1211 #[test]
1214 fn ssrf_not_triggered_for_embedded_localhost_in_query_param() {
1215 let v = ipv();
1217 let result = v.verify(
1218 "http_get",
1219 &json!({"url": "http://evil.com/?r=http://localhost"}),
1220 );
1221 assert_eq!(result, VerificationResult::Allow);
1223 }
1224
1225 #[test]
1226 fn ssrf_triggered_for_bare_localhost_no_port() {
1227 let v = ipv();
1229 let result = v.verify("http_get", &json!({"url": "http://localhost"}));
1230 assert_matches!(result, VerificationResult::Warn { .. });
1231 }
1232
1233 #[test]
1234 fn ssrf_triggered_for_localhost_with_path() {
1235 let v = ipv();
1236 let result = v.verify("http_get", &json!({"url": "http://localhost/api/v1"}));
1237 assert_matches!(result, VerificationResult::Warn { .. });
1238 }
1239
1240 #[test]
1243 fn chain_first_block_wins() {
1244 let dcv = DestructiveCommandVerifier::new(&DestructiveVerifierConfig::default());
1245 let ipv = InjectionPatternVerifier::new(&InjectionVerifierConfig::default());
1246 let verifiers: Vec<Box<dyn PreExecutionVerifier>> = vec![Box::new(dcv), Box::new(ipv)];
1247
1248 let args = json!({"command": "rm -rf /"});
1249 let mut result = VerificationResult::Allow;
1250 for v in &verifiers {
1251 result = v.verify("bash", &args);
1252 if matches!(result, VerificationResult::Block { .. }) {
1253 break;
1254 }
1255 }
1256 assert_matches!(result, VerificationResult::Block { .. });
1257 }
1258
1259 #[test]
1260 fn chain_warn_continues() {
1261 let dcv = DestructiveCommandVerifier::new(&DestructiveVerifierConfig::default());
1262 let ipv = InjectionPatternVerifier::new(&InjectionVerifierConfig::default());
1263 let verifiers: Vec<Box<dyn PreExecutionVerifier>> = vec![Box::new(dcv), Box::new(ipv)];
1264
1265 let args = json!({"url": "http://localhost:8080/api"});
1267 let mut got_warn = false;
1268 let mut got_block = false;
1269 for v in &verifiers {
1270 match v.verify("http_get", &args) {
1271 VerificationResult::Block { .. } => {
1272 got_block = true;
1273 break;
1274 }
1275 VerificationResult::Warn { .. } => {
1276 got_warn = true;
1277 }
1278 VerificationResult::Allow => {}
1279 }
1280 }
1281 assert!(got_warn);
1282 assert!(!got_block);
1283 }
1284
1285 fn ugv(urls: &[&str]) -> UrlGroundingVerifier {
1288 let set: HashSet<String> = urls.iter().map(|s| s.to_lowercase()).collect();
1289 UrlGroundingVerifier::new(
1290 &UrlGroundingVerifierConfig::default(),
1291 Arc::new(RwLock::new(set)),
1292 )
1293 }
1294
1295 #[test]
1296 fn url_grounding_allows_user_provided_url() {
1297 let v = ugv(&["https://docs.anthropic.com/models"]);
1298 assert_eq!(
1299 v.verify(
1300 "fetch",
1301 &json!({"url": "https://docs.anthropic.com/models"})
1302 ),
1303 VerificationResult::Allow
1304 );
1305 }
1306
1307 #[test]
1308 fn url_grounding_blocks_hallucinated_url() {
1309 let v = ugv(&["https://example.com/page"]);
1310 let result = v.verify(
1311 "fetch",
1312 &json!({"url": "https://api.anthropic.ai/v1/models"}),
1313 );
1314 assert_matches!(result, VerificationResult::Block { .. });
1315 }
1316
1317 #[test]
1318 fn url_grounding_blocks_when_no_user_urls_at_all() {
1319 let v = ugv(&[]);
1320 let result = v.verify(
1321 "fetch",
1322 &json!({"url": "https://api.anthropic.ai/v1/models"}),
1323 );
1324 assert_matches!(result, VerificationResult::Block { .. });
1325 }
1326
1327 #[test]
1328 fn url_grounding_allows_non_guarded_tool() {
1329 let v = ugv(&[]);
1330 assert_eq!(
1331 v.verify("read_file", &json!({"path": "/etc/hosts"})),
1332 VerificationResult::Allow
1333 );
1334 }
1335
1336 #[test]
1337 fn url_grounding_guards_fetch_suffix_tool() {
1338 let v = ugv(&[]);
1339 let result = v.verify("http_fetch", &json!({"url": "https://evil.com/"}));
1340 assert_matches!(result, VerificationResult::Block { .. });
1341 }
1342
1343 #[test]
1344 fn url_grounding_allows_web_scrape_with_provided_url() {
1345 let v = ugv(&["https://rust-lang.org/"]);
1346 assert_eq!(
1347 v.verify(
1348 "web_scrape",
1349 &json!({"url": "https://rust-lang.org/", "select": "h1"})
1350 ),
1351 VerificationResult::Allow
1352 );
1353 }
1354
1355 #[test]
1356 fn url_grounding_allows_prefix_match() {
1357 let v = ugv(&["https://docs.rs/"]);
1359 assert_eq!(
1360 v.verify(
1361 "fetch",
1362 &json!({"url": "https://docs.rs/tokio/latest/tokio/"})
1363 ),
1364 VerificationResult::Allow
1365 );
1366 }
1367
1368 #[test]
1375 fn reg_2191_hallucinated_api_endpoint_blocked_with_empty_session() {
1376 let v = ugv(&[]);
1378 let result = v.verify(
1379 "fetch",
1380 &json!({"url": "https://api.anthropic.ai/v1/models"}),
1381 );
1382 assert!(
1383 matches!(result, VerificationResult::Block { .. }),
1384 "fetch must be blocked when no user URL was provided — this is the #2191 regression"
1385 );
1386 }
1387
1388 #[test]
1390 fn reg_2191_user_provided_url_allows_fetch() {
1391 let v = ugv(&["https://api.anthropic.com/v1/models"]);
1392 assert_eq!(
1393 v.verify(
1394 "fetch",
1395 &json!({"url": "https://api.anthropic.com/v1/models"}),
1396 ),
1397 VerificationResult::Allow,
1398 "fetch must be allowed when the URL was explicitly provided by the user"
1399 );
1400 }
1401
1402 #[test]
1404 fn reg_2191_web_scrape_hallucinated_url_blocked() {
1405 let v = ugv(&[]);
1406 let result = v.verify(
1407 "web_scrape",
1408 &json!({"url": "https://api.anthropic.ai/v1/models", "select": "body"}),
1409 );
1410 assert!(
1411 matches!(result, VerificationResult::Block { .. }),
1412 "web_scrape must be blocked for hallucinated URL with empty user_provided_urls"
1413 );
1414 }
1415
1416 #[test]
1421 fn reg_2191_empty_url_set_always_blocks_fetch() {
1422 let v = ugv(&[]);
1425 let result = v.verify(
1426 "fetch",
1427 &json!({"url": "https://docs.anthropic.com/something"}),
1428 );
1429 assert_matches!(result, VerificationResult::Block { .. });
1430 }
1431
1432 #[test]
1434 fn reg_2191_case_insensitive_url_match_allows_fetch() {
1435 let v = ugv(&["https://Docs.Anthropic.COM/models"]);
1438 assert_eq!(
1439 v.verify(
1440 "fetch",
1441 &json!({"url": "https://docs.anthropic.com/models/detail"}),
1442 ),
1443 VerificationResult::Allow,
1444 "URL matching must be case-insensitive"
1445 );
1446 }
1447
1448 #[test]
1451 fn reg_2191_mcp_fetch_suffix_tool_blocked_with_empty_session() {
1452 let v = ugv(&[]);
1453 let result = v.verify(
1454 "anthropic_fetch",
1455 &json!({"url": "https://api.anthropic.ai/v1/models"}),
1456 );
1457 assert!(
1458 matches!(result, VerificationResult::Block { .. }),
1459 "MCP tools ending in _fetch must be guarded even if not in guarded_tools list"
1460 );
1461 }
1462
1463 #[test]
1466 fn reg_2191_reverse_prefix_match_allows_fetch() {
1467 let v = ugv(&["https://docs.rs/tokio/latest/tokio/index.html"]);
1470 assert_eq!(
1471 v.verify("fetch", &json!({"url": "https://docs.rs/"})),
1472 VerificationResult::Allow,
1473 "reverse prefix: fetched URL is a prefix of user-provided URL — should be allowed"
1474 );
1475 }
1476
1477 #[test]
1479 fn reg_2191_different_domain_blocked() {
1480 let v = ugv(&["https://docs.rs/"]);
1482 let result = v.verify("fetch", &json!({"url": "https://evil.com/docs.rs/exfil"}));
1483 assert!(
1484 matches!(result, VerificationResult::Block { .. }),
1485 "different domain must not be allowed even if path looks similar"
1486 );
1487 }
1488
1489 #[test]
1491 fn reg_2191_missing_url_field_allows_fetch() {
1492 let v = ugv(&[]);
1495 assert_eq!(
1496 v.verify(
1497 "fetch",
1498 &json!({"endpoint": "https://api.anthropic.ai/v1/models"})
1499 ),
1500 VerificationResult::Allow,
1501 "missing url field must not trigger blocking — only explicit url field is checked"
1502 );
1503 }
1504
1505 #[test]
1507 fn reg_2191_disabled_verifier_allows_all() {
1508 let config = UrlGroundingVerifierConfig {
1509 enabled: false,
1510 ..UrlGroundingVerifierConfig::default()
1511 };
1512 let set: HashSet<String> = HashSet::new();
1516 let v = UrlGroundingVerifier::new(&config, Arc::new(RwLock::new(set)));
1517 let _ = v.verify("fetch", &json!({"url": "https://example.com/"}));
1521 }
1523
1524 fn fwv() -> FirewallVerifier {
1527 FirewallVerifier::new(&FirewallVerifierConfig::default())
1528 }
1529
1530 #[test]
1531 fn firewall_allows_normal_path() {
1532 let v = fwv();
1533 assert_eq!(
1534 v.verify("shell", &json!({"command": "ls /tmp/build"})),
1535 VerificationResult::Allow
1536 );
1537 }
1538
1539 #[test]
1540 fn firewall_blocks_path_traversal() {
1541 let v = fwv();
1542 let result = v.verify("read", &json!({"file_path": "../../etc/passwd"}));
1543 assert!(
1544 matches!(result, VerificationResult::Block { .. }),
1545 "path traversal must be blocked"
1546 );
1547 }
1548
1549 #[test]
1550 fn firewall_blocks_etc_passwd() {
1551 let v = fwv();
1552 let result = v.verify("read", &json!({"file_path": "/etc/passwd"}));
1553 assert!(
1554 matches!(result, VerificationResult::Block { .. }),
1555 "/etc/passwd must be blocked"
1556 );
1557 }
1558
1559 #[test]
1560 fn firewall_blocks_ssh_key() {
1561 let v = fwv();
1562 let result = v.verify("read", &json!({"file_path": "~/.ssh/id_rsa"}));
1563 assert!(
1564 matches!(result, VerificationResult::Block { .. }),
1565 "SSH key path must be blocked"
1566 );
1567 }
1568
1569 #[test]
1570 fn firewall_blocks_aws_env_var() {
1571 let v = fwv();
1572 let result = v.verify("shell", &json!({"command": "echo $AWS_SECRET_ACCESS_KEY"}));
1573 assert!(
1574 matches!(result, VerificationResult::Block { .. }),
1575 "AWS env var exfiltration must be blocked"
1576 );
1577 }
1578
1579 #[test]
1580 fn firewall_blocks_zeph_env_var() {
1581 let v = fwv();
1582 let result = v.verify("shell", &json!({"command": "cat ${ZEPH_CLAUDE_API_KEY}"}));
1583 assert!(
1584 matches!(result, VerificationResult::Block { .. }),
1585 "ZEPH env var exfiltration must be blocked"
1586 );
1587 }
1588
1589 #[test]
1590 fn firewall_exempt_tool_bypasses_check() {
1591 let cfg = FirewallVerifierConfig {
1592 enabled: true,
1593 blocked_paths: vec![],
1594 blocked_env_vars: vec![],
1595 exempt_tools: vec!["read".to_string()],
1596 };
1597 let v = FirewallVerifier::new(&cfg);
1598 assert_eq!(
1600 v.verify("read", &json!({"file_path": "/etc/passwd"})),
1601 VerificationResult::Allow
1602 );
1603 }
1604
1605 #[test]
1606 fn firewall_custom_blocked_path() {
1607 let cfg = FirewallVerifierConfig {
1608 enabled: true,
1609 blocked_paths: vec!["/data/secrets/*".to_string()],
1610 blocked_env_vars: vec![],
1611 exempt_tools: vec![],
1612 };
1613 let v = FirewallVerifier::new(&cfg);
1614 let result = v.verify("read", &json!({"file_path": "/data/secrets/master.key"}));
1615 assert!(
1616 matches!(result, VerificationResult::Block { .. }),
1617 "custom blocked path must be blocked"
1618 );
1619 }
1620
1621 #[test]
1622 fn firewall_custom_blocked_env_var() {
1623 let cfg = FirewallVerifierConfig {
1624 enabled: true,
1625 blocked_paths: vec![],
1626 blocked_env_vars: vec!["MY_SECRET".to_string()],
1627 exempt_tools: vec![],
1628 };
1629 let v = FirewallVerifier::new(&cfg);
1630 let result = v.verify("shell", &json!({"command": "echo $MY_SECRET"}));
1631 assert!(
1632 matches!(result, VerificationResult::Block { .. }),
1633 "custom blocked env var must be blocked"
1634 );
1635 }
1636
1637 #[test]
1638 fn firewall_invalid_glob_is_skipped() {
1639 let cfg = FirewallVerifierConfig {
1641 enabled: true,
1642 blocked_paths: vec!["[invalid-glob".to_string(), "/valid/path/*".to_string()],
1643 blocked_env_vars: vec![],
1644 exempt_tools: vec![],
1645 };
1646 let v = FirewallVerifier::new(&cfg);
1647 let result = v.verify("read", &json!({"path": "/valid/path/file.txt"}));
1649 assert_matches!(result, VerificationResult::Block { .. });
1650 }
1651
1652 #[test]
1653 fn firewall_config_default_deserialization() {
1654 let cfg: FirewallVerifierConfig = toml::from_str("").unwrap();
1655 assert!(cfg.enabled);
1656 assert!(cfg.blocked_paths.is_empty());
1657 assert!(cfg.blocked_env_vars.is_empty());
1658 assert!(cfg.exempt_tools.is_empty());
1659 }
1660}