1use std::collections::HashSet;
26use std::sync::{Arc, LazyLock, RwLock};
27
28use regex::Regex;
29use serde::{Deserialize, Serialize};
30use unicode_normalization::UnicodeNormalization as _;
31
32fn default_true() -> bool {
33 true
34}
35
36fn default_shell_tools() -> Vec<String> {
37 vec![
38 "bash".to_string(),
39 "shell".to_string(),
40 "terminal".to_string(),
41 ]
42}
43
44#[must_use]
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum VerificationResult {
48 Allow,
50 Block { reason: String },
52 Warn { message: String },
55}
56
57pub trait PreExecutionVerifier: Send + Sync + std::fmt::Debug {
63 fn verify(&self, tool_name: &str, args: &serde_json::Value) -> VerificationResult;
65
66 fn name(&self) -> &'static str;
68}
69
70#[derive(Debug, Clone, Deserialize, Serialize)]
84pub struct DestructiveVerifierConfig {
85 #[serde(default = "default_true")]
86 pub enabled: bool,
87 #[serde(default)]
90 pub allowed_paths: Vec<String>,
91 #[serde(default)]
93 pub extra_patterns: Vec<String>,
94 #[serde(default = "default_shell_tools")]
97 pub shell_tools: Vec<String>,
98}
99
100impl Default for DestructiveVerifierConfig {
101 fn default() -> Self {
102 Self {
103 enabled: true,
104 allowed_paths: Vec::new(),
105 extra_patterns: Vec::new(),
106 shell_tools: default_shell_tools(),
107 }
108 }
109}
110
111#[derive(Debug, Clone, Deserialize, Serialize)]
113pub struct InjectionVerifierConfig {
114 #[serde(default = "default_true")]
115 pub enabled: bool,
116 #[serde(default)]
119 pub extra_patterns: Vec<String>,
120 #[serde(default)]
122 pub allowlisted_urls: Vec<String>,
123}
124
125impl Default for InjectionVerifierConfig {
126 fn default() -> Self {
127 Self {
128 enabled: true,
129 extra_patterns: Vec::new(),
130 allowlisted_urls: Vec::new(),
131 }
132 }
133}
134
135#[derive(Debug, Clone, Deserialize, Serialize)]
142pub struct UrlGroundingVerifierConfig {
143 #[serde(default = "default_true")]
144 pub enabled: bool,
145 #[serde(default = "default_guarded_tools")]
148 pub guarded_tools: Vec<String>,
149}
150
151fn default_guarded_tools() -> Vec<String> {
152 vec!["fetch".to_string(), "web_scrape".to_string()]
153}
154
155impl Default for UrlGroundingVerifierConfig {
156 fn default() -> Self {
157 Self {
158 enabled: true,
159 guarded_tools: default_guarded_tools(),
160 }
161 }
162}
163
164#[derive(Debug, Clone, Deserialize, Serialize)]
166pub struct PreExecutionVerifierConfig {
167 #[serde(default = "default_true")]
168 pub enabled: bool,
169 #[serde(default)]
170 pub destructive_commands: DestructiveVerifierConfig,
171 #[serde(default)]
172 pub injection_patterns: InjectionVerifierConfig,
173 #[serde(default)]
174 pub url_grounding: UrlGroundingVerifierConfig,
175 #[serde(default)]
176 pub firewall: FirewallVerifierConfig,
177}
178
179impl Default for PreExecutionVerifierConfig {
180 fn default() -> Self {
181 Self {
182 enabled: true,
183 destructive_commands: DestructiveVerifierConfig::default(),
184 injection_patterns: InjectionVerifierConfig::default(),
185 url_grounding: UrlGroundingVerifierConfig::default(),
186 firewall: FirewallVerifierConfig::default(),
187 }
188 }
189}
190
191static DESTRUCTIVE_PATTERNS: &[&str] = &[
200 "rm -rf /",
201 "rm -rf ~",
202 "rm -r /",
203 "dd if=",
204 "mkfs",
205 "fdisk",
206 "shred",
207 "wipefs",
208 ":(){ :|:& };:",
209 ":(){:|:&};:",
210 "chmod -r 777 /",
211 "chown -r",
212];
213
214#[derive(Debug)]
222pub struct DestructiveCommandVerifier {
223 shell_tools: Vec<String>,
224 allowed_paths: Vec<String>,
225 extra_patterns: Vec<String>,
226}
227
228impl DestructiveCommandVerifier {
229 #[must_use]
230 pub fn new(config: &DestructiveVerifierConfig) -> Self {
231 Self {
232 shell_tools: config
233 .shell_tools
234 .iter()
235 .map(|s| s.to_lowercase())
236 .collect(),
237 allowed_paths: config
238 .allowed_paths
239 .iter()
240 .map(|s| s.to_lowercase())
241 .collect(),
242 extra_patterns: config
243 .extra_patterns
244 .iter()
245 .map(|s| s.to_lowercase())
246 .collect(),
247 }
248 }
249
250 fn is_shell_tool(&self, tool_name: &str) -> bool {
251 let lower = tool_name.to_lowercase();
252 self.shell_tools.iter().any(|t| t == &lower)
253 }
254
255 fn extract_command(args: &serde_json::Value) -> Option<String> {
265 let raw = match args.get("command") {
266 Some(serde_json::Value::String(s)) => s.clone(),
267 Some(serde_json::Value::Array(arr)) => arr
268 .iter()
269 .filter_map(|v| v.as_str())
270 .collect::<Vec<_>>()
271 .join(" "),
272 _ => return None,
273 };
274 let mut current: String = raw.nfkc().collect::<String>().to_lowercase();
276 for _ in 0..8 {
279 let trimmed = current.trim().to_owned();
280 let after_env = Self::strip_env_prefix(&trimmed);
282 let after_exec = after_env.strip_prefix("exec ").map_or(after_env, str::trim);
284 let mut unwrapped = false;
286 for interp in &["bash -c ", "sh -c ", "zsh -c "] {
287 if let Some(rest) = after_exec.strip_prefix(interp) {
288 let script = rest.trim().trim_matches(|c: char| c == '\'' || c == '"');
289 current.clone_from(&script.to_owned());
290 unwrapped = true;
291 break;
292 }
293 }
294 if !unwrapped {
295 return Some(after_exec.to_owned());
296 }
297 }
298 Some(current)
299 }
300
301 fn strip_env_prefix(cmd: &str) -> &str {
304 let mut rest = cmd;
305 if let Some(after_env) = rest.strip_prefix("env ") {
307 rest = after_env.trim_start();
308 }
309 loop {
311 let mut chars = rest.chars();
313 let key_end = chars
314 .by_ref()
315 .take_while(|c| c.is_alphanumeric() || *c == '_')
316 .count();
317 if key_end == 0 {
318 break;
319 }
320 let remainder = &rest[key_end..];
321 if let Some(after_eq) = remainder.strip_prefix('=') {
322 let val_end = after_eq.find(' ').unwrap_or(after_eq.len());
324 rest = after_eq[val_end..].trim_start();
325 } else {
326 break;
327 }
328 }
329 rest
330 }
331
332 fn is_allowed_path(&self, command: &str) -> bool {
338 if self.allowed_paths.is_empty() {
339 return false;
340 }
341 let tokens: Vec<&str> = command.split_whitespace().collect();
342 for token in &tokens {
343 let t = token.trim_matches(|c| c == '\'' || c == '"');
344 if t.starts_with('/') || t.starts_with('~') || t.starts_with('.') {
345 let normalized = Self::lexical_normalize(std::path::Path::new(t));
346 let n_lower = normalized.to_string_lossy().to_lowercase();
347 if self
348 .allowed_paths
349 .iter()
350 .any(|p| n_lower.starts_with(p.as_str()))
351 {
352 return true;
353 }
354 }
355 }
356 false
357 }
358
359 fn lexical_normalize(p: &std::path::Path) -> std::path::PathBuf {
362 let mut out = std::path::PathBuf::new();
363 for component in p.components() {
364 match component {
365 std::path::Component::ParentDir => {
366 out.pop();
367 }
368 std::path::Component::CurDir => {}
369 other => out.push(other),
370 }
371 }
372 out
373 }
374
375 fn check_patterns(command: &str) -> Option<&'static str> {
376 DESTRUCTIVE_PATTERNS
377 .iter()
378 .find(|&pat| command.contains(pat))
379 .copied()
380 }
381
382 fn check_extra_patterns(&self, command: &str) -> Option<String> {
383 self.extra_patterns
384 .iter()
385 .find(|pat| command.contains(pat.as_str()))
386 .cloned()
387 }
388}
389
390impl PreExecutionVerifier for DestructiveCommandVerifier {
391 fn name(&self) -> &'static str {
392 "DestructiveCommandVerifier"
393 }
394
395 fn verify(&self, tool_name: &str, args: &serde_json::Value) -> VerificationResult {
396 if !self.is_shell_tool(tool_name) {
397 return VerificationResult::Allow;
398 }
399
400 let Some(command) = Self::extract_command(args) else {
401 return VerificationResult::Allow;
402 };
403
404 if let Some(pat) = Self::check_patterns(&command) {
405 if self.is_allowed_path(&command) {
406 return VerificationResult::Allow;
407 }
408 return VerificationResult::Block {
409 reason: format!("[{}] destructive pattern '{}' detected", self.name(), pat),
410 };
411 }
412
413 if let Some(pat) = self.check_extra_patterns(&command) {
414 if self.is_allowed_path(&command) {
415 return VerificationResult::Allow;
416 }
417 return VerificationResult::Block {
418 reason: format!(
419 "[{}] extra destructive pattern '{}' detected",
420 self.name(),
421 pat
422 ),
423 };
424 }
425
426 VerificationResult::Allow
427 }
428}
429
430static INJECTION_BLOCK_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
440 [
441 r"(?i)'\s*OR\s*'1'\s*=\s*'1",
443 r"(?i)'\s*OR\s*1\s*=\s*1",
444 r"(?i);\s*DROP\s+TABLE",
445 r"(?i)UNION\s+SELECT",
446 r"(?i)'\s*;\s*SELECT",
447 r";\s*rm\s+",
449 r"\|\s*rm\s+",
450 r"&&\s*rm\s+",
451 r";\s*curl\s+",
452 r"\|\s*curl\s+",
453 r"&&\s*curl\s+",
454 r";\s*wget\s+",
455 r"\.\./\.\./\.\./etc/passwd",
457 r"\.\./\.\./\.\./etc/shadow",
458 r"\.\./\.\./\.\./windows/",
459 r"\.\.[/\\]\.\.[/\\]\.\.[/\\]",
460 ]
461 .iter()
462 .map(|s| Regex::new(s).expect("static pattern must compile"))
463 .collect()
464});
465
466static SSRF_HOST_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
471 [
472 r"^localhost$",
474 r"^localhost:",
475 r"^127\.0\.0\.1$",
477 r"^127\.0\.0\.1:",
478 r"^\[::1\]$",
480 r"^\[::1\]:",
481 r"^169\.254\.169\.254$",
483 r"^169\.254\.169\.254:",
484 r"^10\.\d+\.\d+\.\d+$",
486 r"^10\.\d+\.\d+\.\d+:",
487 r"^172\.(1[6-9]|2\d|3[01])\.\d+\.\d+$",
488 r"^172\.(1[6-9]|2\d|3[01])\.\d+\.\d+:",
489 r"^192\.168\.\d+\.\d+$",
490 r"^192\.168\.\d+\.\d+:",
491 ]
492 .iter()
493 .map(|s| Regex::new(s).expect("static pattern must compile"))
494 .collect()
495});
496
497fn extract_url_host(url: &str) -> Option<&str> {
501 let after_scheme = url.split_once("://")?.1;
502 let host_end = after_scheme
503 .find(['/', '?', '#'])
504 .unwrap_or(after_scheme.len());
505 Some(&after_scheme[..host_end])
506}
507
508static URL_FIELD_NAMES: &[&str] = &["url", "endpoint", "uri", "href", "src", "host", "base_url"];
510
511static SAFE_QUERY_FIELDS: &[&str] = &["query", "q", "search", "text", "message", "content"];
515
516#[derive(Debug)]
537pub struct InjectionPatternVerifier {
538 extra_patterns: Vec<Regex>,
539 allowlisted_urls: Vec<String>,
540}
541
542impl InjectionPatternVerifier {
543 #[must_use]
544 pub fn new(config: &InjectionVerifierConfig) -> Self {
545 let extra_patterns = config
546 .extra_patterns
547 .iter()
548 .filter_map(|s| match Regex::new(s) {
549 Ok(re) => Some(re),
550 Err(e) => {
551 tracing::warn!(
552 pattern = %s,
553 error = %e,
554 "InjectionPatternVerifier: invalid extra_pattern, skipping"
555 );
556 None
557 }
558 })
559 .collect();
560
561 Self {
562 extra_patterns,
563 allowlisted_urls: config
564 .allowlisted_urls
565 .iter()
566 .map(|s| s.to_lowercase())
567 .collect(),
568 }
569 }
570
571 fn is_allowlisted(&self, text: &str) -> bool {
572 let lower = text.to_lowercase();
573 self.allowlisted_urls
574 .iter()
575 .any(|u| lower.contains(u.as_str()))
576 }
577
578 fn is_url_field(field: &str) -> bool {
579 let lower = field.to_lowercase();
580 URL_FIELD_NAMES.iter().any(|&f| f == lower)
581 }
582
583 fn is_safe_query_field(field: &str) -> bool {
584 let lower = field.to_lowercase();
585 SAFE_QUERY_FIELDS.iter().any(|&f| f == lower)
586 }
587
588 fn check_field_value(&self, field: &str, value: &str) -> VerificationResult {
590 let is_url = Self::is_url_field(field);
591 let is_safe_query = Self::is_safe_query_field(field);
592
593 if !is_safe_query {
595 for pat in INJECTION_BLOCK_PATTERNS.iter() {
596 if pat.is_match(value) {
597 return VerificationResult::Block {
598 reason: format!(
599 "[{}] injection pattern detected in field '{}': {}",
600 "InjectionPatternVerifier",
601 field,
602 pat.as_str()
603 ),
604 };
605 }
606 }
607 for pat in &self.extra_patterns {
608 if pat.is_match(value) {
609 return VerificationResult::Block {
610 reason: format!(
611 "[{}] extra injection pattern detected in field '{}': {}",
612 "InjectionPatternVerifier",
613 field,
614 pat.as_str()
615 ),
616 };
617 }
618 }
619 }
620
621 if is_url && let Some(host) = extract_url_host(value) {
625 for pat in SSRF_HOST_PATTERNS.iter() {
626 if pat.is_match(host) {
627 if self.is_allowlisted(value) {
628 return VerificationResult::Allow;
629 }
630 return VerificationResult::Warn {
631 message: format!(
632 "[{}] possible SSRF in field '{}': host '{}' matches pattern (not blocked)",
633 "InjectionPatternVerifier", field, host,
634 ),
635 };
636 }
637 }
638 }
639
640 VerificationResult::Allow
641 }
642
643 fn check_object(&self, obj: &serde_json::Map<String, serde_json::Value>) -> VerificationResult {
645 for (key, val) in obj {
646 let result = self.check_value(key, val);
647 if !matches!(result, VerificationResult::Allow) {
648 return result;
649 }
650 }
651 VerificationResult::Allow
652 }
653
654 fn check_value(&self, field: &str, val: &serde_json::Value) -> VerificationResult {
655 match val {
656 serde_json::Value::String(s) => self.check_field_value(field, s),
657 serde_json::Value::Array(arr) => {
658 for item in arr {
659 let r = self.check_value(field, item);
660 if !matches!(r, VerificationResult::Allow) {
661 return r;
662 }
663 }
664 VerificationResult::Allow
665 }
666 serde_json::Value::Object(obj) => self.check_object(obj),
667 _ => VerificationResult::Allow,
669 }
670 }
671}
672
673impl PreExecutionVerifier for InjectionPatternVerifier {
674 fn name(&self) -> &'static str {
675 "InjectionPatternVerifier"
676 }
677
678 fn verify(&self, _tool_name: &str, args: &serde_json::Value) -> VerificationResult {
679 match args {
680 serde_json::Value::Object(obj) => self.check_object(obj),
681 serde_json::Value::String(s) => self.check_field_value("_args", s),
683 _ => VerificationResult::Allow,
684 }
685 }
686}
687
688#[derive(Debug, Clone)]
707pub struct UrlGroundingVerifier {
708 guarded_tools: Vec<String>,
709 user_provided_urls: Arc<RwLock<HashSet<String>>>,
710}
711
712impl UrlGroundingVerifier {
713 #[must_use]
714 pub fn new(
715 config: &UrlGroundingVerifierConfig,
716 user_provided_urls: Arc<RwLock<HashSet<String>>>,
717 ) -> Self {
718 Self {
719 guarded_tools: config
720 .guarded_tools
721 .iter()
722 .map(|s| s.to_lowercase())
723 .collect(),
724 user_provided_urls,
725 }
726 }
727
728 fn is_guarded(&self, tool_name: &str) -> bool {
729 let lower = tool_name.to_lowercase();
730 self.guarded_tools.iter().any(|t| t == &lower) || lower.ends_with("_fetch")
731 }
732
733 fn is_grounded(url: &str, user_provided_urls: &HashSet<String>) -> bool {
736 let lower = url.to_lowercase();
737 user_provided_urls
738 .iter()
739 .any(|u| lower.starts_with(u.as_str()) || u.starts_with(lower.as_str()))
740 }
741}
742
743impl PreExecutionVerifier for UrlGroundingVerifier {
744 fn name(&self) -> &'static str {
745 "UrlGroundingVerifier"
746 }
747
748 fn verify(&self, tool_name: &str, args: &serde_json::Value) -> VerificationResult {
749 if !self.is_guarded(tool_name) {
750 return VerificationResult::Allow;
751 }
752
753 let Some(url) = args.get("url").and_then(|v| v.as_str()) else {
754 return VerificationResult::Allow;
755 };
756
757 let Ok(urls) = self.user_provided_urls.read() else {
758 return VerificationResult::Allow;
760 };
761
762 if Self::is_grounded(url, &urls) {
763 return VerificationResult::Allow;
764 }
765
766 VerificationResult::Block {
767 reason: format!(
768 "[UrlGroundingVerifier] fetch rejected: URL '{url}' was not provided by the user",
769 ),
770 }
771 }
772}
773
774#[derive(Debug, Clone, Deserialize, Serialize)]
780pub struct FirewallVerifierConfig {
781 #[serde(default = "default_true")]
782 pub enabled: bool,
783 #[serde(default)]
785 pub blocked_paths: Vec<String>,
786 #[serde(default)]
788 pub blocked_env_vars: Vec<String>,
789 #[serde(default)]
791 pub exempt_tools: Vec<String>,
792}
793
794impl Default for FirewallVerifierConfig {
795 fn default() -> Self {
796 Self {
797 enabled: true,
798 blocked_paths: Vec::new(),
799 blocked_env_vars: Vec::new(),
800 exempt_tools: Vec::new(),
801 }
802 }
803}
804
805#[derive(Debug)]
818pub struct FirewallVerifier {
819 blocked_path_globs: Vec<glob::Pattern>,
820 blocked_env_vars: HashSet<String>,
821 exempt_tools: HashSet<String>,
822}
823
824static SENSITIVE_PATH_PATTERNS: LazyLock<Vec<glob::Pattern>> = LazyLock::new(|| {
826 let raw = [
827 "/etc/passwd",
828 "/etc/shadow",
829 "/etc/sudoers",
830 "~/.ssh/*",
831 "~/.aws/*",
832 "~/.gnupg/*",
833 "**/*.pem",
834 "**/*.key",
835 "**/id_rsa",
836 "**/id_ed25519",
837 "**/.env",
838 "**/credentials",
839 ];
840 raw.iter()
841 .filter_map(|p| {
842 glob::Pattern::new(p)
843 .map_err(|e| {
844 tracing::error!(pattern = p, error = %e, "failed to compile built-in firewall path pattern");
845 e
846 })
847 .ok()
848 })
849 .collect()
850});
851
852static SENSITIVE_ENV_PREFIXES: &[&str] =
854 &["$AWS_", "$ZEPH_", "${AWS_", "${ZEPH_", "%AWS_", "%ZEPH_"];
855
856static INSPECTED_FIELDS: &[&str] = &[
858 "command",
859 "file_path",
860 "path",
861 "url",
862 "query",
863 "uri",
864 "input",
865 "args",
866];
867
868impl FirewallVerifier {
869 #[must_use]
873 pub fn new(config: &FirewallVerifierConfig) -> Self {
874 let blocked_path_globs = config
875 .blocked_paths
876 .iter()
877 .filter_map(|p| {
878 glob::Pattern::new(p)
879 .map_err(|e| {
880 tracing::warn!(pattern = p, error = %e, "invalid glob pattern in firewall blocked_paths, skipping");
881 e
882 })
883 .ok()
884 })
885 .collect();
886
887 let blocked_env_vars = config
888 .blocked_env_vars
889 .iter()
890 .map(|s| s.to_uppercase())
891 .collect();
892
893 let exempt_tools = config
894 .exempt_tools
895 .iter()
896 .map(|s| s.to_lowercase())
897 .collect();
898
899 Self {
900 blocked_path_globs,
901 blocked_env_vars,
902 exempt_tools,
903 }
904 }
905
906 fn collect_args(args: &serde_json::Value) -> Vec<String> {
908 let mut out = Vec::new();
909 match args {
910 serde_json::Value::Object(map) => {
911 for field in INSPECTED_FIELDS {
912 if let Some(val) = map.get(*field) {
913 Self::collect_strings(val, &mut out);
914 }
915 }
916 }
917 serde_json::Value::String(s) => out.push(s.clone()),
918 _ => {}
919 }
920 out
921 }
922
923 fn collect_strings(val: &serde_json::Value, out: &mut Vec<String>) {
924 match val {
925 serde_json::Value::String(s) => out.push(s.clone()),
926 serde_json::Value::Array(arr) => {
927 for item in arr {
928 Self::collect_strings(item, out);
929 }
930 }
931 _ => {}
932 }
933 }
934
935 fn scan_arg(&self, arg: &str) -> Option<VerificationResult> {
936 let normalized: String = arg.nfkc().collect();
938 let lower = normalized.to_lowercase();
939
940 if lower.contains("../") || lower.contains("..\\") {
942 return Some(VerificationResult::Block {
943 reason: format!(
944 "[FirewallVerifier] path traversal pattern detected in argument: {arg}"
945 ),
946 });
947 }
948
949 for pattern in SENSITIVE_PATH_PATTERNS.iter() {
951 if pattern.matches(&normalized) || pattern.matches(&lower) {
952 return Some(VerificationResult::Block {
953 reason: format!(
954 "[FirewallVerifier] sensitive path pattern '{pattern}' matched in argument: {arg}"
955 ),
956 });
957 }
958 }
959
960 for pattern in &self.blocked_path_globs {
962 if pattern.matches(&normalized) || pattern.matches(&lower) {
963 return Some(VerificationResult::Block {
964 reason: format!(
965 "[FirewallVerifier] blocked path pattern '{pattern}' matched in argument: {arg}"
966 ),
967 });
968 }
969 }
970
971 let upper = normalized.to_uppercase();
973 for prefix in SENSITIVE_ENV_PREFIXES {
974 if upper.contains(*prefix) {
975 return Some(VerificationResult::Block {
976 reason: format!(
977 "[FirewallVerifier] env var exfiltration pattern '{prefix}' detected in argument: {arg}"
978 ),
979 });
980 }
981 }
982
983 for var in &self.blocked_env_vars {
985 let dollar_form = format!("${var}");
986 let brace_form = format!("${{{var}}}");
987 let percent_form = format!("%{var}%");
988 if upper.contains(&dollar_form)
989 || upper.contains(&brace_form)
990 || upper.contains(&percent_form)
991 {
992 return Some(VerificationResult::Block {
993 reason: format!(
994 "[FirewallVerifier] blocked env var '{var}' detected in argument: {arg}"
995 ),
996 });
997 }
998 }
999
1000 None
1001 }
1002}
1003
1004impl PreExecutionVerifier for FirewallVerifier {
1005 fn name(&self) -> &'static str {
1006 "FirewallVerifier"
1007 }
1008
1009 fn verify(&self, tool_name: &str, args: &serde_json::Value) -> VerificationResult {
1010 if self.exempt_tools.contains(&tool_name.to_lowercase()) {
1011 return VerificationResult::Allow;
1012 }
1013
1014 for arg in Self::collect_args(args) {
1015 if let Some(result) = self.scan_arg(&arg) {
1016 return result;
1017 }
1018 }
1019
1020 VerificationResult::Allow
1021 }
1022}
1023
1024#[cfg(test)]
1029mod tests {
1030 use serde_json::json;
1031
1032 use super::*;
1033
1034 fn dcv() -> DestructiveCommandVerifier {
1037 DestructiveCommandVerifier::new(&DestructiveVerifierConfig::default())
1038 }
1039
1040 #[test]
1041 fn allow_normal_command() {
1042 let v = dcv();
1043 assert_eq!(
1044 v.verify("bash", &json!({"command": "ls -la /tmp"})),
1045 VerificationResult::Allow
1046 );
1047 }
1048
1049 #[test]
1050 fn block_rm_rf_root() {
1051 let v = dcv();
1052 let result = v.verify("bash", &json!({"command": "rm -rf /"}));
1053 assert!(matches!(result, VerificationResult::Block { .. }));
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 guarded_tools: default_guarded_tools(),
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}