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