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
881 use super::*;
882
883 fn dcv() -> DestructiveCommandVerifier {
886 DestructiveCommandVerifier::new(&DestructiveVerifierConfig::default())
887 }
888
889 #[test]
890 fn allow_normal_command() {
891 let v = dcv();
892 assert_eq!(
893 v.verify("bash", &json!({"command": "ls -la /tmp"})),
894 VerificationResult::Allow
895 );
896 }
897
898 #[test]
899 fn block_rm_rf_root() {
900 let v = dcv();
901 let result = v.verify("bash", &json!({"command": "rm -rf /"}));
902 assert!(matches!(result, VerificationResult::Block { .. }));
903 }
904
905 #[test]
906 fn block_dd_dev_zero() {
907 let v = dcv();
908 let result = v.verify("bash", &json!({"command": "dd if=/dev/zero of=/dev/sda"}));
909 assert!(matches!(result, VerificationResult::Block { .. }));
910 }
911
912 #[test]
913 fn block_mkfs() {
914 let v = dcv();
915 let result = v.verify("bash", &json!({"command": "mkfs.ext4 /dev/sda1"}));
916 assert!(matches!(result, VerificationResult::Block { .. }));
917 }
918
919 #[test]
920 fn allow_rm_rf_in_allowed_path() {
921 let config = DestructiveVerifierConfig {
922 allowed_paths: vec!["/tmp/build".to_string()],
923 ..Default::default()
924 };
925 let v = DestructiveCommandVerifier::new(&config);
926 assert_eq!(
927 v.verify("bash", &json!({"command": "rm -rf /tmp/build/artifacts"})),
928 VerificationResult::Allow
929 );
930 }
931
932 #[test]
933 fn block_rm_rf_when_not_in_allowed_path() {
934 let config = DestructiveVerifierConfig {
935 allowed_paths: vec!["/tmp/build".to_string()],
936 ..Default::default()
937 };
938 let v = DestructiveCommandVerifier::new(&config);
939 let result = v.verify("bash", &json!({"command": "rm -rf /home/user"}));
940 assert!(matches!(result, VerificationResult::Block { .. }));
941 }
942
943 #[test]
944 fn allow_non_shell_tool() {
945 let v = dcv();
946 assert_eq!(
947 v.verify("read_file", &json!({"path": "rm -rf /"})),
948 VerificationResult::Allow
949 );
950 }
951
952 #[test]
953 fn block_extra_pattern() {
954 let config = DestructiveVerifierConfig {
955 extra_patterns: vec!["format c:".to_string()],
956 ..Default::default()
957 };
958 let v = DestructiveCommandVerifier::new(&config);
959 let result = v.verify("bash", &json!({"command": "format c:"}));
960 assert!(matches!(result, VerificationResult::Block { .. }));
961 }
962
963 #[test]
964 fn array_args_normalization() {
965 let v = dcv();
966 let result = v.verify("bash", &json!({"command": ["rm", "-rf", "/"]}));
967 assert!(matches!(result, VerificationResult::Block { .. }));
968 }
969
970 #[test]
971 fn sh_c_wrapping_normalization() {
972 let v = dcv();
973 let result = v.verify("bash", &json!({"command": "bash -c 'rm -rf /'"}));
974 assert!(matches!(result, VerificationResult::Block { .. }));
975 }
976
977 #[test]
978 fn fork_bomb_blocked() {
979 let v = dcv();
980 let result = v.verify("bash", &json!({"command": ":(){ :|:& };:"}));
981 assert!(matches!(result, VerificationResult::Block { .. }));
982 }
983
984 #[test]
985 fn custom_shell_tool_name_blocked() {
986 let config = DestructiveVerifierConfig {
987 shell_tools: vec!["execute".to_string(), "run_command".to_string()],
988 ..Default::default()
989 };
990 let v = DestructiveCommandVerifier::new(&config);
991 let result = v.verify("execute", &json!({"command": "rm -rf /"}));
992 assert!(matches!(result, VerificationResult::Block { .. }));
993 }
994
995 #[test]
996 fn terminal_tool_name_blocked_by_default() {
997 let v = dcv();
998 let result = v.verify("terminal", &json!({"command": "rm -rf /"}));
999 assert!(matches!(result, VerificationResult::Block { .. }));
1000 }
1001
1002 #[test]
1003 fn default_shell_tools_contains_bash_shell_terminal() {
1004 let config = DestructiveVerifierConfig::default();
1005 let lower: Vec<String> = config
1006 .shell_tools
1007 .iter()
1008 .map(|s| s.to_lowercase())
1009 .collect();
1010 assert!(lower.contains(&"bash".to_string()));
1011 assert!(lower.contains(&"shell".to_string()));
1012 assert!(lower.contains(&"terminal".to_string()));
1013 }
1014
1015 fn ipv() -> InjectionPatternVerifier {
1018 InjectionPatternVerifier::new(&InjectionVerifierConfig::default())
1019 }
1020
1021 #[test]
1022 fn allow_clean_args() {
1023 let v = ipv();
1024 assert_eq!(
1025 v.verify("search", &json!({"query": "rust async traits"})),
1026 VerificationResult::Allow
1027 );
1028 }
1029
1030 #[test]
1031 fn allow_sql_discussion_in_query_field() {
1032 let v = ipv();
1034 assert_eq!(
1035 v.verify(
1036 "memory_search",
1037 &json!({"query": "explain SQL UNION SELECT vs JOIN"})
1038 ),
1039 VerificationResult::Allow
1040 );
1041 }
1042
1043 #[test]
1044 fn allow_sql_or_pattern_in_query_field() {
1045 let v = ipv();
1047 assert_eq!(
1048 v.verify("memory_search", &json!({"query": "' OR '1'='1"})),
1049 VerificationResult::Allow
1050 );
1051 }
1052
1053 #[test]
1054 fn block_sql_injection_in_non_query_field() {
1055 let v = ipv();
1056 let result = v.verify("db_query", &json!({"sql": "' OR '1'='1"}));
1057 assert!(matches!(result, VerificationResult::Block { .. }));
1058 }
1059
1060 #[test]
1061 fn block_drop_table() {
1062 let v = ipv();
1063 let result = v.verify("db_query", &json!({"input": "name'; DROP TABLE users"}));
1064 assert!(matches!(result, VerificationResult::Block { .. }));
1065 }
1066
1067 #[test]
1068 fn block_path_traversal() {
1069 let v = ipv();
1070 let result = v.verify("read_file", &json!({"path": "../../../etc/passwd"}));
1071 assert!(matches!(result, VerificationResult::Block { .. }));
1072 }
1073
1074 #[test]
1075 fn warn_on_localhost_url_field() {
1076 let v = ipv();
1078 let result = v.verify("http_get", &json!({"url": "http://localhost:8080/api"}));
1079 assert!(matches!(result, VerificationResult::Warn { .. }));
1080 }
1081
1082 #[test]
1083 fn allow_localhost_in_non_url_field() {
1084 let v = ipv();
1086 assert_eq!(
1087 v.verify(
1088 "memory_search",
1089 &json!({"query": "connect to http://localhost:8080"})
1090 ),
1091 VerificationResult::Allow
1092 );
1093 }
1094
1095 #[test]
1096 fn warn_on_private_ip_url_field() {
1097 let v = ipv();
1098 let result = v.verify("fetch", &json!({"url": "http://192.168.1.1/admin"}));
1099 assert!(matches!(result, VerificationResult::Warn { .. }));
1100 }
1101
1102 #[test]
1103 fn allow_localhost_when_allowlisted() {
1104 let config = InjectionVerifierConfig {
1105 allowlisted_urls: vec!["http://localhost:3000".to_string()],
1106 ..Default::default()
1107 };
1108 let v = InjectionPatternVerifier::new(&config);
1109 assert_eq!(
1110 v.verify("http_get", &json!({"url": "http://localhost:3000/api"})),
1111 VerificationResult::Allow
1112 );
1113 }
1114
1115 #[test]
1116 fn block_union_select_in_non_query_field() {
1117 let v = ipv();
1118 let result = v.verify(
1119 "db_query",
1120 &json!({"input": "id=1 UNION SELECT password FROM users"}),
1121 );
1122 assert!(matches!(result, VerificationResult::Block { .. }));
1123 }
1124
1125 #[test]
1126 fn allow_union_select_in_query_field() {
1127 let v = ipv();
1129 assert_eq!(
1130 v.verify(
1131 "memory_search",
1132 &json!({"query": "id=1 UNION SELECT password FROM users"})
1133 ),
1134 VerificationResult::Allow
1135 );
1136 }
1137
1138 #[test]
1141 fn block_rm_rf_unicode_homoglyph() {
1142 let v = dcv();
1144 let result = v.verify("bash", &json!({"command": "rm -rf \u{FF0F}"}));
1146 assert!(matches!(result, VerificationResult::Block { .. }));
1147 }
1148
1149 #[test]
1152 fn path_traversal_not_allowed_via_dotdot() {
1153 let config = DestructiveVerifierConfig {
1155 allowed_paths: vec!["/tmp/build".to_string()],
1156 ..Default::default()
1157 };
1158 let v = DestructiveCommandVerifier::new(&config);
1159 let result = v.verify("bash", &json!({"command": "rm -rf /tmp/build/../../etc"}));
1161 assert!(matches!(result, VerificationResult::Block { .. }));
1162 }
1163
1164 #[test]
1165 fn allowed_path_with_dotdot_stays_in_allowed() {
1166 let config = DestructiveVerifierConfig {
1168 allowed_paths: vec!["/tmp/build".to_string()],
1169 ..Default::default()
1170 };
1171 let v = DestructiveCommandVerifier::new(&config);
1172 assert_eq!(
1173 v.verify(
1174 "bash",
1175 &json!({"command": "rm -rf /tmp/build/sub/../artifacts"}),
1176 ),
1177 VerificationResult::Allow,
1178 );
1179 }
1180
1181 #[test]
1184 fn double_nested_bash_c_blocked() {
1185 let v = dcv();
1186 let result = v.verify(
1187 "bash",
1188 &json!({"command": "bash -c \"bash -c 'rm -rf /'\""}),
1189 );
1190 assert!(matches!(result, VerificationResult::Block { .. }));
1191 }
1192
1193 #[test]
1194 fn env_prefix_stripping_blocked() {
1195 let v = dcv();
1196 let result = v.verify(
1197 "bash",
1198 &json!({"command": "env FOO=bar bash -c 'rm -rf /'"}),
1199 );
1200 assert!(matches!(result, VerificationResult::Block { .. }));
1201 }
1202
1203 #[test]
1204 fn exec_prefix_stripping_blocked() {
1205 let v = dcv();
1206 let result = v.verify("bash", &json!({"command": "exec bash -c 'rm -rf /'"}));
1207 assert!(matches!(result, VerificationResult::Block { .. }));
1208 }
1209
1210 #[test]
1213 fn ssrf_not_triggered_for_embedded_localhost_in_query_param() {
1214 let v = ipv();
1216 let result = v.verify(
1217 "http_get",
1218 &json!({"url": "http://evil.com/?r=http://localhost"}),
1219 );
1220 assert_eq!(result, VerificationResult::Allow);
1222 }
1223
1224 #[test]
1225 fn ssrf_triggered_for_bare_localhost_no_port() {
1226 let v = ipv();
1228 let result = v.verify("http_get", &json!({"url": "http://localhost"}));
1229 assert!(matches!(result, VerificationResult::Warn { .. }));
1230 }
1231
1232 #[test]
1233 fn ssrf_triggered_for_localhost_with_path() {
1234 let v = ipv();
1235 let result = v.verify("http_get", &json!({"url": "http://localhost/api/v1"}));
1236 assert!(matches!(result, VerificationResult::Warn { .. }));
1237 }
1238
1239 #[test]
1242 fn chain_first_block_wins() {
1243 let dcv = DestructiveCommandVerifier::new(&DestructiveVerifierConfig::default());
1244 let ipv = InjectionPatternVerifier::new(&InjectionVerifierConfig::default());
1245 let verifiers: Vec<Box<dyn PreExecutionVerifier>> = vec![Box::new(dcv), Box::new(ipv)];
1246
1247 let args = json!({"command": "rm -rf /"});
1248 let mut result = VerificationResult::Allow;
1249 for v in &verifiers {
1250 result = v.verify("bash", &args);
1251 if matches!(result, VerificationResult::Block { .. }) {
1252 break;
1253 }
1254 }
1255 assert!(matches!(result, VerificationResult::Block { .. }));
1256 }
1257
1258 #[test]
1259 fn chain_warn_continues() {
1260 let dcv = DestructiveCommandVerifier::new(&DestructiveVerifierConfig::default());
1261 let ipv = InjectionPatternVerifier::new(&InjectionVerifierConfig::default());
1262 let verifiers: Vec<Box<dyn PreExecutionVerifier>> = vec![Box::new(dcv), Box::new(ipv)];
1263
1264 let args = json!({"url": "http://localhost:8080/api"});
1266 let mut got_warn = false;
1267 let mut got_block = false;
1268 for v in &verifiers {
1269 match v.verify("http_get", &args) {
1270 VerificationResult::Block { .. } => {
1271 got_block = true;
1272 break;
1273 }
1274 VerificationResult::Warn { .. } => {
1275 got_warn = true;
1276 }
1277 VerificationResult::Allow => {}
1278 }
1279 }
1280 assert!(got_warn);
1281 assert!(!got_block);
1282 }
1283
1284 fn ugv(urls: &[&str]) -> UrlGroundingVerifier {
1287 let set: HashSet<String> = urls.iter().map(|s| s.to_lowercase()).collect();
1288 UrlGroundingVerifier::new(
1289 &UrlGroundingVerifierConfig::default(),
1290 Arc::new(RwLock::new(set)),
1291 )
1292 }
1293
1294 #[test]
1295 fn url_grounding_allows_user_provided_url() {
1296 let v = ugv(&["https://docs.anthropic.com/models"]);
1297 assert_eq!(
1298 v.verify(
1299 "fetch",
1300 &json!({"url": "https://docs.anthropic.com/models"})
1301 ),
1302 VerificationResult::Allow
1303 );
1304 }
1305
1306 #[test]
1307 fn url_grounding_blocks_hallucinated_url() {
1308 let v = ugv(&["https://example.com/page"]);
1309 let result = v.verify(
1310 "fetch",
1311 &json!({"url": "https://api.anthropic.ai/v1/models"}),
1312 );
1313 assert!(matches!(result, VerificationResult::Block { .. }));
1314 }
1315
1316 #[test]
1317 fn url_grounding_blocks_when_no_user_urls_at_all() {
1318 let v = ugv(&[]);
1319 let result = v.verify(
1320 "fetch",
1321 &json!({"url": "https://api.anthropic.ai/v1/models"}),
1322 );
1323 assert!(matches!(result, VerificationResult::Block { .. }));
1324 }
1325
1326 #[test]
1327 fn url_grounding_allows_non_guarded_tool() {
1328 let v = ugv(&[]);
1329 assert_eq!(
1330 v.verify("read_file", &json!({"path": "/etc/hosts"})),
1331 VerificationResult::Allow
1332 );
1333 }
1334
1335 #[test]
1336 fn url_grounding_guards_fetch_suffix_tool() {
1337 let v = ugv(&[]);
1338 let result = v.verify("http_fetch", &json!({"url": "https://evil.com/"}));
1339 assert!(matches!(result, VerificationResult::Block { .. }));
1340 }
1341
1342 #[test]
1343 fn url_grounding_allows_web_scrape_with_provided_url() {
1344 let v = ugv(&["https://rust-lang.org/"]);
1345 assert_eq!(
1346 v.verify(
1347 "web_scrape",
1348 &json!({"url": "https://rust-lang.org/", "select": "h1"})
1349 ),
1350 VerificationResult::Allow
1351 );
1352 }
1353
1354 #[test]
1355 fn url_grounding_allows_prefix_match() {
1356 let v = ugv(&["https://docs.rs/"]);
1358 assert_eq!(
1359 v.verify(
1360 "fetch",
1361 &json!({"url": "https://docs.rs/tokio/latest/tokio/"})
1362 ),
1363 VerificationResult::Allow
1364 );
1365 }
1366
1367 #[test]
1374 fn reg_2191_hallucinated_api_endpoint_blocked_with_empty_session() {
1375 let v = ugv(&[]);
1377 let result = v.verify(
1378 "fetch",
1379 &json!({"url": "https://api.anthropic.ai/v1/models"}),
1380 );
1381 assert!(
1382 matches!(result, VerificationResult::Block { .. }),
1383 "fetch must be blocked when no user URL was provided — this is the #2191 regression"
1384 );
1385 }
1386
1387 #[test]
1389 fn reg_2191_user_provided_url_allows_fetch() {
1390 let v = ugv(&["https://api.anthropic.com/v1/models"]);
1391 assert_eq!(
1392 v.verify(
1393 "fetch",
1394 &json!({"url": "https://api.anthropic.com/v1/models"}),
1395 ),
1396 VerificationResult::Allow,
1397 "fetch must be allowed when the URL was explicitly provided by the user"
1398 );
1399 }
1400
1401 #[test]
1403 fn reg_2191_web_scrape_hallucinated_url_blocked() {
1404 let v = ugv(&[]);
1405 let result = v.verify(
1406 "web_scrape",
1407 &json!({"url": "https://api.anthropic.ai/v1/models", "select": "body"}),
1408 );
1409 assert!(
1410 matches!(result, VerificationResult::Block { .. }),
1411 "web_scrape must be blocked for hallucinated URL with empty user_provided_urls"
1412 );
1413 }
1414
1415 #[test]
1420 fn reg_2191_empty_url_set_always_blocks_fetch() {
1421 let v = ugv(&[]);
1424 let result = v.verify(
1425 "fetch",
1426 &json!({"url": "https://docs.anthropic.com/something"}),
1427 );
1428 assert!(matches!(result, VerificationResult::Block { .. }));
1429 }
1430
1431 #[test]
1433 fn reg_2191_case_insensitive_url_match_allows_fetch() {
1434 let v = ugv(&["https://Docs.Anthropic.COM/models"]);
1437 assert_eq!(
1438 v.verify(
1439 "fetch",
1440 &json!({"url": "https://docs.anthropic.com/models/detail"}),
1441 ),
1442 VerificationResult::Allow,
1443 "URL matching must be case-insensitive"
1444 );
1445 }
1446
1447 #[test]
1450 fn reg_2191_mcp_fetch_suffix_tool_blocked_with_empty_session() {
1451 let v = ugv(&[]);
1452 let result = v.verify(
1453 "anthropic_fetch",
1454 &json!({"url": "https://api.anthropic.ai/v1/models"}),
1455 );
1456 assert!(
1457 matches!(result, VerificationResult::Block { .. }),
1458 "MCP tools ending in _fetch must be guarded even if not in guarded_tools list"
1459 );
1460 }
1461
1462 #[test]
1465 fn reg_2191_reverse_prefix_match_allows_fetch() {
1466 let v = ugv(&["https://docs.rs/tokio/latest/tokio/index.html"]);
1469 assert_eq!(
1470 v.verify("fetch", &json!({"url": "https://docs.rs/"})),
1471 VerificationResult::Allow,
1472 "reverse prefix: fetched URL is a prefix of user-provided URL — should be allowed"
1473 );
1474 }
1475
1476 #[test]
1478 fn reg_2191_different_domain_blocked() {
1479 let v = ugv(&["https://docs.rs/"]);
1481 let result = v.verify("fetch", &json!({"url": "https://evil.com/docs.rs/exfil"}));
1482 assert!(
1483 matches!(result, VerificationResult::Block { .. }),
1484 "different domain must not be allowed even if path looks similar"
1485 );
1486 }
1487
1488 #[test]
1490 fn reg_2191_missing_url_field_allows_fetch() {
1491 let v = ugv(&[]);
1494 assert_eq!(
1495 v.verify(
1496 "fetch",
1497 &json!({"endpoint": "https://api.anthropic.ai/v1/models"})
1498 ),
1499 VerificationResult::Allow,
1500 "missing url field must not trigger blocking — only explicit url field is checked"
1501 );
1502 }
1503
1504 #[test]
1506 fn reg_2191_disabled_verifier_allows_all() {
1507 let config = UrlGroundingVerifierConfig {
1508 enabled: false,
1509 ..UrlGroundingVerifierConfig::default()
1510 };
1511 let set: HashSet<String> = HashSet::new();
1515 let v = UrlGroundingVerifier::new(&config, Arc::new(RwLock::new(set)));
1516 let _ = v.verify("fetch", &json!({"url": "https://example.com/"}));
1520 }
1522
1523 fn fwv() -> FirewallVerifier {
1526 FirewallVerifier::new(&FirewallVerifierConfig::default())
1527 }
1528
1529 #[test]
1530 fn firewall_allows_normal_path() {
1531 let v = fwv();
1532 assert_eq!(
1533 v.verify("shell", &json!({"command": "ls /tmp/build"})),
1534 VerificationResult::Allow
1535 );
1536 }
1537
1538 #[test]
1539 fn firewall_blocks_path_traversal() {
1540 let v = fwv();
1541 let result = v.verify("read", &json!({"file_path": "../../etc/passwd"}));
1542 assert!(
1543 matches!(result, VerificationResult::Block { .. }),
1544 "path traversal must be blocked"
1545 );
1546 }
1547
1548 #[test]
1549 fn firewall_blocks_etc_passwd() {
1550 let v = fwv();
1551 let result = v.verify("read", &json!({"file_path": "/etc/passwd"}));
1552 assert!(
1553 matches!(result, VerificationResult::Block { .. }),
1554 "/etc/passwd must be blocked"
1555 );
1556 }
1557
1558 #[test]
1559 fn firewall_blocks_ssh_key() {
1560 let v = fwv();
1561 let result = v.verify("read", &json!({"file_path": "~/.ssh/id_rsa"}));
1562 assert!(
1563 matches!(result, VerificationResult::Block { .. }),
1564 "SSH key path must be blocked"
1565 );
1566 }
1567
1568 #[test]
1569 fn firewall_blocks_aws_env_var() {
1570 let v = fwv();
1571 let result = v.verify("shell", &json!({"command": "echo $AWS_SECRET_ACCESS_KEY"}));
1572 assert!(
1573 matches!(result, VerificationResult::Block { .. }),
1574 "AWS env var exfiltration must be blocked"
1575 );
1576 }
1577
1578 #[test]
1579 fn firewall_blocks_zeph_env_var() {
1580 let v = fwv();
1581 let result = v.verify("shell", &json!({"command": "cat ${ZEPH_CLAUDE_API_KEY}"}));
1582 assert!(
1583 matches!(result, VerificationResult::Block { .. }),
1584 "ZEPH env var exfiltration must be blocked"
1585 );
1586 }
1587
1588 #[test]
1589 fn firewall_exempt_tool_bypasses_check() {
1590 let cfg = FirewallVerifierConfig {
1591 enabled: true,
1592 blocked_paths: vec![],
1593 blocked_env_vars: vec![],
1594 exempt_tools: vec!["read".to_string()],
1595 };
1596 let v = FirewallVerifier::new(&cfg);
1597 assert_eq!(
1599 v.verify("read", &json!({"file_path": "/etc/passwd"})),
1600 VerificationResult::Allow
1601 );
1602 }
1603
1604 #[test]
1605 fn firewall_custom_blocked_path() {
1606 let cfg = FirewallVerifierConfig {
1607 enabled: true,
1608 blocked_paths: vec!["/data/secrets/*".to_string()],
1609 blocked_env_vars: vec![],
1610 exempt_tools: vec![],
1611 };
1612 let v = FirewallVerifier::new(&cfg);
1613 let result = v.verify("read", &json!({"file_path": "/data/secrets/master.key"}));
1614 assert!(
1615 matches!(result, VerificationResult::Block { .. }),
1616 "custom blocked path must be blocked"
1617 );
1618 }
1619
1620 #[test]
1621 fn firewall_custom_blocked_env_var() {
1622 let cfg = FirewallVerifierConfig {
1623 enabled: true,
1624 blocked_paths: vec![],
1625 blocked_env_vars: vec!["MY_SECRET".to_string()],
1626 exempt_tools: vec![],
1627 };
1628 let v = FirewallVerifier::new(&cfg);
1629 let result = v.verify("shell", &json!({"command": "echo $MY_SECRET"}));
1630 assert!(
1631 matches!(result, VerificationResult::Block { .. }),
1632 "custom blocked env var must be blocked"
1633 );
1634 }
1635
1636 #[test]
1637 fn firewall_invalid_glob_is_skipped() {
1638 let cfg = FirewallVerifierConfig {
1640 enabled: true,
1641 blocked_paths: vec!["[invalid-glob".to_string(), "/valid/path/*".to_string()],
1642 blocked_env_vars: vec![],
1643 exempt_tools: vec![],
1644 };
1645 let v = FirewallVerifier::new(&cfg);
1646 let result = v.verify("read", &json!({"path": "/valid/path/file.txt"}));
1648 assert!(matches!(result, VerificationResult::Block { .. }));
1649 }
1650
1651 #[test]
1652 fn firewall_config_default_deserialization() {
1653 let cfg: FirewallVerifierConfig = toml::from_str("").unwrap();
1654 assert!(cfg.enabled);
1655 assert!(cfg.blocked_paths.is_empty());
1656 assert!(cfg.blocked_env_vars.is_empty());
1657 assert!(cfg.exempt_tools.is_empty());
1658 }
1659}