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