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.to_string_lossy().to_lowercase();
349 if self
350 .allowed_paths
351 .iter()
352 .any(|p| n_lower.starts_with(p.as_str()))
353 {
354 return true;
355 }
356 }
357 }
358 false
359 }
360
361 fn lexical_normalize(p: &std::path::Path) -> std::path::PathBuf {
364 let mut out = std::path::PathBuf::new();
365 for component in p.components() {
366 match component {
367 std::path::Component::ParentDir => {
368 out.pop();
369 }
370 std::path::Component::CurDir => {}
371 other => out.push(other),
372 }
373 }
374 out
375 }
376
377 fn check_patterns(command: &str) -> Option<&'static str> {
378 DESTRUCTIVE_PATTERNS
379 .iter()
380 .find(|&pat| command.contains(pat))
381 .copied()
382 }
383
384 fn check_extra_patterns(&self, command: &str) -> Option<String> {
385 self.extra_patterns
386 .iter()
387 .find(|pat| command.contains(pat.as_str()))
388 .cloned()
389 }
390}
391
392impl PreExecutionVerifier for DestructiveCommandVerifier {
393 fn name(&self) -> &'static str {
394 "DestructiveCommandVerifier"
395 }
396
397 fn verify(&self, tool_name: &str, args: &serde_json::Value) -> VerificationResult {
398 if !self.is_shell_tool(tool_name) {
399 return VerificationResult::Allow;
400 }
401
402 let Some(command) = Self::extract_command(args) else {
403 return VerificationResult::Allow;
404 };
405
406 if let Some(pat) = Self::check_patterns(&command) {
407 if self.is_allowed_path(&command) {
408 return VerificationResult::Allow;
409 }
410 return VerificationResult::Block {
411 reason: format!("[{}] destructive pattern '{}' detected", self.name(), pat),
412 };
413 }
414
415 if let Some(pat) = self.check_extra_patterns(&command) {
416 if self.is_allowed_path(&command) {
417 return VerificationResult::Allow;
418 }
419 return VerificationResult::Block {
420 reason: format!(
421 "[{}] extra destructive pattern '{}' detected",
422 self.name(),
423 pat
424 ),
425 };
426 }
427
428 VerificationResult::Allow
429 }
430}
431
432static INJECTION_BLOCK_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
442 [
443 r"(?i)'\s*OR\s*'1'\s*=\s*'1",
445 r"(?i)'\s*OR\s*1\s*=\s*1",
446 r"(?i);\s*DROP\s+TABLE",
447 r"(?i)UNION\s+SELECT",
448 r"(?i)'\s*;\s*SELECT",
449 r";\s*rm\s+",
451 r"\|\s*rm\s+",
452 r"&&\s*rm\s+",
453 r";\s*curl\s+",
454 r"\|\s*curl\s+",
455 r"&&\s*curl\s+",
456 r";\s*wget\s+",
457 r"\.\./\.\./\.\./etc/passwd",
459 r"\.\./\.\./\.\./etc/shadow",
460 r"\.\./\.\./\.\./windows/",
461 r"\.\.[/\\]\.\.[/\\]\.\.[/\\]",
462 ]
463 .iter()
464 .map(|s| Regex::new(s).expect("static pattern must compile"))
465 .collect()
466});
467
468static SSRF_HOST_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
473 [
474 r"^localhost$",
476 r"^localhost:",
477 r"^127\.0\.0\.1$",
479 r"^127\.0\.0\.1:",
480 r"^\[::1\]$",
482 r"^\[::1\]:",
483 r"^169\.254\.169\.254$",
485 r"^169\.254\.169\.254:",
486 r"^10\.\d+\.\d+\.\d+$",
488 r"^10\.\d+\.\d+\.\d+:",
489 r"^172\.(1[6-9]|2\d|3[01])\.\d+\.\d+$",
490 r"^172\.(1[6-9]|2\d|3[01])\.\d+\.\d+:",
491 r"^192\.168\.\d+\.\d+$",
492 r"^192\.168\.\d+\.\d+:",
493 ]
494 .iter()
495 .map(|s| Regex::new(s).expect("static pattern must compile"))
496 .collect()
497});
498
499fn extract_url_host(url: &str) -> Option<&str> {
503 let after_scheme = url.split_once("://")?.1;
504 let host_end = after_scheme
505 .find(['/', '?', '#'])
506 .unwrap_or(after_scheme.len());
507 Some(&after_scheme[..host_end])
508}
509
510static URL_FIELD_NAMES: &[&str] = &["url", "endpoint", "uri", "href", "src", "host", "base_url"];
512
513static SAFE_QUERY_FIELDS: &[&str] = &["query", "q", "search", "text", "message", "content"];
517
518#[derive(Debug)]
539pub struct InjectionPatternVerifier {
540 extra_patterns: Vec<Regex>,
541 allowlisted_urls: Vec<String>,
542}
543
544impl InjectionPatternVerifier {
545 #[must_use]
546 pub fn new(config: &InjectionVerifierConfig) -> Self {
547 let extra_patterns = config
548 .extra_patterns
549 .iter()
550 .filter_map(|s| match Regex::new(s) {
551 Ok(re) => Some(re),
552 Err(e) => {
553 tracing::warn!(
554 pattern = %s,
555 error = %e,
556 "InjectionPatternVerifier: invalid extra_pattern, skipping"
557 );
558 None
559 }
560 })
561 .collect();
562
563 Self {
564 extra_patterns,
565 allowlisted_urls: config
566 .allowlisted_urls
567 .iter()
568 .map(|s| s.to_lowercase())
569 .collect(),
570 }
571 }
572
573 fn is_allowlisted(&self, text: &str) -> bool {
574 let lower = text.to_lowercase();
575 self.allowlisted_urls
576 .iter()
577 .any(|u| lower.contains(u.as_str()))
578 }
579
580 fn is_url_field(field: &str) -> bool {
581 let lower = field.to_lowercase();
582 URL_FIELD_NAMES.iter().any(|&f| f == lower)
583 }
584
585 fn is_safe_query_field(field: &str) -> bool {
586 let lower = field.to_lowercase();
587 SAFE_QUERY_FIELDS.iter().any(|&f| f == lower)
588 }
589
590 fn check_field_value(&self, field: &str, value: &str) -> VerificationResult {
592 let is_url = Self::is_url_field(field);
593 let is_safe_query = Self::is_safe_query_field(field);
594
595 if !is_safe_query {
597 for pat in INJECTION_BLOCK_PATTERNS.iter() {
598 if pat.is_match(value) {
599 return VerificationResult::Block {
600 reason: format!(
601 "[{}] injection pattern detected in field '{}': {}",
602 "InjectionPatternVerifier",
603 field,
604 pat.as_str()
605 ),
606 };
607 }
608 }
609 for pat in &self.extra_patterns {
610 if pat.is_match(value) {
611 return VerificationResult::Block {
612 reason: format!(
613 "[{}] extra injection pattern detected in field '{}': {}",
614 "InjectionPatternVerifier",
615 field,
616 pat.as_str()
617 ),
618 };
619 }
620 }
621 }
622
623 if is_url && let Some(host) = extract_url_host(value) {
627 for pat in SSRF_HOST_PATTERNS.iter() {
628 if pat.is_match(host) {
629 if self.is_allowlisted(value) {
630 return VerificationResult::Allow;
631 }
632 return VerificationResult::Warn {
633 message: format!(
634 "[{}] possible SSRF in field '{}': host '{}' matches pattern (not blocked)",
635 "InjectionPatternVerifier", field, host,
636 ),
637 };
638 }
639 }
640 }
641
642 VerificationResult::Allow
643 }
644
645 fn check_object(&self, obj: &serde_json::Map<String, serde_json::Value>) -> VerificationResult {
647 for (key, val) in obj {
648 let result = self.check_value(key, val);
649 if !matches!(result, VerificationResult::Allow) {
650 return result;
651 }
652 }
653 VerificationResult::Allow
654 }
655
656 fn check_value(&self, field: &str, val: &serde_json::Value) -> VerificationResult {
657 match val {
658 serde_json::Value::String(s) => self.check_field_value(field, s),
659 serde_json::Value::Array(arr) => {
660 for item in arr {
661 let r = self.check_value(field, item);
662 if !matches!(r, VerificationResult::Allow) {
663 return r;
664 }
665 }
666 VerificationResult::Allow
667 }
668 serde_json::Value::Object(obj) => self.check_object(obj),
669 _ => VerificationResult::Allow,
671 }
672 }
673}
674
675impl PreExecutionVerifier for InjectionPatternVerifier {
676 fn name(&self) -> &'static str {
677 "InjectionPatternVerifier"
678 }
679
680 fn verify(&self, _tool_name: &str, args: &serde_json::Value) -> VerificationResult {
681 match args {
682 serde_json::Value::Object(obj) => self.check_object(obj),
683 serde_json::Value::String(s) => self.check_field_value("_args", s),
685 _ => VerificationResult::Allow,
686 }
687 }
688}
689
690#[derive(Debug, Clone)]
709pub struct UrlGroundingVerifier {
710 guarded_tools: Vec<String>,
711 user_provided_urls: Arc<RwLock<HashSet<String>>>,
712}
713
714impl UrlGroundingVerifier {
715 #[must_use]
716 pub fn new(
717 config: &UrlGroundingVerifierConfig,
718 user_provided_urls: Arc<RwLock<HashSet<String>>>,
719 ) -> Self {
720 Self {
721 guarded_tools: config
722 .guarded_tools
723 .iter()
724 .map(|s| s.to_lowercase())
725 .collect(),
726 user_provided_urls,
727 }
728 }
729
730 fn is_guarded(&self, tool_name: &str) -> bool {
731 let lower = tool_name.to_lowercase();
732 self.guarded_tools.iter().any(|t| t == &lower) || lower.ends_with("_fetch")
733 }
734
735 fn is_grounded(url: &str, user_provided_urls: &HashSet<String>) -> bool {
738 let lower = url.to_lowercase();
739 user_provided_urls
740 .iter()
741 .any(|u| lower.starts_with(u.as_str()) || u.starts_with(lower.as_str()))
742 }
743}
744
745impl PreExecutionVerifier for UrlGroundingVerifier {
746 fn name(&self) -> &'static str {
747 "UrlGroundingVerifier"
748 }
749
750 fn verify(&self, tool_name: &str, args: &serde_json::Value) -> VerificationResult {
751 if !self.is_guarded(tool_name) {
752 return VerificationResult::Allow;
753 }
754
755 let Some(url) = args.get("url").and_then(|v| v.as_str()) else {
756 return VerificationResult::Allow;
757 };
758
759 let urls = self.user_provided_urls.read();
760
761 if Self::is_grounded(url, &urls) {
762 return VerificationResult::Allow;
763 }
764
765 VerificationResult::Block {
766 reason: format!(
767 "[UrlGroundingVerifier] fetch rejected: URL '{url}' was not provided by the user",
768 ),
769 }
770 }
771}
772
773#[derive(Debug, Clone, Deserialize, Serialize)]
779pub struct FirewallVerifierConfig {
780 #[serde(default = "default_true")]
781 pub enabled: bool,
782 #[serde(default)]
784 pub blocked_paths: Vec<String>,
785 #[serde(default)]
787 pub blocked_env_vars: Vec<String>,
788 #[serde(default)]
790 pub exempt_tools: Vec<String>,
791}
792
793impl Default for FirewallVerifierConfig {
794 fn default() -> Self {
795 Self {
796 enabled: true,
797 blocked_paths: Vec::new(),
798 blocked_env_vars: Vec::new(),
799 exempt_tools: Vec::new(),
800 }
801 }
802}
803
804#[derive(Debug)]
817pub struct FirewallVerifier {
818 blocked_path_globs: Vec<glob::Pattern>,
819 blocked_env_vars: HashSet<String>,
820 exempt_tools: HashSet<String>,
821}
822
823static SENSITIVE_PATH_PATTERNS: LazyLock<Vec<glob::Pattern>> = LazyLock::new(|| {
825 let raw = [
826 "/etc/passwd",
827 "/etc/shadow",
828 "/etc/sudoers",
829 "~/.ssh/*",
830 "~/.aws/*",
831 "~/.gnupg/*",
832 "**/*.pem",
833 "**/*.key",
834 "**/id_rsa",
835 "**/id_ed25519",
836 "**/.env",
837 "**/credentials",
838 ];
839 raw.iter()
840 .filter_map(|p| {
841 glob::Pattern::new(p)
842 .map_err(|e| {
843 tracing::error!(pattern = p, error = %e, "failed to compile built-in firewall path pattern");
844 e
845 })
846 .ok()
847 })
848 .collect()
849});
850
851static SENSITIVE_ENV_PREFIXES: &[&str] =
853 &["$AWS_", "$ZEPH_", "${AWS_", "${ZEPH_", "%AWS_", "%ZEPH_"];
854
855static INSPECTED_FIELDS: &[&str] = &[
857 "command",
858 "file_path",
859 "path",
860 "url",
861 "query",
862 "uri",
863 "input",
864 "args",
865];
866
867impl FirewallVerifier {
868 #[must_use]
872 pub fn new(config: &FirewallVerifierConfig) -> Self {
873 let blocked_path_globs = config
874 .blocked_paths
875 .iter()
876 .filter_map(|p| {
877 glob::Pattern::new(p)
878 .map_err(|e| {
879 tracing::warn!(pattern = p, error = %e, "invalid glob pattern in firewall blocked_paths, skipping");
880 e
881 })
882 .ok()
883 })
884 .collect();
885
886 let blocked_env_vars = config
887 .blocked_env_vars
888 .iter()
889 .map(|s| s.to_uppercase())
890 .collect();
891
892 let exempt_tools = config
893 .exempt_tools
894 .iter()
895 .map(|s| s.to_lowercase())
896 .collect();
897
898 Self {
899 blocked_path_globs,
900 blocked_env_vars,
901 exempt_tools,
902 }
903 }
904
905 fn collect_args(args: &serde_json::Value) -> Vec<String> {
907 let mut out = Vec::new();
908 match args {
909 serde_json::Value::Object(map) => {
910 for field in INSPECTED_FIELDS {
911 if let Some(val) = map.get(*field) {
912 Self::collect_strings(val, &mut out);
913 }
914 }
915 }
916 serde_json::Value::String(s) => out.push(s.clone()),
917 _ => {}
918 }
919 out
920 }
921
922 fn collect_strings(val: &serde_json::Value, out: &mut Vec<String>) {
923 match val {
924 serde_json::Value::String(s) => out.push(s.clone()),
925 serde_json::Value::Array(arr) => {
926 for item in arr {
927 Self::collect_strings(item, out);
928 }
929 }
930 _ => {}
931 }
932 }
933
934 fn scan_arg(&self, arg: &str) -> Option<VerificationResult> {
935 let normalized: String = arg.nfkc().collect();
937 let lower = normalized.to_lowercase();
938
939 if lower.contains("../") || lower.contains("..\\") {
941 return Some(VerificationResult::Block {
942 reason: format!(
943 "[FirewallVerifier] path traversal pattern detected in argument: {arg}"
944 ),
945 });
946 }
947
948 for pattern in SENSITIVE_PATH_PATTERNS.iter() {
950 if pattern.matches(&normalized) || pattern.matches(&lower) {
951 return Some(VerificationResult::Block {
952 reason: format!(
953 "[FirewallVerifier] sensitive path pattern '{pattern}' matched in argument: {arg}"
954 ),
955 });
956 }
957 }
958
959 for pattern in &self.blocked_path_globs {
961 if pattern.matches(&normalized) || pattern.matches(&lower) {
962 return Some(VerificationResult::Block {
963 reason: format!(
964 "[FirewallVerifier] blocked path pattern '{pattern}' matched in argument: {arg}"
965 ),
966 });
967 }
968 }
969
970 let upper = normalized.to_uppercase();
972 for prefix in SENSITIVE_ENV_PREFIXES {
973 if upper.contains(*prefix) {
974 return Some(VerificationResult::Block {
975 reason: format!(
976 "[FirewallVerifier] env var exfiltration pattern '{prefix}' detected in argument: {arg}"
977 ),
978 });
979 }
980 }
981
982 for var in &self.blocked_env_vars {
984 let dollar_form = format!("${var}");
985 let brace_form = format!("${{{var}}}");
986 let percent_form = format!("%{var}%");
987 if upper.contains(&dollar_form)
988 || upper.contains(&brace_form)
989 || upper.contains(&percent_form)
990 {
991 return Some(VerificationResult::Block {
992 reason: format!(
993 "[FirewallVerifier] blocked env var '{var}' detected in argument: {arg}"
994 ),
995 });
996 }
997 }
998
999 None
1000 }
1001}
1002
1003impl PreExecutionVerifier for FirewallVerifier {
1004 fn name(&self) -> &'static str {
1005 "FirewallVerifier"
1006 }
1007
1008 fn verify(&self, tool_name: &str, args: &serde_json::Value) -> VerificationResult {
1009 if self.exempt_tools.contains(&tool_name.to_lowercase()) {
1010 return VerificationResult::Allow;
1011 }
1012
1013 for arg in Self::collect_args(args) {
1014 if let Some(result) = self.scan_arg(&arg) {
1015 return result;
1016 }
1017 }
1018
1019 VerificationResult::Allow
1020 }
1021}
1022
1023#[cfg(test)]
1028mod tests {
1029 use serde_json::json;
1030
1031 use super::*;
1032
1033 fn dcv() -> DestructiveCommandVerifier {
1036 DestructiveCommandVerifier::new(&DestructiveVerifierConfig::default())
1037 }
1038
1039 #[test]
1040 fn allow_normal_command() {
1041 let v = dcv();
1042 assert_eq!(
1043 v.verify("bash", &json!({"command": "ls -la /tmp"})),
1044 VerificationResult::Allow
1045 );
1046 }
1047
1048 #[test]
1049 fn block_rm_rf_root() {
1050 let v = dcv();
1051 let result = v.verify("bash", &json!({"command": "rm -rf /"}));
1052 assert!(matches!(result, VerificationResult::Block { .. }));
1053 }
1054
1055 #[test]
1056 fn block_dd_dev_zero() {
1057 let v = dcv();
1058 let result = v.verify("bash", &json!({"command": "dd if=/dev/zero of=/dev/sda"}));
1059 assert!(matches!(result, VerificationResult::Block { .. }));
1060 }
1061
1062 #[test]
1063 fn block_mkfs() {
1064 let v = dcv();
1065 let result = v.verify("bash", &json!({"command": "mkfs.ext4 /dev/sda1"}));
1066 assert!(matches!(result, VerificationResult::Block { .. }));
1067 }
1068
1069 #[test]
1070 fn allow_rm_rf_in_allowed_path() {
1071 let config = DestructiveVerifierConfig {
1072 allowed_paths: vec!["/tmp/build".to_string()],
1073 ..Default::default()
1074 };
1075 let v = DestructiveCommandVerifier::new(&config);
1076 assert_eq!(
1077 v.verify("bash", &json!({"command": "rm -rf /tmp/build/artifacts"})),
1078 VerificationResult::Allow
1079 );
1080 }
1081
1082 #[test]
1083 fn block_rm_rf_when_not_in_allowed_path() {
1084 let config = DestructiveVerifierConfig {
1085 allowed_paths: vec!["/tmp/build".to_string()],
1086 ..Default::default()
1087 };
1088 let v = DestructiveCommandVerifier::new(&config);
1089 let result = v.verify("bash", &json!({"command": "rm -rf /home/user"}));
1090 assert!(matches!(result, VerificationResult::Block { .. }));
1091 }
1092
1093 #[test]
1094 fn allow_non_shell_tool() {
1095 let v = dcv();
1096 assert_eq!(
1097 v.verify("read_file", &json!({"path": "rm -rf /"})),
1098 VerificationResult::Allow
1099 );
1100 }
1101
1102 #[test]
1103 fn block_extra_pattern() {
1104 let config = DestructiveVerifierConfig {
1105 extra_patterns: vec!["format c:".to_string()],
1106 ..Default::default()
1107 };
1108 let v = DestructiveCommandVerifier::new(&config);
1109 let result = v.verify("bash", &json!({"command": "format c:"}));
1110 assert!(matches!(result, VerificationResult::Block { .. }));
1111 }
1112
1113 #[test]
1114 fn array_args_normalization() {
1115 let v = dcv();
1116 let result = v.verify("bash", &json!({"command": ["rm", "-rf", "/"]}));
1117 assert!(matches!(result, VerificationResult::Block { .. }));
1118 }
1119
1120 #[test]
1121 fn sh_c_wrapping_normalization() {
1122 let v = dcv();
1123 let result = v.verify("bash", &json!({"command": "bash -c 'rm -rf /'"}));
1124 assert!(matches!(result, VerificationResult::Block { .. }));
1125 }
1126
1127 #[test]
1128 fn fork_bomb_blocked() {
1129 let v = dcv();
1130 let result = v.verify("bash", &json!({"command": ":(){ :|:& };:"}));
1131 assert!(matches!(result, VerificationResult::Block { .. }));
1132 }
1133
1134 #[test]
1135 fn custom_shell_tool_name_blocked() {
1136 let config = DestructiveVerifierConfig {
1137 shell_tools: vec!["execute".to_string(), "run_command".to_string()],
1138 ..Default::default()
1139 };
1140 let v = DestructiveCommandVerifier::new(&config);
1141 let result = v.verify("execute", &json!({"command": "rm -rf /"}));
1142 assert!(matches!(result, VerificationResult::Block { .. }));
1143 }
1144
1145 #[test]
1146 fn terminal_tool_name_blocked_by_default() {
1147 let v = dcv();
1148 let result = v.verify("terminal", &json!({"command": "rm -rf /"}));
1149 assert!(matches!(result, VerificationResult::Block { .. }));
1150 }
1151
1152 #[test]
1153 fn default_shell_tools_contains_bash_shell_terminal() {
1154 let config = DestructiveVerifierConfig::default();
1155 let lower: Vec<String> = config
1156 .shell_tools
1157 .iter()
1158 .map(|s| s.to_lowercase())
1159 .collect();
1160 assert!(lower.contains(&"bash".to_string()));
1161 assert!(lower.contains(&"shell".to_string()));
1162 assert!(lower.contains(&"terminal".to_string()));
1163 }
1164
1165 fn ipv() -> InjectionPatternVerifier {
1168 InjectionPatternVerifier::new(&InjectionVerifierConfig::default())
1169 }
1170
1171 #[test]
1172 fn allow_clean_args() {
1173 let v = ipv();
1174 assert_eq!(
1175 v.verify("search", &json!({"query": "rust async traits"})),
1176 VerificationResult::Allow
1177 );
1178 }
1179
1180 #[test]
1181 fn allow_sql_discussion_in_query_field() {
1182 let v = ipv();
1184 assert_eq!(
1185 v.verify(
1186 "memory_search",
1187 &json!({"query": "explain SQL UNION SELECT vs JOIN"})
1188 ),
1189 VerificationResult::Allow
1190 );
1191 }
1192
1193 #[test]
1194 fn allow_sql_or_pattern_in_query_field() {
1195 let v = ipv();
1197 assert_eq!(
1198 v.verify("memory_search", &json!({"query": "' OR '1'='1"})),
1199 VerificationResult::Allow
1200 );
1201 }
1202
1203 #[test]
1204 fn block_sql_injection_in_non_query_field() {
1205 let v = ipv();
1206 let result = v.verify("db_query", &json!({"sql": "' OR '1'='1"}));
1207 assert!(matches!(result, VerificationResult::Block { .. }));
1208 }
1209
1210 #[test]
1211 fn block_drop_table() {
1212 let v = ipv();
1213 let result = v.verify("db_query", &json!({"input": "name'; DROP TABLE users"}));
1214 assert!(matches!(result, VerificationResult::Block { .. }));
1215 }
1216
1217 #[test]
1218 fn block_path_traversal() {
1219 let v = ipv();
1220 let result = v.verify("read_file", &json!({"path": "../../../etc/passwd"}));
1221 assert!(matches!(result, VerificationResult::Block { .. }));
1222 }
1223
1224 #[test]
1225 fn warn_on_localhost_url_field() {
1226 let v = ipv();
1228 let result = v.verify("http_get", &json!({"url": "http://localhost:8080/api"}));
1229 assert!(matches!(result, VerificationResult::Warn { .. }));
1230 }
1231
1232 #[test]
1233 fn allow_localhost_in_non_url_field() {
1234 let v = ipv();
1236 assert_eq!(
1237 v.verify(
1238 "memory_search",
1239 &json!({"query": "connect to http://localhost:8080"})
1240 ),
1241 VerificationResult::Allow
1242 );
1243 }
1244
1245 #[test]
1246 fn warn_on_private_ip_url_field() {
1247 let v = ipv();
1248 let result = v.verify("fetch", &json!({"url": "http://192.168.1.1/admin"}));
1249 assert!(matches!(result, VerificationResult::Warn { .. }));
1250 }
1251
1252 #[test]
1253 fn allow_localhost_when_allowlisted() {
1254 let config = InjectionVerifierConfig {
1255 allowlisted_urls: vec!["http://localhost:3000".to_string()],
1256 ..Default::default()
1257 };
1258 let v = InjectionPatternVerifier::new(&config);
1259 assert_eq!(
1260 v.verify("http_get", &json!({"url": "http://localhost:3000/api"})),
1261 VerificationResult::Allow
1262 );
1263 }
1264
1265 #[test]
1266 fn block_union_select_in_non_query_field() {
1267 let v = ipv();
1268 let result = v.verify(
1269 "db_query",
1270 &json!({"input": "id=1 UNION SELECT password FROM users"}),
1271 );
1272 assert!(matches!(result, VerificationResult::Block { .. }));
1273 }
1274
1275 #[test]
1276 fn allow_union_select_in_query_field() {
1277 let v = ipv();
1279 assert_eq!(
1280 v.verify(
1281 "memory_search",
1282 &json!({"query": "id=1 UNION SELECT password FROM users"})
1283 ),
1284 VerificationResult::Allow
1285 );
1286 }
1287
1288 #[test]
1291 fn block_rm_rf_unicode_homoglyph() {
1292 let v = dcv();
1294 let result = v.verify("bash", &json!({"command": "rm -rf \u{FF0F}"}));
1296 assert!(matches!(result, VerificationResult::Block { .. }));
1297 }
1298
1299 #[test]
1302 fn path_traversal_not_allowed_via_dotdot() {
1303 let config = DestructiveVerifierConfig {
1305 allowed_paths: vec!["/tmp/build".to_string()],
1306 ..Default::default()
1307 };
1308 let v = DestructiveCommandVerifier::new(&config);
1309 let result = v.verify("bash", &json!({"command": "rm -rf /tmp/build/../../etc"}));
1311 assert!(matches!(result, VerificationResult::Block { .. }));
1312 }
1313
1314 #[test]
1315 fn allowed_path_with_dotdot_stays_in_allowed() {
1316 let config = DestructiveVerifierConfig {
1318 allowed_paths: vec!["/tmp/build".to_string()],
1319 ..Default::default()
1320 };
1321 let v = DestructiveCommandVerifier::new(&config);
1322 assert_eq!(
1323 v.verify(
1324 "bash",
1325 &json!({"command": "rm -rf /tmp/build/sub/../artifacts"}),
1326 ),
1327 VerificationResult::Allow,
1328 );
1329 }
1330
1331 #[test]
1334 fn double_nested_bash_c_blocked() {
1335 let v = dcv();
1336 let result = v.verify(
1337 "bash",
1338 &json!({"command": "bash -c \"bash -c 'rm -rf /'\""}),
1339 );
1340 assert!(matches!(result, VerificationResult::Block { .. }));
1341 }
1342
1343 #[test]
1344 fn env_prefix_stripping_blocked() {
1345 let v = dcv();
1346 let result = v.verify(
1347 "bash",
1348 &json!({"command": "env FOO=bar bash -c 'rm -rf /'"}),
1349 );
1350 assert!(matches!(result, VerificationResult::Block { .. }));
1351 }
1352
1353 #[test]
1354 fn exec_prefix_stripping_blocked() {
1355 let v = dcv();
1356 let result = v.verify("bash", &json!({"command": "exec bash -c 'rm -rf /'"}));
1357 assert!(matches!(result, VerificationResult::Block { .. }));
1358 }
1359
1360 #[test]
1363 fn ssrf_not_triggered_for_embedded_localhost_in_query_param() {
1364 let v = ipv();
1366 let result = v.verify(
1367 "http_get",
1368 &json!({"url": "http://evil.com/?r=http://localhost"}),
1369 );
1370 assert_eq!(result, VerificationResult::Allow);
1372 }
1373
1374 #[test]
1375 fn ssrf_triggered_for_bare_localhost_no_port() {
1376 let v = ipv();
1378 let result = v.verify("http_get", &json!({"url": "http://localhost"}));
1379 assert!(matches!(result, VerificationResult::Warn { .. }));
1380 }
1381
1382 #[test]
1383 fn ssrf_triggered_for_localhost_with_path() {
1384 let v = ipv();
1385 let result = v.verify("http_get", &json!({"url": "http://localhost/api/v1"}));
1386 assert!(matches!(result, VerificationResult::Warn { .. }));
1387 }
1388
1389 #[test]
1392 fn chain_first_block_wins() {
1393 let dcv = DestructiveCommandVerifier::new(&DestructiveVerifierConfig::default());
1394 let ipv = InjectionPatternVerifier::new(&InjectionVerifierConfig::default());
1395 let verifiers: Vec<Box<dyn PreExecutionVerifier>> = vec![Box::new(dcv), Box::new(ipv)];
1396
1397 let args = json!({"command": "rm -rf /"});
1398 let mut result = VerificationResult::Allow;
1399 for v in &verifiers {
1400 result = v.verify("bash", &args);
1401 if matches!(result, VerificationResult::Block { .. }) {
1402 break;
1403 }
1404 }
1405 assert!(matches!(result, VerificationResult::Block { .. }));
1406 }
1407
1408 #[test]
1409 fn chain_warn_continues() {
1410 let dcv = DestructiveCommandVerifier::new(&DestructiveVerifierConfig::default());
1411 let ipv = InjectionPatternVerifier::new(&InjectionVerifierConfig::default());
1412 let verifiers: Vec<Box<dyn PreExecutionVerifier>> = vec![Box::new(dcv), Box::new(ipv)];
1413
1414 let args = json!({"url": "http://localhost:8080/api"});
1416 let mut got_warn = false;
1417 let mut got_block = false;
1418 for v in &verifiers {
1419 match v.verify("http_get", &args) {
1420 VerificationResult::Block { .. } => {
1421 got_block = true;
1422 break;
1423 }
1424 VerificationResult::Warn { .. } => {
1425 got_warn = true;
1426 }
1427 VerificationResult::Allow => {}
1428 }
1429 }
1430 assert!(got_warn);
1431 assert!(!got_block);
1432 }
1433
1434 fn ugv(urls: &[&str]) -> UrlGroundingVerifier {
1437 let set: HashSet<String> = urls.iter().map(|s| s.to_lowercase()).collect();
1438 UrlGroundingVerifier::new(
1439 &UrlGroundingVerifierConfig::default(),
1440 Arc::new(RwLock::new(set)),
1441 )
1442 }
1443
1444 #[test]
1445 fn url_grounding_allows_user_provided_url() {
1446 let v = ugv(&["https://docs.anthropic.com/models"]);
1447 assert_eq!(
1448 v.verify(
1449 "fetch",
1450 &json!({"url": "https://docs.anthropic.com/models"})
1451 ),
1452 VerificationResult::Allow
1453 );
1454 }
1455
1456 #[test]
1457 fn url_grounding_blocks_hallucinated_url() {
1458 let v = ugv(&["https://example.com/page"]);
1459 let result = v.verify(
1460 "fetch",
1461 &json!({"url": "https://api.anthropic.ai/v1/models"}),
1462 );
1463 assert!(matches!(result, VerificationResult::Block { .. }));
1464 }
1465
1466 #[test]
1467 fn url_grounding_blocks_when_no_user_urls_at_all() {
1468 let v = ugv(&[]);
1469 let result = v.verify(
1470 "fetch",
1471 &json!({"url": "https://api.anthropic.ai/v1/models"}),
1472 );
1473 assert!(matches!(result, VerificationResult::Block { .. }));
1474 }
1475
1476 #[test]
1477 fn url_grounding_allows_non_guarded_tool() {
1478 let v = ugv(&[]);
1479 assert_eq!(
1480 v.verify("read_file", &json!({"path": "/etc/hosts"})),
1481 VerificationResult::Allow
1482 );
1483 }
1484
1485 #[test]
1486 fn url_grounding_guards_fetch_suffix_tool() {
1487 let v = ugv(&[]);
1488 let result = v.verify("http_fetch", &json!({"url": "https://evil.com/"}));
1489 assert!(matches!(result, VerificationResult::Block { .. }));
1490 }
1491
1492 #[test]
1493 fn url_grounding_allows_web_scrape_with_provided_url() {
1494 let v = ugv(&["https://rust-lang.org/"]);
1495 assert_eq!(
1496 v.verify(
1497 "web_scrape",
1498 &json!({"url": "https://rust-lang.org/", "select": "h1"})
1499 ),
1500 VerificationResult::Allow
1501 );
1502 }
1503
1504 #[test]
1505 fn url_grounding_allows_prefix_match() {
1506 let v = ugv(&["https://docs.rs/"]);
1508 assert_eq!(
1509 v.verify(
1510 "fetch",
1511 &json!({"url": "https://docs.rs/tokio/latest/tokio/"})
1512 ),
1513 VerificationResult::Allow
1514 );
1515 }
1516
1517 #[test]
1524 fn reg_2191_hallucinated_api_endpoint_blocked_with_empty_session() {
1525 let v = ugv(&[]);
1527 let result = v.verify(
1528 "fetch",
1529 &json!({"url": "https://api.anthropic.ai/v1/models"}),
1530 );
1531 assert!(
1532 matches!(result, VerificationResult::Block { .. }),
1533 "fetch must be blocked when no user URL was provided — this is the #2191 regression"
1534 );
1535 }
1536
1537 #[test]
1539 fn reg_2191_user_provided_url_allows_fetch() {
1540 let v = ugv(&["https://api.anthropic.com/v1/models"]);
1541 assert_eq!(
1542 v.verify(
1543 "fetch",
1544 &json!({"url": "https://api.anthropic.com/v1/models"}),
1545 ),
1546 VerificationResult::Allow,
1547 "fetch must be allowed when the URL was explicitly provided by the user"
1548 );
1549 }
1550
1551 #[test]
1553 fn reg_2191_web_scrape_hallucinated_url_blocked() {
1554 let v = ugv(&[]);
1555 let result = v.verify(
1556 "web_scrape",
1557 &json!({"url": "https://api.anthropic.ai/v1/models", "select": "body"}),
1558 );
1559 assert!(
1560 matches!(result, VerificationResult::Block { .. }),
1561 "web_scrape must be blocked for hallucinated URL with empty user_provided_urls"
1562 );
1563 }
1564
1565 #[test]
1570 fn reg_2191_empty_url_set_always_blocks_fetch() {
1571 let v = ugv(&[]);
1574 let result = v.verify(
1575 "fetch",
1576 &json!({"url": "https://docs.anthropic.com/something"}),
1577 );
1578 assert!(matches!(result, VerificationResult::Block { .. }));
1579 }
1580
1581 #[test]
1583 fn reg_2191_case_insensitive_url_match_allows_fetch() {
1584 let v = ugv(&["https://Docs.Anthropic.COM/models"]);
1587 assert_eq!(
1588 v.verify(
1589 "fetch",
1590 &json!({"url": "https://docs.anthropic.com/models/detail"}),
1591 ),
1592 VerificationResult::Allow,
1593 "URL matching must be case-insensitive"
1594 );
1595 }
1596
1597 #[test]
1600 fn reg_2191_mcp_fetch_suffix_tool_blocked_with_empty_session() {
1601 let v = ugv(&[]);
1602 let result = v.verify(
1603 "anthropic_fetch",
1604 &json!({"url": "https://api.anthropic.ai/v1/models"}),
1605 );
1606 assert!(
1607 matches!(result, VerificationResult::Block { .. }),
1608 "MCP tools ending in _fetch must be guarded even if not in guarded_tools list"
1609 );
1610 }
1611
1612 #[test]
1615 fn reg_2191_reverse_prefix_match_allows_fetch() {
1616 let v = ugv(&["https://docs.rs/tokio/latest/tokio/index.html"]);
1619 assert_eq!(
1620 v.verify("fetch", &json!({"url": "https://docs.rs/"})),
1621 VerificationResult::Allow,
1622 "reverse prefix: fetched URL is a prefix of user-provided URL — should be allowed"
1623 );
1624 }
1625
1626 #[test]
1628 fn reg_2191_different_domain_blocked() {
1629 let v = ugv(&["https://docs.rs/"]);
1631 let result = v.verify("fetch", &json!({"url": "https://evil.com/docs.rs/exfil"}));
1632 assert!(
1633 matches!(result, VerificationResult::Block { .. }),
1634 "different domain must not be allowed even if path looks similar"
1635 );
1636 }
1637
1638 #[test]
1640 fn reg_2191_missing_url_field_allows_fetch() {
1641 let v = ugv(&[]);
1644 assert_eq!(
1645 v.verify(
1646 "fetch",
1647 &json!({"endpoint": "https://api.anthropic.ai/v1/models"})
1648 ),
1649 VerificationResult::Allow,
1650 "missing url field must not trigger blocking — only explicit url field is checked"
1651 );
1652 }
1653
1654 #[test]
1656 fn reg_2191_disabled_verifier_allows_all() {
1657 let config = UrlGroundingVerifierConfig {
1658 enabled: false,
1659 guarded_tools: default_guarded_tools(),
1660 };
1661 let set: HashSet<String> = HashSet::new();
1665 let v = UrlGroundingVerifier::new(&config, Arc::new(RwLock::new(set)));
1666 let _ = v.verify("fetch", &json!({"url": "https://example.com/"}));
1670 }
1672
1673 fn fwv() -> FirewallVerifier {
1676 FirewallVerifier::new(&FirewallVerifierConfig::default())
1677 }
1678
1679 #[test]
1680 fn firewall_allows_normal_path() {
1681 let v = fwv();
1682 assert_eq!(
1683 v.verify("shell", &json!({"command": "ls /tmp/build"})),
1684 VerificationResult::Allow
1685 );
1686 }
1687
1688 #[test]
1689 fn firewall_blocks_path_traversal() {
1690 let v = fwv();
1691 let result = v.verify("read", &json!({"file_path": "../../etc/passwd"}));
1692 assert!(
1693 matches!(result, VerificationResult::Block { .. }),
1694 "path traversal must be blocked"
1695 );
1696 }
1697
1698 #[test]
1699 fn firewall_blocks_etc_passwd() {
1700 let v = fwv();
1701 let result = v.verify("read", &json!({"file_path": "/etc/passwd"}));
1702 assert!(
1703 matches!(result, VerificationResult::Block { .. }),
1704 "/etc/passwd must be blocked"
1705 );
1706 }
1707
1708 #[test]
1709 fn firewall_blocks_ssh_key() {
1710 let v = fwv();
1711 let result = v.verify("read", &json!({"file_path": "~/.ssh/id_rsa"}));
1712 assert!(
1713 matches!(result, VerificationResult::Block { .. }),
1714 "SSH key path must be blocked"
1715 );
1716 }
1717
1718 #[test]
1719 fn firewall_blocks_aws_env_var() {
1720 let v = fwv();
1721 let result = v.verify("shell", &json!({"command": "echo $AWS_SECRET_ACCESS_KEY"}));
1722 assert!(
1723 matches!(result, VerificationResult::Block { .. }),
1724 "AWS env var exfiltration must be blocked"
1725 );
1726 }
1727
1728 #[test]
1729 fn firewall_blocks_zeph_env_var() {
1730 let v = fwv();
1731 let result = v.verify("shell", &json!({"command": "cat ${ZEPH_CLAUDE_API_KEY}"}));
1732 assert!(
1733 matches!(result, VerificationResult::Block { .. }),
1734 "ZEPH env var exfiltration must be blocked"
1735 );
1736 }
1737
1738 #[test]
1739 fn firewall_exempt_tool_bypasses_check() {
1740 let cfg = FirewallVerifierConfig {
1741 enabled: true,
1742 blocked_paths: vec![],
1743 blocked_env_vars: vec![],
1744 exempt_tools: vec!["read".to_string()],
1745 };
1746 let v = FirewallVerifier::new(&cfg);
1747 assert_eq!(
1749 v.verify("read", &json!({"file_path": "/etc/passwd"})),
1750 VerificationResult::Allow
1751 );
1752 }
1753
1754 #[test]
1755 fn firewall_custom_blocked_path() {
1756 let cfg = FirewallVerifierConfig {
1757 enabled: true,
1758 blocked_paths: vec!["/data/secrets/*".to_string()],
1759 blocked_env_vars: vec![],
1760 exempt_tools: vec![],
1761 };
1762 let v = FirewallVerifier::new(&cfg);
1763 let result = v.verify("read", &json!({"file_path": "/data/secrets/master.key"}));
1764 assert!(
1765 matches!(result, VerificationResult::Block { .. }),
1766 "custom blocked path must be blocked"
1767 );
1768 }
1769
1770 #[test]
1771 fn firewall_custom_blocked_env_var() {
1772 let cfg = FirewallVerifierConfig {
1773 enabled: true,
1774 blocked_paths: vec![],
1775 blocked_env_vars: vec!["MY_SECRET".to_string()],
1776 exempt_tools: vec![],
1777 };
1778 let v = FirewallVerifier::new(&cfg);
1779 let result = v.verify("shell", &json!({"command": "echo $MY_SECRET"}));
1780 assert!(
1781 matches!(result, VerificationResult::Block { .. }),
1782 "custom blocked env var must be blocked"
1783 );
1784 }
1785
1786 #[test]
1787 fn firewall_invalid_glob_is_skipped() {
1788 let cfg = FirewallVerifierConfig {
1790 enabled: true,
1791 blocked_paths: vec!["[invalid-glob".to_string(), "/valid/path/*".to_string()],
1792 blocked_env_vars: vec![],
1793 exempt_tools: vec![],
1794 };
1795 let v = FirewallVerifier::new(&cfg);
1796 let result = v.verify("read", &json!({"path": "/valid/path/file.txt"}));
1798 assert!(matches!(result, VerificationResult::Block { .. }));
1799 }
1800
1801 #[test]
1802 fn firewall_config_default_deserialization() {
1803 let cfg: FirewallVerifierConfig = toml::from_str("").unwrap();
1804 assert!(cfg.enabled);
1805 assert!(cfg.blocked_paths.is_empty());
1806 assert!(cfg.blocked_env_vars.is_empty());
1807 assert!(cfg.exempt_tools.is_empty());
1808 }
1809}