1use std::collections::HashSet;
27use std::sync::{Arc, LazyLock};
28
29use parking_lot::RwLock;
30
31use regex::Regex;
32use unicode_normalization::UnicodeNormalization as _;
33
34use zeph_config::tools::{
35 DestructiveVerifierConfig, FirewallVerifierConfig, InjectionVerifierConfig,
36 UrlGroundingVerifierConfig,
37};
38
39#[non_exhaustive]
40#[must_use]
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum VerificationResult {
44 Allow,
46 Block { reason: String },
48 Warn { message: String },
51}
52
53pub trait PreExecutionVerifier: Send + Sync + std::fmt::Debug {
59 fn verify(&self, tool_name: &str, args: &serde_json::Value) -> VerificationResult;
61
62 fn name(&self) -> &'static str;
64}
65
66static DESTRUCTIVE_PATTERNS: &[&str] = &[
90 "rm -rf /",
91 "rm -rf ~",
92 "rm -r /",
93 "dd if=",
94 "mkfs",
95 "fdisk",
96 "shred",
97 "wipefs",
98 ":(){ :|:& };:",
99 ":(){:|:&};:",
100 "chmod -r 777 /",
101 "chown -r",
102];
103
104#[derive(Debug)]
112pub struct DestructiveCommandVerifier {
113 shell_tools: Vec<String>,
114 allowed_paths: Vec<String>,
115 extra_patterns: Vec<String>,
116}
117
118impl DestructiveCommandVerifier {
119 #[must_use]
120 pub fn new(config: &DestructiveVerifierConfig) -> Self {
121 Self {
122 shell_tools: config
123 .shell_tools
124 .iter()
125 .map(|s| s.to_lowercase())
126 .collect(),
127 allowed_paths: config
128 .allowed_paths
129 .iter()
130 .map(|s| s.to_lowercase())
131 .collect(),
132 extra_patterns: config
133 .extra_patterns
134 .iter()
135 .map(|s| s.to_lowercase())
136 .collect(),
137 }
138 }
139
140 fn is_shell_tool(&self, tool_name: &str) -> bool {
141 let lower = tool_name.to_lowercase();
142 self.shell_tools.iter().any(|t| t == &lower)
143 }
144
145 fn extract_command(args: &serde_json::Value) -> Option<String> {
155 let raw = match args.get("command") {
156 Some(serde_json::Value::String(s)) => s.clone(),
157 Some(serde_json::Value::Array(arr)) => arr
158 .iter()
159 .filter_map(|v| v.as_str())
160 .collect::<Vec<_>>()
161 .join(" "),
162 _ => return None,
163 };
164 let mut current: String = raw.nfkc().collect::<String>().to_lowercase();
166 for _ in 0..8 {
169 let trimmed = current.trim().to_owned();
170 let after_env = Self::strip_env_prefix(&trimmed);
172 let after_exec = after_env.strip_prefix("exec ").map_or(after_env, str::trim);
174 let mut unwrapped = false;
176 for interp in &["bash -c ", "sh -c ", "zsh -c "] {
177 if let Some(rest) = after_exec.strip_prefix(interp) {
178 let script = rest.trim().trim_matches(|c: char| c == '\'' || c == '"');
179 current.clone_from(&script.to_owned());
180 unwrapped = true;
181 break;
182 }
183 }
184 if !unwrapped {
185 return Some(after_exec.to_owned());
186 }
187 }
188 Some(current)
189 }
190
191 fn strip_env_prefix(cmd: &str) -> &str {
194 let mut rest = cmd;
195 if let Some(after_env) = rest.strip_prefix("env ") {
197 rest = after_env.trim_start();
198 }
199 loop {
201 let mut chars = rest.chars();
203 let key_end = chars
204 .by_ref()
205 .take_while(|c| c.is_alphanumeric() || *c == '_')
206 .count();
207 if key_end == 0 {
208 break;
209 }
210 let remainder = &rest[key_end..];
211 if let Some(after_eq) = remainder.strip_prefix('=') {
212 let val_end = after_eq.find(' ').unwrap_or(after_eq.len());
214 rest = after_eq[val_end..].trim_start();
215 } else {
216 break;
217 }
218 }
219 rest
220 }
221
222 fn is_allowed_path(&self, command: &str) -> bool {
228 if self.allowed_paths.is_empty() {
229 return false;
230 }
231 let tokens: Vec<&str> = command.split_whitespace().collect();
232 for token in &tokens {
233 let t = token.trim_matches(|c| c == '\'' || c == '"');
234 if t.starts_with('/') || t.starts_with('~') || t.starts_with('.') {
235 let normalized = Self::lexical_normalize(std::path::Path::new(t));
236 let n_lower = normalized
239 .to_string_lossy()
240 .replace('\\', "/")
241 .to_lowercase();
242 if self
243 .allowed_paths
244 .iter()
245 .any(|p| n_lower.starts_with(p.replace('\\', "/").to_lowercase().as_str()))
246 {
247 return true;
248 }
249 }
250 }
251 false
252 }
253
254 fn lexical_normalize(p: &std::path::Path) -> std::path::PathBuf {
257 let mut out = std::path::PathBuf::new();
258 for component in p.components() {
259 match component {
260 std::path::Component::ParentDir => {
261 out.pop();
262 }
263 std::path::Component::CurDir => {}
264 other => out.push(other),
265 }
266 }
267 out
268 }
269
270 fn check_patterns(command: &str) -> Option<&'static str> {
271 if crate::shell::is_blocked_rm_root_or_home(command) {
272 return Some("rm -rf / (recursive/force targeting root, ~, or $HOME)");
273 }
274 DESTRUCTIVE_PATTERNS
275 .iter()
276 .find(|&pat| command.contains(pat))
277 .copied()
278 }
279
280 fn check_extra_patterns(&self, command: &str) -> Option<String> {
281 self.extra_patterns
282 .iter()
283 .find(|pat| command.contains(pat.as_str()))
284 .cloned()
285 }
286}
287
288impl PreExecutionVerifier for DestructiveCommandVerifier {
289 fn name(&self) -> &'static str {
290 "DestructiveCommandVerifier"
291 }
292
293 fn verify(&self, tool_name: &str, args: &serde_json::Value) -> VerificationResult {
294 if !self.is_shell_tool(tool_name) {
295 return VerificationResult::Allow;
296 }
297
298 let Some(command) = Self::extract_command(args) else {
299 return VerificationResult::Allow;
300 };
301
302 if let Some(pat) = Self::check_patterns(&command) {
303 if self.is_allowed_path(&command) {
304 return VerificationResult::Allow;
305 }
306 return VerificationResult::Block {
307 reason: format!("[{}] destructive pattern '{}' detected", self.name(), pat),
308 };
309 }
310
311 if let Some(pat) = self.check_extra_patterns(&command) {
312 if self.is_allowed_path(&command) {
313 return VerificationResult::Allow;
314 }
315 return VerificationResult::Block {
316 reason: format!(
317 "[{}] extra destructive pattern '{}' detected",
318 self.name(),
319 pat
320 ),
321 };
322 }
323
324 VerificationResult::Allow
325 }
326}
327
328static INJECTION_BLOCK_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
338 [
339 r"(?i)'\s*OR\s*'1'\s*=\s*'1",
341 r"(?i)'\s*OR\s*1\s*=\s*1",
342 r"(?i);\s*DROP\s+TABLE",
343 r"(?i)UNION\s+SELECT",
344 r"(?i)'\s*;\s*SELECT",
345 r";\s*rm\s+",
347 r"\|\s*rm\s+",
348 r"&&\s*rm\s+",
349 r";\s*curl\s+",
350 r"\|\s*curl\s+",
351 r"&&\s*curl\s+",
352 r";\s*wget\s+",
353 r"\.\./\.\./\.\./etc/passwd",
355 r"\.\./\.\./\.\./etc/shadow",
356 r"\.\./\.\./\.\./windows/",
357 r"\.\.[/\\]\.\.[/\\]\.\.[/\\]",
358 ]
359 .iter()
360 .map(|s| Regex::new(s).expect("static pattern must compile"))
361 .collect()
362});
363
364static SSRF_HOST_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
369 [
370 r"^localhost$",
372 r"^localhost:",
373 r"^127\.0\.0\.1$",
375 r"^127\.0\.0\.1:",
376 r"^\[::1\]$",
378 r"^\[::1\]:",
379 r"^169\.254\.169\.254$",
381 r"^169\.254\.169\.254:",
382 r"^10\.\d+\.\d+\.\d+$",
384 r"^10\.\d+\.\d+\.\d+:",
385 r"^172\.(1[6-9]|2\d|3[01])\.\d+\.\d+$",
386 r"^172\.(1[6-9]|2\d|3[01])\.\d+\.\d+:",
387 r"^192\.168\.\d+\.\d+$",
388 r"^192\.168\.\d+\.\d+:",
389 ]
390 .iter()
391 .map(|s| Regex::new(s).expect("static pattern must compile"))
392 .collect()
393});
394
395fn extract_url_host(url: &str) -> Option<&str> {
399 let after_scheme = url.split_once("://")?.1;
400 let host_end = after_scheme
401 .find(['/', '?', '#'])
402 .unwrap_or(after_scheme.len());
403 Some(&after_scheme[..host_end])
404}
405
406static URL_FIELD_NAMES: &[&str] = &["url", "endpoint", "uri", "href", "src", "host", "base_url"];
408
409static SAFE_QUERY_FIELDS: &[&str] = &["query", "q", "search", "text", "message", "content"];
413
414#[derive(Debug)]
435pub struct InjectionPatternVerifier {
436 extra_patterns: Vec<Regex>,
437 allowlisted_urls: Vec<String>,
438}
439
440impl InjectionPatternVerifier {
441 #[must_use]
442 pub fn new(config: &InjectionVerifierConfig) -> Self {
443 let extra_patterns = config
444 .extra_patterns
445 .iter()
446 .filter_map(|s| match Regex::new(s) {
447 Ok(re) => Some(re),
448 Err(e) => {
449 tracing::warn!(
450 pattern = %s,
451 error = %e,
452 "InjectionPatternVerifier: invalid extra_pattern, skipping"
453 );
454 None
455 }
456 })
457 .collect();
458
459 Self {
460 extra_patterns,
461 allowlisted_urls: config
462 .allowlisted_urls
463 .iter()
464 .map(|s| s.to_lowercase())
465 .collect(),
466 }
467 }
468
469 fn is_allowlisted(&self, text: &str) -> bool {
470 let lower = text.to_lowercase();
471 self.allowlisted_urls
472 .iter()
473 .any(|u| lower.contains(u.as_str()))
474 }
475
476 fn is_url_field(field: &str) -> bool {
477 let lower = field.to_lowercase();
478 URL_FIELD_NAMES.iter().any(|&f| f == lower)
479 }
480
481 fn is_safe_query_field(field: &str) -> bool {
482 let lower = field.to_lowercase();
483 SAFE_QUERY_FIELDS.iter().any(|&f| f == lower)
484 }
485
486 fn check_field_value(&self, field: &str, value: &str) -> VerificationResult {
488 let is_url = Self::is_url_field(field);
489 let is_safe_query = Self::is_safe_query_field(field);
490
491 if !is_safe_query {
493 for pat in INJECTION_BLOCK_PATTERNS.iter() {
494 if pat.is_match(value) {
495 return VerificationResult::Block {
496 reason: format!(
497 "[{}] injection pattern detected in field '{}': {}",
498 "InjectionPatternVerifier",
499 field,
500 pat.as_str()
501 ),
502 };
503 }
504 }
505 for pat in &self.extra_patterns {
506 if pat.is_match(value) {
507 return VerificationResult::Block {
508 reason: format!(
509 "[{}] extra injection pattern detected in field '{}': {}",
510 "InjectionPatternVerifier",
511 field,
512 pat.as_str()
513 ),
514 };
515 }
516 }
517 }
518
519 if is_url && let Some(host) = extract_url_host(value) {
523 for pat in SSRF_HOST_PATTERNS.iter() {
524 if pat.is_match(host) {
525 if self.is_allowlisted(value) {
526 return VerificationResult::Allow;
527 }
528 return VerificationResult::Warn {
529 message: format!(
530 "[{}] possible SSRF in field '{}': host '{}' matches pattern (not blocked)",
531 "InjectionPatternVerifier", field, host,
532 ),
533 };
534 }
535 }
536 }
537
538 VerificationResult::Allow
539 }
540
541 fn check_object(
543 &self,
544 obj: &serde_json::Map<String, serde_json::Value>,
545 depth: usize,
546 ) -> VerificationResult {
547 for (key, val) in obj {
548 let result = self.check_value(key, val, depth);
549 if !matches!(result, VerificationResult::Allow) {
550 return result;
551 }
552 }
553 VerificationResult::Allow
554 }
555
556 fn check_value(
557 &self,
558 field: &str,
559 val: &serde_json::Value,
560 depth: usize,
561 ) -> VerificationResult {
562 if depth >= MAX_JSON_DEPTH {
563 tracing::warn!(
564 depth,
565 "check_value: max JSON nesting depth reached, skipping further descent"
566 );
567 return VerificationResult::Allow;
568 }
569 match val {
570 serde_json::Value::String(s) => self.check_field_value(field, s),
571 serde_json::Value::Array(arr) => {
572 for item in arr {
573 let r = self.check_value(field, item, depth + 1);
574 if !matches!(r, VerificationResult::Allow) {
575 return r;
576 }
577 }
578 VerificationResult::Allow
579 }
580 serde_json::Value::Object(obj) => self.check_object(obj, depth + 1),
581 _ => VerificationResult::Allow,
583 }
584 }
585}
586
587impl PreExecutionVerifier for InjectionPatternVerifier {
588 fn name(&self) -> &'static str {
589 "InjectionPatternVerifier"
590 }
591
592 fn verify(&self, _tool_name: &str, args: &serde_json::Value) -> VerificationResult {
593 match args {
594 serde_json::Value::Object(obj) => self.check_object(obj, 0),
595 serde_json::Value::String(s) => self.check_field_value("_args", s),
597 _ => VerificationResult::Allow,
598 }
599 }
600}
601
602#[derive(Debug, Clone)]
621pub struct UrlGroundingVerifier {
622 guarded_tools: Vec<String>,
623 user_provided_urls: Arc<RwLock<HashSet<String>>>,
624}
625
626impl UrlGroundingVerifier {
627 #[must_use]
628 pub fn new(
629 config: &UrlGroundingVerifierConfig,
630 user_provided_urls: Arc<RwLock<HashSet<String>>>,
631 ) -> Self {
632 Self {
633 guarded_tools: config
634 .guarded_tools
635 .iter()
636 .map(|s| s.to_lowercase())
637 .collect(),
638 user_provided_urls,
639 }
640 }
641
642 fn is_guarded(&self, tool_name: &str) -> bool {
643 let lower = tool_name.to_lowercase();
644 self.guarded_tools.iter().any(|t| t == &lower) || lower.ends_with("_fetch")
645 }
646
647 fn is_grounded(url: &str, user_provided_urls: &HashSet<String>) -> bool {
650 let lower = url.to_lowercase();
651 user_provided_urls
652 .iter()
653 .any(|u| lower.starts_with(u.as_str()) || u.starts_with(lower.as_str()))
654 }
655}
656
657impl PreExecutionVerifier for UrlGroundingVerifier {
658 fn name(&self) -> &'static str {
659 "UrlGroundingVerifier"
660 }
661
662 fn verify(&self, tool_name: &str, args: &serde_json::Value) -> VerificationResult {
663 if !self.is_guarded(tool_name) {
664 return VerificationResult::Allow;
665 }
666
667 let Some(url) = args.get("url").and_then(|v| v.as_str()) else {
668 return VerificationResult::Allow;
669 };
670
671 let urls = self.user_provided_urls.read();
672
673 if Self::is_grounded(url, &urls) {
674 return VerificationResult::Allow;
675 }
676
677 VerificationResult::Block {
678 reason: format!(
679 "[UrlGroundingVerifier] fetch rejected: URL '{url}' was not provided by the user",
680 ),
681 }
682 }
683}
684
685#[derive(Debug)]
702pub struct FirewallVerifier {
703 blocked_path_globs: Vec<glob::Pattern>,
704 blocked_env_vars: HashSet<String>,
705 exempt_tools: HashSet<String>,
706}
707
708static SENSITIVE_PATH_PATTERNS: LazyLock<Vec<glob::Pattern>> = LazyLock::new(|| {
710 let raw = [
711 "/etc/passwd",
712 "/etc/shadow",
713 "/etc/sudoers",
714 "~/.ssh/*",
715 "~/.aws/*",
716 "~/.gnupg/*",
717 "**/*.pem",
718 "**/*.key",
719 "**/id_rsa",
720 "**/id_ed25519",
721 "**/.env",
722 "**/credentials",
723 ];
724 raw.iter()
725 .filter_map(|p| {
726 glob::Pattern::new(p)
727 .map_err(|e| {
728 tracing::error!(pattern = p, error = %e, "failed to compile built-in firewall path pattern");
729 e
730 })
731 .ok()
732 })
733 .collect()
734});
735
736static SENSITIVE_ENV_PREFIXES: &[&str] =
738 &["$AWS_", "$ZEPH_", "${AWS_", "${ZEPH_", "%AWS_", "%ZEPH_"];
739
740static INSPECTED_FIELDS: &[&str] = &[
742 "command",
743 "file_path",
744 "path",
745 "url",
746 "query",
747 "uri",
748 "input",
749 "args",
750];
751
752const MAX_JSON_DEPTH: usize = 256;
759
760impl FirewallVerifier {
761 #[must_use]
765 pub fn new(config: &FirewallVerifierConfig) -> Self {
766 let blocked_path_globs = config
767 .blocked_paths
768 .iter()
769 .filter_map(|p| {
770 glob::Pattern::new(p)
771 .map_err(|e| {
772 tracing::warn!(pattern = p, error = %e, "invalid glob pattern in firewall blocked_paths, skipping");
773 e
774 })
775 .ok()
776 })
777 .collect();
778
779 let blocked_env_vars = config
780 .blocked_env_vars
781 .iter()
782 .map(|s| s.to_uppercase())
783 .collect();
784
785 let exempt_tools = config
786 .exempt_tools
787 .iter()
788 .map(|s| s.to_lowercase())
789 .collect();
790
791 Self {
792 blocked_path_globs,
793 blocked_env_vars,
794 exempt_tools,
795 }
796 }
797
798 fn collect_args(args: &serde_json::Value) -> Vec<String> {
800 let mut out = Vec::new();
801 match args {
802 serde_json::Value::Object(map) => {
803 for field in INSPECTED_FIELDS {
804 if let Some(val) = map.get(*field) {
805 Self::collect_strings(val, &mut out, 0);
806 }
807 }
808 }
809 serde_json::Value::String(s) => out.push(s.clone()),
810 _ => {}
811 }
812 out
813 }
814
815 fn collect_strings(val: &serde_json::Value, out: &mut Vec<String>, depth: usize) {
816 if depth >= MAX_JSON_DEPTH {
817 tracing::warn!(
818 depth,
819 "collect_strings: max JSON nesting depth reached, skipping further descent"
820 );
821 return;
822 }
823 match val {
824 serde_json::Value::String(s) => out.push(s.clone()),
825 serde_json::Value::Array(arr) => {
826 for item in arr {
827 Self::collect_strings(item, out, depth + 1);
828 }
829 }
830 _ => {}
831 }
832 }
833
834 fn scan_arg(&self, arg: &str) -> Option<VerificationResult> {
835 let normalized: String = arg.nfkc().collect();
837 let lower = normalized.to_lowercase();
838
839 if lower.contains("../") || lower.contains("..\\") {
841 return Some(VerificationResult::Block {
842 reason: format!(
843 "[FirewallVerifier] path traversal pattern detected in argument: {arg}"
844 ),
845 });
846 }
847
848 for pattern in SENSITIVE_PATH_PATTERNS.iter() {
850 if pattern.matches(&normalized) || pattern.matches(&lower) {
851 return Some(VerificationResult::Block {
852 reason: format!(
853 "[FirewallVerifier] sensitive path pattern '{pattern}' matched in argument: {arg}"
854 ),
855 });
856 }
857 }
858
859 for pattern in &self.blocked_path_globs {
861 if pattern.matches(&normalized) || pattern.matches(&lower) {
862 return Some(VerificationResult::Block {
863 reason: format!(
864 "[FirewallVerifier] blocked path pattern '{pattern}' matched in argument: {arg}"
865 ),
866 });
867 }
868 }
869
870 let upper = normalized.to_uppercase();
872 for prefix in SENSITIVE_ENV_PREFIXES {
873 if upper.contains(*prefix) {
874 return Some(VerificationResult::Block {
875 reason: format!(
876 "[FirewallVerifier] env var exfiltration pattern '{prefix}' detected in argument: {arg}"
877 ),
878 });
879 }
880 }
881
882 for var in &self.blocked_env_vars {
884 let dollar_form = format!("${var}");
885 let brace_form = format!("${{{var}}}");
886 let percent_form = format!("%{var}%");
887 if upper.contains(&dollar_form)
888 || upper.contains(&brace_form)
889 || upper.contains(&percent_form)
890 {
891 return Some(VerificationResult::Block {
892 reason: format!(
893 "[FirewallVerifier] blocked env var '{var}' detected in argument: {arg}"
894 ),
895 });
896 }
897 }
898
899 None
900 }
901}
902
903impl PreExecutionVerifier for FirewallVerifier {
904 fn name(&self) -> &'static str {
905 "FirewallVerifier"
906 }
907
908 fn verify(&self, tool_name: &str, args: &serde_json::Value) -> VerificationResult {
909 if self.exempt_tools.contains(&tool_name.to_lowercase()) {
910 return VerificationResult::Allow;
911 }
912
913 for arg in Self::collect_args(args) {
914 if let Some(result) = self.scan_arg(&arg) {
915 return result;
916 }
917 }
918
919 VerificationResult::Allow
920 }
921}
922
923#[cfg(test)]
928mod tests {
929 use serde_json::json;
930 use std::assert_matches;
931
932 use super::*;
933
934 fn dcv() -> DestructiveCommandVerifier {
937 DestructiveCommandVerifier::new(&DestructiveVerifierConfig::default())
938 }
939
940 #[test]
941 fn allow_normal_command() {
942 let v = dcv();
943 assert_eq!(
944 v.verify("bash", &json!({"command": "ls -la /tmp"})),
945 VerificationResult::Allow
946 );
947 }
948
949 #[test]
950 fn block_rm_rf_root() {
951 let v = dcv();
952 let result = v.verify("bash", &json!({"command": "rm -rf /"}));
953 assert_matches!(result, VerificationResult::Block { .. });
954 }
955
956 #[test]
959 fn block_rm_root_bypass_vectors() {
960 let v = dcv();
961 for cmd in &["rm -fr /", "rm -r -f /", "rm --force /", "rm / -f"] {
962 let result = v.verify("bash", &json!({"command": cmd}));
963 assert_matches!(
964 result,
965 VerificationResult::Block { .. },
966 "expected `{cmd}` to be blocked"
967 );
968 }
969 }
970
971 #[test]
972 fn block_rm_home_env_var() {
973 let v = dcv();
974 let result = v.verify("bash", &json!({"command": "rm -rf \"$HOME\""}));
975 assert_matches!(result, VerificationResult::Block { .. });
976 }
977
978 #[test]
979 fn allow_rm_relative_path() {
980 let v = dcv();
981 assert_eq!(
982 v.verify("bash", &json!({"command": "rm -rf ./some/relative/path"})),
983 VerificationResult::Allow
984 );
985 }
986
987 #[test]
988 fn allow_rm_single_file_force_only() {
989 let v = dcv();
990 assert_eq!(
991 v.verify("bash", &json!({"command": "rm -f /tmp/build/output.log"})),
992 VerificationResult::Allow
993 );
994 }
995
996 #[test]
1005 fn block_chained_rm_root() {
1006 let v = dcv();
1007 for cmd in &["cd /tmp && rm -rf /", "echo hi; rm -rf /"] {
1008 let result = v.verify("bash", &json!({"command": cmd}));
1009 assert_matches!(
1010 result,
1011 VerificationResult::Block { .. },
1012 "expected `{cmd}` to be blocked"
1013 );
1014 }
1015 }
1016
1017 #[test]
1018 fn block_prefixed_rm_root() {
1019 let v = dcv();
1020 for cmd in &["sudo rm -rf /", "env rm -rf /"] {
1021 let result = v.verify("bash", &json!({"command": cmd}));
1022 assert_matches!(
1023 result,
1024 VerificationResult::Block { .. },
1025 "expected `{cmd}` to be blocked"
1026 );
1027 }
1028 }
1029
1030 #[test]
1031 fn block_recursive_only_on_system_path() {
1032 let v = dcv();
1033 for cmd in &["rm -r /etc", "rm -r /usr", "rm -r /var"] {
1034 let result = v.verify("bash", &json!({"command": cmd}));
1035 assert_matches!(
1036 result,
1037 VerificationResult::Block { .. },
1038 "expected `{cmd}` to be blocked"
1039 );
1040 }
1041 }
1042
1043 #[test]
1044 fn block_rm_rf_home_subpath() {
1045 let v = dcv();
1046 for cmd in &["rm -rf ~/Documents", "rm -rf ~/.ssh"] {
1047 let result = v.verify("bash", &json!({"command": cmd}));
1048 assert_matches!(
1049 result,
1050 VerificationResult::Block { .. },
1051 "expected `{cmd}` to be blocked"
1052 );
1053 }
1054 }
1055
1056 #[test]
1057 fn block_dd_dev_zero() {
1058 let v = dcv();
1059 let result = v.verify("bash", &json!({"command": "dd if=/dev/zero of=/dev/sda"}));
1060 assert_matches!(result, VerificationResult::Block { .. });
1061 }
1062
1063 #[test]
1064 fn block_mkfs() {
1065 let v = dcv();
1066 let result = v.verify("bash", &json!({"command": "mkfs.ext4 /dev/sda1"}));
1067 assert_matches!(result, VerificationResult::Block { .. });
1068 }
1069
1070 #[test]
1071 fn allow_rm_rf_in_allowed_path() {
1072 let config = DestructiveVerifierConfig {
1073 allowed_paths: vec!["/tmp/build".to_string()],
1074 ..Default::default()
1075 };
1076 let v = DestructiveCommandVerifier::new(&config);
1077 assert_eq!(
1078 v.verify("bash", &json!({"command": "rm -rf /tmp/build/artifacts"})),
1079 VerificationResult::Allow
1080 );
1081 }
1082
1083 #[test]
1084 fn block_rm_rf_when_not_in_allowed_path() {
1085 let config = DestructiveVerifierConfig {
1086 allowed_paths: vec!["/tmp/build".to_string()],
1087 ..Default::default()
1088 };
1089 let v = DestructiveCommandVerifier::new(&config);
1090 let result = v.verify("bash", &json!({"command": "rm -rf /home/user"}));
1091 assert_matches!(result, VerificationResult::Block { .. });
1092 }
1093
1094 #[test]
1095 fn allow_non_shell_tool() {
1096 let v = dcv();
1097 assert_eq!(
1098 v.verify("read_file", &json!({"path": "rm -rf /"})),
1099 VerificationResult::Allow
1100 );
1101 }
1102
1103 #[test]
1104 fn block_extra_pattern() {
1105 let config = DestructiveVerifierConfig {
1106 extra_patterns: vec!["format c:".to_string()],
1107 ..Default::default()
1108 };
1109 let v = DestructiveCommandVerifier::new(&config);
1110 let result = v.verify("bash", &json!({"command": "format c:"}));
1111 assert_matches!(result, VerificationResult::Block { .. });
1112 }
1113
1114 #[test]
1115 fn array_args_normalization() {
1116 let v = dcv();
1117 let result = v.verify("bash", &json!({"command": ["rm", "-rf", "/"]}));
1118 assert_matches!(result, VerificationResult::Block { .. });
1119 }
1120
1121 #[test]
1122 fn sh_c_wrapping_normalization() {
1123 let v = dcv();
1124 let result = v.verify("bash", &json!({"command": "bash -c 'rm -rf /'"}));
1125 assert_matches!(result, VerificationResult::Block { .. });
1126 }
1127
1128 #[test]
1129 fn fork_bomb_blocked() {
1130 let v = dcv();
1131 let result = v.verify("bash", &json!({"command": ":(){ :|:& };:"}));
1132 assert_matches!(result, VerificationResult::Block { .. });
1133 }
1134
1135 #[test]
1136 fn custom_shell_tool_name_blocked() {
1137 let config = DestructiveVerifierConfig {
1138 shell_tools: vec!["execute".to_string(), "run_command".to_string()],
1139 ..Default::default()
1140 };
1141 let v = DestructiveCommandVerifier::new(&config);
1142 let result = v.verify("execute", &json!({"command": "rm -rf /"}));
1143 assert_matches!(result, VerificationResult::Block { .. });
1144 }
1145
1146 #[test]
1147 fn terminal_tool_name_blocked_by_default() {
1148 let v = dcv();
1149 let result = v.verify("terminal", &json!({"command": "rm -rf /"}));
1150 assert_matches!(result, VerificationResult::Block { .. });
1151 }
1152
1153 #[test]
1154 fn default_shell_tools_contains_bash_shell_terminal() {
1155 let config = DestructiveVerifierConfig::default();
1156 let lower: Vec<String> = config
1157 .shell_tools
1158 .iter()
1159 .map(|s| s.to_lowercase())
1160 .collect();
1161 assert!(lower.contains(&"bash".to_string()));
1162 assert!(lower.contains(&"shell".to_string()));
1163 assert!(lower.contains(&"terminal".to_string()));
1164 }
1165
1166 fn ipv() -> InjectionPatternVerifier {
1169 InjectionPatternVerifier::new(&InjectionVerifierConfig::default())
1170 }
1171
1172 #[test]
1173 fn allow_clean_args() {
1174 let v = ipv();
1175 assert_eq!(
1176 v.verify("search", &json!({"query": "rust async traits"})),
1177 VerificationResult::Allow
1178 );
1179 }
1180
1181 #[test]
1182 fn allow_sql_discussion_in_query_field() {
1183 let v = ipv();
1185 assert_eq!(
1186 v.verify(
1187 "memory_search",
1188 &json!({"query": "explain SQL UNION SELECT vs JOIN"})
1189 ),
1190 VerificationResult::Allow
1191 );
1192 }
1193
1194 #[test]
1195 fn allow_sql_or_pattern_in_query_field() {
1196 let v = ipv();
1198 assert_eq!(
1199 v.verify("memory_search", &json!({"query": "' OR '1'='1"})),
1200 VerificationResult::Allow
1201 );
1202 }
1203
1204 #[test]
1205 fn block_sql_injection_in_non_query_field() {
1206 let v = ipv();
1207 let result = v.verify("db_query", &json!({"sql": "' OR '1'='1"}));
1208 assert_matches!(result, VerificationResult::Block { .. });
1209 }
1210
1211 #[test]
1212 fn block_drop_table() {
1213 let v = ipv();
1214 let result = v.verify("db_query", &json!({"input": "name'; DROP TABLE users"}));
1215 assert_matches!(result, VerificationResult::Block { .. });
1216 }
1217
1218 #[test]
1219 fn block_path_traversal() {
1220 let v = ipv();
1221 let result = v.verify("read_file", &json!({"path": "../../../etc/passwd"}));
1222 assert_matches!(result, VerificationResult::Block { .. });
1223 }
1224
1225 #[test]
1226 fn warn_on_localhost_url_field() {
1227 let v = ipv();
1229 let result = v.verify("http_get", &json!({"url": "http://localhost:8080/api"}));
1230 assert_matches!(result, VerificationResult::Warn { .. });
1231 }
1232
1233 #[test]
1234 fn allow_localhost_in_non_url_field() {
1235 let v = ipv();
1237 assert_eq!(
1238 v.verify(
1239 "memory_search",
1240 &json!({"query": "connect to http://localhost:8080"})
1241 ),
1242 VerificationResult::Allow
1243 );
1244 }
1245
1246 #[test]
1247 fn warn_on_private_ip_url_field() {
1248 let v = ipv();
1249 let result = v.verify("fetch", &json!({"url": "http://192.168.1.1/admin"}));
1250 assert_matches!(result, VerificationResult::Warn { .. });
1251 }
1252
1253 #[test]
1254 fn allow_localhost_when_allowlisted() {
1255 let config = InjectionVerifierConfig {
1256 allowlisted_urls: vec!["http://localhost:3000".to_string()],
1257 ..Default::default()
1258 };
1259 let v = InjectionPatternVerifier::new(&config);
1260 assert_eq!(
1261 v.verify("http_get", &json!({"url": "http://localhost:3000/api"})),
1262 VerificationResult::Allow
1263 );
1264 }
1265
1266 #[test]
1267 fn block_union_select_in_non_query_field() {
1268 let v = ipv();
1269 let result = v.verify(
1270 "db_query",
1271 &json!({"input": "id=1 UNION SELECT password FROM users"}),
1272 );
1273 assert_matches!(result, VerificationResult::Block { .. });
1274 }
1275
1276 #[test]
1277 fn allow_union_select_in_query_field() {
1278 let v = ipv();
1280 assert_eq!(
1281 v.verify(
1282 "memory_search",
1283 &json!({"query": "id=1 UNION SELECT password FROM users"})
1284 ),
1285 VerificationResult::Allow
1286 );
1287 }
1288
1289 #[test]
1292 fn block_rm_rf_unicode_homoglyph() {
1293 let v = dcv();
1295 let result = v.verify("bash", &json!({"command": "rm -rf \u{FF0F}"}));
1297 assert_matches!(result, VerificationResult::Block { .. });
1298 }
1299
1300 #[test]
1303 fn path_traversal_not_allowed_via_dotdot() {
1304 let config = DestructiveVerifierConfig {
1306 allowed_paths: vec!["/tmp/build".to_string()],
1307 ..Default::default()
1308 };
1309 let v = DestructiveCommandVerifier::new(&config);
1310 let result = v.verify("bash", &json!({"command": "rm -rf /tmp/build/../../etc"}));
1312 assert_matches!(result, VerificationResult::Block { .. });
1313 }
1314
1315 #[test]
1316 fn allowed_path_with_dotdot_stays_in_allowed() {
1317 let config = DestructiveVerifierConfig {
1319 allowed_paths: vec!["/tmp/build".to_string()],
1320 ..Default::default()
1321 };
1322 let v = DestructiveCommandVerifier::new(&config);
1323 assert_eq!(
1324 v.verify(
1325 "bash",
1326 &json!({"command": "rm -rf /tmp/build/sub/../artifacts"}),
1327 ),
1328 VerificationResult::Allow,
1329 );
1330 }
1331
1332 #[test]
1335 fn double_nested_bash_c_blocked() {
1336 let v = dcv();
1337 let result = v.verify(
1338 "bash",
1339 &json!({"command": "bash -c \"bash -c 'rm -rf /'\""}),
1340 );
1341 assert_matches!(result, VerificationResult::Block { .. });
1342 }
1343
1344 #[test]
1345 fn env_prefix_stripping_blocked() {
1346 let v = dcv();
1347 let result = v.verify(
1348 "bash",
1349 &json!({"command": "env FOO=bar bash -c 'rm -rf /'"}),
1350 );
1351 assert_matches!(result, VerificationResult::Block { .. });
1352 }
1353
1354 #[test]
1355 fn exec_prefix_stripping_blocked() {
1356 let v = dcv();
1357 let result = v.verify("bash", &json!({"command": "exec bash -c 'rm -rf /'"}));
1358 assert_matches!(result, VerificationResult::Block { .. });
1359 }
1360
1361 #[test]
1364 fn ssrf_not_triggered_for_embedded_localhost_in_query_param() {
1365 let v = ipv();
1367 let result = v.verify(
1368 "http_get",
1369 &json!({"url": "http://evil.com/?r=http://localhost"}),
1370 );
1371 assert_eq!(result, VerificationResult::Allow);
1373 }
1374
1375 #[test]
1376 fn ssrf_triggered_for_bare_localhost_no_port() {
1377 let v = ipv();
1379 let result = v.verify("http_get", &json!({"url": "http://localhost"}));
1380 assert_matches!(result, VerificationResult::Warn { .. });
1381 }
1382
1383 #[test]
1384 fn ssrf_triggered_for_localhost_with_path() {
1385 let v = ipv();
1386 let result = v.verify("http_get", &json!({"url": "http://localhost/api/v1"}));
1387 assert_matches!(result, VerificationResult::Warn { .. });
1388 }
1389
1390 #[test]
1393 fn chain_first_block_wins() {
1394 let dcv = DestructiveCommandVerifier::new(&DestructiveVerifierConfig::default());
1395 let ipv = InjectionPatternVerifier::new(&InjectionVerifierConfig::default());
1396 let verifiers: Vec<Box<dyn PreExecutionVerifier>> = vec![Box::new(dcv), Box::new(ipv)];
1397
1398 let args = json!({"command": "rm -rf /"});
1399 let mut result = VerificationResult::Allow;
1400 for v in &verifiers {
1401 result = v.verify("bash", &args);
1402 if matches!(result, VerificationResult::Block { .. }) {
1403 break;
1404 }
1405 }
1406 assert_matches!(result, VerificationResult::Block { .. });
1407 }
1408
1409 #[test]
1410 fn chain_warn_continues() {
1411 let dcv = DestructiveCommandVerifier::new(&DestructiveVerifierConfig::default());
1412 let ipv = InjectionPatternVerifier::new(&InjectionVerifierConfig::default());
1413 let verifiers: Vec<Box<dyn PreExecutionVerifier>> = vec![Box::new(dcv), Box::new(ipv)];
1414
1415 let args = json!({"url": "http://localhost:8080/api"});
1417 let mut got_warn = false;
1418 let mut got_block = false;
1419 for v in &verifiers {
1420 match v.verify("http_get", &args) {
1421 VerificationResult::Block { .. } => {
1422 got_block = true;
1423 break;
1424 }
1425 VerificationResult::Warn { .. } => {
1426 got_warn = true;
1427 }
1428 VerificationResult::Allow => {}
1429 }
1430 }
1431 assert!(got_warn);
1432 assert!(!got_block);
1433 }
1434
1435 fn ugv(urls: &[&str]) -> UrlGroundingVerifier {
1438 let set: HashSet<String> = urls.iter().map(|s| s.to_lowercase()).collect();
1439 UrlGroundingVerifier::new(
1440 &UrlGroundingVerifierConfig::default(),
1441 Arc::new(RwLock::new(set)),
1442 )
1443 }
1444
1445 #[test]
1446 fn url_grounding_allows_user_provided_url() {
1447 let v = ugv(&["https://docs.anthropic.com/models"]);
1448 assert_eq!(
1449 v.verify(
1450 "fetch",
1451 &json!({"url": "https://docs.anthropic.com/models"})
1452 ),
1453 VerificationResult::Allow
1454 );
1455 }
1456
1457 #[test]
1458 fn url_grounding_blocks_hallucinated_url() {
1459 let v = ugv(&["https://example.com/page"]);
1460 let result = v.verify(
1461 "fetch",
1462 &json!({"url": "https://api.anthropic.ai/v1/models"}),
1463 );
1464 assert_matches!(result, VerificationResult::Block { .. });
1465 }
1466
1467 #[test]
1468 fn url_grounding_blocks_when_no_user_urls_at_all() {
1469 let v = ugv(&[]);
1470 let result = v.verify(
1471 "fetch",
1472 &json!({"url": "https://api.anthropic.ai/v1/models"}),
1473 );
1474 assert_matches!(result, VerificationResult::Block { .. });
1475 }
1476
1477 #[test]
1478 fn url_grounding_allows_non_guarded_tool() {
1479 let v = ugv(&[]);
1480 assert_eq!(
1481 v.verify("read_file", &json!({"path": "/etc/hosts"})),
1482 VerificationResult::Allow
1483 );
1484 }
1485
1486 #[test]
1487 fn url_grounding_guards_fetch_suffix_tool() {
1488 let v = ugv(&[]);
1489 let result = v.verify("http_fetch", &json!({"url": "https://evil.com/"}));
1490 assert_matches!(result, VerificationResult::Block { .. });
1491 }
1492
1493 #[test]
1494 fn url_grounding_allows_web_scrape_with_provided_url() {
1495 let v = ugv(&["https://rust-lang.org/"]);
1496 assert_eq!(
1497 v.verify(
1498 "web_scrape",
1499 &json!({"url": "https://rust-lang.org/", "select": "h1"})
1500 ),
1501 VerificationResult::Allow
1502 );
1503 }
1504
1505 #[test]
1506 fn url_grounding_allows_prefix_match() {
1507 let v = ugv(&["https://docs.rs/"]);
1509 assert_eq!(
1510 v.verify(
1511 "fetch",
1512 &json!({"url": "https://docs.rs/tokio/latest/tokio/"})
1513 ),
1514 VerificationResult::Allow
1515 );
1516 }
1517
1518 #[test]
1525 fn reg_2191_hallucinated_api_endpoint_blocked_with_empty_session() {
1526 let v = ugv(&[]);
1528 let result = v.verify(
1529 "fetch",
1530 &json!({"url": "https://api.anthropic.ai/v1/models"}),
1531 );
1532 assert!(
1533 matches!(result, VerificationResult::Block { .. }),
1534 "fetch must be blocked when no user URL was provided — this is the #2191 regression"
1535 );
1536 }
1537
1538 #[test]
1540 fn reg_2191_user_provided_url_allows_fetch() {
1541 let v = ugv(&["https://api.anthropic.com/v1/models"]);
1542 assert_eq!(
1543 v.verify(
1544 "fetch",
1545 &json!({"url": "https://api.anthropic.com/v1/models"}),
1546 ),
1547 VerificationResult::Allow,
1548 "fetch must be allowed when the URL was explicitly provided by the user"
1549 );
1550 }
1551
1552 #[test]
1554 fn reg_2191_web_scrape_hallucinated_url_blocked() {
1555 let v = ugv(&[]);
1556 let result = v.verify(
1557 "web_scrape",
1558 &json!({"url": "https://api.anthropic.ai/v1/models", "select": "body"}),
1559 );
1560 assert!(
1561 matches!(result, VerificationResult::Block { .. }),
1562 "web_scrape must be blocked for hallucinated URL with empty user_provided_urls"
1563 );
1564 }
1565
1566 #[test]
1571 fn reg_2191_empty_url_set_always_blocks_fetch() {
1572 let v = ugv(&[]);
1575 let result = v.verify(
1576 "fetch",
1577 &json!({"url": "https://docs.anthropic.com/something"}),
1578 );
1579 assert_matches!(result, VerificationResult::Block { .. });
1580 }
1581
1582 #[test]
1584 fn reg_2191_case_insensitive_url_match_allows_fetch() {
1585 let v = ugv(&["https://Docs.Anthropic.COM/models"]);
1588 assert_eq!(
1589 v.verify(
1590 "fetch",
1591 &json!({"url": "https://docs.anthropic.com/models/detail"}),
1592 ),
1593 VerificationResult::Allow,
1594 "URL matching must be case-insensitive"
1595 );
1596 }
1597
1598 #[test]
1601 fn reg_2191_mcp_fetch_suffix_tool_blocked_with_empty_session() {
1602 let v = ugv(&[]);
1603 let result = v.verify(
1604 "anthropic_fetch",
1605 &json!({"url": "https://api.anthropic.ai/v1/models"}),
1606 );
1607 assert!(
1608 matches!(result, VerificationResult::Block { .. }),
1609 "MCP tools ending in _fetch must be guarded even if not in guarded_tools list"
1610 );
1611 }
1612
1613 #[test]
1616 fn reg_2191_reverse_prefix_match_allows_fetch() {
1617 let v = ugv(&["https://docs.rs/tokio/latest/tokio/index.html"]);
1620 assert_eq!(
1621 v.verify("fetch", &json!({"url": "https://docs.rs/"})),
1622 VerificationResult::Allow,
1623 "reverse prefix: fetched URL is a prefix of user-provided URL — should be allowed"
1624 );
1625 }
1626
1627 #[test]
1629 fn reg_2191_different_domain_blocked() {
1630 let v = ugv(&["https://docs.rs/"]);
1632 let result = v.verify("fetch", &json!({"url": "https://evil.com/docs.rs/exfil"}));
1633 assert!(
1634 matches!(result, VerificationResult::Block { .. }),
1635 "different domain must not be allowed even if path looks similar"
1636 );
1637 }
1638
1639 #[test]
1641 fn reg_2191_missing_url_field_allows_fetch() {
1642 let v = ugv(&[]);
1645 assert_eq!(
1646 v.verify(
1647 "fetch",
1648 &json!({"endpoint": "https://api.anthropic.ai/v1/models"})
1649 ),
1650 VerificationResult::Allow,
1651 "missing url field must not trigger blocking — only explicit url field is checked"
1652 );
1653 }
1654
1655 #[test]
1657 fn reg_2191_disabled_verifier_allows_all() {
1658 let config = UrlGroundingVerifierConfig {
1659 enabled: false,
1660 ..UrlGroundingVerifierConfig::default()
1661 };
1662 let set: HashSet<String> = HashSet::new();
1666 let v = UrlGroundingVerifier::new(&config, Arc::new(RwLock::new(set)));
1667 let _ = v.verify("fetch", &json!({"url": "https://example.com/"}));
1671 }
1673
1674 fn fwv() -> FirewallVerifier {
1677 FirewallVerifier::new(&FirewallVerifierConfig::default())
1678 }
1679
1680 #[test]
1681 fn firewall_allows_normal_path() {
1682 let v = fwv();
1683 assert_eq!(
1684 v.verify("shell", &json!({"command": "ls /tmp/build"})),
1685 VerificationResult::Allow
1686 );
1687 }
1688
1689 #[test]
1690 fn firewall_blocks_path_traversal() {
1691 let v = fwv();
1692 let result = v.verify("read", &json!({"file_path": "../../etc/passwd"}));
1693 assert!(
1694 matches!(result, VerificationResult::Block { .. }),
1695 "path traversal must be blocked"
1696 );
1697 }
1698
1699 #[test]
1700 fn firewall_blocks_etc_passwd() {
1701 let v = fwv();
1702 let result = v.verify("read", &json!({"file_path": "/etc/passwd"}));
1703 assert!(
1704 matches!(result, VerificationResult::Block { .. }),
1705 "/etc/passwd must be blocked"
1706 );
1707 }
1708
1709 #[test]
1710 fn firewall_blocks_ssh_key() {
1711 let v = fwv();
1712 let result = v.verify("read", &json!({"file_path": "~/.ssh/id_rsa"}));
1713 assert!(
1714 matches!(result, VerificationResult::Block { .. }),
1715 "SSH key path must be blocked"
1716 );
1717 }
1718
1719 #[test]
1720 fn firewall_blocks_aws_env_var() {
1721 let v = fwv();
1722 let result = v.verify("shell", &json!({"command": "echo $AWS_SECRET_ACCESS_KEY"}));
1723 assert!(
1724 matches!(result, VerificationResult::Block { .. }),
1725 "AWS env var exfiltration must be blocked"
1726 );
1727 }
1728
1729 #[test]
1730 fn firewall_blocks_zeph_env_var() {
1731 let v = fwv();
1732 let result = v.verify("shell", &json!({"command": "cat ${ZEPH_CLAUDE_API_KEY}"}));
1733 assert!(
1734 matches!(result, VerificationResult::Block { .. }),
1735 "ZEPH env var exfiltration must be blocked"
1736 );
1737 }
1738
1739 #[test]
1740 fn firewall_exempt_tool_bypasses_check() {
1741 let cfg = FirewallVerifierConfig {
1742 enabled: true,
1743 blocked_paths: vec![],
1744 blocked_env_vars: vec![],
1745 exempt_tools: vec!["read".to_string()],
1746 };
1747 let v = FirewallVerifier::new(&cfg);
1748 assert_eq!(
1750 v.verify("read", &json!({"file_path": "/etc/passwd"})),
1751 VerificationResult::Allow
1752 );
1753 }
1754
1755 #[test]
1756 fn firewall_custom_blocked_path() {
1757 let cfg = FirewallVerifierConfig {
1758 enabled: true,
1759 blocked_paths: vec!["/data/secrets/*".to_string()],
1760 blocked_env_vars: vec![],
1761 exempt_tools: vec![],
1762 };
1763 let v = FirewallVerifier::new(&cfg);
1764 let result = v.verify("read", &json!({"file_path": "/data/secrets/master.key"}));
1765 assert!(
1766 matches!(result, VerificationResult::Block { .. }),
1767 "custom blocked path must be blocked"
1768 );
1769 }
1770
1771 #[test]
1772 fn firewall_custom_blocked_env_var() {
1773 let cfg = FirewallVerifierConfig {
1774 enabled: true,
1775 blocked_paths: vec![],
1776 blocked_env_vars: vec!["MY_SECRET".to_string()],
1777 exempt_tools: vec![],
1778 };
1779 let v = FirewallVerifier::new(&cfg);
1780 let result = v.verify("shell", &json!({"command": "echo $MY_SECRET"}));
1781 assert!(
1782 matches!(result, VerificationResult::Block { .. }),
1783 "custom blocked env var must be blocked"
1784 );
1785 }
1786
1787 #[test]
1788 fn firewall_invalid_glob_is_skipped() {
1789 let cfg = FirewallVerifierConfig {
1791 enabled: true,
1792 blocked_paths: vec!["[invalid-glob".to_string(), "/valid/path/*".to_string()],
1793 blocked_env_vars: vec![],
1794 exempt_tools: vec![],
1795 };
1796 let v = FirewallVerifier::new(&cfg);
1797 let result = v.verify("read", &json!({"path": "/valid/path/file.txt"}));
1799 assert_matches!(result, VerificationResult::Block { .. });
1800 }
1801
1802 #[test]
1803 fn firewall_config_default_deserialization() {
1804 let cfg: FirewallVerifierConfig = toml::from_str("").unwrap();
1805 assert!(cfg.enabled);
1806 assert!(cfg.blocked_paths.is_empty());
1807 assert!(cfg.blocked_env_vars.is_empty());
1808 assert!(cfg.exempt_tools.is_empty());
1809 }
1810
1811 fn nested_array(depth: usize, leaf: &str) -> serde_json::Value {
1815 let mut v = json!(leaf);
1816 for _ in 0..depth {
1817 v = serde_json::Value::Array(vec![v]);
1818 }
1819 v
1820 }
1821
1822 #[test]
1823 fn collect_strings_adversarial_scale_does_not_crash() {
1824 let value = nested_array(10_000, "deep");
1828 let mut out = Vec::new();
1829 FirewallVerifier::collect_strings(&value, &mut out, 0);
1830 assert!(out.is_empty());
1831 }
1832
1833 #[test]
1834 fn collect_strings_exact_depth_boundary() {
1835 let just_inside = nested_array(MAX_JSON_DEPTH - 1, "just_inside");
1836 let mut out = Vec::new();
1837 FirewallVerifier::collect_strings(&just_inside, &mut out, 0);
1838 assert_eq!(out, vec!["just_inside".to_string()]);
1839
1840 let just_outside = nested_array(MAX_JSON_DEPTH, "just_outside");
1841 let mut out = Vec::new();
1842 FirewallVerifier::collect_strings(&just_outside, &mut out, 0);
1843 assert!(out.is_empty());
1844 }
1845
1846 fn wrap_command(value: serde_json::Value) -> serde_json::Value {
1852 let mut map = serde_json::Map::new();
1853 map.insert("command".to_string(), value);
1854 serde_json::Value::Object(map)
1855 }
1856
1857 #[test]
1858 fn check_value_adversarial_scale_does_not_crash() {
1859 let args = wrap_command(nested_array(10_000, "; rm -rf /"));
1863 let result = ipv().verify("shell", &args);
1864 assert_matches!(result, VerificationResult::Allow);
1865 }
1866
1867 #[test]
1868 fn check_value_exact_depth_boundary() {
1869 let just_inside = wrap_command(nested_array(MAX_JSON_DEPTH - 1, "; rm -rf /"));
1871 assert_matches!(
1872 ipv().verify("shell", &just_inside),
1873 VerificationResult::Block { .. }
1874 );
1875
1876 let just_outside = wrap_command(nested_array(MAX_JSON_DEPTH, "; rm -rf /"));
1878 assert_matches!(
1879 ipv().verify("shell", &just_outside),
1880 VerificationResult::Allow
1881 );
1882 }
1883}