Skip to main content

zeph_tools/
verifier.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Pre-execution verification for tool calls.
5//!
6//! Based on the `TrustBench` pattern (arXiv:2603.09157): intercept tool calls before
7//! execution to block or warn on destructive or injection patterns.
8//!
9//! ## Blocklist separation
10//!
11//! `DESTRUCTIVE_PATTERNS` (this module) is intentionally separate from
12//! `DEFAULT_BLOCKED_COMMANDS` in `shell.rs`. The two lists serve different purposes:
13//!
14//! - `DEFAULT_BLOCKED_COMMANDS` — shell safety net: prevents the *shell executor* from
15//!   running network tools (`curl`, `wget`, `nc`) and a few destructive commands.
16//!   It is applied at tool-execution time by `ShellExecutor`.
17//!
18//! - `DESTRUCTIVE_PATTERNS` — pre-execution guard: targets filesystem/system destruction
19//!   commands (disk formats, wipefs, fork bombs, recursive permission changes).
20//!   It runs *before* dispatch, in the LLM-call hot path, and must not be conflated
21//!   with the shell safety net to avoid accidental allow-listing via config drift.
22//!
23//! Overlap (3 entries: `rm -rf /`, `mkfs`, `dd if=`) is intentional — belt-and-suspenders.
24
25use std::collections::HashSet;
26use std::sync::{Arc, LazyLock};
27
28use parking_lot::RwLock;
29
30use regex::Regex;
31use unicode_normalization::UnicodeNormalization as _;
32
33use zeph_config::tools::{
34    DestructiveVerifierConfig, FirewallVerifierConfig, InjectionVerifierConfig,
35    UrlGroundingVerifierConfig,
36};
37
38#[non_exhaustive]
39/// Result of a pre-execution verification check.
40#[must_use]
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum VerificationResult {
43    /// Tool call is safe to proceed.
44    Allow,
45    /// Tool call must be blocked. Executor returns an error to the LLM.
46    Block { reason: String },
47    /// Tool call proceeds but a warning is logged and tracked in metrics (metrics-only,
48    /// not visible to the LLM or user beyond the TUI security panel).
49    Warn { message: String },
50}
51
52/// Pre-execution verification trait. Implementations intercept tool calls
53/// before the executor runs them. Based on `TrustBench` pattern (arXiv:2603.09157).
54///
55/// Sync by design: verifiers inspect arguments only — no I/O needed.
56/// Object-safe: uses `&self` and returns a concrete enum.
57pub trait PreExecutionVerifier: Send + Sync + std::fmt::Debug {
58    /// Verify whether a tool call should proceed.
59    fn verify(&self, tool_name: &str, args: &serde_json::Value) -> VerificationResult;
60
61    /// Human-readable name for logging and TUI display.
62    fn name(&self) -> &'static str;
63}
64
65// ---------------------------------------------------------------------------
66// DestructiveCommandVerifier
67// ---------------------------------------------------------------------------
68
69/// Destructive command patterns for `DestructiveCommandVerifier`.
70///
71/// Intentionally separate from `DEFAULT_BLOCKED_COMMANDS` in `shell.rs` — see module
72/// docs for the semantic distinction between the two lists.
73static DESTRUCTIVE_PATTERNS: &[&str] = &[
74    "rm -rf /",
75    "rm -rf ~",
76    "rm -r /",
77    "dd if=",
78    "mkfs",
79    "fdisk",
80    "shred",
81    "wipefs",
82    ":(){ :|:& };:",
83    ":(){:|:&};:",
84    "chmod -r 777 /",
85    "chown -r",
86];
87
88/// Verifier that blocks destructive shell commands (e.g., `rm -rf /`, `dd`, `mkfs`)
89/// before the shell tool executes them.
90///
91/// Applies to any tool whose name is in the configured `shell_tools` set (default:
92/// `["bash", "shell", "terminal"]`). For commands targeting a specific path, execution
93/// is allowed when the path starts with one of the configured `allowed_paths`. When
94/// `allowed_paths` is empty (the default), **all** matching destructive commands are blocked.
95#[derive(Debug)]
96pub struct DestructiveCommandVerifier {
97    shell_tools: Vec<String>,
98    allowed_paths: Vec<String>,
99    extra_patterns: Vec<String>,
100}
101
102impl DestructiveCommandVerifier {
103    #[must_use]
104    pub fn new(config: &DestructiveVerifierConfig) -> Self {
105        Self {
106            shell_tools: config
107                .shell_tools
108                .iter()
109                .map(|s| s.to_lowercase())
110                .collect(),
111            allowed_paths: config
112                .allowed_paths
113                .iter()
114                .map(|s| s.to_lowercase())
115                .collect(),
116            extra_patterns: config
117                .extra_patterns
118                .iter()
119                .map(|s| s.to_lowercase())
120                .collect(),
121        }
122    }
123
124    fn is_shell_tool(&self, tool_name: &str) -> bool {
125        let lower = tool_name.to_lowercase();
126        self.shell_tools.iter().any(|t| t == &lower)
127    }
128
129    /// Extract the effective command string from `args`.
130    ///
131    /// Supports:
132    /// - `{"command": "rm -rf /"}` (string)
133    /// - `{"command": ["rm", "-rf", "/"]}` (array — joined with spaces)
134    /// - `{"command": "bash -c 'rm -rf /'"}` (shell `-c` unwrapping, looped up to 8 levels)
135    /// - `env VAR=val bash -c '...'` and `exec bash -c '...'` prefix stripping
136    ///
137    /// NFKC-normalizes the result to defeat Unicode homoglyph bypasses.
138    fn extract_command(args: &serde_json::Value) -> Option<String> {
139        let raw = match args.get("command") {
140            Some(serde_json::Value::String(s)) => s.clone(),
141            Some(serde_json::Value::Array(arr)) => arr
142                .iter()
143                .filter_map(|v| v.as_str())
144                .collect::<Vec<_>>()
145                .join(" "),
146            _ => return None,
147        };
148        // NFKC-normalize + lowercase to defeat Unicode homoglyph and case bypasses.
149        let mut current: String = raw.nfkc().collect::<String>().to_lowercase();
150        // Loop: strip shell wrapper prefixes up to 8 levels deep.
151        // Handles double-nested: `bash -c "bash -c 'rm -rf /'"`.
152        for _ in 0..8 {
153            let trimmed = current.trim().to_owned();
154            // Strip `env VAR=value ... CMD` prefix (one or more VAR=value tokens).
155            let after_env = Self::strip_env_prefix(&trimmed);
156            // Strip `exec ` prefix.
157            let after_exec = after_env.strip_prefix("exec ").map_or(after_env, str::trim);
158            // Strip interpreter wrapper: `bash -c '...'` / `sh -c '...'` / `zsh -c '...'`.
159            let mut unwrapped = false;
160            for interp in &["bash -c ", "sh -c ", "zsh -c "] {
161                if let Some(rest) = after_exec.strip_prefix(interp) {
162                    let script = rest.trim().trim_matches(|c: char| c == '\'' || c == '"');
163                    current.clone_from(&script.to_owned());
164                    unwrapped = true;
165                    break;
166                }
167            }
168            if !unwrapped {
169                return Some(after_exec.to_owned());
170            }
171        }
172        Some(current)
173    }
174
175    /// Strip leading `env VAR=value` tokens from a command string.
176    /// Returns the remainder after all `KEY=VALUE` pairs are consumed.
177    fn strip_env_prefix(cmd: &str) -> &str {
178        let mut rest = cmd;
179        // `env` keyword is optional; strip it if present.
180        if let Some(after_env) = rest.strip_prefix("env ") {
181            rest = after_env.trim_start();
182        }
183        // Consume `KEY=VALUE` tokens.
184        loop {
185            // A VAR=value token: identifier chars + '=' + non-space chars.
186            let mut chars = rest.chars();
187            let key_end = chars
188                .by_ref()
189                .take_while(|c| c.is_alphanumeric() || *c == '_')
190                .count();
191            if key_end == 0 {
192                break;
193            }
194            let remainder = &rest[key_end..];
195            if let Some(after_eq) = remainder.strip_prefix('=') {
196                // Consume the value (up to the first space).
197                let val_end = after_eq.find(' ').unwrap_or(after_eq.len());
198                rest = after_eq[val_end..].trim_start();
199            } else {
200                break;
201            }
202        }
203        rest
204    }
205
206    /// Returns `true` if `command` targets a path that is covered by `allowed_paths`.
207    ///
208    /// Uses lexical normalization (resolves `..` and `.` without filesystem access)
209    /// so that `/tmp/build/../../etc` is correctly resolved to `/etc` before comparison,
210    /// defeating path traversal bypasses like `/tmp/build/../../etc/passwd`.
211    fn is_allowed_path(&self, command: &str) -> bool {
212        if self.allowed_paths.is_empty() {
213            return false;
214        }
215        let tokens: Vec<&str> = command.split_whitespace().collect();
216        for token in &tokens {
217            let t = token.trim_matches(|c| c == '\'' || c == '"');
218            if t.starts_with('/') || t.starts_with('~') || t.starts_with('.') {
219                let normalized = Self::lexical_normalize(std::path::Path::new(t));
220                // Normalize separators to '/' for cross-platform comparison so that
221                // Unix-style allowed_paths (e.g. "/tmp/build") match on Windows too.
222                let n_lower = normalized
223                    .to_string_lossy()
224                    .replace('\\', "/")
225                    .to_lowercase();
226                if self
227                    .allowed_paths
228                    .iter()
229                    .any(|p| n_lower.starts_with(p.replace('\\', "/").to_lowercase().as_str()))
230                {
231                    return true;
232                }
233            }
234        }
235        false
236    }
237
238    /// Lexically normalize a path by resolving `.` and `..` components without
239    /// hitting the filesystem. Does not require the path to exist.
240    fn lexical_normalize(p: &std::path::Path) -> std::path::PathBuf {
241        let mut out = std::path::PathBuf::new();
242        for component in p.components() {
243            match component {
244                std::path::Component::ParentDir => {
245                    out.pop();
246                }
247                std::path::Component::CurDir => {}
248                other => out.push(other),
249            }
250        }
251        out
252    }
253
254    fn check_patterns(command: &str) -> Option<&'static str> {
255        DESTRUCTIVE_PATTERNS
256            .iter()
257            .find(|&pat| command.contains(pat))
258            .copied()
259    }
260
261    fn check_extra_patterns(&self, command: &str) -> Option<String> {
262        self.extra_patterns
263            .iter()
264            .find(|pat| command.contains(pat.as_str()))
265            .cloned()
266    }
267}
268
269impl PreExecutionVerifier for DestructiveCommandVerifier {
270    fn name(&self) -> &'static str {
271        "DestructiveCommandVerifier"
272    }
273
274    fn verify(&self, tool_name: &str, args: &serde_json::Value) -> VerificationResult {
275        if !self.is_shell_tool(tool_name) {
276            return VerificationResult::Allow;
277        }
278
279        let Some(command) = Self::extract_command(args) else {
280            return VerificationResult::Allow;
281        };
282
283        if let Some(pat) = Self::check_patterns(&command) {
284            if self.is_allowed_path(&command) {
285                return VerificationResult::Allow;
286            }
287            return VerificationResult::Block {
288                reason: format!("[{}] destructive pattern '{}' detected", self.name(), pat),
289            };
290        }
291
292        if let Some(pat) = self.check_extra_patterns(&command) {
293            if self.is_allowed_path(&command) {
294                return VerificationResult::Allow;
295            }
296            return VerificationResult::Block {
297                reason: format!(
298                    "[{}] extra destructive pattern '{}' detected",
299                    self.name(),
300                    pat
301                ),
302            };
303        }
304
305        VerificationResult::Allow
306    }
307}
308
309// ---------------------------------------------------------------------------
310// InjectionPatternVerifier
311// ---------------------------------------------------------------------------
312
313/// High-confidence injection block patterns applied to string field values in tool args.
314///
315/// These require *structural* patterns, not just keywords — e.g., `UNION SELECT` is
316/// blocked but a plain mention of "SELECT" is not. This avoids false positives for
317/// `memory_search` queries discussing SQL or coding assistants writing SQL examples.
318static INJECTION_BLOCK_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
319    [
320        // SQL injection structural patterns
321        r"(?i)'\s*OR\s*'1'\s*=\s*'1",
322        r"(?i)'\s*OR\s*1\s*=\s*1",
323        r"(?i);\s*DROP\s+TABLE",
324        r"(?i)UNION\s+SELECT",
325        r"(?i)'\s*;\s*SELECT",
326        // Command injection via shell metacharacters with dangerous commands
327        r";\s*rm\s+",
328        r"\|\s*rm\s+",
329        r"&&\s*rm\s+",
330        r";\s*curl\s+",
331        r"\|\s*curl\s+",
332        r"&&\s*curl\s+",
333        r";\s*wget\s+",
334        // Path traversal to sensitive system files
335        r"\.\./\.\./\.\./etc/passwd",
336        r"\.\./\.\./\.\./etc/shadow",
337        r"\.\./\.\./\.\./windows/",
338        r"\.\.[/\\]\.\.[/\\]\.\.[/\\]",
339    ]
340    .iter()
341    .map(|s| Regex::new(s).expect("static pattern must compile"))
342    .collect()
343});
344
345/// SSRF host patterns — matched against the *extracted host* (not the full URL string).
346/// This prevents bypasses like `http://evil.com/?r=http://localhost` where the SSRF
347/// target appears only in a query parameter, not as the actual request host.
348/// Bare hostnames (no port/path) are included alongside `host:port` variants.
349static SSRF_HOST_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
350    [
351        // localhost — with or without port
352        r"^localhost$",
353        r"^localhost:",
354        // IPv4 loopback
355        r"^127\.0\.0\.1$",
356        r"^127\.0\.0\.1:",
357        // IPv6 loopback
358        r"^\[::1\]$",
359        r"^\[::1\]:",
360        // AWS metadata service
361        r"^169\.254\.169\.254$",
362        r"^169\.254\.169\.254:",
363        // RFC-1918 private ranges
364        r"^10\.\d+\.\d+\.\d+$",
365        r"^10\.\d+\.\d+\.\d+:",
366        r"^172\.(1[6-9]|2\d|3[01])\.\d+\.\d+$",
367        r"^172\.(1[6-9]|2\d|3[01])\.\d+\.\d+:",
368        r"^192\.168\.\d+\.\d+$",
369        r"^192\.168\.\d+\.\d+:",
370    ]
371    .iter()
372    .map(|s| Regex::new(s).expect("static pattern must compile"))
373    .collect()
374});
375
376/// Extract the host (and optional port) from a URL string.
377/// Returns the portion between `://` and the next `/`, `?`, `#`, or end of string.
378/// If the URL has no scheme, returns `None`.
379fn extract_url_host(url: &str) -> Option<&str> {
380    let after_scheme = url.split_once("://")?.1;
381    let host_end = after_scheme
382        .find(['/', '?', '#'])
383        .unwrap_or(after_scheme.len());
384    Some(&after_scheme[..host_end])
385}
386
387/// Field names that suggest URL/endpoint content — SSRF patterns are applied here.
388static URL_FIELD_NAMES: &[&str] = &["url", "endpoint", "uri", "href", "src", "host", "base_url"];
389
390/// Field names that are known to carry user-provided text queries — SQL injection and
391/// command injection patterns are skipped for these fields to avoid false positives.
392/// Examples: `memory_search(query=...)`, `web_search(query=...)`.
393static SAFE_QUERY_FIELDS: &[&str] = &["query", "q", "search", "text", "message", "content"];
394
395/// Verifier that blocks tool arguments containing SQL injection, command injection,
396/// or path traversal patterns. Applies to ALL tools using field-aware matching.
397///
398/// ## Field-aware matching
399///
400/// Rather than serialising all args to a flat string (which causes false positives),
401/// this verifier iterates over each string-valued field and applies pattern categories
402/// based on field semantics:
403///
404/// - `SAFE_QUERY_FIELDS` (`query`, `q`, `search`, `text`, …): injection patterns are
405///   **skipped** — these fields contain user-provided text and generate too many false
406///   positives for SQL/command discussions in chat.
407/// - `URL_FIELD_NAMES` (`url`, `endpoint`, `uri`, …): SSRF patterns are applied.
408/// - All other string fields: injection + path traversal patterns are applied.
409///
410/// ## Warn semantics
411///
412/// `VerificationResult::Warn` is metrics-only — the tool call proceeds, a WARN log
413/// entry is emitted, and the TUI security panel counter increments. The LLM does not
414/// see the warning in its tool result.
415#[derive(Debug)]
416pub struct InjectionPatternVerifier {
417    extra_patterns: Vec<Regex>,
418    allowlisted_urls: Vec<String>,
419}
420
421impl InjectionPatternVerifier {
422    #[must_use]
423    pub fn new(config: &InjectionVerifierConfig) -> Self {
424        let extra_patterns = config
425            .extra_patterns
426            .iter()
427            .filter_map(|s| match Regex::new(s) {
428                Ok(re) => Some(re),
429                Err(e) => {
430                    tracing::warn!(
431                        pattern = %s,
432                        error = %e,
433                        "InjectionPatternVerifier: invalid extra_pattern, skipping"
434                    );
435                    None
436                }
437            })
438            .collect();
439
440        Self {
441            extra_patterns,
442            allowlisted_urls: config
443                .allowlisted_urls
444                .iter()
445                .map(|s| s.to_lowercase())
446                .collect(),
447        }
448    }
449
450    fn is_allowlisted(&self, text: &str) -> bool {
451        let lower = text.to_lowercase();
452        self.allowlisted_urls
453            .iter()
454            .any(|u| lower.contains(u.as_str()))
455    }
456
457    fn is_url_field(field: &str) -> bool {
458        let lower = field.to_lowercase();
459        URL_FIELD_NAMES.iter().any(|&f| f == lower)
460    }
461
462    fn is_safe_query_field(field: &str) -> bool {
463        let lower = field.to_lowercase();
464        SAFE_QUERY_FIELDS.iter().any(|&f| f == lower)
465    }
466
467    /// Check a single string value from a named field.
468    fn check_field_value(&self, field: &str, value: &str) -> VerificationResult {
469        let is_url = Self::is_url_field(field);
470        let is_safe_query = Self::is_safe_query_field(field);
471
472        // Injection + path traversal: skip safe query fields (user text), apply elsewhere.
473        if !is_safe_query {
474            for pat in INJECTION_BLOCK_PATTERNS.iter() {
475                if pat.is_match(value) {
476                    return VerificationResult::Block {
477                        reason: format!(
478                            "[{}] injection pattern detected in field '{}': {}",
479                            "InjectionPatternVerifier",
480                            field,
481                            pat.as_str()
482                        ),
483                    };
484                }
485            }
486            for pat in &self.extra_patterns {
487                if pat.is_match(value) {
488                    return VerificationResult::Block {
489                        reason: format!(
490                            "[{}] extra injection pattern detected in field '{}': {}",
491                            "InjectionPatternVerifier",
492                            field,
493                            pat.as_str()
494                        ),
495                    };
496                }
497            }
498        }
499
500        // SSRF: apply only to URL-like fields.
501        // Extract the host first so that SSRF targets embedded in query parameters
502        // (e.g. `http://evil.com/?r=http://localhost`) are not falsely matched.
503        if is_url && let Some(host) = extract_url_host(value) {
504            for pat in SSRF_HOST_PATTERNS.iter() {
505                if pat.is_match(host) {
506                    if self.is_allowlisted(value) {
507                        return VerificationResult::Allow;
508                    }
509                    return VerificationResult::Warn {
510                        message: format!(
511                            "[{}] possible SSRF in field '{}': host '{}' matches pattern (not blocked)",
512                            "InjectionPatternVerifier", field, host,
513                        ),
514                    };
515                }
516            }
517        }
518
519        VerificationResult::Allow
520    }
521
522    /// Walk all string leaf values in a JSON object, collecting field names for context.
523    fn check_object(&self, obj: &serde_json::Map<String, serde_json::Value>) -> VerificationResult {
524        for (key, val) in obj {
525            let result = self.check_value(key, val);
526            if !matches!(result, VerificationResult::Allow) {
527                return result;
528            }
529        }
530        VerificationResult::Allow
531    }
532
533    fn check_value(&self, field: &str, val: &serde_json::Value) -> VerificationResult {
534        match val {
535            serde_json::Value::String(s) => self.check_field_value(field, s),
536            serde_json::Value::Array(arr) => {
537                for item in arr {
538                    let r = self.check_value(field, item);
539                    if !matches!(r, VerificationResult::Allow) {
540                        return r;
541                    }
542                }
543                VerificationResult::Allow
544            }
545            serde_json::Value::Object(obj) => self.check_object(obj),
546            // Non-string primitives (numbers, booleans, null) cannot contain injection.
547            _ => VerificationResult::Allow,
548        }
549    }
550}
551
552impl PreExecutionVerifier for InjectionPatternVerifier {
553    fn name(&self) -> &'static str {
554        "InjectionPatternVerifier"
555    }
556
557    fn verify(&self, _tool_name: &str, args: &serde_json::Value) -> VerificationResult {
558        match args {
559            serde_json::Value::Object(obj) => self.check_object(obj),
560            // Flat string args (unusual but handle gracefully — treat as unnamed field).
561            serde_json::Value::String(s) => self.check_field_value("_args", s),
562            _ => VerificationResult::Allow,
563        }
564    }
565}
566
567// ---------------------------------------------------------------------------
568// UrlGroundingVerifier
569// ---------------------------------------------------------------------------
570
571/// Verifier that blocks `fetch` and `web_scrape` calls when the requested URL
572/// was not explicitly provided by the user in the conversation.
573///
574/// The agent populates `user_provided_urls` whenever a user message is received,
575/// by extracting all http/https URLs from the raw input. This set persists across
576/// turns within a session and is cleared on `/clear`.
577///
578/// ## Bypass rules
579///
580/// - Tools not in the `guarded_tools` list (and not ending in `_fetch`) pass through.
581/// - If the URL in the tool call is a prefix-match or exact match of any URL in
582///   `user_provided_urls`, the call is allowed.
583/// - If `user_provided_urls` is empty (no URLs seen in this session at all), the call
584///   is blocked — the LLM must not fetch arbitrary URLs when the user never provided one.
585#[derive(Debug, Clone)]
586pub struct UrlGroundingVerifier {
587    guarded_tools: Vec<String>,
588    user_provided_urls: Arc<RwLock<HashSet<String>>>,
589}
590
591impl UrlGroundingVerifier {
592    #[must_use]
593    pub fn new(
594        config: &UrlGroundingVerifierConfig,
595        user_provided_urls: Arc<RwLock<HashSet<String>>>,
596    ) -> Self {
597        Self {
598            guarded_tools: config
599                .guarded_tools
600                .iter()
601                .map(|s| s.to_lowercase())
602                .collect(),
603            user_provided_urls,
604        }
605    }
606
607    fn is_guarded(&self, tool_name: &str) -> bool {
608        let lower = tool_name.to_lowercase();
609        self.guarded_tools.iter().any(|t| t == &lower) || lower.ends_with("_fetch")
610    }
611
612    /// Returns true if `url` is grounded — i.e., it appears in (or is a prefix of)
613    /// a URL from `user_provided_urls`.
614    fn is_grounded(url: &str, user_provided_urls: &HashSet<String>) -> bool {
615        let lower = url.to_lowercase();
616        user_provided_urls
617            .iter()
618            .any(|u| lower.starts_with(u.as_str()) || u.starts_with(lower.as_str()))
619    }
620}
621
622impl PreExecutionVerifier for UrlGroundingVerifier {
623    fn name(&self) -> &'static str {
624        "UrlGroundingVerifier"
625    }
626
627    fn verify(&self, tool_name: &str, args: &serde_json::Value) -> VerificationResult {
628        if !self.is_guarded(tool_name) {
629            return VerificationResult::Allow;
630        }
631
632        let Some(url) = args.get("url").and_then(|v| v.as_str()) else {
633            return VerificationResult::Allow;
634        };
635
636        let urls = self.user_provided_urls.read();
637
638        if Self::is_grounded(url, &urls) {
639            return VerificationResult::Allow;
640        }
641
642        VerificationResult::Block {
643            reason: format!(
644                "[UrlGroundingVerifier] fetch rejected: URL '{url}' was not provided by the user",
645            ),
646        }
647    }
648}
649
650// ---------------------------------------------------------------------------
651// FirewallVerifier
652// ---------------------------------------------------------------------------
653
654/// Policy-enforcement verifier that inspects tool arguments for path traversal,
655/// environment-variable exfiltration, sensitive file access, and command chaining.
656///
657/// ## Scope delineation with `InjectionPatternVerifier`
658///
659/// `FirewallVerifier` enforces *configurable policy* (blocked paths, env vars, sensitive
660/// file patterns). `InjectionPatternVerifier` performs regex-based *injection pattern
661/// detection* (prompt injection, SSRF, etc.). They are complementary — belt-and-suspenders,
662/// the same intentional overlap documented at the top of this module.
663///
664/// Both verifiers may produce `Block` for the same call (e.g. command chaining detected
665/// by both). The pipeline stops at the first `Block` result.
666#[derive(Debug)]
667pub struct FirewallVerifier {
668    blocked_path_globs: Vec<glob::Pattern>,
669    blocked_env_vars: HashSet<String>,
670    exempt_tools: HashSet<String>,
671}
672
673/// Built-in path patterns that are always blocked regardless of config.
674static SENSITIVE_PATH_PATTERNS: LazyLock<Vec<glob::Pattern>> = LazyLock::new(|| {
675    let raw = [
676        "/etc/passwd",
677        "/etc/shadow",
678        "/etc/sudoers",
679        "~/.ssh/*",
680        "~/.aws/*",
681        "~/.gnupg/*",
682        "**/*.pem",
683        "**/*.key",
684        "**/id_rsa",
685        "**/id_ed25519",
686        "**/.env",
687        "**/credentials",
688    ];
689    raw.iter()
690        .filter_map(|p| {
691            glob::Pattern::new(p)
692                .map_err(|e| {
693                    tracing::error!(pattern = p, error = %e, "failed to compile built-in firewall path pattern");
694                    e
695                })
696                .ok()
697        })
698        .collect()
699});
700
701/// Built-in env var prefixes that trigger a block when found in tool arguments.
702static SENSITIVE_ENV_PREFIXES: &[&str] =
703    &["$AWS_", "$ZEPH_", "${AWS_", "${ZEPH_", "%AWS_", "%ZEPH_"];
704
705/// Argument field names to extract and inspect.
706static INSPECTED_FIELDS: &[&str] = &[
707    "command",
708    "file_path",
709    "path",
710    "url",
711    "query",
712    "uri",
713    "input",
714    "args",
715];
716
717impl FirewallVerifier {
718    /// Build a `FirewallVerifier` from config.
719    ///
720    /// Invalid glob patterns in `blocked_paths` are logged at WARN level and skipped.
721    #[must_use]
722    pub fn new(config: &FirewallVerifierConfig) -> Self {
723        let blocked_path_globs = config
724            .blocked_paths
725            .iter()
726            .filter_map(|p| {
727                glob::Pattern::new(p)
728                    .map_err(|e| {
729                        tracing::warn!(pattern = p, error = %e, "invalid glob pattern in firewall blocked_paths, skipping");
730                        e
731                    })
732                    .ok()
733            })
734            .collect();
735
736        let blocked_env_vars = config
737            .blocked_env_vars
738            .iter()
739            .map(|s| s.to_uppercase())
740            .collect();
741
742        let exempt_tools = config
743            .exempt_tools
744            .iter()
745            .map(|s| s.to_lowercase())
746            .collect();
747
748        Self {
749            blocked_path_globs,
750            blocked_env_vars,
751            exempt_tools,
752        }
753    }
754
755    /// Extract all string argument values from a tool call's JSON args.
756    fn collect_args(args: &serde_json::Value) -> Vec<String> {
757        let mut out = Vec::new();
758        match args {
759            serde_json::Value::Object(map) => {
760                for field in INSPECTED_FIELDS {
761                    if let Some(val) = map.get(*field) {
762                        Self::collect_strings(val, &mut out);
763                    }
764                }
765            }
766            serde_json::Value::String(s) => out.push(s.clone()),
767            _ => {}
768        }
769        out
770    }
771
772    fn collect_strings(val: &serde_json::Value, out: &mut Vec<String>) {
773        match val {
774            serde_json::Value::String(s) => out.push(s.clone()),
775            serde_json::Value::Array(arr) => {
776                for item in arr {
777                    Self::collect_strings(item, out);
778                }
779            }
780            _ => {}
781        }
782    }
783
784    fn scan_arg(&self, arg: &str) -> Option<VerificationResult> {
785        // Apply NFKC normalization consistent with DestructiveCommandVerifier.
786        let normalized: String = arg.nfkc().collect();
787        let lower = normalized.to_lowercase();
788
789        // Path traversal
790        if lower.contains("../") || lower.contains("..\\") {
791            return Some(VerificationResult::Block {
792                reason: format!(
793                    "[FirewallVerifier] path traversal pattern detected in argument: {arg}"
794                ),
795            });
796        }
797
798        // Sensitive paths (built-in)
799        for pattern in SENSITIVE_PATH_PATTERNS.iter() {
800            if pattern.matches(&normalized) || pattern.matches(&lower) {
801                return Some(VerificationResult::Block {
802                    reason: format!(
803                        "[FirewallVerifier] sensitive path pattern '{pattern}' matched in argument: {arg}"
804                    ),
805                });
806            }
807        }
808
809        // User-configured blocked paths
810        for pattern in &self.blocked_path_globs {
811            if pattern.matches(&normalized) || pattern.matches(&lower) {
812                return Some(VerificationResult::Block {
813                    reason: format!(
814                        "[FirewallVerifier] blocked path pattern '{pattern}' matched in argument: {arg}"
815                    ),
816                });
817            }
818        }
819
820        // Env var exfiltration (built-in prefixes)
821        let upper = normalized.to_uppercase();
822        for prefix in SENSITIVE_ENV_PREFIXES {
823            if upper.contains(*prefix) {
824                return Some(VerificationResult::Block {
825                    reason: format!(
826                        "[FirewallVerifier] env var exfiltration pattern '{prefix}' detected in argument: {arg}"
827                    ),
828                });
829            }
830        }
831
832        // User-configured blocked env vars (match $VAR or %VAR% patterns)
833        for var in &self.blocked_env_vars {
834            let dollar_form = format!("${var}");
835            let brace_form = format!("${{{var}}}");
836            let percent_form = format!("%{var}%");
837            if upper.contains(&dollar_form)
838                || upper.contains(&brace_form)
839                || upper.contains(&percent_form)
840            {
841                return Some(VerificationResult::Block {
842                    reason: format!(
843                        "[FirewallVerifier] blocked env var '{var}' detected in argument: {arg}"
844                    ),
845                });
846            }
847        }
848
849        None
850    }
851}
852
853impl PreExecutionVerifier for FirewallVerifier {
854    fn name(&self) -> &'static str {
855        "FirewallVerifier"
856    }
857
858    fn verify(&self, tool_name: &str, args: &serde_json::Value) -> VerificationResult {
859        if self.exempt_tools.contains(&tool_name.to_lowercase()) {
860            return VerificationResult::Allow;
861        }
862
863        for arg in Self::collect_args(args) {
864            if let Some(result) = self.scan_arg(&arg) {
865                return result;
866            }
867        }
868
869        VerificationResult::Allow
870    }
871}
872
873// ---------------------------------------------------------------------------
874// Tests
875// ---------------------------------------------------------------------------
876
877#[cfg(test)]
878mod tests {
879    use serde_json::json;
880    use std::assert_matches;
881
882    use super::*;
883
884    // --- DestructiveCommandVerifier ---
885
886    fn dcv() -> DestructiveCommandVerifier {
887        DestructiveCommandVerifier::new(&DestructiveVerifierConfig::default())
888    }
889
890    #[test]
891    fn allow_normal_command() {
892        let v = dcv();
893        assert_eq!(
894            v.verify("bash", &json!({"command": "ls -la /tmp"})),
895            VerificationResult::Allow
896        );
897    }
898
899    #[test]
900    fn block_rm_rf_root() {
901        let v = dcv();
902        let result = v.verify("bash", &json!({"command": "rm -rf /"}));
903        assert_matches!(result, VerificationResult::Block { .. });
904    }
905
906    #[test]
907    fn block_dd_dev_zero() {
908        let v = dcv();
909        let result = v.verify("bash", &json!({"command": "dd if=/dev/zero of=/dev/sda"}));
910        assert_matches!(result, VerificationResult::Block { .. });
911    }
912
913    #[test]
914    fn block_mkfs() {
915        let v = dcv();
916        let result = v.verify("bash", &json!({"command": "mkfs.ext4 /dev/sda1"}));
917        assert_matches!(result, VerificationResult::Block { .. });
918    }
919
920    #[test]
921    fn allow_rm_rf_in_allowed_path() {
922        let config = DestructiveVerifierConfig {
923            allowed_paths: vec!["/tmp/build".to_string()],
924            ..Default::default()
925        };
926        let v = DestructiveCommandVerifier::new(&config);
927        assert_eq!(
928            v.verify("bash", &json!({"command": "rm -rf /tmp/build/artifacts"})),
929            VerificationResult::Allow
930        );
931    }
932
933    #[test]
934    fn block_rm_rf_when_not_in_allowed_path() {
935        let config = DestructiveVerifierConfig {
936            allowed_paths: vec!["/tmp/build".to_string()],
937            ..Default::default()
938        };
939        let v = DestructiveCommandVerifier::new(&config);
940        let result = v.verify("bash", &json!({"command": "rm -rf /home/user"}));
941        assert_matches!(result, VerificationResult::Block { .. });
942    }
943
944    #[test]
945    fn allow_non_shell_tool() {
946        let v = dcv();
947        assert_eq!(
948            v.verify("read_file", &json!({"path": "rm -rf /"})),
949            VerificationResult::Allow
950        );
951    }
952
953    #[test]
954    fn block_extra_pattern() {
955        let config = DestructiveVerifierConfig {
956            extra_patterns: vec!["format c:".to_string()],
957            ..Default::default()
958        };
959        let v = DestructiveCommandVerifier::new(&config);
960        let result = v.verify("bash", &json!({"command": "format c:"}));
961        assert_matches!(result, VerificationResult::Block { .. });
962    }
963
964    #[test]
965    fn array_args_normalization() {
966        let v = dcv();
967        let result = v.verify("bash", &json!({"command": ["rm", "-rf", "/"]}));
968        assert_matches!(result, VerificationResult::Block { .. });
969    }
970
971    #[test]
972    fn sh_c_wrapping_normalization() {
973        let v = dcv();
974        let result = v.verify("bash", &json!({"command": "bash -c 'rm -rf /'"}));
975        assert_matches!(result, VerificationResult::Block { .. });
976    }
977
978    #[test]
979    fn fork_bomb_blocked() {
980        let v = dcv();
981        let result = v.verify("bash", &json!({"command": ":(){ :|:& };:"}));
982        assert_matches!(result, VerificationResult::Block { .. });
983    }
984
985    #[test]
986    fn custom_shell_tool_name_blocked() {
987        let config = DestructiveVerifierConfig {
988            shell_tools: vec!["execute".to_string(), "run_command".to_string()],
989            ..Default::default()
990        };
991        let v = DestructiveCommandVerifier::new(&config);
992        let result = v.verify("execute", &json!({"command": "rm -rf /"}));
993        assert_matches!(result, VerificationResult::Block { .. });
994    }
995
996    #[test]
997    fn terminal_tool_name_blocked_by_default() {
998        let v = dcv();
999        let result = v.verify("terminal", &json!({"command": "rm -rf /"}));
1000        assert_matches!(result, VerificationResult::Block { .. });
1001    }
1002
1003    #[test]
1004    fn default_shell_tools_contains_bash_shell_terminal() {
1005        let config = DestructiveVerifierConfig::default();
1006        let lower: Vec<String> = config
1007            .shell_tools
1008            .iter()
1009            .map(|s| s.to_lowercase())
1010            .collect();
1011        assert!(lower.contains(&"bash".to_string()));
1012        assert!(lower.contains(&"shell".to_string()));
1013        assert!(lower.contains(&"terminal".to_string()));
1014    }
1015
1016    // --- InjectionPatternVerifier ---
1017
1018    fn ipv() -> InjectionPatternVerifier {
1019        InjectionPatternVerifier::new(&InjectionVerifierConfig::default())
1020    }
1021
1022    #[test]
1023    fn allow_clean_args() {
1024        let v = ipv();
1025        assert_eq!(
1026            v.verify("search", &json!({"query": "rust async traits"})),
1027            VerificationResult::Allow
1028        );
1029    }
1030
1031    #[test]
1032    fn allow_sql_discussion_in_query_field() {
1033        // S2: memory_search with SQL discussion must NOT be blocked.
1034        let v = ipv();
1035        assert_eq!(
1036            v.verify(
1037                "memory_search",
1038                &json!({"query": "explain SQL UNION SELECT vs JOIN"})
1039            ),
1040            VerificationResult::Allow
1041        );
1042    }
1043
1044    #[test]
1045    fn allow_sql_or_pattern_in_query_field() {
1046        // S2: safe query field must not trigger SQL injection pattern.
1047        let v = ipv();
1048        assert_eq!(
1049            v.verify("memory_search", &json!({"query": "' OR '1'='1"})),
1050            VerificationResult::Allow
1051        );
1052    }
1053
1054    #[test]
1055    fn block_sql_injection_in_non_query_field() {
1056        let v = ipv();
1057        let result = v.verify("db_query", &json!({"sql": "' OR '1'='1"}));
1058        assert_matches!(result, VerificationResult::Block { .. });
1059    }
1060
1061    #[test]
1062    fn block_drop_table() {
1063        let v = ipv();
1064        let result = v.verify("db_query", &json!({"input": "name'; DROP TABLE users"}));
1065        assert_matches!(result, VerificationResult::Block { .. });
1066    }
1067
1068    #[test]
1069    fn block_path_traversal() {
1070        let v = ipv();
1071        let result = v.verify("read_file", &json!({"path": "../../../etc/passwd"}));
1072        assert_matches!(result, VerificationResult::Block { .. });
1073    }
1074
1075    #[test]
1076    fn warn_on_localhost_url_field() {
1077        // S2: SSRF warn only fires on URL-like fields.
1078        let v = ipv();
1079        let result = v.verify("http_get", &json!({"url": "http://localhost:8080/api"}));
1080        assert_matches!(result, VerificationResult::Warn { .. });
1081    }
1082
1083    #[test]
1084    fn allow_localhost_in_non_url_field() {
1085        // S2: localhost in a "text" field (not a URL field) must not warn.
1086        let v = ipv();
1087        assert_eq!(
1088            v.verify(
1089                "memory_search",
1090                &json!({"query": "connect to http://localhost:8080"})
1091            ),
1092            VerificationResult::Allow
1093        );
1094    }
1095
1096    #[test]
1097    fn warn_on_private_ip_url_field() {
1098        let v = ipv();
1099        let result = v.verify("fetch", &json!({"url": "http://192.168.1.1/admin"}));
1100        assert_matches!(result, VerificationResult::Warn { .. });
1101    }
1102
1103    #[test]
1104    fn allow_localhost_when_allowlisted() {
1105        let config = InjectionVerifierConfig {
1106            allowlisted_urls: vec!["http://localhost:3000".to_string()],
1107            ..Default::default()
1108        };
1109        let v = InjectionPatternVerifier::new(&config);
1110        assert_eq!(
1111            v.verify("http_get", &json!({"url": "http://localhost:3000/api"})),
1112            VerificationResult::Allow
1113        );
1114    }
1115
1116    #[test]
1117    fn block_union_select_in_non_query_field() {
1118        let v = ipv();
1119        let result = v.verify(
1120            "db_query",
1121            &json!({"input": "id=1 UNION SELECT password FROM users"}),
1122        );
1123        assert_matches!(result, VerificationResult::Block { .. });
1124    }
1125
1126    #[test]
1127    fn allow_union_select_in_query_field() {
1128        // S2: "UNION SELECT" in a `query` field is a SQL discussion, not an injection.
1129        let v = ipv();
1130        assert_eq!(
1131            v.verify(
1132                "memory_search",
1133                &json!({"query": "id=1 UNION SELECT password FROM users"})
1134            ),
1135            VerificationResult::Allow
1136        );
1137    }
1138
1139    // --- FIX-1: Unicode normalization bypass ---
1140
1141    #[test]
1142    fn block_rm_rf_unicode_homoglyph() {
1143        // U+FF0F FULLWIDTH SOLIDUS looks like '/' and NFKC-normalizes to '/'.
1144        let v = dcv();
1145        // "rm -rf /" where / is U+FF0F
1146        let result = v.verify("bash", &json!({"command": "rm -rf \u{FF0F}"}));
1147        assert_matches!(result, VerificationResult::Block { .. });
1148    }
1149
1150    // --- FIX-2: Path traversal in is_allowed_path ---
1151
1152    #[test]
1153    fn path_traversal_not_allowed_via_dotdot() {
1154        // `/tmp/build/../../etc` lexically resolves to `/etc`, NOT under `/tmp/build`.
1155        let config = DestructiveVerifierConfig {
1156            allowed_paths: vec!["/tmp/build".to_string()],
1157            ..Default::default()
1158        };
1159        let v = DestructiveCommandVerifier::new(&config);
1160        // Should be BLOCKED: resolved path is /etc, not under /tmp/build.
1161        let result = v.verify("bash", &json!({"command": "rm -rf /tmp/build/../../etc"}));
1162        assert_matches!(result, VerificationResult::Block { .. });
1163    }
1164
1165    #[test]
1166    fn allowed_path_with_dotdot_stays_in_allowed() {
1167        // `/tmp/build/sub/../artifacts` resolves to `/tmp/build/artifacts` — still allowed.
1168        let config = DestructiveVerifierConfig {
1169            allowed_paths: vec!["/tmp/build".to_string()],
1170            ..Default::default()
1171        };
1172        let v = DestructiveCommandVerifier::new(&config);
1173        assert_eq!(
1174            v.verify(
1175                "bash",
1176                &json!({"command": "rm -rf /tmp/build/sub/../artifacts"}),
1177            ),
1178            VerificationResult::Allow,
1179        );
1180    }
1181
1182    // --- FIX-3: Double-nested shell wrapping ---
1183
1184    #[test]
1185    fn double_nested_bash_c_blocked() {
1186        let v = dcv();
1187        let result = v.verify(
1188            "bash",
1189            &json!({"command": "bash -c \"bash -c 'rm -rf /'\""}),
1190        );
1191        assert_matches!(result, VerificationResult::Block { .. });
1192    }
1193
1194    #[test]
1195    fn env_prefix_stripping_blocked() {
1196        let v = dcv();
1197        let result = v.verify(
1198            "bash",
1199            &json!({"command": "env FOO=bar bash -c 'rm -rf /'"}),
1200        );
1201        assert_matches!(result, VerificationResult::Block { .. });
1202    }
1203
1204    #[test]
1205    fn exec_prefix_stripping_blocked() {
1206        let v = dcv();
1207        let result = v.verify("bash", &json!({"command": "exec bash -c 'rm -rf /'"}));
1208        assert_matches!(result, VerificationResult::Block { .. });
1209    }
1210
1211    // --- FIX-4: SSRF host extraction (not substring match) ---
1212
1213    #[test]
1214    fn ssrf_not_triggered_for_embedded_localhost_in_query_param() {
1215        // `evil.com/?r=http://localhost` — host is `evil.com`, not localhost.
1216        let v = ipv();
1217        let result = v.verify(
1218            "http_get",
1219            &json!({"url": "http://evil.com/?r=http://localhost"}),
1220        );
1221        // Should NOT warn — the actual request host is evil.com, not localhost.
1222        assert_eq!(result, VerificationResult::Allow);
1223    }
1224
1225    #[test]
1226    fn ssrf_triggered_for_bare_localhost_no_port() {
1227        // FIX-7: `http://localhost` with no trailing slash or port must warn.
1228        let v = ipv();
1229        let result = v.verify("http_get", &json!({"url": "http://localhost"}));
1230        assert_matches!(result, VerificationResult::Warn { .. });
1231    }
1232
1233    #[test]
1234    fn ssrf_triggered_for_localhost_with_path() {
1235        let v = ipv();
1236        let result = v.verify("http_get", &json!({"url": "http://localhost/api/v1"}));
1237        assert_matches!(result, VerificationResult::Warn { .. });
1238    }
1239
1240    // --- Verifier chain: first Block wins, Warn continues ---
1241
1242    #[test]
1243    fn chain_first_block_wins() {
1244        let dcv = DestructiveCommandVerifier::new(&DestructiveVerifierConfig::default());
1245        let ipv = InjectionPatternVerifier::new(&InjectionVerifierConfig::default());
1246        let verifiers: Vec<Box<dyn PreExecutionVerifier>> = vec![Box::new(dcv), Box::new(ipv)];
1247
1248        let args = json!({"command": "rm -rf /"});
1249        let mut result = VerificationResult::Allow;
1250        for v in &verifiers {
1251            result = v.verify("bash", &args);
1252            if matches!(result, VerificationResult::Block { .. }) {
1253                break;
1254            }
1255        }
1256        assert_matches!(result, VerificationResult::Block { .. });
1257    }
1258
1259    #[test]
1260    fn chain_warn_continues() {
1261        let dcv = DestructiveCommandVerifier::new(&DestructiveVerifierConfig::default());
1262        let ipv = InjectionPatternVerifier::new(&InjectionVerifierConfig::default());
1263        let verifiers: Vec<Box<dyn PreExecutionVerifier>> = vec![Box::new(dcv), Box::new(ipv)];
1264
1265        // localhost URL in `url` field: dcv allows, ipv warns, chain does NOT block.
1266        let args = json!({"url": "http://localhost:8080/api"});
1267        let mut got_warn = false;
1268        let mut got_block = false;
1269        for v in &verifiers {
1270            match v.verify("http_get", &args) {
1271                VerificationResult::Block { .. } => {
1272                    got_block = true;
1273                    break;
1274                }
1275                VerificationResult::Warn { .. } => {
1276                    got_warn = true;
1277                }
1278                VerificationResult::Allow => {}
1279            }
1280        }
1281        assert!(got_warn);
1282        assert!(!got_block);
1283    }
1284
1285    // --- UrlGroundingVerifier ---
1286
1287    fn ugv(urls: &[&str]) -> UrlGroundingVerifier {
1288        let set: HashSet<String> = urls.iter().map(|s| s.to_lowercase()).collect();
1289        UrlGroundingVerifier::new(
1290            &UrlGroundingVerifierConfig::default(),
1291            Arc::new(RwLock::new(set)),
1292        )
1293    }
1294
1295    #[test]
1296    fn url_grounding_allows_user_provided_url() {
1297        let v = ugv(&["https://docs.anthropic.com/models"]);
1298        assert_eq!(
1299            v.verify(
1300                "fetch",
1301                &json!({"url": "https://docs.anthropic.com/models"})
1302            ),
1303            VerificationResult::Allow
1304        );
1305    }
1306
1307    #[test]
1308    fn url_grounding_blocks_hallucinated_url() {
1309        let v = ugv(&["https://example.com/page"]);
1310        let result = v.verify(
1311            "fetch",
1312            &json!({"url": "https://api.anthropic.ai/v1/models"}),
1313        );
1314        assert_matches!(result, VerificationResult::Block { .. });
1315    }
1316
1317    #[test]
1318    fn url_grounding_blocks_when_no_user_urls_at_all() {
1319        let v = ugv(&[]);
1320        let result = v.verify(
1321            "fetch",
1322            &json!({"url": "https://api.anthropic.ai/v1/models"}),
1323        );
1324        assert_matches!(result, VerificationResult::Block { .. });
1325    }
1326
1327    #[test]
1328    fn url_grounding_allows_non_guarded_tool() {
1329        let v = ugv(&[]);
1330        assert_eq!(
1331            v.verify("read_file", &json!({"path": "/etc/hosts"})),
1332            VerificationResult::Allow
1333        );
1334    }
1335
1336    #[test]
1337    fn url_grounding_guards_fetch_suffix_tool() {
1338        let v = ugv(&[]);
1339        let result = v.verify("http_fetch", &json!({"url": "https://evil.com/"}));
1340        assert_matches!(result, VerificationResult::Block { .. });
1341    }
1342
1343    #[test]
1344    fn url_grounding_allows_web_scrape_with_provided_url() {
1345        let v = ugv(&["https://rust-lang.org/"]);
1346        assert_eq!(
1347            v.verify(
1348                "web_scrape",
1349                &json!({"url": "https://rust-lang.org/", "select": "h1"})
1350            ),
1351            VerificationResult::Allow
1352        );
1353    }
1354
1355    #[test]
1356    fn url_grounding_allows_prefix_match() {
1357        // User provided https://docs.rs/ — agent fetches a sub-path.
1358        let v = ugv(&["https://docs.rs/"]);
1359        assert_eq!(
1360            v.verify(
1361                "fetch",
1362                &json!({"url": "https://docs.rs/tokio/latest/tokio/"})
1363            ),
1364            VerificationResult::Allow
1365        );
1366    }
1367
1368    // --- Regression: #2191 — fetch URL hallucination ---
1369
1370    /// REG-2191-1: exact reproduction of the bug scenario.
1371    /// Agent asks "do you know Anthropic?" (no URL provided) and halluccinates
1372    /// `https://api.anthropic.ai/v1/models`. With an empty `user_provided_urls` set
1373    /// the fetch must be blocked.
1374    #[test]
1375    fn reg_2191_hallucinated_api_endpoint_blocked_with_empty_session() {
1376        // Simulate: user never sent any URL in the conversation.
1377        let v = ugv(&[]);
1378        let result = v.verify(
1379            "fetch",
1380            &json!({"url": "https://api.anthropic.ai/v1/models"}),
1381        );
1382        assert!(
1383            matches!(result, VerificationResult::Block { .. }),
1384            "fetch must be blocked when no user URL was provided — this is the #2191 regression"
1385        );
1386    }
1387
1388    /// REG-2191-2: passthrough — user explicitly pasted the URL, fetch must proceed.
1389    #[test]
1390    fn reg_2191_user_provided_url_allows_fetch() {
1391        let v = ugv(&["https://api.anthropic.com/v1/models"]);
1392        assert_eq!(
1393            v.verify(
1394                "fetch",
1395                &json!({"url": "https://api.anthropic.com/v1/models"}),
1396            ),
1397            VerificationResult::Allow,
1398            "fetch must be allowed when the URL was explicitly provided by the user"
1399        );
1400    }
1401
1402    /// REG-2191-3: `web_scrape` variant — same rejection for `web_scrape` tool.
1403    #[test]
1404    fn reg_2191_web_scrape_hallucinated_url_blocked() {
1405        let v = ugv(&[]);
1406        let result = v.verify(
1407            "web_scrape",
1408            &json!({"url": "https://api.anthropic.ai/v1/models", "select": "body"}),
1409        );
1410        assert!(
1411            matches!(result, VerificationResult::Block { .. }),
1412            "web_scrape must be blocked for hallucinated URL with empty user_provided_urls"
1413        );
1414    }
1415
1416    /// REG-2191-4: URL present only in an imagined system/assistant message context
1417    /// is NOT in `user_provided_urls` (the agent only populates from user messages).
1418    /// The verifier itself cannot distinguish message roles — it only sees the set
1419    /// populated by the agent. This test confirms: an empty set always blocks.
1420    #[test]
1421    fn reg_2191_empty_url_set_always_blocks_fetch() {
1422        // Whether the URL came from a system/assistant message or was never seen —
1423        // if user_provided_urls is empty, fetch must be blocked.
1424        let v = ugv(&[]);
1425        let result = v.verify(
1426            "fetch",
1427            &json!({"url": "https://docs.anthropic.com/something"}),
1428        );
1429        assert_matches!(result, VerificationResult::Block { .. });
1430    }
1431
1432    /// REG-2191-5: URL matching is case-insensitive — user pastes mixed-case URL.
1433    #[test]
1434    fn reg_2191_case_insensitive_url_match_allows_fetch() {
1435        // user_provided_urls stores lowercase; verify that the fetched URL with
1436        // different casing still matches.
1437        let v = ugv(&["https://Docs.Anthropic.COM/models"]);
1438        assert_eq!(
1439            v.verify(
1440                "fetch",
1441                &json!({"url": "https://docs.anthropic.com/models/detail"}),
1442            ),
1443            VerificationResult::Allow,
1444            "URL matching must be case-insensitive"
1445        );
1446    }
1447
1448    /// REG-2191-6: tool name ending in `_fetch` is auto-guarded regardless of config.
1449    /// An MCP-registered `anthropic_fetch` tool must not bypass the gate.
1450    #[test]
1451    fn reg_2191_mcp_fetch_suffix_tool_blocked_with_empty_session() {
1452        let v = ugv(&[]);
1453        let result = v.verify(
1454            "anthropic_fetch",
1455            &json!({"url": "https://api.anthropic.ai/v1/models"}),
1456        );
1457        assert!(
1458            matches!(result, VerificationResult::Block { .. }),
1459            "MCP tools ending in _fetch must be guarded even if not in guarded_tools list"
1460        );
1461    }
1462
1463    /// REG-2191-7: reverse prefix — user provided a specific URL, agent fetches
1464    /// the root. This is the "reverse prefix" case: `user_url` `starts_with` `fetch_url`.
1465    #[test]
1466    fn reg_2191_reverse_prefix_match_allows_fetch() {
1467        // User provided a deep URL; agent wants to fetch the root.
1468        // Allowed: user_url.starts_with(fetch_url).
1469        let v = ugv(&["https://docs.rs/tokio/latest/tokio/index.html"]);
1470        assert_eq!(
1471            v.verify("fetch", &json!({"url": "https://docs.rs/"})),
1472            VerificationResult::Allow,
1473            "reverse prefix: fetched URL is a prefix of user-provided URL — should be allowed"
1474        );
1475    }
1476
1477    /// REG-2191-8: completely different domain with same path prefix must be blocked.
1478    #[test]
1479    fn reg_2191_different_domain_blocked() {
1480        // User provided docs.rs, agent wants to fetch evil.com/docs.rs path — must block.
1481        let v = ugv(&["https://docs.rs/"]);
1482        let result = v.verify("fetch", &json!({"url": "https://evil.com/docs.rs/exfil"}));
1483        assert!(
1484            matches!(result, VerificationResult::Block { .. }),
1485            "different domain must not be allowed even if path looks similar"
1486        );
1487    }
1488
1489    /// REG-2191-9: args without a `url` field — verifier must not block (Allow).
1490    #[test]
1491    fn reg_2191_missing_url_field_allows_fetch() {
1492        // Some fetch-like tools may call with different arg names.
1493        // Verifier only checks the `url` field; missing field → Allow.
1494        let v = ugv(&[]);
1495        assert_eq!(
1496            v.verify(
1497                "fetch",
1498                &json!({"endpoint": "https://api.anthropic.ai/v1/models"})
1499            ),
1500            VerificationResult::Allow,
1501            "missing url field must not trigger blocking — only explicit url field is checked"
1502        );
1503    }
1504
1505    /// REG-2191-10: verifier disabled via config — all fetch calls pass through.
1506    #[test]
1507    fn reg_2191_disabled_verifier_allows_all() {
1508        let config = UrlGroundingVerifierConfig {
1509            enabled: false,
1510            ..UrlGroundingVerifierConfig::default()
1511        };
1512        // Note: the enabled flag is checked by the pipeline, not inside verify().
1513        // The pipeline skips disabled verifiers. This test documents that the struct
1514        // can be constructed with enabled=false (config round-trip).
1515        let set: HashSet<String> = HashSet::new();
1516        let v = UrlGroundingVerifier::new(&config, Arc::new(RwLock::new(set)));
1517        // verify() itself doesn't check enabled — the pipeline is responsible.
1518        // When called directly it will still block (the field has no effect here).
1519        // This is an API documentation test, not a behaviour test.
1520        let _ = v.verify("fetch", &json!({"url": "https://example.com/"}));
1521        // No assertion: just verifies the struct can be built with enabled=false.
1522    }
1523
1524    // --- FirewallVerifier ---
1525
1526    fn fwv() -> FirewallVerifier {
1527        FirewallVerifier::new(&FirewallVerifierConfig::default())
1528    }
1529
1530    #[test]
1531    fn firewall_allows_normal_path() {
1532        let v = fwv();
1533        assert_eq!(
1534            v.verify("shell", &json!({"command": "ls /tmp/build"})),
1535            VerificationResult::Allow
1536        );
1537    }
1538
1539    #[test]
1540    fn firewall_blocks_path_traversal() {
1541        let v = fwv();
1542        let result = v.verify("read", &json!({"file_path": "../../etc/passwd"}));
1543        assert!(
1544            matches!(result, VerificationResult::Block { .. }),
1545            "path traversal must be blocked"
1546        );
1547    }
1548
1549    #[test]
1550    fn firewall_blocks_etc_passwd() {
1551        let v = fwv();
1552        let result = v.verify("read", &json!({"file_path": "/etc/passwd"}));
1553        assert!(
1554            matches!(result, VerificationResult::Block { .. }),
1555            "/etc/passwd must be blocked"
1556        );
1557    }
1558
1559    #[test]
1560    fn firewall_blocks_ssh_key() {
1561        let v = fwv();
1562        let result = v.verify("read", &json!({"file_path": "~/.ssh/id_rsa"}));
1563        assert!(
1564            matches!(result, VerificationResult::Block { .. }),
1565            "SSH key path must be blocked"
1566        );
1567    }
1568
1569    #[test]
1570    fn firewall_blocks_aws_env_var() {
1571        let v = fwv();
1572        let result = v.verify("shell", &json!({"command": "echo $AWS_SECRET_ACCESS_KEY"}));
1573        assert!(
1574            matches!(result, VerificationResult::Block { .. }),
1575            "AWS env var exfiltration must be blocked"
1576        );
1577    }
1578
1579    #[test]
1580    fn firewall_blocks_zeph_env_var() {
1581        let v = fwv();
1582        let result = v.verify("shell", &json!({"command": "cat ${ZEPH_CLAUDE_API_KEY}"}));
1583        assert!(
1584            matches!(result, VerificationResult::Block { .. }),
1585            "ZEPH env var exfiltration must be blocked"
1586        );
1587    }
1588
1589    #[test]
1590    fn firewall_exempt_tool_bypasses_check() {
1591        let cfg = FirewallVerifierConfig {
1592            enabled: true,
1593            blocked_paths: vec![],
1594            blocked_env_vars: vec![],
1595            exempt_tools: vec!["read".to_string()],
1596        };
1597        let v = FirewallVerifier::new(&cfg);
1598        // /etc/passwd would normally be blocked but tool is exempt.
1599        assert_eq!(
1600            v.verify("read", &json!({"file_path": "/etc/passwd"})),
1601            VerificationResult::Allow
1602        );
1603    }
1604
1605    #[test]
1606    fn firewall_custom_blocked_path() {
1607        let cfg = FirewallVerifierConfig {
1608            enabled: true,
1609            blocked_paths: vec!["/data/secrets/*".to_string()],
1610            blocked_env_vars: vec![],
1611            exempt_tools: vec![],
1612        };
1613        let v = FirewallVerifier::new(&cfg);
1614        let result = v.verify("read", &json!({"file_path": "/data/secrets/master.key"}));
1615        assert!(
1616            matches!(result, VerificationResult::Block { .. }),
1617            "custom blocked path must be blocked"
1618        );
1619    }
1620
1621    #[test]
1622    fn firewall_custom_blocked_env_var() {
1623        let cfg = FirewallVerifierConfig {
1624            enabled: true,
1625            blocked_paths: vec![],
1626            blocked_env_vars: vec!["MY_SECRET".to_string()],
1627            exempt_tools: vec![],
1628        };
1629        let v = FirewallVerifier::new(&cfg);
1630        let result = v.verify("shell", &json!({"command": "echo $MY_SECRET"}));
1631        assert!(
1632            matches!(result, VerificationResult::Block { .. }),
1633            "custom blocked env var must be blocked"
1634        );
1635    }
1636
1637    #[test]
1638    fn firewall_invalid_glob_is_skipped() {
1639        // Invalid glob should not panic — logged and skipped at construction.
1640        let cfg = FirewallVerifierConfig {
1641            enabled: true,
1642            blocked_paths: vec!["[invalid-glob".to_string(), "/valid/path/*".to_string()],
1643            blocked_env_vars: vec![],
1644            exempt_tools: vec![],
1645        };
1646        let v = FirewallVerifier::new(&cfg);
1647        // Valid pattern still works
1648        let result = v.verify("read", &json!({"path": "/valid/path/file.txt"}));
1649        assert_matches!(result, VerificationResult::Block { .. });
1650    }
1651
1652    #[test]
1653    fn firewall_config_default_deserialization() {
1654        let cfg: FirewallVerifierConfig = toml::from_str("").unwrap();
1655        assert!(cfg.enabled);
1656        assert!(cfg.blocked_paths.is_empty());
1657        assert!(cfg.blocked_env_vars.is_empty());
1658        assert!(cfg.exempt_tools.is_empty());
1659    }
1660}