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
881    use super::*;
882
883    // --- DestructiveCommandVerifier ---
884
885    fn dcv() -> DestructiveCommandVerifier {
886        DestructiveCommandVerifier::new(&DestructiveVerifierConfig::default())
887    }
888
889    #[test]
890    fn allow_normal_command() {
891        let v = dcv();
892        assert_eq!(
893            v.verify("bash", &json!({"command": "ls -la /tmp"})),
894            VerificationResult::Allow
895        );
896    }
897
898    #[test]
899    fn block_rm_rf_root() {
900        let v = dcv();
901        let result = v.verify("bash", &json!({"command": "rm -rf /"}));
902        assert!(matches!(result, VerificationResult::Block { .. }));
903    }
904
905    #[test]
906    fn block_dd_dev_zero() {
907        let v = dcv();
908        let result = v.verify("bash", &json!({"command": "dd if=/dev/zero of=/dev/sda"}));
909        assert!(matches!(result, VerificationResult::Block { .. }));
910    }
911
912    #[test]
913    fn block_mkfs() {
914        let v = dcv();
915        let result = v.verify("bash", &json!({"command": "mkfs.ext4 /dev/sda1"}));
916        assert!(matches!(result, VerificationResult::Block { .. }));
917    }
918
919    #[test]
920    fn allow_rm_rf_in_allowed_path() {
921        let config = DestructiveVerifierConfig {
922            allowed_paths: vec!["/tmp/build".to_string()],
923            ..Default::default()
924        };
925        let v = DestructiveCommandVerifier::new(&config);
926        assert_eq!(
927            v.verify("bash", &json!({"command": "rm -rf /tmp/build/artifacts"})),
928            VerificationResult::Allow
929        );
930    }
931
932    #[test]
933    fn block_rm_rf_when_not_in_allowed_path() {
934        let config = DestructiveVerifierConfig {
935            allowed_paths: vec!["/tmp/build".to_string()],
936            ..Default::default()
937        };
938        let v = DestructiveCommandVerifier::new(&config);
939        let result = v.verify("bash", &json!({"command": "rm -rf /home/user"}));
940        assert!(matches!(result, VerificationResult::Block { .. }));
941    }
942
943    #[test]
944    fn allow_non_shell_tool() {
945        let v = dcv();
946        assert_eq!(
947            v.verify("read_file", &json!({"path": "rm -rf /"})),
948            VerificationResult::Allow
949        );
950    }
951
952    #[test]
953    fn block_extra_pattern() {
954        let config = DestructiveVerifierConfig {
955            extra_patterns: vec!["format c:".to_string()],
956            ..Default::default()
957        };
958        let v = DestructiveCommandVerifier::new(&config);
959        let result = v.verify("bash", &json!({"command": "format c:"}));
960        assert!(matches!(result, VerificationResult::Block { .. }));
961    }
962
963    #[test]
964    fn array_args_normalization() {
965        let v = dcv();
966        let result = v.verify("bash", &json!({"command": ["rm", "-rf", "/"]}));
967        assert!(matches!(result, VerificationResult::Block { .. }));
968    }
969
970    #[test]
971    fn sh_c_wrapping_normalization() {
972        let v = dcv();
973        let result = v.verify("bash", &json!({"command": "bash -c 'rm -rf /'"}));
974        assert!(matches!(result, VerificationResult::Block { .. }));
975    }
976
977    #[test]
978    fn fork_bomb_blocked() {
979        let v = dcv();
980        let result = v.verify("bash", &json!({"command": ":(){ :|:& };:"}));
981        assert!(matches!(result, VerificationResult::Block { .. }));
982    }
983
984    #[test]
985    fn custom_shell_tool_name_blocked() {
986        let config = DestructiveVerifierConfig {
987            shell_tools: vec!["execute".to_string(), "run_command".to_string()],
988            ..Default::default()
989        };
990        let v = DestructiveCommandVerifier::new(&config);
991        let result = v.verify("execute", &json!({"command": "rm -rf /"}));
992        assert!(matches!(result, VerificationResult::Block { .. }));
993    }
994
995    #[test]
996    fn terminal_tool_name_blocked_by_default() {
997        let v = dcv();
998        let result = v.verify("terminal", &json!({"command": "rm -rf /"}));
999        assert!(matches!(result, VerificationResult::Block { .. }));
1000    }
1001
1002    #[test]
1003    fn default_shell_tools_contains_bash_shell_terminal() {
1004        let config = DestructiveVerifierConfig::default();
1005        let lower: Vec<String> = config
1006            .shell_tools
1007            .iter()
1008            .map(|s| s.to_lowercase())
1009            .collect();
1010        assert!(lower.contains(&"bash".to_string()));
1011        assert!(lower.contains(&"shell".to_string()));
1012        assert!(lower.contains(&"terminal".to_string()));
1013    }
1014
1015    // --- InjectionPatternVerifier ---
1016
1017    fn ipv() -> InjectionPatternVerifier {
1018        InjectionPatternVerifier::new(&InjectionVerifierConfig::default())
1019    }
1020
1021    #[test]
1022    fn allow_clean_args() {
1023        let v = ipv();
1024        assert_eq!(
1025            v.verify("search", &json!({"query": "rust async traits"})),
1026            VerificationResult::Allow
1027        );
1028    }
1029
1030    #[test]
1031    fn allow_sql_discussion_in_query_field() {
1032        // S2: memory_search with SQL discussion must NOT be blocked.
1033        let v = ipv();
1034        assert_eq!(
1035            v.verify(
1036                "memory_search",
1037                &json!({"query": "explain SQL UNION SELECT vs JOIN"})
1038            ),
1039            VerificationResult::Allow
1040        );
1041    }
1042
1043    #[test]
1044    fn allow_sql_or_pattern_in_query_field() {
1045        // S2: safe query field must not trigger SQL injection pattern.
1046        let v = ipv();
1047        assert_eq!(
1048            v.verify("memory_search", &json!({"query": "' OR '1'='1"})),
1049            VerificationResult::Allow
1050        );
1051    }
1052
1053    #[test]
1054    fn block_sql_injection_in_non_query_field() {
1055        let v = ipv();
1056        let result = v.verify("db_query", &json!({"sql": "' OR '1'='1"}));
1057        assert!(matches!(result, VerificationResult::Block { .. }));
1058    }
1059
1060    #[test]
1061    fn block_drop_table() {
1062        let v = ipv();
1063        let result = v.verify("db_query", &json!({"input": "name'; DROP TABLE users"}));
1064        assert!(matches!(result, VerificationResult::Block { .. }));
1065    }
1066
1067    #[test]
1068    fn block_path_traversal() {
1069        let v = ipv();
1070        let result = v.verify("read_file", &json!({"path": "../../../etc/passwd"}));
1071        assert!(matches!(result, VerificationResult::Block { .. }));
1072    }
1073
1074    #[test]
1075    fn warn_on_localhost_url_field() {
1076        // S2: SSRF warn only fires on URL-like fields.
1077        let v = ipv();
1078        let result = v.verify("http_get", &json!({"url": "http://localhost:8080/api"}));
1079        assert!(matches!(result, VerificationResult::Warn { .. }));
1080    }
1081
1082    #[test]
1083    fn allow_localhost_in_non_url_field() {
1084        // S2: localhost in a "text" field (not a URL field) must not warn.
1085        let v = ipv();
1086        assert_eq!(
1087            v.verify(
1088                "memory_search",
1089                &json!({"query": "connect to http://localhost:8080"})
1090            ),
1091            VerificationResult::Allow
1092        );
1093    }
1094
1095    #[test]
1096    fn warn_on_private_ip_url_field() {
1097        let v = ipv();
1098        let result = v.verify("fetch", &json!({"url": "http://192.168.1.1/admin"}));
1099        assert!(matches!(result, VerificationResult::Warn { .. }));
1100    }
1101
1102    #[test]
1103    fn allow_localhost_when_allowlisted() {
1104        let config = InjectionVerifierConfig {
1105            allowlisted_urls: vec!["http://localhost:3000".to_string()],
1106            ..Default::default()
1107        };
1108        let v = InjectionPatternVerifier::new(&config);
1109        assert_eq!(
1110            v.verify("http_get", &json!({"url": "http://localhost:3000/api"})),
1111            VerificationResult::Allow
1112        );
1113    }
1114
1115    #[test]
1116    fn block_union_select_in_non_query_field() {
1117        let v = ipv();
1118        let result = v.verify(
1119            "db_query",
1120            &json!({"input": "id=1 UNION SELECT password FROM users"}),
1121        );
1122        assert!(matches!(result, VerificationResult::Block { .. }));
1123    }
1124
1125    #[test]
1126    fn allow_union_select_in_query_field() {
1127        // S2: "UNION SELECT" in a `query` field is a SQL discussion, not an injection.
1128        let v = ipv();
1129        assert_eq!(
1130            v.verify(
1131                "memory_search",
1132                &json!({"query": "id=1 UNION SELECT password FROM users"})
1133            ),
1134            VerificationResult::Allow
1135        );
1136    }
1137
1138    // --- FIX-1: Unicode normalization bypass ---
1139
1140    #[test]
1141    fn block_rm_rf_unicode_homoglyph() {
1142        // U+FF0F FULLWIDTH SOLIDUS looks like '/' and NFKC-normalizes to '/'.
1143        let v = dcv();
1144        // "rm -rf /" where / is U+FF0F
1145        let result = v.verify("bash", &json!({"command": "rm -rf \u{FF0F}"}));
1146        assert!(matches!(result, VerificationResult::Block { .. }));
1147    }
1148
1149    // --- FIX-2: Path traversal in is_allowed_path ---
1150
1151    #[test]
1152    fn path_traversal_not_allowed_via_dotdot() {
1153        // `/tmp/build/../../etc` lexically resolves to `/etc`, NOT under `/tmp/build`.
1154        let config = DestructiveVerifierConfig {
1155            allowed_paths: vec!["/tmp/build".to_string()],
1156            ..Default::default()
1157        };
1158        let v = DestructiveCommandVerifier::new(&config);
1159        // Should be BLOCKED: resolved path is /etc, not under /tmp/build.
1160        let result = v.verify("bash", &json!({"command": "rm -rf /tmp/build/../../etc"}));
1161        assert!(matches!(result, VerificationResult::Block { .. }));
1162    }
1163
1164    #[test]
1165    fn allowed_path_with_dotdot_stays_in_allowed() {
1166        // `/tmp/build/sub/../artifacts` resolves to `/tmp/build/artifacts` — still allowed.
1167        let config = DestructiveVerifierConfig {
1168            allowed_paths: vec!["/tmp/build".to_string()],
1169            ..Default::default()
1170        };
1171        let v = DestructiveCommandVerifier::new(&config);
1172        assert_eq!(
1173            v.verify(
1174                "bash",
1175                &json!({"command": "rm -rf /tmp/build/sub/../artifacts"}),
1176            ),
1177            VerificationResult::Allow,
1178        );
1179    }
1180
1181    // --- FIX-3: Double-nested shell wrapping ---
1182
1183    #[test]
1184    fn double_nested_bash_c_blocked() {
1185        let v = dcv();
1186        let result = v.verify(
1187            "bash",
1188            &json!({"command": "bash -c \"bash -c 'rm -rf /'\""}),
1189        );
1190        assert!(matches!(result, VerificationResult::Block { .. }));
1191    }
1192
1193    #[test]
1194    fn env_prefix_stripping_blocked() {
1195        let v = dcv();
1196        let result = v.verify(
1197            "bash",
1198            &json!({"command": "env FOO=bar bash -c 'rm -rf /'"}),
1199        );
1200        assert!(matches!(result, VerificationResult::Block { .. }));
1201    }
1202
1203    #[test]
1204    fn exec_prefix_stripping_blocked() {
1205        let v = dcv();
1206        let result = v.verify("bash", &json!({"command": "exec bash -c 'rm -rf /'"}));
1207        assert!(matches!(result, VerificationResult::Block { .. }));
1208    }
1209
1210    // --- FIX-4: SSRF host extraction (not substring match) ---
1211
1212    #[test]
1213    fn ssrf_not_triggered_for_embedded_localhost_in_query_param() {
1214        // `evil.com/?r=http://localhost` — host is `evil.com`, not localhost.
1215        let v = ipv();
1216        let result = v.verify(
1217            "http_get",
1218            &json!({"url": "http://evil.com/?r=http://localhost"}),
1219        );
1220        // Should NOT warn — the actual request host is evil.com, not localhost.
1221        assert_eq!(result, VerificationResult::Allow);
1222    }
1223
1224    #[test]
1225    fn ssrf_triggered_for_bare_localhost_no_port() {
1226        // FIX-7: `http://localhost` with no trailing slash or port must warn.
1227        let v = ipv();
1228        let result = v.verify("http_get", &json!({"url": "http://localhost"}));
1229        assert!(matches!(result, VerificationResult::Warn { .. }));
1230    }
1231
1232    #[test]
1233    fn ssrf_triggered_for_localhost_with_path() {
1234        let v = ipv();
1235        let result = v.verify("http_get", &json!({"url": "http://localhost/api/v1"}));
1236        assert!(matches!(result, VerificationResult::Warn { .. }));
1237    }
1238
1239    // --- Verifier chain: first Block wins, Warn continues ---
1240
1241    #[test]
1242    fn chain_first_block_wins() {
1243        let dcv = DestructiveCommandVerifier::new(&DestructiveVerifierConfig::default());
1244        let ipv = InjectionPatternVerifier::new(&InjectionVerifierConfig::default());
1245        let verifiers: Vec<Box<dyn PreExecutionVerifier>> = vec![Box::new(dcv), Box::new(ipv)];
1246
1247        let args = json!({"command": "rm -rf /"});
1248        let mut result = VerificationResult::Allow;
1249        for v in &verifiers {
1250            result = v.verify("bash", &args);
1251            if matches!(result, VerificationResult::Block { .. }) {
1252                break;
1253            }
1254        }
1255        assert!(matches!(result, VerificationResult::Block { .. }));
1256    }
1257
1258    #[test]
1259    fn chain_warn_continues() {
1260        let dcv = DestructiveCommandVerifier::new(&DestructiveVerifierConfig::default());
1261        let ipv = InjectionPatternVerifier::new(&InjectionVerifierConfig::default());
1262        let verifiers: Vec<Box<dyn PreExecutionVerifier>> = vec![Box::new(dcv), Box::new(ipv)];
1263
1264        // localhost URL in `url` field: dcv allows, ipv warns, chain does NOT block.
1265        let args = json!({"url": "http://localhost:8080/api"});
1266        let mut got_warn = false;
1267        let mut got_block = false;
1268        for v in &verifiers {
1269            match v.verify("http_get", &args) {
1270                VerificationResult::Block { .. } => {
1271                    got_block = true;
1272                    break;
1273                }
1274                VerificationResult::Warn { .. } => {
1275                    got_warn = true;
1276                }
1277                VerificationResult::Allow => {}
1278            }
1279        }
1280        assert!(got_warn);
1281        assert!(!got_block);
1282    }
1283
1284    // --- UrlGroundingVerifier ---
1285
1286    fn ugv(urls: &[&str]) -> UrlGroundingVerifier {
1287        let set: HashSet<String> = urls.iter().map(|s| s.to_lowercase()).collect();
1288        UrlGroundingVerifier::new(
1289            &UrlGroundingVerifierConfig::default(),
1290            Arc::new(RwLock::new(set)),
1291        )
1292    }
1293
1294    #[test]
1295    fn url_grounding_allows_user_provided_url() {
1296        let v = ugv(&["https://docs.anthropic.com/models"]);
1297        assert_eq!(
1298            v.verify(
1299                "fetch",
1300                &json!({"url": "https://docs.anthropic.com/models"})
1301            ),
1302            VerificationResult::Allow
1303        );
1304    }
1305
1306    #[test]
1307    fn url_grounding_blocks_hallucinated_url() {
1308        let v = ugv(&["https://example.com/page"]);
1309        let result = v.verify(
1310            "fetch",
1311            &json!({"url": "https://api.anthropic.ai/v1/models"}),
1312        );
1313        assert!(matches!(result, VerificationResult::Block { .. }));
1314    }
1315
1316    #[test]
1317    fn url_grounding_blocks_when_no_user_urls_at_all() {
1318        let v = ugv(&[]);
1319        let result = v.verify(
1320            "fetch",
1321            &json!({"url": "https://api.anthropic.ai/v1/models"}),
1322        );
1323        assert!(matches!(result, VerificationResult::Block { .. }));
1324    }
1325
1326    #[test]
1327    fn url_grounding_allows_non_guarded_tool() {
1328        let v = ugv(&[]);
1329        assert_eq!(
1330            v.verify("read_file", &json!({"path": "/etc/hosts"})),
1331            VerificationResult::Allow
1332        );
1333    }
1334
1335    #[test]
1336    fn url_grounding_guards_fetch_suffix_tool() {
1337        let v = ugv(&[]);
1338        let result = v.verify("http_fetch", &json!({"url": "https://evil.com/"}));
1339        assert!(matches!(result, VerificationResult::Block { .. }));
1340    }
1341
1342    #[test]
1343    fn url_grounding_allows_web_scrape_with_provided_url() {
1344        let v = ugv(&["https://rust-lang.org/"]);
1345        assert_eq!(
1346            v.verify(
1347                "web_scrape",
1348                &json!({"url": "https://rust-lang.org/", "select": "h1"})
1349            ),
1350            VerificationResult::Allow
1351        );
1352    }
1353
1354    #[test]
1355    fn url_grounding_allows_prefix_match() {
1356        // User provided https://docs.rs/ — agent fetches a sub-path.
1357        let v = ugv(&["https://docs.rs/"]);
1358        assert_eq!(
1359            v.verify(
1360                "fetch",
1361                &json!({"url": "https://docs.rs/tokio/latest/tokio/"})
1362            ),
1363            VerificationResult::Allow
1364        );
1365    }
1366
1367    // --- Regression: #2191 — fetch URL hallucination ---
1368
1369    /// REG-2191-1: exact reproduction of the bug scenario.
1370    /// Agent asks "do you know Anthropic?" (no URL provided) and halluccinates
1371    /// `https://api.anthropic.ai/v1/models`. With an empty `user_provided_urls` set
1372    /// the fetch must be blocked.
1373    #[test]
1374    fn reg_2191_hallucinated_api_endpoint_blocked_with_empty_session() {
1375        // Simulate: user never sent any URL in the conversation.
1376        let v = ugv(&[]);
1377        let result = v.verify(
1378            "fetch",
1379            &json!({"url": "https://api.anthropic.ai/v1/models"}),
1380        );
1381        assert!(
1382            matches!(result, VerificationResult::Block { .. }),
1383            "fetch must be blocked when no user URL was provided — this is the #2191 regression"
1384        );
1385    }
1386
1387    /// REG-2191-2: passthrough — user explicitly pasted the URL, fetch must proceed.
1388    #[test]
1389    fn reg_2191_user_provided_url_allows_fetch() {
1390        let v = ugv(&["https://api.anthropic.com/v1/models"]);
1391        assert_eq!(
1392            v.verify(
1393                "fetch",
1394                &json!({"url": "https://api.anthropic.com/v1/models"}),
1395            ),
1396            VerificationResult::Allow,
1397            "fetch must be allowed when the URL was explicitly provided by the user"
1398        );
1399    }
1400
1401    /// REG-2191-3: `web_scrape` variant — same rejection for `web_scrape` tool.
1402    #[test]
1403    fn reg_2191_web_scrape_hallucinated_url_blocked() {
1404        let v = ugv(&[]);
1405        let result = v.verify(
1406            "web_scrape",
1407            &json!({"url": "https://api.anthropic.ai/v1/models", "select": "body"}),
1408        );
1409        assert!(
1410            matches!(result, VerificationResult::Block { .. }),
1411            "web_scrape must be blocked for hallucinated URL with empty user_provided_urls"
1412        );
1413    }
1414
1415    /// REG-2191-4: URL present only in an imagined system/assistant message context
1416    /// is NOT in `user_provided_urls` (the agent only populates from user messages).
1417    /// The verifier itself cannot distinguish message roles — it only sees the set
1418    /// populated by the agent. This test confirms: an empty set always blocks.
1419    #[test]
1420    fn reg_2191_empty_url_set_always_blocks_fetch() {
1421        // Whether the URL came from a system/assistant message or was never seen —
1422        // if user_provided_urls is empty, fetch must be blocked.
1423        let v = ugv(&[]);
1424        let result = v.verify(
1425            "fetch",
1426            &json!({"url": "https://docs.anthropic.com/something"}),
1427        );
1428        assert!(matches!(result, VerificationResult::Block { .. }));
1429    }
1430
1431    /// REG-2191-5: URL matching is case-insensitive — user pastes mixed-case URL.
1432    #[test]
1433    fn reg_2191_case_insensitive_url_match_allows_fetch() {
1434        // user_provided_urls stores lowercase; verify that the fetched URL with
1435        // different casing still matches.
1436        let v = ugv(&["https://Docs.Anthropic.COM/models"]);
1437        assert_eq!(
1438            v.verify(
1439                "fetch",
1440                &json!({"url": "https://docs.anthropic.com/models/detail"}),
1441            ),
1442            VerificationResult::Allow,
1443            "URL matching must be case-insensitive"
1444        );
1445    }
1446
1447    /// REG-2191-6: tool name ending in `_fetch` is auto-guarded regardless of config.
1448    /// An MCP-registered `anthropic_fetch` tool must not bypass the gate.
1449    #[test]
1450    fn reg_2191_mcp_fetch_suffix_tool_blocked_with_empty_session() {
1451        let v = ugv(&[]);
1452        let result = v.verify(
1453            "anthropic_fetch",
1454            &json!({"url": "https://api.anthropic.ai/v1/models"}),
1455        );
1456        assert!(
1457            matches!(result, VerificationResult::Block { .. }),
1458            "MCP tools ending in _fetch must be guarded even if not in guarded_tools list"
1459        );
1460    }
1461
1462    /// REG-2191-7: reverse prefix — user provided a specific URL, agent fetches
1463    /// the root. This is the "reverse prefix" case: `user_url` `starts_with` `fetch_url`.
1464    #[test]
1465    fn reg_2191_reverse_prefix_match_allows_fetch() {
1466        // User provided a deep URL; agent wants to fetch the root.
1467        // Allowed: user_url.starts_with(fetch_url).
1468        let v = ugv(&["https://docs.rs/tokio/latest/tokio/index.html"]);
1469        assert_eq!(
1470            v.verify("fetch", &json!({"url": "https://docs.rs/"})),
1471            VerificationResult::Allow,
1472            "reverse prefix: fetched URL is a prefix of user-provided URL — should be allowed"
1473        );
1474    }
1475
1476    /// REG-2191-8: completely different domain with same path prefix must be blocked.
1477    #[test]
1478    fn reg_2191_different_domain_blocked() {
1479        // User provided docs.rs, agent wants to fetch evil.com/docs.rs path — must block.
1480        let v = ugv(&["https://docs.rs/"]);
1481        let result = v.verify("fetch", &json!({"url": "https://evil.com/docs.rs/exfil"}));
1482        assert!(
1483            matches!(result, VerificationResult::Block { .. }),
1484            "different domain must not be allowed even if path looks similar"
1485        );
1486    }
1487
1488    /// REG-2191-9: args without a `url` field — verifier must not block (Allow).
1489    #[test]
1490    fn reg_2191_missing_url_field_allows_fetch() {
1491        // Some fetch-like tools may call with different arg names.
1492        // Verifier only checks the `url` field; missing field → Allow.
1493        let v = ugv(&[]);
1494        assert_eq!(
1495            v.verify(
1496                "fetch",
1497                &json!({"endpoint": "https://api.anthropic.ai/v1/models"})
1498            ),
1499            VerificationResult::Allow,
1500            "missing url field must not trigger blocking — only explicit url field is checked"
1501        );
1502    }
1503
1504    /// REG-2191-10: verifier disabled via config — all fetch calls pass through.
1505    #[test]
1506    fn reg_2191_disabled_verifier_allows_all() {
1507        let config = UrlGroundingVerifierConfig {
1508            enabled: false,
1509            ..UrlGroundingVerifierConfig::default()
1510        };
1511        // Note: the enabled flag is checked by the pipeline, not inside verify().
1512        // The pipeline skips disabled verifiers. This test documents that the struct
1513        // can be constructed with enabled=false (config round-trip).
1514        let set: HashSet<String> = HashSet::new();
1515        let v = UrlGroundingVerifier::new(&config, Arc::new(RwLock::new(set)));
1516        // verify() itself doesn't check enabled — the pipeline is responsible.
1517        // When called directly it will still block (the field has no effect here).
1518        // This is an API documentation test, not a behaviour test.
1519        let _ = v.verify("fetch", &json!({"url": "https://example.com/"}));
1520        // No assertion: just verifies the struct can be built with enabled=false.
1521    }
1522
1523    // --- FirewallVerifier ---
1524
1525    fn fwv() -> FirewallVerifier {
1526        FirewallVerifier::new(&FirewallVerifierConfig::default())
1527    }
1528
1529    #[test]
1530    fn firewall_allows_normal_path() {
1531        let v = fwv();
1532        assert_eq!(
1533            v.verify("shell", &json!({"command": "ls /tmp/build"})),
1534            VerificationResult::Allow
1535        );
1536    }
1537
1538    #[test]
1539    fn firewall_blocks_path_traversal() {
1540        let v = fwv();
1541        let result = v.verify("read", &json!({"file_path": "../../etc/passwd"}));
1542        assert!(
1543            matches!(result, VerificationResult::Block { .. }),
1544            "path traversal must be blocked"
1545        );
1546    }
1547
1548    #[test]
1549    fn firewall_blocks_etc_passwd() {
1550        let v = fwv();
1551        let result = v.verify("read", &json!({"file_path": "/etc/passwd"}));
1552        assert!(
1553            matches!(result, VerificationResult::Block { .. }),
1554            "/etc/passwd must be blocked"
1555        );
1556    }
1557
1558    #[test]
1559    fn firewall_blocks_ssh_key() {
1560        let v = fwv();
1561        let result = v.verify("read", &json!({"file_path": "~/.ssh/id_rsa"}));
1562        assert!(
1563            matches!(result, VerificationResult::Block { .. }),
1564            "SSH key path must be blocked"
1565        );
1566    }
1567
1568    #[test]
1569    fn firewall_blocks_aws_env_var() {
1570        let v = fwv();
1571        let result = v.verify("shell", &json!({"command": "echo $AWS_SECRET_ACCESS_KEY"}));
1572        assert!(
1573            matches!(result, VerificationResult::Block { .. }),
1574            "AWS env var exfiltration must be blocked"
1575        );
1576    }
1577
1578    #[test]
1579    fn firewall_blocks_zeph_env_var() {
1580        let v = fwv();
1581        let result = v.verify("shell", &json!({"command": "cat ${ZEPH_CLAUDE_API_KEY}"}));
1582        assert!(
1583            matches!(result, VerificationResult::Block { .. }),
1584            "ZEPH env var exfiltration must be blocked"
1585        );
1586    }
1587
1588    #[test]
1589    fn firewall_exempt_tool_bypasses_check() {
1590        let cfg = FirewallVerifierConfig {
1591            enabled: true,
1592            blocked_paths: vec![],
1593            blocked_env_vars: vec![],
1594            exempt_tools: vec!["read".to_string()],
1595        };
1596        let v = FirewallVerifier::new(&cfg);
1597        // /etc/passwd would normally be blocked but tool is exempt.
1598        assert_eq!(
1599            v.verify("read", &json!({"file_path": "/etc/passwd"})),
1600            VerificationResult::Allow
1601        );
1602    }
1603
1604    #[test]
1605    fn firewall_custom_blocked_path() {
1606        let cfg = FirewallVerifierConfig {
1607            enabled: true,
1608            blocked_paths: vec!["/data/secrets/*".to_string()],
1609            blocked_env_vars: vec![],
1610            exempt_tools: vec![],
1611        };
1612        let v = FirewallVerifier::new(&cfg);
1613        let result = v.verify("read", &json!({"file_path": "/data/secrets/master.key"}));
1614        assert!(
1615            matches!(result, VerificationResult::Block { .. }),
1616            "custom blocked path must be blocked"
1617        );
1618    }
1619
1620    #[test]
1621    fn firewall_custom_blocked_env_var() {
1622        let cfg = FirewallVerifierConfig {
1623            enabled: true,
1624            blocked_paths: vec![],
1625            blocked_env_vars: vec!["MY_SECRET".to_string()],
1626            exempt_tools: vec![],
1627        };
1628        let v = FirewallVerifier::new(&cfg);
1629        let result = v.verify("shell", &json!({"command": "echo $MY_SECRET"}));
1630        assert!(
1631            matches!(result, VerificationResult::Block { .. }),
1632            "custom blocked env var must be blocked"
1633        );
1634    }
1635
1636    #[test]
1637    fn firewall_invalid_glob_is_skipped() {
1638        // Invalid glob should not panic — logged and skipped at construction.
1639        let cfg = FirewallVerifierConfig {
1640            enabled: true,
1641            blocked_paths: vec!["[invalid-glob".to_string(), "/valid/path/*".to_string()],
1642            blocked_env_vars: vec![],
1643            exempt_tools: vec![],
1644        };
1645        let v = FirewallVerifier::new(&cfg);
1646        // Valid pattern still works
1647        let result = v.verify("read", &json!({"path": "/valid/path/file.txt"}));
1648        assert!(matches!(result, VerificationResult::Block { .. }));
1649    }
1650
1651    #[test]
1652    fn firewall_config_default_deserialization() {
1653        let cfg: FirewallVerifierConfig = toml::from_str("").unwrap();
1654        assert!(cfg.enabled);
1655        assert!(cfg.blocked_paths.is_empty());
1656        assert!(cfg.blocked_env_vars.is_empty());
1657        assert!(cfg.exempt_tools.is_empty());
1658    }
1659}