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