1use std::sync::LazyLock;
26
27use regex::Regex;
28use serde::{Deserialize, Serialize};
29use unicode_normalization::UnicodeNormalization as _;
30
31fn default_true() -> bool {
32 true
33}
34
35fn default_shell_tools() -> Vec<String> {
36 vec![
37 "bash".to_string(),
38 "shell".to_string(),
39 "terminal".to_string(),
40 ]
41}
42
43#[must_use]
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub enum VerificationResult {
47 Allow,
49 Block { reason: String },
51 Warn { message: String },
54}
55
56pub trait PreExecutionVerifier: Send + Sync + std::fmt::Debug {
62 fn verify(&self, tool_name: &str, args: &serde_json::Value) -> VerificationResult;
64
65 fn name(&self) -> &'static str;
67}
68
69#[derive(Debug, Clone, Deserialize, Serialize)]
83pub struct DestructiveVerifierConfig {
84 #[serde(default = "default_true")]
85 pub enabled: bool,
86 #[serde(default)]
89 pub allowed_paths: Vec<String>,
90 #[serde(default)]
92 pub extra_patterns: Vec<String>,
93 #[serde(default = "default_shell_tools")]
96 pub shell_tools: Vec<String>,
97}
98
99impl Default for DestructiveVerifierConfig {
100 fn default() -> Self {
101 Self {
102 enabled: true,
103 allowed_paths: Vec::new(),
104 extra_patterns: Vec::new(),
105 shell_tools: default_shell_tools(),
106 }
107 }
108}
109
110#[derive(Debug, Clone, Deserialize, Serialize)]
112pub struct InjectionVerifierConfig {
113 #[serde(default = "default_true")]
114 pub enabled: bool,
115 #[serde(default)]
118 pub extra_patterns: Vec<String>,
119 #[serde(default)]
121 pub allowlisted_urls: Vec<String>,
122}
123
124impl Default for InjectionVerifierConfig {
125 fn default() -> Self {
126 Self {
127 enabled: true,
128 extra_patterns: Vec::new(),
129 allowlisted_urls: Vec::new(),
130 }
131 }
132}
133
134#[derive(Debug, Clone, Deserialize, Serialize)]
136pub struct PreExecutionVerifierConfig {
137 #[serde(default = "default_true")]
138 pub enabled: bool,
139 #[serde(default)]
140 pub destructive_commands: DestructiveVerifierConfig,
141 #[serde(default)]
142 pub injection_patterns: InjectionVerifierConfig,
143}
144
145impl Default for PreExecutionVerifierConfig {
146 fn default() -> Self {
147 Self {
148 enabled: true,
149 destructive_commands: DestructiveVerifierConfig::default(),
150 injection_patterns: InjectionVerifierConfig::default(),
151 }
152 }
153}
154
155static DESTRUCTIVE_PATTERNS: &[&str] = &[
164 "rm -rf /",
165 "rm -rf ~",
166 "rm -r /",
167 "dd if=",
168 "mkfs",
169 "fdisk",
170 "shred",
171 "wipefs",
172 ":(){ :|:& };:",
173 ":(){:|:&};:",
174 "chmod -r 777 /",
175 "chown -r",
176];
177
178#[derive(Debug)]
186pub struct DestructiveCommandVerifier {
187 shell_tools: Vec<String>,
188 allowed_paths: Vec<String>,
189 extra_patterns: Vec<String>,
190}
191
192impl DestructiveCommandVerifier {
193 #[must_use]
194 pub fn new(config: &DestructiveVerifierConfig) -> Self {
195 Self {
196 shell_tools: config
197 .shell_tools
198 .iter()
199 .map(|s| s.to_lowercase())
200 .collect(),
201 allowed_paths: config
202 .allowed_paths
203 .iter()
204 .map(|s| s.to_lowercase())
205 .collect(),
206 extra_patterns: config
207 .extra_patterns
208 .iter()
209 .map(|s| s.to_lowercase())
210 .collect(),
211 }
212 }
213
214 fn is_shell_tool(&self, tool_name: &str) -> bool {
215 let lower = tool_name.to_lowercase();
216 self.shell_tools.iter().any(|t| t == &lower)
217 }
218
219 fn extract_command(args: &serde_json::Value) -> Option<String> {
229 let raw = match args.get("command") {
230 Some(serde_json::Value::String(s)) => s.clone(),
231 Some(serde_json::Value::Array(arr)) => arr
232 .iter()
233 .filter_map(|v| v.as_str())
234 .collect::<Vec<_>>()
235 .join(" "),
236 _ => return None,
237 };
238 let mut current: String = raw.nfkc().collect::<String>().to_lowercase();
240 for _ in 0..8 {
243 let trimmed = current.trim().to_owned();
244 let after_env = Self::strip_env_prefix(&trimmed);
246 let after_exec = after_env.strip_prefix("exec ").map_or(after_env, str::trim);
248 let mut unwrapped = false;
250 for interp in &["bash -c ", "sh -c ", "zsh -c "] {
251 if let Some(rest) = after_exec.strip_prefix(interp) {
252 let script = rest.trim().trim_matches(|c: char| c == '\'' || c == '"');
253 current.clone_from(&script.to_owned());
254 unwrapped = true;
255 break;
256 }
257 }
258 if !unwrapped {
259 return Some(after_exec.to_owned());
260 }
261 }
262 Some(current)
263 }
264
265 fn strip_env_prefix(cmd: &str) -> &str {
268 let mut rest = cmd;
269 if let Some(after_env) = rest.strip_prefix("env ") {
271 rest = after_env.trim_start();
272 }
273 loop {
275 let mut chars = rest.chars();
277 let key_end = chars
278 .by_ref()
279 .take_while(|c| c.is_alphanumeric() || *c == '_')
280 .count();
281 if key_end == 0 {
282 break;
283 }
284 let remainder = &rest[key_end..];
285 if let Some(after_eq) = remainder.strip_prefix('=') {
286 let val_end = after_eq.find(' ').unwrap_or(after_eq.len());
288 rest = after_eq[val_end..].trim_start();
289 } else {
290 break;
291 }
292 }
293 rest
294 }
295
296 fn is_allowed_path(&self, command: &str) -> bool {
302 if self.allowed_paths.is_empty() {
303 return false;
304 }
305 let tokens: Vec<&str> = command.split_whitespace().collect();
306 for token in &tokens {
307 let t = token.trim_matches(|c| c == '\'' || c == '"');
308 if t.starts_with('/') || t.starts_with('~') || t.starts_with('.') {
309 let normalized = Self::lexical_normalize(std::path::Path::new(t));
310 let n_lower = normalized.to_string_lossy().to_lowercase();
311 if self
312 .allowed_paths
313 .iter()
314 .any(|p| n_lower.starts_with(p.as_str()))
315 {
316 return true;
317 }
318 }
319 }
320 false
321 }
322
323 fn lexical_normalize(p: &std::path::Path) -> std::path::PathBuf {
326 let mut out = std::path::PathBuf::new();
327 for component in p.components() {
328 match component {
329 std::path::Component::ParentDir => {
330 out.pop();
331 }
332 std::path::Component::CurDir => {}
333 other => out.push(other),
334 }
335 }
336 out
337 }
338
339 fn check_patterns(command: &str) -> Option<&'static str> {
340 DESTRUCTIVE_PATTERNS
341 .iter()
342 .find(|&pat| command.contains(pat))
343 .copied()
344 }
345
346 fn check_extra_patterns(&self, command: &str) -> Option<String> {
347 self.extra_patterns
348 .iter()
349 .find(|pat| command.contains(pat.as_str()))
350 .cloned()
351 }
352}
353
354impl PreExecutionVerifier for DestructiveCommandVerifier {
355 fn name(&self) -> &'static str {
356 "DestructiveCommandVerifier"
357 }
358
359 fn verify(&self, tool_name: &str, args: &serde_json::Value) -> VerificationResult {
360 if !self.is_shell_tool(tool_name) {
361 return VerificationResult::Allow;
362 }
363
364 let Some(command) = Self::extract_command(args) else {
365 return VerificationResult::Allow;
366 };
367
368 if let Some(pat) = Self::check_patterns(&command) {
369 if self.is_allowed_path(&command) {
370 return VerificationResult::Allow;
371 }
372 return VerificationResult::Block {
373 reason: format!("[{}] destructive pattern '{}' detected", self.name(), pat),
374 };
375 }
376
377 if let Some(pat) = self.check_extra_patterns(&command) {
378 if self.is_allowed_path(&command) {
379 return VerificationResult::Allow;
380 }
381 return VerificationResult::Block {
382 reason: format!(
383 "[{}] extra destructive pattern '{}' detected",
384 self.name(),
385 pat
386 ),
387 };
388 }
389
390 VerificationResult::Allow
391 }
392}
393
394static INJECTION_BLOCK_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
404 [
405 r"(?i)'\s*OR\s*'1'\s*=\s*'1",
407 r"(?i)'\s*OR\s*1\s*=\s*1",
408 r"(?i);\s*DROP\s+TABLE",
409 r"(?i)UNION\s+SELECT",
410 r"(?i)'\s*;\s*SELECT",
411 r";\s*rm\s+",
413 r"\|\s*rm\s+",
414 r"&&\s*rm\s+",
415 r";\s*curl\s+",
416 r"\|\s*curl\s+",
417 r"&&\s*curl\s+",
418 r";\s*wget\s+",
419 r"\.\./\.\./\.\./etc/passwd",
421 r"\.\./\.\./\.\./etc/shadow",
422 r"\.\./\.\./\.\./windows/",
423 r"\.\.[/\\]\.\.[/\\]\.\.[/\\]",
424 ]
425 .iter()
426 .map(|s| Regex::new(s).expect("static pattern must compile"))
427 .collect()
428});
429
430static SSRF_HOST_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
435 [
436 r"^localhost$",
438 r"^localhost:",
439 r"^127\.0\.0\.1$",
441 r"^127\.0\.0\.1:",
442 r"^\[::1\]$",
444 r"^\[::1\]:",
445 r"^169\.254\.169\.254$",
447 r"^169\.254\.169\.254:",
448 r"^10\.\d+\.\d+\.\d+$",
450 r"^10\.\d+\.\d+\.\d+:",
451 r"^172\.(1[6-9]|2\d|3[01])\.\d+\.\d+$",
452 r"^172\.(1[6-9]|2\d|3[01])\.\d+\.\d+:",
453 r"^192\.168\.\d+\.\d+$",
454 r"^192\.168\.\d+\.\d+:",
455 ]
456 .iter()
457 .map(|s| Regex::new(s).expect("static pattern must compile"))
458 .collect()
459});
460
461fn extract_url_host(url: &str) -> Option<&str> {
465 let after_scheme = url.split_once("://")?.1;
466 let host_end = after_scheme
467 .find(['/', '?', '#'])
468 .unwrap_or(after_scheme.len());
469 Some(&after_scheme[..host_end])
470}
471
472static URL_FIELD_NAMES: &[&str] = &["url", "endpoint", "uri", "href", "src", "host", "base_url"];
474
475static SAFE_QUERY_FIELDS: &[&str] = &["query", "q", "search", "text", "message", "content"];
479
480#[derive(Debug)]
501pub struct InjectionPatternVerifier {
502 extra_patterns: Vec<Regex>,
503 allowlisted_urls: Vec<String>,
504}
505
506impl InjectionPatternVerifier {
507 #[must_use]
508 pub fn new(config: &InjectionVerifierConfig) -> Self {
509 let extra_patterns = config
510 .extra_patterns
511 .iter()
512 .filter_map(|s| match Regex::new(s) {
513 Ok(re) => Some(re),
514 Err(e) => {
515 tracing::warn!(
516 pattern = %s,
517 error = %e,
518 "InjectionPatternVerifier: invalid extra_pattern, skipping"
519 );
520 None
521 }
522 })
523 .collect();
524
525 Self {
526 extra_patterns,
527 allowlisted_urls: config
528 .allowlisted_urls
529 .iter()
530 .map(|s| s.to_lowercase())
531 .collect(),
532 }
533 }
534
535 fn is_allowlisted(&self, text: &str) -> bool {
536 let lower = text.to_lowercase();
537 self.allowlisted_urls
538 .iter()
539 .any(|u| lower.contains(u.as_str()))
540 }
541
542 fn is_url_field(field: &str) -> bool {
543 let lower = field.to_lowercase();
544 URL_FIELD_NAMES.iter().any(|&f| f == lower)
545 }
546
547 fn is_safe_query_field(field: &str) -> bool {
548 let lower = field.to_lowercase();
549 SAFE_QUERY_FIELDS.iter().any(|&f| f == lower)
550 }
551
552 fn check_field_value(&self, field: &str, value: &str) -> VerificationResult {
554 let is_url = Self::is_url_field(field);
555 let is_safe_query = Self::is_safe_query_field(field);
556
557 if !is_safe_query {
559 for pat in INJECTION_BLOCK_PATTERNS.iter() {
560 if pat.is_match(value) {
561 return VerificationResult::Block {
562 reason: format!(
563 "[{}] injection pattern detected in field '{}': {}",
564 "InjectionPatternVerifier",
565 field,
566 pat.as_str()
567 ),
568 };
569 }
570 }
571 for pat in &self.extra_patterns {
572 if pat.is_match(value) {
573 return VerificationResult::Block {
574 reason: format!(
575 "[{}] extra injection pattern detected in field '{}': {}",
576 "InjectionPatternVerifier",
577 field,
578 pat.as_str()
579 ),
580 };
581 }
582 }
583 }
584
585 if is_url && let Some(host) = extract_url_host(value) {
589 for pat in SSRF_HOST_PATTERNS.iter() {
590 if pat.is_match(host) {
591 if self.is_allowlisted(value) {
592 return VerificationResult::Allow;
593 }
594 return VerificationResult::Warn {
595 message: format!(
596 "[{}] possible SSRF in field '{}': host '{}' matches pattern (not blocked)",
597 "InjectionPatternVerifier", field, host,
598 ),
599 };
600 }
601 }
602 }
603
604 VerificationResult::Allow
605 }
606
607 fn check_object(&self, obj: &serde_json::Map<String, serde_json::Value>) -> VerificationResult {
609 for (key, val) in obj {
610 let result = self.check_value(key, val);
611 if !matches!(result, VerificationResult::Allow) {
612 return result;
613 }
614 }
615 VerificationResult::Allow
616 }
617
618 fn check_value(&self, field: &str, val: &serde_json::Value) -> VerificationResult {
619 match val {
620 serde_json::Value::String(s) => self.check_field_value(field, s),
621 serde_json::Value::Array(arr) => {
622 for item in arr {
623 let r = self.check_value(field, item);
624 if !matches!(r, VerificationResult::Allow) {
625 return r;
626 }
627 }
628 VerificationResult::Allow
629 }
630 serde_json::Value::Object(obj) => self.check_object(obj),
631 _ => VerificationResult::Allow,
633 }
634 }
635}
636
637impl PreExecutionVerifier for InjectionPatternVerifier {
638 fn name(&self) -> &'static str {
639 "InjectionPatternVerifier"
640 }
641
642 fn verify(&self, _tool_name: &str, args: &serde_json::Value) -> VerificationResult {
643 match args {
644 serde_json::Value::Object(obj) => self.check_object(obj),
645 serde_json::Value::String(s) => self.check_field_value("_args", s),
647 _ => VerificationResult::Allow,
648 }
649 }
650}
651
652#[cfg(test)]
657mod tests {
658 use serde_json::json;
659
660 use super::*;
661
662 fn dcv() -> DestructiveCommandVerifier {
665 DestructiveCommandVerifier::new(&DestructiveVerifierConfig::default())
666 }
667
668 #[test]
669 fn allow_normal_command() {
670 let v = dcv();
671 assert_eq!(
672 v.verify("bash", &json!({"command": "ls -la /tmp"})),
673 VerificationResult::Allow
674 );
675 }
676
677 #[test]
678 fn block_rm_rf_root() {
679 let v = dcv();
680 let result = v.verify("bash", &json!({"command": "rm -rf /"}));
681 assert!(matches!(result, VerificationResult::Block { .. }));
682 }
683
684 #[test]
685 fn block_dd_dev_zero() {
686 let v = dcv();
687 let result = v.verify("bash", &json!({"command": "dd if=/dev/zero of=/dev/sda"}));
688 assert!(matches!(result, VerificationResult::Block { .. }));
689 }
690
691 #[test]
692 fn block_mkfs() {
693 let v = dcv();
694 let result = v.verify("bash", &json!({"command": "mkfs.ext4 /dev/sda1"}));
695 assert!(matches!(result, VerificationResult::Block { .. }));
696 }
697
698 #[test]
699 fn allow_rm_rf_in_allowed_path() {
700 let config = DestructiveVerifierConfig {
701 allowed_paths: vec!["/tmp/build".to_string()],
702 ..Default::default()
703 };
704 let v = DestructiveCommandVerifier::new(&config);
705 assert_eq!(
706 v.verify("bash", &json!({"command": "rm -rf /tmp/build/artifacts"})),
707 VerificationResult::Allow
708 );
709 }
710
711 #[test]
712 fn block_rm_rf_when_not_in_allowed_path() {
713 let config = DestructiveVerifierConfig {
714 allowed_paths: vec!["/tmp/build".to_string()],
715 ..Default::default()
716 };
717 let v = DestructiveCommandVerifier::new(&config);
718 let result = v.verify("bash", &json!({"command": "rm -rf /home/user"}));
719 assert!(matches!(result, VerificationResult::Block { .. }));
720 }
721
722 #[test]
723 fn allow_non_shell_tool() {
724 let v = dcv();
725 assert_eq!(
726 v.verify("read_file", &json!({"path": "rm -rf /"})),
727 VerificationResult::Allow
728 );
729 }
730
731 #[test]
732 fn block_extra_pattern() {
733 let config = DestructiveVerifierConfig {
734 extra_patterns: vec!["format c:".to_string()],
735 ..Default::default()
736 };
737 let v = DestructiveCommandVerifier::new(&config);
738 let result = v.verify("bash", &json!({"command": "format c:"}));
739 assert!(matches!(result, VerificationResult::Block { .. }));
740 }
741
742 #[test]
743 fn array_args_normalization() {
744 let v = dcv();
745 let result = v.verify("bash", &json!({"command": ["rm", "-rf", "/"]}));
746 assert!(matches!(result, VerificationResult::Block { .. }));
747 }
748
749 #[test]
750 fn sh_c_wrapping_normalization() {
751 let v = dcv();
752 let result = v.verify("bash", &json!({"command": "bash -c 'rm -rf /'"}));
753 assert!(matches!(result, VerificationResult::Block { .. }));
754 }
755
756 #[test]
757 fn fork_bomb_blocked() {
758 let v = dcv();
759 let result = v.verify("bash", &json!({"command": ":(){ :|:& };:"}));
760 assert!(matches!(result, VerificationResult::Block { .. }));
761 }
762
763 #[test]
764 fn custom_shell_tool_name_blocked() {
765 let config = DestructiveVerifierConfig {
766 shell_tools: vec!["execute".to_string(), "run_command".to_string()],
767 ..Default::default()
768 };
769 let v = DestructiveCommandVerifier::new(&config);
770 let result = v.verify("execute", &json!({"command": "rm -rf /"}));
771 assert!(matches!(result, VerificationResult::Block { .. }));
772 }
773
774 #[test]
775 fn terminal_tool_name_blocked_by_default() {
776 let v = dcv();
777 let result = v.verify("terminal", &json!({"command": "rm -rf /"}));
778 assert!(matches!(result, VerificationResult::Block { .. }));
779 }
780
781 #[test]
782 fn default_shell_tools_contains_bash_shell_terminal() {
783 let config = DestructiveVerifierConfig::default();
784 let lower: Vec<String> = config
785 .shell_tools
786 .iter()
787 .map(|s| s.to_lowercase())
788 .collect();
789 assert!(lower.contains(&"bash".to_string()));
790 assert!(lower.contains(&"shell".to_string()));
791 assert!(lower.contains(&"terminal".to_string()));
792 }
793
794 fn ipv() -> InjectionPatternVerifier {
797 InjectionPatternVerifier::new(&InjectionVerifierConfig::default())
798 }
799
800 #[test]
801 fn allow_clean_args() {
802 let v = ipv();
803 assert_eq!(
804 v.verify("search", &json!({"query": "rust async traits"})),
805 VerificationResult::Allow
806 );
807 }
808
809 #[test]
810 fn allow_sql_discussion_in_query_field() {
811 let v = ipv();
813 assert_eq!(
814 v.verify(
815 "memory_search",
816 &json!({"query": "explain SQL UNION SELECT vs JOIN"})
817 ),
818 VerificationResult::Allow
819 );
820 }
821
822 #[test]
823 fn allow_sql_or_pattern_in_query_field() {
824 let v = ipv();
826 assert_eq!(
827 v.verify("memory_search", &json!({"query": "' OR '1'='1"})),
828 VerificationResult::Allow
829 );
830 }
831
832 #[test]
833 fn block_sql_injection_in_non_query_field() {
834 let v = ipv();
835 let result = v.verify("db_query", &json!({"sql": "' OR '1'='1"}));
836 assert!(matches!(result, VerificationResult::Block { .. }));
837 }
838
839 #[test]
840 fn block_drop_table() {
841 let v = ipv();
842 let result = v.verify("db_query", &json!({"input": "name'; DROP TABLE users"}));
843 assert!(matches!(result, VerificationResult::Block { .. }));
844 }
845
846 #[test]
847 fn block_path_traversal() {
848 let v = ipv();
849 let result = v.verify("read_file", &json!({"path": "../../../etc/passwd"}));
850 assert!(matches!(result, VerificationResult::Block { .. }));
851 }
852
853 #[test]
854 fn warn_on_localhost_url_field() {
855 let v = ipv();
857 let result = v.verify("http_get", &json!({"url": "http://localhost:8080/api"}));
858 assert!(matches!(result, VerificationResult::Warn { .. }));
859 }
860
861 #[test]
862 fn allow_localhost_in_non_url_field() {
863 let v = ipv();
865 assert_eq!(
866 v.verify(
867 "memory_search",
868 &json!({"query": "connect to http://localhost:8080"})
869 ),
870 VerificationResult::Allow
871 );
872 }
873
874 #[test]
875 fn warn_on_private_ip_url_field() {
876 let v = ipv();
877 let result = v.verify("fetch", &json!({"url": "http://192.168.1.1/admin"}));
878 assert!(matches!(result, VerificationResult::Warn { .. }));
879 }
880
881 #[test]
882 fn allow_localhost_when_allowlisted() {
883 let config = InjectionVerifierConfig {
884 allowlisted_urls: vec!["http://localhost:3000".to_string()],
885 ..Default::default()
886 };
887 let v = InjectionPatternVerifier::new(&config);
888 assert_eq!(
889 v.verify("http_get", &json!({"url": "http://localhost:3000/api"})),
890 VerificationResult::Allow
891 );
892 }
893
894 #[test]
895 fn block_union_select_in_non_query_field() {
896 let v = ipv();
897 let result = v.verify(
898 "db_query",
899 &json!({"input": "id=1 UNION SELECT password FROM users"}),
900 );
901 assert!(matches!(result, VerificationResult::Block { .. }));
902 }
903
904 #[test]
905 fn allow_union_select_in_query_field() {
906 let v = ipv();
908 assert_eq!(
909 v.verify(
910 "memory_search",
911 &json!({"query": "id=1 UNION SELECT password FROM users"})
912 ),
913 VerificationResult::Allow
914 );
915 }
916
917 #[test]
920 fn block_rm_rf_unicode_homoglyph() {
921 let v = dcv();
923 let result = v.verify("bash", &json!({"command": "rm -rf \u{FF0F}"}));
925 assert!(matches!(result, VerificationResult::Block { .. }));
926 }
927
928 #[test]
931 fn path_traversal_not_allowed_via_dotdot() {
932 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 /tmp/build/../../etc"}));
940 assert!(matches!(result, VerificationResult::Block { .. }));
941 }
942
943 #[test]
944 fn allowed_path_with_dotdot_stays_in_allowed() {
945 let config = DestructiveVerifierConfig {
947 allowed_paths: vec!["/tmp/build".to_string()],
948 ..Default::default()
949 };
950 let v = DestructiveCommandVerifier::new(&config);
951 assert_eq!(
952 v.verify(
953 "bash",
954 &json!({"command": "rm -rf /tmp/build/sub/../artifacts"}),
955 ),
956 VerificationResult::Allow,
957 );
958 }
959
960 #[test]
963 fn double_nested_bash_c_blocked() {
964 let v = dcv();
965 let result = v.verify(
966 "bash",
967 &json!({"command": "bash -c \"bash -c 'rm -rf /'\""}),
968 );
969 assert!(matches!(result, VerificationResult::Block { .. }));
970 }
971
972 #[test]
973 fn env_prefix_stripping_blocked() {
974 let v = dcv();
975 let result = v.verify(
976 "bash",
977 &json!({"command": "env FOO=bar bash -c 'rm -rf /'"}),
978 );
979 assert!(matches!(result, VerificationResult::Block { .. }));
980 }
981
982 #[test]
983 fn exec_prefix_stripping_blocked() {
984 let v = dcv();
985 let result = v.verify("bash", &json!({"command": "exec bash -c 'rm -rf /'"}));
986 assert!(matches!(result, VerificationResult::Block { .. }));
987 }
988
989 #[test]
992 fn ssrf_not_triggered_for_embedded_localhost_in_query_param() {
993 let v = ipv();
995 let result = v.verify(
996 "http_get",
997 &json!({"url": "http://evil.com/?r=http://localhost"}),
998 );
999 assert_eq!(result, VerificationResult::Allow);
1001 }
1002
1003 #[test]
1004 fn ssrf_triggered_for_bare_localhost_no_port() {
1005 let v = ipv();
1007 let result = v.verify("http_get", &json!({"url": "http://localhost"}));
1008 assert!(matches!(result, VerificationResult::Warn { .. }));
1009 }
1010
1011 #[test]
1012 fn ssrf_triggered_for_localhost_with_path() {
1013 let v = ipv();
1014 let result = v.verify("http_get", &json!({"url": "http://localhost/api/v1"}));
1015 assert!(matches!(result, VerificationResult::Warn { .. }));
1016 }
1017
1018 #[test]
1021 fn chain_first_block_wins() {
1022 let dcv = DestructiveCommandVerifier::new(&DestructiveVerifierConfig::default());
1023 let ipv = InjectionPatternVerifier::new(&InjectionVerifierConfig::default());
1024 let verifiers: Vec<Box<dyn PreExecutionVerifier>> = vec![Box::new(dcv), Box::new(ipv)];
1025
1026 let args = json!({"command": "rm -rf /"});
1027 let mut result = VerificationResult::Allow;
1028 for v in &verifiers {
1029 result = v.verify("bash", &args);
1030 if matches!(result, VerificationResult::Block { .. }) {
1031 break;
1032 }
1033 }
1034 assert!(matches!(result, VerificationResult::Block { .. }));
1035 }
1036
1037 #[test]
1038 fn chain_warn_continues() {
1039 let dcv = DestructiveCommandVerifier::new(&DestructiveVerifierConfig::default());
1040 let ipv = InjectionPatternVerifier::new(&InjectionVerifierConfig::default());
1041 let verifiers: Vec<Box<dyn PreExecutionVerifier>> = vec![Box::new(dcv), Box::new(ipv)];
1042
1043 let args = json!({"url": "http://localhost:8080/api"});
1045 let mut got_warn = false;
1046 let mut got_block = false;
1047 for v in &verifiers {
1048 match v.verify("http_get", &args) {
1049 VerificationResult::Block { .. } => {
1050 got_block = true;
1051 break;
1052 }
1053 VerificationResult::Warn { .. } => {
1054 got_warn = true;
1055 }
1056 VerificationResult::Allow => {}
1057 }
1058 }
1059 assert!(got_warn);
1060 assert!(!got_block);
1061 }
1062}