1use std::collections::HashSet;
26use std::sync::{Arc, LazyLock};
27
28use parking_lot::RwLock;
29
30use regex::Regex;
31use serde::{Deserialize, Serialize};
32use unicode_normalization::UnicodeNormalization as _;
33
34fn default_true() -> bool {
35 true
36}
37
38fn default_shell_tools() -> Vec<String> {
39 vec![
40 "bash".to_string(),
41 "shell".to_string(),
42 "terminal".to_string(),
43 ]
44}
45
46#[must_use]
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub enum VerificationResult {
50 Allow,
52 Block { reason: String },
54 Warn { message: String },
57}
58
59pub trait PreExecutionVerifier: Send + Sync + std::fmt::Debug {
65 fn verify(&self, tool_name: &str, args: &serde_json::Value) -> VerificationResult;
67
68 fn name(&self) -> &'static str;
70}
71
72#[derive(Debug, Clone, Deserialize, Serialize)]
86pub struct DestructiveVerifierConfig {
87 #[serde(default = "default_true")]
88 pub enabled: bool,
89 #[serde(default)]
92 pub allowed_paths: Vec<String>,
93 #[serde(default)]
95 pub extra_patterns: Vec<String>,
96 #[serde(default = "default_shell_tools")]
99 pub shell_tools: Vec<String>,
100}
101
102impl Default for DestructiveVerifierConfig {
103 fn default() -> Self {
104 Self {
105 enabled: true,
106 allowed_paths: Vec::new(),
107 extra_patterns: Vec::new(),
108 shell_tools: default_shell_tools(),
109 }
110 }
111}
112
113#[derive(Debug, Clone, Deserialize, Serialize)]
115pub struct InjectionVerifierConfig {
116 #[serde(default = "default_true")]
117 pub enabled: bool,
118 #[serde(default)]
121 pub extra_patterns: Vec<String>,
122 #[serde(default)]
124 pub allowlisted_urls: Vec<String>,
125}
126
127impl Default for InjectionVerifierConfig {
128 fn default() -> Self {
129 Self {
130 enabled: true,
131 extra_patterns: Vec::new(),
132 allowlisted_urls: Vec::new(),
133 }
134 }
135}
136
137#[derive(Debug, Clone, Deserialize, Serialize)]
144pub struct UrlGroundingVerifierConfig {
145 #[serde(default = "default_true")]
146 pub enabled: bool,
147 #[serde(default = "default_guarded_tools")]
150 pub guarded_tools: Vec<String>,
151}
152
153fn default_guarded_tools() -> Vec<String> {
154 vec!["fetch".to_string(), "web_scrape".to_string()]
155}
156
157impl Default for UrlGroundingVerifierConfig {
158 fn default() -> Self {
159 Self {
160 enabled: true,
161 guarded_tools: default_guarded_tools(),
162 }
163 }
164}
165
166#[derive(Debug, Clone, Deserialize, Serialize)]
168pub struct PreExecutionVerifierConfig {
169 #[serde(default = "default_true")]
170 pub enabled: bool,
171 #[serde(default)]
172 pub destructive_commands: DestructiveVerifierConfig,
173 #[serde(default)]
174 pub injection_patterns: InjectionVerifierConfig,
175 #[serde(default)]
176 pub url_grounding: UrlGroundingVerifierConfig,
177 #[serde(default)]
178 pub firewall: FirewallVerifierConfig,
179}
180
181impl Default for PreExecutionVerifierConfig {
182 fn default() -> Self {
183 Self {
184 enabled: true,
185 destructive_commands: DestructiveVerifierConfig::default(),
186 injection_patterns: InjectionVerifierConfig::default(),
187 url_grounding: UrlGroundingVerifierConfig::default(),
188 firewall: FirewallVerifierConfig::default(),
189 }
190 }
191}
192
193static DESTRUCTIVE_PATTERNS: &[&str] = &[
202 "rm -rf /",
203 "rm -rf ~",
204 "rm -r /",
205 "dd if=",
206 "mkfs",
207 "fdisk",
208 "shred",
209 "wipefs",
210 ":(){ :|:& };:",
211 ":(){:|:&};:",
212 "chmod -r 777 /",
213 "chown -r",
214];
215
216#[derive(Debug)]
224pub struct DestructiveCommandVerifier {
225 shell_tools: Vec<String>,
226 allowed_paths: Vec<String>,
227 extra_patterns: Vec<String>,
228}
229
230impl DestructiveCommandVerifier {
231 #[must_use]
232 pub fn new(config: &DestructiveVerifierConfig) -> Self {
233 Self {
234 shell_tools: config
235 .shell_tools
236 .iter()
237 .map(|s| s.to_lowercase())
238 .collect(),
239 allowed_paths: config
240 .allowed_paths
241 .iter()
242 .map(|s| s.to_lowercase())
243 .collect(),
244 extra_patterns: config
245 .extra_patterns
246 .iter()
247 .map(|s| s.to_lowercase())
248 .collect(),
249 }
250 }
251
252 fn is_shell_tool(&self, tool_name: &str) -> bool {
253 let lower = tool_name.to_lowercase();
254 self.shell_tools.iter().any(|t| t == &lower)
255 }
256
257 fn extract_command(args: &serde_json::Value) -> Option<String> {
267 let raw = match args.get("command") {
268 Some(serde_json::Value::String(s)) => s.clone(),
269 Some(serde_json::Value::Array(arr)) => arr
270 .iter()
271 .filter_map(|v| v.as_str())
272 .collect::<Vec<_>>()
273 .join(" "),
274 _ => return None,
275 };
276 let mut current: String = raw.nfkc().collect::<String>().to_lowercase();
278 for _ in 0..8 {
281 let trimmed = current.trim().to_owned();
282 let after_env = Self::strip_env_prefix(&trimmed);
284 let after_exec = after_env.strip_prefix("exec ").map_or(after_env, str::trim);
286 let mut unwrapped = false;
288 for interp in &["bash -c ", "sh -c ", "zsh -c "] {
289 if let Some(rest) = after_exec.strip_prefix(interp) {
290 let script = rest.trim().trim_matches(|c: char| c == '\'' || c == '"');
291 current.clone_from(&script.to_owned());
292 unwrapped = true;
293 break;
294 }
295 }
296 if !unwrapped {
297 return Some(after_exec.to_owned());
298 }
299 }
300 Some(current)
301 }
302
303 fn strip_env_prefix(cmd: &str) -> &str {
306 let mut rest = cmd;
307 if let Some(after_env) = rest.strip_prefix("env ") {
309 rest = after_env.trim_start();
310 }
311 loop {
313 let mut chars = rest.chars();
315 let key_end = chars
316 .by_ref()
317 .take_while(|c| c.is_alphanumeric() || *c == '_')
318 .count();
319 if key_end == 0 {
320 break;
321 }
322 let remainder = &rest[key_end..];
323 if let Some(after_eq) = remainder.strip_prefix('=') {
324 let val_end = after_eq.find(' ').unwrap_or(after_eq.len());
326 rest = after_eq[val_end..].trim_start();
327 } else {
328 break;
329 }
330 }
331 rest
332 }
333
334 fn is_allowed_path(&self, command: &str) -> bool {
340 if self.allowed_paths.is_empty() {
341 return false;
342 }
343 let tokens: Vec<&str> = command.split_whitespace().collect();
344 for token in &tokens {
345 let t = token.trim_matches(|c| c == '\'' || c == '"');
346 if t.starts_with('/') || t.starts_with('~') || t.starts_with('.') {
347 let normalized = Self::lexical_normalize(std::path::Path::new(t));
348 let n_lower = normalized
351 .to_string_lossy()
352 .replace('\\', "/")
353 .to_lowercase();
354 if self
355 .allowed_paths
356 .iter()
357 .any(|p| n_lower.starts_with(p.replace('\\', "/").to_lowercase().as_str()))
358 {
359 return true;
360 }
361 }
362 }
363 false
364 }
365
366 fn lexical_normalize(p: &std::path::Path) -> std::path::PathBuf {
369 let mut out = std::path::PathBuf::new();
370 for component in p.components() {
371 match component {
372 std::path::Component::ParentDir => {
373 out.pop();
374 }
375 std::path::Component::CurDir => {}
376 other => out.push(other),
377 }
378 }
379 out
380 }
381
382 fn check_patterns(command: &str) -> Option<&'static str> {
383 DESTRUCTIVE_PATTERNS
384 .iter()
385 .find(|&pat| command.contains(pat))
386 .copied()
387 }
388
389 fn check_extra_patterns(&self, command: &str) -> Option<String> {
390 self.extra_patterns
391 .iter()
392 .find(|pat| command.contains(pat.as_str()))
393 .cloned()
394 }
395}
396
397impl PreExecutionVerifier for DestructiveCommandVerifier {
398 fn name(&self) -> &'static str {
399 "DestructiveCommandVerifier"
400 }
401
402 fn verify(&self, tool_name: &str, args: &serde_json::Value) -> VerificationResult {
403 if !self.is_shell_tool(tool_name) {
404 return VerificationResult::Allow;
405 }
406
407 let Some(command) = Self::extract_command(args) else {
408 return VerificationResult::Allow;
409 };
410
411 if let Some(pat) = Self::check_patterns(&command) {
412 if self.is_allowed_path(&command) {
413 return VerificationResult::Allow;
414 }
415 return VerificationResult::Block {
416 reason: format!("[{}] destructive pattern '{}' detected", self.name(), pat),
417 };
418 }
419
420 if let Some(pat) = self.check_extra_patterns(&command) {
421 if self.is_allowed_path(&command) {
422 return VerificationResult::Allow;
423 }
424 return VerificationResult::Block {
425 reason: format!(
426 "[{}] extra destructive pattern '{}' detected",
427 self.name(),
428 pat
429 ),
430 };
431 }
432
433 VerificationResult::Allow
434 }
435}
436
437static INJECTION_BLOCK_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
447 [
448 r"(?i)'\s*OR\s*'1'\s*=\s*'1",
450 r"(?i)'\s*OR\s*1\s*=\s*1",
451 r"(?i);\s*DROP\s+TABLE",
452 r"(?i)UNION\s+SELECT",
453 r"(?i)'\s*;\s*SELECT",
454 r";\s*rm\s+",
456 r"\|\s*rm\s+",
457 r"&&\s*rm\s+",
458 r";\s*curl\s+",
459 r"\|\s*curl\s+",
460 r"&&\s*curl\s+",
461 r";\s*wget\s+",
462 r"\.\./\.\./\.\./etc/passwd",
464 r"\.\./\.\./\.\./etc/shadow",
465 r"\.\./\.\./\.\./windows/",
466 r"\.\.[/\\]\.\.[/\\]\.\.[/\\]",
467 ]
468 .iter()
469 .map(|s| Regex::new(s).expect("static pattern must compile"))
470 .collect()
471});
472
473static SSRF_HOST_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
478 [
479 r"^localhost$",
481 r"^localhost:",
482 r"^127\.0\.0\.1$",
484 r"^127\.0\.0\.1:",
485 r"^\[::1\]$",
487 r"^\[::1\]:",
488 r"^169\.254\.169\.254$",
490 r"^169\.254\.169\.254:",
491 r"^10\.\d+\.\d+\.\d+$",
493 r"^10\.\d+\.\d+\.\d+:",
494 r"^172\.(1[6-9]|2\d|3[01])\.\d+\.\d+$",
495 r"^172\.(1[6-9]|2\d|3[01])\.\d+\.\d+:",
496 r"^192\.168\.\d+\.\d+$",
497 r"^192\.168\.\d+\.\d+:",
498 ]
499 .iter()
500 .map(|s| Regex::new(s).expect("static pattern must compile"))
501 .collect()
502});
503
504fn extract_url_host(url: &str) -> Option<&str> {
508 let after_scheme = url.split_once("://")?.1;
509 let host_end = after_scheme
510 .find(['/', '?', '#'])
511 .unwrap_or(after_scheme.len());
512 Some(&after_scheme[..host_end])
513}
514
515static URL_FIELD_NAMES: &[&str] = &["url", "endpoint", "uri", "href", "src", "host", "base_url"];
517
518static SAFE_QUERY_FIELDS: &[&str] = &["query", "q", "search", "text", "message", "content"];
522
523#[derive(Debug)]
544pub struct InjectionPatternVerifier {
545 extra_patterns: Vec<Regex>,
546 allowlisted_urls: Vec<String>,
547}
548
549impl InjectionPatternVerifier {
550 #[must_use]
551 pub fn new(config: &InjectionVerifierConfig) -> Self {
552 let extra_patterns = config
553 .extra_patterns
554 .iter()
555 .filter_map(|s| match Regex::new(s) {
556 Ok(re) => Some(re),
557 Err(e) => {
558 tracing::warn!(
559 pattern = %s,
560 error = %e,
561 "InjectionPatternVerifier: invalid extra_pattern, skipping"
562 );
563 None
564 }
565 })
566 .collect();
567
568 Self {
569 extra_patterns,
570 allowlisted_urls: config
571 .allowlisted_urls
572 .iter()
573 .map(|s| s.to_lowercase())
574 .collect(),
575 }
576 }
577
578 fn is_allowlisted(&self, text: &str) -> bool {
579 let lower = text.to_lowercase();
580 self.allowlisted_urls
581 .iter()
582 .any(|u| lower.contains(u.as_str()))
583 }
584
585 fn is_url_field(field: &str) -> bool {
586 let lower = field.to_lowercase();
587 URL_FIELD_NAMES.iter().any(|&f| f == lower)
588 }
589
590 fn is_safe_query_field(field: &str) -> bool {
591 let lower = field.to_lowercase();
592 SAFE_QUERY_FIELDS.iter().any(|&f| f == lower)
593 }
594
595 fn check_field_value(&self, field: &str, value: &str) -> VerificationResult {
597 let is_url = Self::is_url_field(field);
598 let is_safe_query = Self::is_safe_query_field(field);
599
600 if !is_safe_query {
602 for pat in INJECTION_BLOCK_PATTERNS.iter() {
603 if pat.is_match(value) {
604 return VerificationResult::Block {
605 reason: format!(
606 "[{}] injection pattern detected in field '{}': {}",
607 "InjectionPatternVerifier",
608 field,
609 pat.as_str()
610 ),
611 };
612 }
613 }
614 for pat in &self.extra_patterns {
615 if pat.is_match(value) {
616 return VerificationResult::Block {
617 reason: format!(
618 "[{}] extra injection pattern detected in field '{}': {}",
619 "InjectionPatternVerifier",
620 field,
621 pat.as_str()
622 ),
623 };
624 }
625 }
626 }
627
628 if is_url && let Some(host) = extract_url_host(value) {
632 for pat in SSRF_HOST_PATTERNS.iter() {
633 if pat.is_match(host) {
634 if self.is_allowlisted(value) {
635 return VerificationResult::Allow;
636 }
637 return VerificationResult::Warn {
638 message: format!(
639 "[{}] possible SSRF in field '{}': host '{}' matches pattern (not blocked)",
640 "InjectionPatternVerifier", field, host,
641 ),
642 };
643 }
644 }
645 }
646
647 VerificationResult::Allow
648 }
649
650 fn check_object(&self, obj: &serde_json::Map<String, serde_json::Value>) -> VerificationResult {
652 for (key, val) in obj {
653 let result = self.check_value(key, val);
654 if !matches!(result, VerificationResult::Allow) {
655 return result;
656 }
657 }
658 VerificationResult::Allow
659 }
660
661 fn check_value(&self, field: &str, val: &serde_json::Value) -> VerificationResult {
662 match val {
663 serde_json::Value::String(s) => self.check_field_value(field, s),
664 serde_json::Value::Array(arr) => {
665 for item in arr {
666 let r = self.check_value(field, item);
667 if !matches!(r, VerificationResult::Allow) {
668 return r;
669 }
670 }
671 VerificationResult::Allow
672 }
673 serde_json::Value::Object(obj) => self.check_object(obj),
674 _ => VerificationResult::Allow,
676 }
677 }
678}
679
680impl PreExecutionVerifier for InjectionPatternVerifier {
681 fn name(&self) -> &'static str {
682 "InjectionPatternVerifier"
683 }
684
685 fn verify(&self, _tool_name: &str, args: &serde_json::Value) -> VerificationResult {
686 match args {
687 serde_json::Value::Object(obj) => self.check_object(obj),
688 serde_json::Value::String(s) => self.check_field_value("_args", s),
690 _ => VerificationResult::Allow,
691 }
692 }
693}
694
695#[derive(Debug, Clone)]
714pub struct UrlGroundingVerifier {
715 guarded_tools: Vec<String>,
716 user_provided_urls: Arc<RwLock<HashSet<String>>>,
717}
718
719impl UrlGroundingVerifier {
720 #[must_use]
721 pub fn new(
722 config: &UrlGroundingVerifierConfig,
723 user_provided_urls: Arc<RwLock<HashSet<String>>>,
724 ) -> Self {
725 Self {
726 guarded_tools: config
727 .guarded_tools
728 .iter()
729 .map(|s| s.to_lowercase())
730 .collect(),
731 user_provided_urls,
732 }
733 }
734
735 fn is_guarded(&self, tool_name: &str) -> bool {
736 let lower = tool_name.to_lowercase();
737 self.guarded_tools.iter().any(|t| t == &lower) || lower.ends_with("_fetch")
738 }
739
740 fn is_grounded(url: &str, user_provided_urls: &HashSet<String>) -> bool {
743 let lower = url.to_lowercase();
744 user_provided_urls
745 .iter()
746 .any(|u| lower.starts_with(u.as_str()) || u.starts_with(lower.as_str()))
747 }
748}
749
750impl PreExecutionVerifier for UrlGroundingVerifier {
751 fn name(&self) -> &'static str {
752 "UrlGroundingVerifier"
753 }
754
755 fn verify(&self, tool_name: &str, args: &serde_json::Value) -> VerificationResult {
756 if !self.is_guarded(tool_name) {
757 return VerificationResult::Allow;
758 }
759
760 let Some(url) = args.get("url").and_then(|v| v.as_str()) else {
761 return VerificationResult::Allow;
762 };
763
764 let urls = self.user_provided_urls.read();
765
766 if Self::is_grounded(url, &urls) {
767 return VerificationResult::Allow;
768 }
769
770 VerificationResult::Block {
771 reason: format!(
772 "[UrlGroundingVerifier] fetch rejected: URL '{url}' was not provided by the user",
773 ),
774 }
775 }
776}
777
778#[derive(Debug, Clone, Deserialize, Serialize)]
784pub struct FirewallVerifierConfig {
785 #[serde(default = "default_true")]
786 pub enabled: bool,
787 #[serde(default)]
789 pub blocked_paths: Vec<String>,
790 #[serde(default)]
792 pub blocked_env_vars: Vec<String>,
793 #[serde(default)]
795 pub exempt_tools: Vec<String>,
796}
797
798impl Default for FirewallVerifierConfig {
799 fn default() -> Self {
800 Self {
801 enabled: true,
802 blocked_paths: Vec::new(),
803 blocked_env_vars: Vec::new(),
804 exempt_tools: Vec::new(),
805 }
806 }
807}
808
809#[derive(Debug)]
822pub struct FirewallVerifier {
823 blocked_path_globs: Vec<glob::Pattern>,
824 blocked_env_vars: HashSet<String>,
825 exempt_tools: HashSet<String>,
826}
827
828static SENSITIVE_PATH_PATTERNS: LazyLock<Vec<glob::Pattern>> = LazyLock::new(|| {
830 let raw = [
831 "/etc/passwd",
832 "/etc/shadow",
833 "/etc/sudoers",
834 "~/.ssh/*",
835 "~/.aws/*",
836 "~/.gnupg/*",
837 "**/*.pem",
838 "**/*.key",
839 "**/id_rsa",
840 "**/id_ed25519",
841 "**/.env",
842 "**/credentials",
843 ];
844 raw.iter()
845 .filter_map(|p| {
846 glob::Pattern::new(p)
847 .map_err(|e| {
848 tracing::error!(pattern = p, error = %e, "failed to compile built-in firewall path pattern");
849 e
850 })
851 .ok()
852 })
853 .collect()
854});
855
856static SENSITIVE_ENV_PREFIXES: &[&str] =
858 &["$AWS_", "$ZEPH_", "${AWS_", "${ZEPH_", "%AWS_", "%ZEPH_"];
859
860static INSPECTED_FIELDS: &[&str] = &[
862 "command",
863 "file_path",
864 "path",
865 "url",
866 "query",
867 "uri",
868 "input",
869 "args",
870];
871
872impl FirewallVerifier {
873 #[must_use]
877 pub fn new(config: &FirewallVerifierConfig) -> Self {
878 let blocked_path_globs = config
879 .blocked_paths
880 .iter()
881 .filter_map(|p| {
882 glob::Pattern::new(p)
883 .map_err(|e| {
884 tracing::warn!(pattern = p, error = %e, "invalid glob pattern in firewall blocked_paths, skipping");
885 e
886 })
887 .ok()
888 })
889 .collect();
890
891 let blocked_env_vars = config
892 .blocked_env_vars
893 .iter()
894 .map(|s| s.to_uppercase())
895 .collect();
896
897 let exempt_tools = config
898 .exempt_tools
899 .iter()
900 .map(|s| s.to_lowercase())
901 .collect();
902
903 Self {
904 blocked_path_globs,
905 blocked_env_vars,
906 exempt_tools,
907 }
908 }
909
910 fn collect_args(args: &serde_json::Value) -> Vec<String> {
912 let mut out = Vec::new();
913 match args {
914 serde_json::Value::Object(map) => {
915 for field in INSPECTED_FIELDS {
916 if let Some(val) = map.get(*field) {
917 Self::collect_strings(val, &mut out);
918 }
919 }
920 }
921 serde_json::Value::String(s) => out.push(s.clone()),
922 _ => {}
923 }
924 out
925 }
926
927 fn collect_strings(val: &serde_json::Value, out: &mut Vec<String>) {
928 match val {
929 serde_json::Value::String(s) => out.push(s.clone()),
930 serde_json::Value::Array(arr) => {
931 for item in arr {
932 Self::collect_strings(item, out);
933 }
934 }
935 _ => {}
936 }
937 }
938
939 fn scan_arg(&self, arg: &str) -> Option<VerificationResult> {
940 let normalized: String = arg.nfkc().collect();
942 let lower = normalized.to_lowercase();
943
944 if lower.contains("../") || lower.contains("..\\") {
946 return Some(VerificationResult::Block {
947 reason: format!(
948 "[FirewallVerifier] path traversal pattern detected in argument: {arg}"
949 ),
950 });
951 }
952
953 for pattern in SENSITIVE_PATH_PATTERNS.iter() {
955 if pattern.matches(&normalized) || pattern.matches(&lower) {
956 return Some(VerificationResult::Block {
957 reason: format!(
958 "[FirewallVerifier] sensitive path pattern '{pattern}' matched in argument: {arg}"
959 ),
960 });
961 }
962 }
963
964 for pattern in &self.blocked_path_globs {
966 if pattern.matches(&normalized) || pattern.matches(&lower) {
967 return Some(VerificationResult::Block {
968 reason: format!(
969 "[FirewallVerifier] blocked path pattern '{pattern}' matched in argument: {arg}"
970 ),
971 });
972 }
973 }
974
975 let upper = normalized.to_uppercase();
977 for prefix in SENSITIVE_ENV_PREFIXES {
978 if upper.contains(*prefix) {
979 return Some(VerificationResult::Block {
980 reason: format!(
981 "[FirewallVerifier] env var exfiltration pattern '{prefix}' detected in argument: {arg}"
982 ),
983 });
984 }
985 }
986
987 for var in &self.blocked_env_vars {
989 let dollar_form = format!("${var}");
990 let brace_form = format!("${{{var}}}");
991 let percent_form = format!("%{var}%");
992 if upper.contains(&dollar_form)
993 || upper.contains(&brace_form)
994 || upper.contains(&percent_form)
995 {
996 return Some(VerificationResult::Block {
997 reason: format!(
998 "[FirewallVerifier] blocked env var '{var}' detected in argument: {arg}"
999 ),
1000 });
1001 }
1002 }
1003
1004 None
1005 }
1006}
1007
1008impl PreExecutionVerifier for FirewallVerifier {
1009 fn name(&self) -> &'static str {
1010 "FirewallVerifier"
1011 }
1012
1013 fn verify(&self, tool_name: &str, args: &serde_json::Value) -> VerificationResult {
1014 if self.exempt_tools.contains(&tool_name.to_lowercase()) {
1015 return VerificationResult::Allow;
1016 }
1017
1018 for arg in Self::collect_args(args) {
1019 if let Some(result) = self.scan_arg(&arg) {
1020 return result;
1021 }
1022 }
1023
1024 VerificationResult::Allow
1025 }
1026}
1027
1028#[cfg(test)]
1033mod tests {
1034 use serde_json::json;
1035
1036 use super::*;
1037
1038 fn dcv() -> DestructiveCommandVerifier {
1041 DestructiveCommandVerifier::new(&DestructiveVerifierConfig::default())
1042 }
1043
1044 #[test]
1045 fn allow_normal_command() {
1046 let v = dcv();
1047 assert_eq!(
1048 v.verify("bash", &json!({"command": "ls -la /tmp"})),
1049 VerificationResult::Allow
1050 );
1051 }
1052
1053 #[test]
1054 fn block_rm_rf_root() {
1055 let v = dcv();
1056 let result = v.verify("bash", &json!({"command": "rm -rf /"}));
1057 assert!(matches!(result, VerificationResult::Block { .. }));
1058 }
1059
1060 #[test]
1061 fn block_dd_dev_zero() {
1062 let v = dcv();
1063 let result = v.verify("bash", &json!({"command": "dd if=/dev/zero of=/dev/sda"}));
1064 assert!(matches!(result, VerificationResult::Block { .. }));
1065 }
1066
1067 #[test]
1068 fn block_mkfs() {
1069 let v = dcv();
1070 let result = v.verify("bash", &json!({"command": "mkfs.ext4 /dev/sda1"}));
1071 assert!(matches!(result, VerificationResult::Block { .. }));
1072 }
1073
1074 #[test]
1075 fn allow_rm_rf_in_allowed_path() {
1076 let config = DestructiveVerifierConfig {
1077 allowed_paths: vec!["/tmp/build".to_string()],
1078 ..Default::default()
1079 };
1080 let v = DestructiveCommandVerifier::new(&config);
1081 assert_eq!(
1082 v.verify("bash", &json!({"command": "rm -rf /tmp/build/artifacts"})),
1083 VerificationResult::Allow
1084 );
1085 }
1086
1087 #[test]
1088 fn block_rm_rf_when_not_in_allowed_path() {
1089 let config = DestructiveVerifierConfig {
1090 allowed_paths: vec!["/tmp/build".to_string()],
1091 ..Default::default()
1092 };
1093 let v = DestructiveCommandVerifier::new(&config);
1094 let result = v.verify("bash", &json!({"command": "rm -rf /home/user"}));
1095 assert!(matches!(result, VerificationResult::Block { .. }));
1096 }
1097
1098 #[test]
1099 fn allow_non_shell_tool() {
1100 let v = dcv();
1101 assert_eq!(
1102 v.verify("read_file", &json!({"path": "rm -rf /"})),
1103 VerificationResult::Allow
1104 );
1105 }
1106
1107 #[test]
1108 fn block_extra_pattern() {
1109 let config = DestructiveVerifierConfig {
1110 extra_patterns: vec!["format c:".to_string()],
1111 ..Default::default()
1112 };
1113 let v = DestructiveCommandVerifier::new(&config);
1114 let result = v.verify("bash", &json!({"command": "format c:"}));
1115 assert!(matches!(result, VerificationResult::Block { .. }));
1116 }
1117
1118 #[test]
1119 fn array_args_normalization() {
1120 let v = dcv();
1121 let result = v.verify("bash", &json!({"command": ["rm", "-rf", "/"]}));
1122 assert!(matches!(result, VerificationResult::Block { .. }));
1123 }
1124
1125 #[test]
1126 fn sh_c_wrapping_normalization() {
1127 let v = dcv();
1128 let result = v.verify("bash", &json!({"command": "bash -c 'rm -rf /'"}));
1129 assert!(matches!(result, VerificationResult::Block { .. }));
1130 }
1131
1132 #[test]
1133 fn fork_bomb_blocked() {
1134 let v = dcv();
1135 let result = v.verify("bash", &json!({"command": ":(){ :|:& };:"}));
1136 assert!(matches!(result, VerificationResult::Block { .. }));
1137 }
1138
1139 #[test]
1140 fn custom_shell_tool_name_blocked() {
1141 let config = DestructiveVerifierConfig {
1142 shell_tools: vec!["execute".to_string(), "run_command".to_string()],
1143 ..Default::default()
1144 };
1145 let v = DestructiveCommandVerifier::new(&config);
1146 let result = v.verify("execute", &json!({"command": "rm -rf /"}));
1147 assert!(matches!(result, VerificationResult::Block { .. }));
1148 }
1149
1150 #[test]
1151 fn terminal_tool_name_blocked_by_default() {
1152 let v = dcv();
1153 let result = v.verify("terminal", &json!({"command": "rm -rf /"}));
1154 assert!(matches!(result, VerificationResult::Block { .. }));
1155 }
1156
1157 #[test]
1158 fn default_shell_tools_contains_bash_shell_terminal() {
1159 let config = DestructiveVerifierConfig::default();
1160 let lower: Vec<String> = config
1161 .shell_tools
1162 .iter()
1163 .map(|s| s.to_lowercase())
1164 .collect();
1165 assert!(lower.contains(&"bash".to_string()));
1166 assert!(lower.contains(&"shell".to_string()));
1167 assert!(lower.contains(&"terminal".to_string()));
1168 }
1169
1170 fn ipv() -> InjectionPatternVerifier {
1173 InjectionPatternVerifier::new(&InjectionVerifierConfig::default())
1174 }
1175
1176 #[test]
1177 fn allow_clean_args() {
1178 let v = ipv();
1179 assert_eq!(
1180 v.verify("search", &json!({"query": "rust async traits"})),
1181 VerificationResult::Allow
1182 );
1183 }
1184
1185 #[test]
1186 fn allow_sql_discussion_in_query_field() {
1187 let v = ipv();
1189 assert_eq!(
1190 v.verify(
1191 "memory_search",
1192 &json!({"query": "explain SQL UNION SELECT vs JOIN"})
1193 ),
1194 VerificationResult::Allow
1195 );
1196 }
1197
1198 #[test]
1199 fn allow_sql_or_pattern_in_query_field() {
1200 let v = ipv();
1202 assert_eq!(
1203 v.verify("memory_search", &json!({"query": "' OR '1'='1"})),
1204 VerificationResult::Allow
1205 );
1206 }
1207
1208 #[test]
1209 fn block_sql_injection_in_non_query_field() {
1210 let v = ipv();
1211 let result = v.verify("db_query", &json!({"sql": "' OR '1'='1"}));
1212 assert!(matches!(result, VerificationResult::Block { .. }));
1213 }
1214
1215 #[test]
1216 fn block_drop_table() {
1217 let v = ipv();
1218 let result = v.verify("db_query", &json!({"input": "name'; DROP TABLE users"}));
1219 assert!(matches!(result, VerificationResult::Block { .. }));
1220 }
1221
1222 #[test]
1223 fn block_path_traversal() {
1224 let v = ipv();
1225 let result = v.verify("read_file", &json!({"path": "../../../etc/passwd"}));
1226 assert!(matches!(result, VerificationResult::Block { .. }));
1227 }
1228
1229 #[test]
1230 fn warn_on_localhost_url_field() {
1231 let v = ipv();
1233 let result = v.verify("http_get", &json!({"url": "http://localhost:8080/api"}));
1234 assert!(matches!(result, VerificationResult::Warn { .. }));
1235 }
1236
1237 #[test]
1238 fn allow_localhost_in_non_url_field() {
1239 let v = ipv();
1241 assert_eq!(
1242 v.verify(
1243 "memory_search",
1244 &json!({"query": "connect to http://localhost:8080"})
1245 ),
1246 VerificationResult::Allow
1247 );
1248 }
1249
1250 #[test]
1251 fn warn_on_private_ip_url_field() {
1252 let v = ipv();
1253 let result = v.verify("fetch", &json!({"url": "http://192.168.1.1/admin"}));
1254 assert!(matches!(result, VerificationResult::Warn { .. }));
1255 }
1256
1257 #[test]
1258 fn allow_localhost_when_allowlisted() {
1259 let config = InjectionVerifierConfig {
1260 allowlisted_urls: vec!["http://localhost:3000".to_string()],
1261 ..Default::default()
1262 };
1263 let v = InjectionPatternVerifier::new(&config);
1264 assert_eq!(
1265 v.verify("http_get", &json!({"url": "http://localhost:3000/api"})),
1266 VerificationResult::Allow
1267 );
1268 }
1269
1270 #[test]
1271 fn block_union_select_in_non_query_field() {
1272 let v = ipv();
1273 let result = v.verify(
1274 "db_query",
1275 &json!({"input": "id=1 UNION SELECT password FROM users"}),
1276 );
1277 assert!(matches!(result, VerificationResult::Block { .. }));
1278 }
1279
1280 #[test]
1281 fn allow_union_select_in_query_field() {
1282 let v = ipv();
1284 assert_eq!(
1285 v.verify(
1286 "memory_search",
1287 &json!({"query": "id=1 UNION SELECT password FROM users"})
1288 ),
1289 VerificationResult::Allow
1290 );
1291 }
1292
1293 #[test]
1296 fn block_rm_rf_unicode_homoglyph() {
1297 let v = dcv();
1299 let result = v.verify("bash", &json!({"command": "rm -rf \u{FF0F}"}));
1301 assert!(matches!(result, VerificationResult::Block { .. }));
1302 }
1303
1304 #[test]
1307 fn path_traversal_not_allowed_via_dotdot() {
1308 let config = DestructiveVerifierConfig {
1310 allowed_paths: vec!["/tmp/build".to_string()],
1311 ..Default::default()
1312 };
1313 let v = DestructiveCommandVerifier::new(&config);
1314 let result = v.verify("bash", &json!({"command": "rm -rf /tmp/build/../../etc"}));
1316 assert!(matches!(result, VerificationResult::Block { .. }));
1317 }
1318
1319 #[test]
1320 fn allowed_path_with_dotdot_stays_in_allowed() {
1321 let config = DestructiveVerifierConfig {
1323 allowed_paths: vec!["/tmp/build".to_string()],
1324 ..Default::default()
1325 };
1326 let v = DestructiveCommandVerifier::new(&config);
1327 assert_eq!(
1328 v.verify(
1329 "bash",
1330 &json!({"command": "rm -rf /tmp/build/sub/../artifacts"}),
1331 ),
1332 VerificationResult::Allow,
1333 );
1334 }
1335
1336 #[test]
1339 fn double_nested_bash_c_blocked() {
1340 let v = dcv();
1341 let result = v.verify(
1342 "bash",
1343 &json!({"command": "bash -c \"bash -c 'rm -rf /'\""}),
1344 );
1345 assert!(matches!(result, VerificationResult::Block { .. }));
1346 }
1347
1348 #[test]
1349 fn env_prefix_stripping_blocked() {
1350 let v = dcv();
1351 let result = v.verify(
1352 "bash",
1353 &json!({"command": "env FOO=bar bash -c 'rm -rf /'"}),
1354 );
1355 assert!(matches!(result, VerificationResult::Block { .. }));
1356 }
1357
1358 #[test]
1359 fn exec_prefix_stripping_blocked() {
1360 let v = dcv();
1361 let result = v.verify("bash", &json!({"command": "exec bash -c 'rm -rf /'"}));
1362 assert!(matches!(result, VerificationResult::Block { .. }));
1363 }
1364
1365 #[test]
1368 fn ssrf_not_triggered_for_embedded_localhost_in_query_param() {
1369 let v = ipv();
1371 let result = v.verify(
1372 "http_get",
1373 &json!({"url": "http://evil.com/?r=http://localhost"}),
1374 );
1375 assert_eq!(result, VerificationResult::Allow);
1377 }
1378
1379 #[test]
1380 fn ssrf_triggered_for_bare_localhost_no_port() {
1381 let v = ipv();
1383 let result = v.verify("http_get", &json!({"url": "http://localhost"}));
1384 assert!(matches!(result, VerificationResult::Warn { .. }));
1385 }
1386
1387 #[test]
1388 fn ssrf_triggered_for_localhost_with_path() {
1389 let v = ipv();
1390 let result = v.verify("http_get", &json!({"url": "http://localhost/api/v1"}));
1391 assert!(matches!(result, VerificationResult::Warn { .. }));
1392 }
1393
1394 #[test]
1397 fn chain_first_block_wins() {
1398 let dcv = DestructiveCommandVerifier::new(&DestructiveVerifierConfig::default());
1399 let ipv = InjectionPatternVerifier::new(&InjectionVerifierConfig::default());
1400 let verifiers: Vec<Box<dyn PreExecutionVerifier>> = vec![Box::new(dcv), Box::new(ipv)];
1401
1402 let args = json!({"command": "rm -rf /"});
1403 let mut result = VerificationResult::Allow;
1404 for v in &verifiers {
1405 result = v.verify("bash", &args);
1406 if matches!(result, VerificationResult::Block { .. }) {
1407 break;
1408 }
1409 }
1410 assert!(matches!(result, VerificationResult::Block { .. }));
1411 }
1412
1413 #[test]
1414 fn chain_warn_continues() {
1415 let dcv = DestructiveCommandVerifier::new(&DestructiveVerifierConfig::default());
1416 let ipv = InjectionPatternVerifier::new(&InjectionVerifierConfig::default());
1417 let verifiers: Vec<Box<dyn PreExecutionVerifier>> = vec![Box::new(dcv), Box::new(ipv)];
1418
1419 let args = json!({"url": "http://localhost:8080/api"});
1421 let mut got_warn = false;
1422 let mut got_block = false;
1423 for v in &verifiers {
1424 match v.verify("http_get", &args) {
1425 VerificationResult::Block { .. } => {
1426 got_block = true;
1427 break;
1428 }
1429 VerificationResult::Warn { .. } => {
1430 got_warn = true;
1431 }
1432 VerificationResult::Allow => {}
1433 }
1434 }
1435 assert!(got_warn);
1436 assert!(!got_block);
1437 }
1438
1439 fn ugv(urls: &[&str]) -> UrlGroundingVerifier {
1442 let set: HashSet<String> = urls.iter().map(|s| s.to_lowercase()).collect();
1443 UrlGroundingVerifier::new(
1444 &UrlGroundingVerifierConfig::default(),
1445 Arc::new(RwLock::new(set)),
1446 )
1447 }
1448
1449 #[test]
1450 fn url_grounding_allows_user_provided_url() {
1451 let v = ugv(&["https://docs.anthropic.com/models"]);
1452 assert_eq!(
1453 v.verify(
1454 "fetch",
1455 &json!({"url": "https://docs.anthropic.com/models"})
1456 ),
1457 VerificationResult::Allow
1458 );
1459 }
1460
1461 #[test]
1462 fn url_grounding_blocks_hallucinated_url() {
1463 let v = ugv(&["https://example.com/page"]);
1464 let result = v.verify(
1465 "fetch",
1466 &json!({"url": "https://api.anthropic.ai/v1/models"}),
1467 );
1468 assert!(matches!(result, VerificationResult::Block { .. }));
1469 }
1470
1471 #[test]
1472 fn url_grounding_blocks_when_no_user_urls_at_all() {
1473 let v = ugv(&[]);
1474 let result = v.verify(
1475 "fetch",
1476 &json!({"url": "https://api.anthropic.ai/v1/models"}),
1477 );
1478 assert!(matches!(result, VerificationResult::Block { .. }));
1479 }
1480
1481 #[test]
1482 fn url_grounding_allows_non_guarded_tool() {
1483 let v = ugv(&[]);
1484 assert_eq!(
1485 v.verify("read_file", &json!({"path": "/etc/hosts"})),
1486 VerificationResult::Allow
1487 );
1488 }
1489
1490 #[test]
1491 fn url_grounding_guards_fetch_suffix_tool() {
1492 let v = ugv(&[]);
1493 let result = v.verify("http_fetch", &json!({"url": "https://evil.com/"}));
1494 assert!(matches!(result, VerificationResult::Block { .. }));
1495 }
1496
1497 #[test]
1498 fn url_grounding_allows_web_scrape_with_provided_url() {
1499 let v = ugv(&["https://rust-lang.org/"]);
1500 assert_eq!(
1501 v.verify(
1502 "web_scrape",
1503 &json!({"url": "https://rust-lang.org/", "select": "h1"})
1504 ),
1505 VerificationResult::Allow
1506 );
1507 }
1508
1509 #[test]
1510 fn url_grounding_allows_prefix_match() {
1511 let v = ugv(&["https://docs.rs/"]);
1513 assert_eq!(
1514 v.verify(
1515 "fetch",
1516 &json!({"url": "https://docs.rs/tokio/latest/tokio/"})
1517 ),
1518 VerificationResult::Allow
1519 );
1520 }
1521
1522 #[test]
1529 fn reg_2191_hallucinated_api_endpoint_blocked_with_empty_session() {
1530 let v = ugv(&[]);
1532 let result = v.verify(
1533 "fetch",
1534 &json!({"url": "https://api.anthropic.ai/v1/models"}),
1535 );
1536 assert!(
1537 matches!(result, VerificationResult::Block { .. }),
1538 "fetch must be blocked when no user URL was provided — this is the #2191 regression"
1539 );
1540 }
1541
1542 #[test]
1544 fn reg_2191_user_provided_url_allows_fetch() {
1545 let v = ugv(&["https://api.anthropic.com/v1/models"]);
1546 assert_eq!(
1547 v.verify(
1548 "fetch",
1549 &json!({"url": "https://api.anthropic.com/v1/models"}),
1550 ),
1551 VerificationResult::Allow,
1552 "fetch must be allowed when the URL was explicitly provided by the user"
1553 );
1554 }
1555
1556 #[test]
1558 fn reg_2191_web_scrape_hallucinated_url_blocked() {
1559 let v = ugv(&[]);
1560 let result = v.verify(
1561 "web_scrape",
1562 &json!({"url": "https://api.anthropic.ai/v1/models", "select": "body"}),
1563 );
1564 assert!(
1565 matches!(result, VerificationResult::Block { .. }),
1566 "web_scrape must be blocked for hallucinated URL with empty user_provided_urls"
1567 );
1568 }
1569
1570 #[test]
1575 fn reg_2191_empty_url_set_always_blocks_fetch() {
1576 let v = ugv(&[]);
1579 let result = v.verify(
1580 "fetch",
1581 &json!({"url": "https://docs.anthropic.com/something"}),
1582 );
1583 assert!(matches!(result, VerificationResult::Block { .. }));
1584 }
1585
1586 #[test]
1588 fn reg_2191_case_insensitive_url_match_allows_fetch() {
1589 let v = ugv(&["https://Docs.Anthropic.COM/models"]);
1592 assert_eq!(
1593 v.verify(
1594 "fetch",
1595 &json!({"url": "https://docs.anthropic.com/models/detail"}),
1596 ),
1597 VerificationResult::Allow,
1598 "URL matching must be case-insensitive"
1599 );
1600 }
1601
1602 #[test]
1605 fn reg_2191_mcp_fetch_suffix_tool_blocked_with_empty_session() {
1606 let v = ugv(&[]);
1607 let result = v.verify(
1608 "anthropic_fetch",
1609 &json!({"url": "https://api.anthropic.ai/v1/models"}),
1610 );
1611 assert!(
1612 matches!(result, VerificationResult::Block { .. }),
1613 "MCP tools ending in _fetch must be guarded even if not in guarded_tools list"
1614 );
1615 }
1616
1617 #[test]
1620 fn reg_2191_reverse_prefix_match_allows_fetch() {
1621 let v = ugv(&["https://docs.rs/tokio/latest/tokio/index.html"]);
1624 assert_eq!(
1625 v.verify("fetch", &json!({"url": "https://docs.rs/"})),
1626 VerificationResult::Allow,
1627 "reverse prefix: fetched URL is a prefix of user-provided URL — should be allowed"
1628 );
1629 }
1630
1631 #[test]
1633 fn reg_2191_different_domain_blocked() {
1634 let v = ugv(&["https://docs.rs/"]);
1636 let result = v.verify("fetch", &json!({"url": "https://evil.com/docs.rs/exfil"}));
1637 assert!(
1638 matches!(result, VerificationResult::Block { .. }),
1639 "different domain must not be allowed even if path looks similar"
1640 );
1641 }
1642
1643 #[test]
1645 fn reg_2191_missing_url_field_allows_fetch() {
1646 let v = ugv(&[]);
1649 assert_eq!(
1650 v.verify(
1651 "fetch",
1652 &json!({"endpoint": "https://api.anthropic.ai/v1/models"})
1653 ),
1654 VerificationResult::Allow,
1655 "missing url field must not trigger blocking — only explicit url field is checked"
1656 );
1657 }
1658
1659 #[test]
1661 fn reg_2191_disabled_verifier_allows_all() {
1662 let config = UrlGroundingVerifierConfig {
1663 enabled: false,
1664 guarded_tools: default_guarded_tools(),
1665 };
1666 let set: HashSet<String> = HashSet::new();
1670 let v = UrlGroundingVerifier::new(&config, Arc::new(RwLock::new(set)));
1671 let _ = v.verify("fetch", &json!({"url": "https://example.com/"}));
1675 }
1677
1678 fn fwv() -> FirewallVerifier {
1681 FirewallVerifier::new(&FirewallVerifierConfig::default())
1682 }
1683
1684 #[test]
1685 fn firewall_allows_normal_path() {
1686 let v = fwv();
1687 assert_eq!(
1688 v.verify("shell", &json!({"command": "ls /tmp/build"})),
1689 VerificationResult::Allow
1690 );
1691 }
1692
1693 #[test]
1694 fn firewall_blocks_path_traversal() {
1695 let v = fwv();
1696 let result = v.verify("read", &json!({"file_path": "../../etc/passwd"}));
1697 assert!(
1698 matches!(result, VerificationResult::Block { .. }),
1699 "path traversal must be blocked"
1700 );
1701 }
1702
1703 #[test]
1704 fn firewall_blocks_etc_passwd() {
1705 let v = fwv();
1706 let result = v.verify("read", &json!({"file_path": "/etc/passwd"}));
1707 assert!(
1708 matches!(result, VerificationResult::Block { .. }),
1709 "/etc/passwd must be blocked"
1710 );
1711 }
1712
1713 #[test]
1714 fn firewall_blocks_ssh_key() {
1715 let v = fwv();
1716 let result = v.verify("read", &json!({"file_path": "~/.ssh/id_rsa"}));
1717 assert!(
1718 matches!(result, VerificationResult::Block { .. }),
1719 "SSH key path must be blocked"
1720 );
1721 }
1722
1723 #[test]
1724 fn firewall_blocks_aws_env_var() {
1725 let v = fwv();
1726 let result = v.verify("shell", &json!({"command": "echo $AWS_SECRET_ACCESS_KEY"}));
1727 assert!(
1728 matches!(result, VerificationResult::Block { .. }),
1729 "AWS env var exfiltration must be blocked"
1730 );
1731 }
1732
1733 #[test]
1734 fn firewall_blocks_zeph_env_var() {
1735 let v = fwv();
1736 let result = v.verify("shell", &json!({"command": "cat ${ZEPH_CLAUDE_API_KEY}"}));
1737 assert!(
1738 matches!(result, VerificationResult::Block { .. }),
1739 "ZEPH env var exfiltration must be blocked"
1740 );
1741 }
1742
1743 #[test]
1744 fn firewall_exempt_tool_bypasses_check() {
1745 let cfg = FirewallVerifierConfig {
1746 enabled: true,
1747 blocked_paths: vec![],
1748 blocked_env_vars: vec![],
1749 exempt_tools: vec!["read".to_string()],
1750 };
1751 let v = FirewallVerifier::new(&cfg);
1752 assert_eq!(
1754 v.verify("read", &json!({"file_path": "/etc/passwd"})),
1755 VerificationResult::Allow
1756 );
1757 }
1758
1759 #[test]
1760 fn firewall_custom_blocked_path() {
1761 let cfg = FirewallVerifierConfig {
1762 enabled: true,
1763 blocked_paths: vec!["/data/secrets/*".to_string()],
1764 blocked_env_vars: vec![],
1765 exempt_tools: vec![],
1766 };
1767 let v = FirewallVerifier::new(&cfg);
1768 let result = v.verify("read", &json!({"file_path": "/data/secrets/master.key"}));
1769 assert!(
1770 matches!(result, VerificationResult::Block { .. }),
1771 "custom blocked path must be blocked"
1772 );
1773 }
1774
1775 #[test]
1776 fn firewall_custom_blocked_env_var() {
1777 let cfg = FirewallVerifierConfig {
1778 enabled: true,
1779 blocked_paths: vec![],
1780 blocked_env_vars: vec!["MY_SECRET".to_string()],
1781 exempt_tools: vec![],
1782 };
1783 let v = FirewallVerifier::new(&cfg);
1784 let result = v.verify("shell", &json!({"command": "echo $MY_SECRET"}));
1785 assert!(
1786 matches!(result, VerificationResult::Block { .. }),
1787 "custom blocked env var must be blocked"
1788 );
1789 }
1790
1791 #[test]
1792 fn firewall_invalid_glob_is_skipped() {
1793 let cfg = FirewallVerifierConfig {
1795 enabled: true,
1796 blocked_paths: vec!["[invalid-glob".to_string(), "/valid/path/*".to_string()],
1797 blocked_env_vars: vec![],
1798 exempt_tools: vec![],
1799 };
1800 let v = FirewallVerifier::new(&cfg);
1801 let result = v.verify("read", &json!({"path": "/valid/path/file.txt"}));
1803 assert!(matches!(result, VerificationResult::Block { .. }));
1804 }
1805
1806 #[test]
1807 fn firewall_config_default_deserialization() {
1808 let cfg: FirewallVerifierConfig = toml::from_str("").unwrap();
1809 assert!(cfg.enabled);
1810 assert!(cfg.blocked_paths.is_empty());
1811 assert!(cfg.blocked_env_vars.is_empty());
1812 assert!(cfg.exempt_tools.is_empty());
1813 }
1814}