use std::collections::HashSet;
use std::sync::{Arc, LazyLock};
use parking_lot::RwLock;
use regex::Regex;
use serde::{Deserialize, Serialize};
use unicode_normalization::UnicodeNormalization as _;
fn default_true() -> bool {
true
}
fn default_shell_tools() -> Vec<String> {
vec![
"bash".to_string(),
"shell".to_string(),
"terminal".to_string(),
]
}
#[must_use]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VerificationResult {
Allow,
Block { reason: String },
Warn { message: String },
}
pub trait PreExecutionVerifier: Send + Sync + std::fmt::Debug {
fn verify(&self, tool_name: &str, args: &serde_json::Value) -> VerificationResult;
fn name(&self) -> &'static str;
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct DestructiveVerifierConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub allowed_paths: Vec<String>,
#[serde(default)]
pub extra_patterns: Vec<String>,
#[serde(default = "default_shell_tools")]
pub shell_tools: Vec<String>,
}
impl Default for DestructiveVerifierConfig {
fn default() -> Self {
Self {
enabled: true,
allowed_paths: Vec::new(),
extra_patterns: Vec::new(),
shell_tools: default_shell_tools(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct InjectionVerifierConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub extra_patterns: Vec<String>,
#[serde(default)]
pub allowlisted_urls: Vec<String>,
}
impl Default for InjectionVerifierConfig {
fn default() -> Self {
Self {
enabled: true,
extra_patterns: Vec::new(),
allowlisted_urls: Vec::new(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct UrlGroundingVerifierConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_guarded_tools")]
pub guarded_tools: Vec<String>,
}
fn default_guarded_tools() -> Vec<String> {
vec!["fetch".to_string(), "web_scrape".to_string()]
}
impl Default for UrlGroundingVerifierConfig {
fn default() -> Self {
Self {
enabled: true,
guarded_tools: default_guarded_tools(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct PreExecutionVerifierConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub destructive_commands: DestructiveVerifierConfig,
#[serde(default)]
pub injection_patterns: InjectionVerifierConfig,
#[serde(default)]
pub url_grounding: UrlGroundingVerifierConfig,
#[serde(default)]
pub firewall: FirewallVerifierConfig,
}
impl Default for PreExecutionVerifierConfig {
fn default() -> Self {
Self {
enabled: true,
destructive_commands: DestructiveVerifierConfig::default(),
injection_patterns: InjectionVerifierConfig::default(),
url_grounding: UrlGroundingVerifierConfig::default(),
firewall: FirewallVerifierConfig::default(),
}
}
}
static DESTRUCTIVE_PATTERNS: &[&str] = &[
"rm -rf /",
"rm -rf ~",
"rm -r /",
"dd if=",
"mkfs",
"fdisk",
"shred",
"wipefs",
":(){ :|:& };:",
":(){:|:&};:",
"chmod -r 777 /",
"chown -r",
];
#[derive(Debug)]
pub struct DestructiveCommandVerifier {
shell_tools: Vec<String>,
allowed_paths: Vec<String>,
extra_patterns: Vec<String>,
}
impl DestructiveCommandVerifier {
#[must_use]
pub fn new(config: &DestructiveVerifierConfig) -> Self {
Self {
shell_tools: config
.shell_tools
.iter()
.map(|s| s.to_lowercase())
.collect(),
allowed_paths: config
.allowed_paths
.iter()
.map(|s| s.to_lowercase())
.collect(),
extra_patterns: config
.extra_patterns
.iter()
.map(|s| s.to_lowercase())
.collect(),
}
}
fn is_shell_tool(&self, tool_name: &str) -> bool {
let lower = tool_name.to_lowercase();
self.shell_tools.iter().any(|t| t == &lower)
}
fn extract_command(args: &serde_json::Value) -> Option<String> {
let raw = match args.get("command") {
Some(serde_json::Value::String(s)) => s.clone(),
Some(serde_json::Value::Array(arr)) => arr
.iter()
.filter_map(|v| v.as_str())
.collect::<Vec<_>>()
.join(" "),
_ => return None,
};
let mut current: String = raw.nfkc().collect::<String>().to_lowercase();
for _ in 0..8 {
let trimmed = current.trim().to_owned();
let after_env = Self::strip_env_prefix(&trimmed);
let after_exec = after_env.strip_prefix("exec ").map_or(after_env, str::trim);
let mut unwrapped = false;
for interp in &["bash -c ", "sh -c ", "zsh -c "] {
if let Some(rest) = after_exec.strip_prefix(interp) {
let script = rest.trim().trim_matches(|c: char| c == '\'' || c == '"');
current.clone_from(&script.to_owned());
unwrapped = true;
break;
}
}
if !unwrapped {
return Some(after_exec.to_owned());
}
}
Some(current)
}
fn strip_env_prefix(cmd: &str) -> &str {
let mut rest = cmd;
if let Some(after_env) = rest.strip_prefix("env ") {
rest = after_env.trim_start();
}
loop {
let mut chars = rest.chars();
let key_end = chars
.by_ref()
.take_while(|c| c.is_alphanumeric() || *c == '_')
.count();
if key_end == 0 {
break;
}
let remainder = &rest[key_end..];
if let Some(after_eq) = remainder.strip_prefix('=') {
let val_end = after_eq.find(' ').unwrap_or(after_eq.len());
rest = after_eq[val_end..].trim_start();
} else {
break;
}
}
rest
}
fn is_allowed_path(&self, command: &str) -> bool {
if self.allowed_paths.is_empty() {
return false;
}
let tokens: Vec<&str> = command.split_whitespace().collect();
for token in &tokens {
let t = token.trim_matches(|c| c == '\'' || c == '"');
if t.starts_with('/') || t.starts_with('~') || t.starts_with('.') {
let normalized = Self::lexical_normalize(std::path::Path::new(t));
let n_lower = normalized.to_string_lossy().to_lowercase();
if self
.allowed_paths
.iter()
.any(|p| n_lower.starts_with(p.as_str()))
{
return true;
}
}
}
false
}
fn lexical_normalize(p: &std::path::Path) -> std::path::PathBuf {
let mut out = std::path::PathBuf::new();
for component in p.components() {
match component {
std::path::Component::ParentDir => {
out.pop();
}
std::path::Component::CurDir => {}
other => out.push(other),
}
}
out
}
fn check_patterns(command: &str) -> Option<&'static str> {
DESTRUCTIVE_PATTERNS
.iter()
.find(|&pat| command.contains(pat))
.copied()
}
fn check_extra_patterns(&self, command: &str) -> Option<String> {
self.extra_patterns
.iter()
.find(|pat| command.contains(pat.as_str()))
.cloned()
}
}
impl PreExecutionVerifier for DestructiveCommandVerifier {
fn name(&self) -> &'static str {
"DestructiveCommandVerifier"
}
fn verify(&self, tool_name: &str, args: &serde_json::Value) -> VerificationResult {
if !self.is_shell_tool(tool_name) {
return VerificationResult::Allow;
}
let Some(command) = Self::extract_command(args) else {
return VerificationResult::Allow;
};
if let Some(pat) = Self::check_patterns(&command) {
if self.is_allowed_path(&command) {
return VerificationResult::Allow;
}
return VerificationResult::Block {
reason: format!("[{}] destructive pattern '{}' detected", self.name(), pat),
};
}
if let Some(pat) = self.check_extra_patterns(&command) {
if self.is_allowed_path(&command) {
return VerificationResult::Allow;
}
return VerificationResult::Block {
reason: format!(
"[{}] extra destructive pattern '{}' detected",
self.name(),
pat
),
};
}
VerificationResult::Allow
}
}
static INJECTION_BLOCK_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
[
r"(?i)'\s*OR\s*'1'\s*=\s*'1",
r"(?i)'\s*OR\s*1\s*=\s*1",
r"(?i);\s*DROP\s+TABLE",
r"(?i)UNION\s+SELECT",
r"(?i)'\s*;\s*SELECT",
r";\s*rm\s+",
r"\|\s*rm\s+",
r"&&\s*rm\s+",
r";\s*curl\s+",
r"\|\s*curl\s+",
r"&&\s*curl\s+",
r";\s*wget\s+",
r"\.\./\.\./\.\./etc/passwd",
r"\.\./\.\./\.\./etc/shadow",
r"\.\./\.\./\.\./windows/",
r"\.\.[/\\]\.\.[/\\]\.\.[/\\]",
]
.iter()
.map(|s| Regex::new(s).expect("static pattern must compile"))
.collect()
});
static SSRF_HOST_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
[
r"^localhost$",
r"^localhost:",
r"^127\.0\.0\.1$",
r"^127\.0\.0\.1:",
r"^\[::1\]$",
r"^\[::1\]:",
r"^169\.254\.169\.254$",
r"^169\.254\.169\.254:",
r"^10\.\d+\.\d+\.\d+$",
r"^10\.\d+\.\d+\.\d+:",
r"^172\.(1[6-9]|2\d|3[01])\.\d+\.\d+$",
r"^172\.(1[6-9]|2\d|3[01])\.\d+\.\d+:",
r"^192\.168\.\d+\.\d+$",
r"^192\.168\.\d+\.\d+:",
]
.iter()
.map(|s| Regex::new(s).expect("static pattern must compile"))
.collect()
});
fn extract_url_host(url: &str) -> Option<&str> {
let after_scheme = url.split_once("://")?.1;
let host_end = after_scheme
.find(['/', '?', '#'])
.unwrap_or(after_scheme.len());
Some(&after_scheme[..host_end])
}
static URL_FIELD_NAMES: &[&str] = &["url", "endpoint", "uri", "href", "src", "host", "base_url"];
static SAFE_QUERY_FIELDS: &[&str] = &["query", "q", "search", "text", "message", "content"];
#[derive(Debug)]
pub struct InjectionPatternVerifier {
extra_patterns: Vec<Regex>,
allowlisted_urls: Vec<String>,
}
impl InjectionPatternVerifier {
#[must_use]
pub fn new(config: &InjectionVerifierConfig) -> Self {
let extra_patterns = config
.extra_patterns
.iter()
.filter_map(|s| match Regex::new(s) {
Ok(re) => Some(re),
Err(e) => {
tracing::warn!(
pattern = %s,
error = %e,
"InjectionPatternVerifier: invalid extra_pattern, skipping"
);
None
}
})
.collect();
Self {
extra_patterns,
allowlisted_urls: config
.allowlisted_urls
.iter()
.map(|s| s.to_lowercase())
.collect(),
}
}
fn is_allowlisted(&self, text: &str) -> bool {
let lower = text.to_lowercase();
self.allowlisted_urls
.iter()
.any(|u| lower.contains(u.as_str()))
}
fn is_url_field(field: &str) -> bool {
let lower = field.to_lowercase();
URL_FIELD_NAMES.iter().any(|&f| f == lower)
}
fn is_safe_query_field(field: &str) -> bool {
let lower = field.to_lowercase();
SAFE_QUERY_FIELDS.iter().any(|&f| f == lower)
}
fn check_field_value(&self, field: &str, value: &str) -> VerificationResult {
let is_url = Self::is_url_field(field);
let is_safe_query = Self::is_safe_query_field(field);
if !is_safe_query {
for pat in INJECTION_BLOCK_PATTERNS.iter() {
if pat.is_match(value) {
return VerificationResult::Block {
reason: format!(
"[{}] injection pattern detected in field '{}': {}",
"InjectionPatternVerifier",
field,
pat.as_str()
),
};
}
}
for pat in &self.extra_patterns {
if pat.is_match(value) {
return VerificationResult::Block {
reason: format!(
"[{}] extra injection pattern detected in field '{}': {}",
"InjectionPatternVerifier",
field,
pat.as_str()
),
};
}
}
}
if is_url && let Some(host) = extract_url_host(value) {
for pat in SSRF_HOST_PATTERNS.iter() {
if pat.is_match(host) {
if self.is_allowlisted(value) {
return VerificationResult::Allow;
}
return VerificationResult::Warn {
message: format!(
"[{}] possible SSRF in field '{}': host '{}' matches pattern (not blocked)",
"InjectionPatternVerifier", field, host,
),
};
}
}
}
VerificationResult::Allow
}
fn check_object(&self, obj: &serde_json::Map<String, serde_json::Value>) -> VerificationResult {
for (key, val) in obj {
let result = self.check_value(key, val);
if !matches!(result, VerificationResult::Allow) {
return result;
}
}
VerificationResult::Allow
}
fn check_value(&self, field: &str, val: &serde_json::Value) -> VerificationResult {
match val {
serde_json::Value::String(s) => self.check_field_value(field, s),
serde_json::Value::Array(arr) => {
for item in arr {
let r = self.check_value(field, item);
if !matches!(r, VerificationResult::Allow) {
return r;
}
}
VerificationResult::Allow
}
serde_json::Value::Object(obj) => self.check_object(obj),
_ => VerificationResult::Allow,
}
}
}
impl PreExecutionVerifier for InjectionPatternVerifier {
fn name(&self) -> &'static str {
"InjectionPatternVerifier"
}
fn verify(&self, _tool_name: &str, args: &serde_json::Value) -> VerificationResult {
match args {
serde_json::Value::Object(obj) => self.check_object(obj),
serde_json::Value::String(s) => self.check_field_value("_args", s),
_ => VerificationResult::Allow,
}
}
}
#[derive(Debug, Clone)]
pub struct UrlGroundingVerifier {
guarded_tools: Vec<String>,
user_provided_urls: Arc<RwLock<HashSet<String>>>,
}
impl UrlGroundingVerifier {
#[must_use]
pub fn new(
config: &UrlGroundingVerifierConfig,
user_provided_urls: Arc<RwLock<HashSet<String>>>,
) -> Self {
Self {
guarded_tools: config
.guarded_tools
.iter()
.map(|s| s.to_lowercase())
.collect(),
user_provided_urls,
}
}
fn is_guarded(&self, tool_name: &str) -> bool {
let lower = tool_name.to_lowercase();
self.guarded_tools.iter().any(|t| t == &lower) || lower.ends_with("_fetch")
}
fn is_grounded(url: &str, user_provided_urls: &HashSet<String>) -> bool {
let lower = url.to_lowercase();
user_provided_urls
.iter()
.any(|u| lower.starts_with(u.as_str()) || u.starts_with(lower.as_str()))
}
}
impl PreExecutionVerifier for UrlGroundingVerifier {
fn name(&self) -> &'static str {
"UrlGroundingVerifier"
}
fn verify(&self, tool_name: &str, args: &serde_json::Value) -> VerificationResult {
if !self.is_guarded(tool_name) {
return VerificationResult::Allow;
}
let Some(url) = args.get("url").and_then(|v| v.as_str()) else {
return VerificationResult::Allow;
};
let urls = self.user_provided_urls.read();
if Self::is_grounded(url, &urls) {
return VerificationResult::Allow;
}
VerificationResult::Block {
reason: format!(
"[UrlGroundingVerifier] fetch rejected: URL '{url}' was not provided by the user",
),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct FirewallVerifierConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub blocked_paths: Vec<String>,
#[serde(default)]
pub blocked_env_vars: Vec<String>,
#[serde(default)]
pub exempt_tools: Vec<String>,
}
impl Default for FirewallVerifierConfig {
fn default() -> Self {
Self {
enabled: true,
blocked_paths: Vec::new(),
blocked_env_vars: Vec::new(),
exempt_tools: Vec::new(),
}
}
}
#[derive(Debug)]
pub struct FirewallVerifier {
blocked_path_globs: Vec<glob::Pattern>,
blocked_env_vars: HashSet<String>,
exempt_tools: HashSet<String>,
}
static SENSITIVE_PATH_PATTERNS: LazyLock<Vec<glob::Pattern>> = LazyLock::new(|| {
let raw = [
"/etc/passwd",
"/etc/shadow",
"/etc/sudoers",
"~/.ssh/*",
"~/.aws/*",
"~/.gnupg/*",
"**/*.pem",
"**/*.key",
"**/id_rsa",
"**/id_ed25519",
"**/.env",
"**/credentials",
];
raw.iter()
.filter_map(|p| {
glob::Pattern::new(p)
.map_err(|e| {
tracing::error!(pattern = p, error = %e, "failed to compile built-in firewall path pattern");
e
})
.ok()
})
.collect()
});
static SENSITIVE_ENV_PREFIXES: &[&str] =
&["$AWS_", "$ZEPH_", "${AWS_", "${ZEPH_", "%AWS_", "%ZEPH_"];
static INSPECTED_FIELDS: &[&str] = &[
"command",
"file_path",
"path",
"url",
"query",
"uri",
"input",
"args",
];
impl FirewallVerifier {
#[must_use]
pub fn new(config: &FirewallVerifierConfig) -> Self {
let blocked_path_globs = config
.blocked_paths
.iter()
.filter_map(|p| {
glob::Pattern::new(p)
.map_err(|e| {
tracing::warn!(pattern = p, error = %e, "invalid glob pattern in firewall blocked_paths, skipping");
e
})
.ok()
})
.collect();
let blocked_env_vars = config
.blocked_env_vars
.iter()
.map(|s| s.to_uppercase())
.collect();
let exempt_tools = config
.exempt_tools
.iter()
.map(|s| s.to_lowercase())
.collect();
Self {
blocked_path_globs,
blocked_env_vars,
exempt_tools,
}
}
fn collect_args(args: &serde_json::Value) -> Vec<String> {
let mut out = Vec::new();
match args {
serde_json::Value::Object(map) => {
for field in INSPECTED_FIELDS {
if let Some(val) = map.get(*field) {
Self::collect_strings(val, &mut out);
}
}
}
serde_json::Value::String(s) => out.push(s.clone()),
_ => {}
}
out
}
fn collect_strings(val: &serde_json::Value, out: &mut Vec<String>) {
match val {
serde_json::Value::String(s) => out.push(s.clone()),
serde_json::Value::Array(arr) => {
for item in arr {
Self::collect_strings(item, out);
}
}
_ => {}
}
}
fn scan_arg(&self, arg: &str) -> Option<VerificationResult> {
let normalized: String = arg.nfkc().collect();
let lower = normalized.to_lowercase();
if lower.contains("../") || lower.contains("..\\") {
return Some(VerificationResult::Block {
reason: format!(
"[FirewallVerifier] path traversal pattern detected in argument: {arg}"
),
});
}
for pattern in SENSITIVE_PATH_PATTERNS.iter() {
if pattern.matches(&normalized) || pattern.matches(&lower) {
return Some(VerificationResult::Block {
reason: format!(
"[FirewallVerifier] sensitive path pattern '{pattern}' matched in argument: {arg}"
),
});
}
}
for pattern in &self.blocked_path_globs {
if pattern.matches(&normalized) || pattern.matches(&lower) {
return Some(VerificationResult::Block {
reason: format!(
"[FirewallVerifier] blocked path pattern '{pattern}' matched in argument: {arg}"
),
});
}
}
let upper = normalized.to_uppercase();
for prefix in SENSITIVE_ENV_PREFIXES {
if upper.contains(*prefix) {
return Some(VerificationResult::Block {
reason: format!(
"[FirewallVerifier] env var exfiltration pattern '{prefix}' detected in argument: {arg}"
),
});
}
}
for var in &self.blocked_env_vars {
let dollar_form = format!("${var}");
let brace_form = format!("${{{var}}}");
let percent_form = format!("%{var}%");
if upper.contains(&dollar_form)
|| upper.contains(&brace_form)
|| upper.contains(&percent_form)
{
return Some(VerificationResult::Block {
reason: format!(
"[FirewallVerifier] blocked env var '{var}' detected in argument: {arg}"
),
});
}
}
None
}
}
impl PreExecutionVerifier for FirewallVerifier {
fn name(&self) -> &'static str {
"FirewallVerifier"
}
fn verify(&self, tool_name: &str, args: &serde_json::Value) -> VerificationResult {
if self.exempt_tools.contains(&tool_name.to_lowercase()) {
return VerificationResult::Allow;
}
for arg in Self::collect_args(args) {
if let Some(result) = self.scan_arg(&arg) {
return result;
}
}
VerificationResult::Allow
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
fn dcv() -> DestructiveCommandVerifier {
DestructiveCommandVerifier::new(&DestructiveVerifierConfig::default())
}
#[test]
fn allow_normal_command() {
let v = dcv();
assert_eq!(
v.verify("bash", &json!({"command": "ls -la /tmp"})),
VerificationResult::Allow
);
}
#[test]
fn block_rm_rf_root() {
let v = dcv();
let result = v.verify("bash", &json!({"command": "rm -rf /"}));
assert!(matches!(result, VerificationResult::Block { .. }));
}
#[test]
fn block_dd_dev_zero() {
let v = dcv();
let result = v.verify("bash", &json!({"command": "dd if=/dev/zero of=/dev/sda"}));
assert!(matches!(result, VerificationResult::Block { .. }));
}
#[test]
fn block_mkfs() {
let v = dcv();
let result = v.verify("bash", &json!({"command": "mkfs.ext4 /dev/sda1"}));
assert!(matches!(result, VerificationResult::Block { .. }));
}
#[test]
fn allow_rm_rf_in_allowed_path() {
let config = DestructiveVerifierConfig {
allowed_paths: vec!["/tmp/build".to_string()],
..Default::default()
};
let v = DestructiveCommandVerifier::new(&config);
assert_eq!(
v.verify("bash", &json!({"command": "rm -rf /tmp/build/artifacts"})),
VerificationResult::Allow
);
}
#[test]
fn block_rm_rf_when_not_in_allowed_path() {
let config = DestructiveVerifierConfig {
allowed_paths: vec!["/tmp/build".to_string()],
..Default::default()
};
let v = DestructiveCommandVerifier::new(&config);
let result = v.verify("bash", &json!({"command": "rm -rf /home/user"}));
assert!(matches!(result, VerificationResult::Block { .. }));
}
#[test]
fn allow_non_shell_tool() {
let v = dcv();
assert_eq!(
v.verify("read_file", &json!({"path": "rm -rf /"})),
VerificationResult::Allow
);
}
#[test]
fn block_extra_pattern() {
let config = DestructiveVerifierConfig {
extra_patterns: vec!["format c:".to_string()],
..Default::default()
};
let v = DestructiveCommandVerifier::new(&config);
let result = v.verify("bash", &json!({"command": "format c:"}));
assert!(matches!(result, VerificationResult::Block { .. }));
}
#[test]
fn array_args_normalization() {
let v = dcv();
let result = v.verify("bash", &json!({"command": ["rm", "-rf", "/"]}));
assert!(matches!(result, VerificationResult::Block { .. }));
}
#[test]
fn sh_c_wrapping_normalization() {
let v = dcv();
let result = v.verify("bash", &json!({"command": "bash -c 'rm -rf /'"}));
assert!(matches!(result, VerificationResult::Block { .. }));
}
#[test]
fn fork_bomb_blocked() {
let v = dcv();
let result = v.verify("bash", &json!({"command": ":(){ :|:& };:"}));
assert!(matches!(result, VerificationResult::Block { .. }));
}
#[test]
fn custom_shell_tool_name_blocked() {
let config = DestructiveVerifierConfig {
shell_tools: vec!["execute".to_string(), "run_command".to_string()],
..Default::default()
};
let v = DestructiveCommandVerifier::new(&config);
let result = v.verify("execute", &json!({"command": "rm -rf /"}));
assert!(matches!(result, VerificationResult::Block { .. }));
}
#[test]
fn terminal_tool_name_blocked_by_default() {
let v = dcv();
let result = v.verify("terminal", &json!({"command": "rm -rf /"}));
assert!(matches!(result, VerificationResult::Block { .. }));
}
#[test]
fn default_shell_tools_contains_bash_shell_terminal() {
let config = DestructiveVerifierConfig::default();
let lower: Vec<String> = config
.shell_tools
.iter()
.map(|s| s.to_lowercase())
.collect();
assert!(lower.contains(&"bash".to_string()));
assert!(lower.contains(&"shell".to_string()));
assert!(lower.contains(&"terminal".to_string()));
}
fn ipv() -> InjectionPatternVerifier {
InjectionPatternVerifier::new(&InjectionVerifierConfig::default())
}
#[test]
fn allow_clean_args() {
let v = ipv();
assert_eq!(
v.verify("search", &json!({"query": "rust async traits"})),
VerificationResult::Allow
);
}
#[test]
fn allow_sql_discussion_in_query_field() {
let v = ipv();
assert_eq!(
v.verify(
"memory_search",
&json!({"query": "explain SQL UNION SELECT vs JOIN"})
),
VerificationResult::Allow
);
}
#[test]
fn allow_sql_or_pattern_in_query_field() {
let v = ipv();
assert_eq!(
v.verify("memory_search", &json!({"query": "' OR '1'='1"})),
VerificationResult::Allow
);
}
#[test]
fn block_sql_injection_in_non_query_field() {
let v = ipv();
let result = v.verify("db_query", &json!({"sql": "' OR '1'='1"}));
assert!(matches!(result, VerificationResult::Block { .. }));
}
#[test]
fn block_drop_table() {
let v = ipv();
let result = v.verify("db_query", &json!({"input": "name'; DROP TABLE users"}));
assert!(matches!(result, VerificationResult::Block { .. }));
}
#[test]
fn block_path_traversal() {
let v = ipv();
let result = v.verify("read_file", &json!({"path": "../../../etc/passwd"}));
assert!(matches!(result, VerificationResult::Block { .. }));
}
#[test]
fn warn_on_localhost_url_field() {
let v = ipv();
let result = v.verify("http_get", &json!({"url": "http://localhost:8080/api"}));
assert!(matches!(result, VerificationResult::Warn { .. }));
}
#[test]
fn allow_localhost_in_non_url_field() {
let v = ipv();
assert_eq!(
v.verify(
"memory_search",
&json!({"query": "connect to http://localhost:8080"})
),
VerificationResult::Allow
);
}
#[test]
fn warn_on_private_ip_url_field() {
let v = ipv();
let result = v.verify("fetch", &json!({"url": "http://192.168.1.1/admin"}));
assert!(matches!(result, VerificationResult::Warn { .. }));
}
#[test]
fn allow_localhost_when_allowlisted() {
let config = InjectionVerifierConfig {
allowlisted_urls: vec!["http://localhost:3000".to_string()],
..Default::default()
};
let v = InjectionPatternVerifier::new(&config);
assert_eq!(
v.verify("http_get", &json!({"url": "http://localhost:3000/api"})),
VerificationResult::Allow
);
}
#[test]
fn block_union_select_in_non_query_field() {
let v = ipv();
let result = v.verify(
"db_query",
&json!({"input": "id=1 UNION SELECT password FROM users"}),
);
assert!(matches!(result, VerificationResult::Block { .. }));
}
#[test]
fn allow_union_select_in_query_field() {
let v = ipv();
assert_eq!(
v.verify(
"memory_search",
&json!({"query": "id=1 UNION SELECT password FROM users"})
),
VerificationResult::Allow
);
}
#[test]
fn block_rm_rf_unicode_homoglyph() {
let v = dcv();
let result = v.verify("bash", &json!({"command": "rm -rf \u{FF0F}"}));
assert!(matches!(result, VerificationResult::Block { .. }));
}
#[test]
fn path_traversal_not_allowed_via_dotdot() {
let config = DestructiveVerifierConfig {
allowed_paths: vec!["/tmp/build".to_string()],
..Default::default()
};
let v = DestructiveCommandVerifier::new(&config);
let result = v.verify("bash", &json!({"command": "rm -rf /tmp/build/../../etc"}));
assert!(matches!(result, VerificationResult::Block { .. }));
}
#[test]
fn allowed_path_with_dotdot_stays_in_allowed() {
let config = DestructiveVerifierConfig {
allowed_paths: vec!["/tmp/build".to_string()],
..Default::default()
};
let v = DestructiveCommandVerifier::new(&config);
assert_eq!(
v.verify(
"bash",
&json!({"command": "rm -rf /tmp/build/sub/../artifacts"}),
),
VerificationResult::Allow,
);
}
#[test]
fn double_nested_bash_c_blocked() {
let v = dcv();
let result = v.verify(
"bash",
&json!({"command": "bash -c \"bash -c 'rm -rf /'\""}),
);
assert!(matches!(result, VerificationResult::Block { .. }));
}
#[test]
fn env_prefix_stripping_blocked() {
let v = dcv();
let result = v.verify(
"bash",
&json!({"command": "env FOO=bar bash -c 'rm -rf /'"}),
);
assert!(matches!(result, VerificationResult::Block { .. }));
}
#[test]
fn exec_prefix_stripping_blocked() {
let v = dcv();
let result = v.verify("bash", &json!({"command": "exec bash -c 'rm -rf /'"}));
assert!(matches!(result, VerificationResult::Block { .. }));
}
#[test]
fn ssrf_not_triggered_for_embedded_localhost_in_query_param() {
let v = ipv();
let result = v.verify(
"http_get",
&json!({"url": "http://evil.com/?r=http://localhost"}),
);
assert_eq!(result, VerificationResult::Allow);
}
#[test]
fn ssrf_triggered_for_bare_localhost_no_port() {
let v = ipv();
let result = v.verify("http_get", &json!({"url": "http://localhost"}));
assert!(matches!(result, VerificationResult::Warn { .. }));
}
#[test]
fn ssrf_triggered_for_localhost_with_path() {
let v = ipv();
let result = v.verify("http_get", &json!({"url": "http://localhost/api/v1"}));
assert!(matches!(result, VerificationResult::Warn { .. }));
}
#[test]
fn chain_first_block_wins() {
let dcv = DestructiveCommandVerifier::new(&DestructiveVerifierConfig::default());
let ipv = InjectionPatternVerifier::new(&InjectionVerifierConfig::default());
let verifiers: Vec<Box<dyn PreExecutionVerifier>> = vec![Box::new(dcv), Box::new(ipv)];
let args = json!({"command": "rm -rf /"});
let mut result = VerificationResult::Allow;
for v in &verifiers {
result = v.verify("bash", &args);
if matches!(result, VerificationResult::Block { .. }) {
break;
}
}
assert!(matches!(result, VerificationResult::Block { .. }));
}
#[test]
fn chain_warn_continues() {
let dcv = DestructiveCommandVerifier::new(&DestructiveVerifierConfig::default());
let ipv = InjectionPatternVerifier::new(&InjectionVerifierConfig::default());
let verifiers: Vec<Box<dyn PreExecutionVerifier>> = vec![Box::new(dcv), Box::new(ipv)];
let args = json!({"url": "http://localhost:8080/api"});
let mut got_warn = false;
let mut got_block = false;
for v in &verifiers {
match v.verify("http_get", &args) {
VerificationResult::Block { .. } => {
got_block = true;
break;
}
VerificationResult::Warn { .. } => {
got_warn = true;
}
VerificationResult::Allow => {}
}
}
assert!(got_warn);
assert!(!got_block);
}
fn ugv(urls: &[&str]) -> UrlGroundingVerifier {
let set: HashSet<String> = urls.iter().map(|s| s.to_lowercase()).collect();
UrlGroundingVerifier::new(
&UrlGroundingVerifierConfig::default(),
Arc::new(RwLock::new(set)),
)
}
#[test]
fn url_grounding_allows_user_provided_url() {
let v = ugv(&["https://docs.anthropic.com/models"]);
assert_eq!(
v.verify(
"fetch",
&json!({"url": "https://docs.anthropic.com/models"})
),
VerificationResult::Allow
);
}
#[test]
fn url_grounding_blocks_hallucinated_url() {
let v = ugv(&["https://example.com/page"]);
let result = v.verify(
"fetch",
&json!({"url": "https://api.anthropic.ai/v1/models"}),
);
assert!(matches!(result, VerificationResult::Block { .. }));
}
#[test]
fn url_grounding_blocks_when_no_user_urls_at_all() {
let v = ugv(&[]);
let result = v.verify(
"fetch",
&json!({"url": "https://api.anthropic.ai/v1/models"}),
);
assert!(matches!(result, VerificationResult::Block { .. }));
}
#[test]
fn url_grounding_allows_non_guarded_tool() {
let v = ugv(&[]);
assert_eq!(
v.verify("read_file", &json!({"path": "/etc/hosts"})),
VerificationResult::Allow
);
}
#[test]
fn url_grounding_guards_fetch_suffix_tool() {
let v = ugv(&[]);
let result = v.verify("http_fetch", &json!({"url": "https://evil.com/"}));
assert!(matches!(result, VerificationResult::Block { .. }));
}
#[test]
fn url_grounding_allows_web_scrape_with_provided_url() {
let v = ugv(&["https://rust-lang.org/"]);
assert_eq!(
v.verify(
"web_scrape",
&json!({"url": "https://rust-lang.org/", "select": "h1"})
),
VerificationResult::Allow
);
}
#[test]
fn url_grounding_allows_prefix_match() {
let v = ugv(&["https://docs.rs/"]);
assert_eq!(
v.verify(
"fetch",
&json!({"url": "https://docs.rs/tokio/latest/tokio/"})
),
VerificationResult::Allow
);
}
#[test]
fn reg_2191_hallucinated_api_endpoint_blocked_with_empty_session() {
let v = ugv(&[]);
let result = v.verify(
"fetch",
&json!({"url": "https://api.anthropic.ai/v1/models"}),
);
assert!(
matches!(result, VerificationResult::Block { .. }),
"fetch must be blocked when no user URL was provided — this is the #2191 regression"
);
}
#[test]
fn reg_2191_user_provided_url_allows_fetch() {
let v = ugv(&["https://api.anthropic.com/v1/models"]);
assert_eq!(
v.verify(
"fetch",
&json!({"url": "https://api.anthropic.com/v1/models"}),
),
VerificationResult::Allow,
"fetch must be allowed when the URL was explicitly provided by the user"
);
}
#[test]
fn reg_2191_web_scrape_hallucinated_url_blocked() {
let v = ugv(&[]);
let result = v.verify(
"web_scrape",
&json!({"url": "https://api.anthropic.ai/v1/models", "select": "body"}),
);
assert!(
matches!(result, VerificationResult::Block { .. }),
"web_scrape must be blocked for hallucinated URL with empty user_provided_urls"
);
}
#[test]
fn reg_2191_empty_url_set_always_blocks_fetch() {
let v = ugv(&[]);
let result = v.verify(
"fetch",
&json!({"url": "https://docs.anthropic.com/something"}),
);
assert!(matches!(result, VerificationResult::Block { .. }));
}
#[test]
fn reg_2191_case_insensitive_url_match_allows_fetch() {
let v = ugv(&["https://Docs.Anthropic.COM/models"]);
assert_eq!(
v.verify(
"fetch",
&json!({"url": "https://docs.anthropic.com/models/detail"}),
),
VerificationResult::Allow,
"URL matching must be case-insensitive"
);
}
#[test]
fn reg_2191_mcp_fetch_suffix_tool_blocked_with_empty_session() {
let v = ugv(&[]);
let result = v.verify(
"anthropic_fetch",
&json!({"url": "https://api.anthropic.ai/v1/models"}),
);
assert!(
matches!(result, VerificationResult::Block { .. }),
"MCP tools ending in _fetch must be guarded even if not in guarded_tools list"
);
}
#[test]
fn reg_2191_reverse_prefix_match_allows_fetch() {
let v = ugv(&["https://docs.rs/tokio/latest/tokio/index.html"]);
assert_eq!(
v.verify("fetch", &json!({"url": "https://docs.rs/"})),
VerificationResult::Allow,
"reverse prefix: fetched URL is a prefix of user-provided URL — should be allowed"
);
}
#[test]
fn reg_2191_different_domain_blocked() {
let v = ugv(&["https://docs.rs/"]);
let result = v.verify("fetch", &json!({"url": "https://evil.com/docs.rs/exfil"}));
assert!(
matches!(result, VerificationResult::Block { .. }),
"different domain must not be allowed even if path looks similar"
);
}
#[test]
fn reg_2191_missing_url_field_allows_fetch() {
let v = ugv(&[]);
assert_eq!(
v.verify(
"fetch",
&json!({"endpoint": "https://api.anthropic.ai/v1/models"})
),
VerificationResult::Allow,
"missing url field must not trigger blocking — only explicit url field is checked"
);
}
#[test]
fn reg_2191_disabled_verifier_allows_all() {
let config = UrlGroundingVerifierConfig {
enabled: false,
guarded_tools: default_guarded_tools(),
};
let set: HashSet<String> = HashSet::new();
let v = UrlGroundingVerifier::new(&config, Arc::new(RwLock::new(set)));
let _ = v.verify("fetch", &json!({"url": "https://example.com/"}));
}
fn fwv() -> FirewallVerifier {
FirewallVerifier::new(&FirewallVerifierConfig::default())
}
#[test]
fn firewall_allows_normal_path() {
let v = fwv();
assert_eq!(
v.verify("shell", &json!({"command": "ls /tmp/build"})),
VerificationResult::Allow
);
}
#[test]
fn firewall_blocks_path_traversal() {
let v = fwv();
let result = v.verify("read", &json!({"file_path": "../../etc/passwd"}));
assert!(
matches!(result, VerificationResult::Block { .. }),
"path traversal must be blocked"
);
}
#[test]
fn firewall_blocks_etc_passwd() {
let v = fwv();
let result = v.verify("read", &json!({"file_path": "/etc/passwd"}));
assert!(
matches!(result, VerificationResult::Block { .. }),
"/etc/passwd must be blocked"
);
}
#[test]
fn firewall_blocks_ssh_key() {
let v = fwv();
let result = v.verify("read", &json!({"file_path": "~/.ssh/id_rsa"}));
assert!(
matches!(result, VerificationResult::Block { .. }),
"SSH key path must be blocked"
);
}
#[test]
fn firewall_blocks_aws_env_var() {
let v = fwv();
let result = v.verify("shell", &json!({"command": "echo $AWS_SECRET_ACCESS_KEY"}));
assert!(
matches!(result, VerificationResult::Block { .. }),
"AWS env var exfiltration must be blocked"
);
}
#[test]
fn firewall_blocks_zeph_env_var() {
let v = fwv();
let result = v.verify("shell", &json!({"command": "cat ${ZEPH_CLAUDE_API_KEY}"}));
assert!(
matches!(result, VerificationResult::Block { .. }),
"ZEPH env var exfiltration must be blocked"
);
}
#[test]
fn firewall_exempt_tool_bypasses_check() {
let cfg = FirewallVerifierConfig {
enabled: true,
blocked_paths: vec![],
blocked_env_vars: vec![],
exempt_tools: vec!["read".to_string()],
};
let v = FirewallVerifier::new(&cfg);
assert_eq!(
v.verify("read", &json!({"file_path": "/etc/passwd"})),
VerificationResult::Allow
);
}
#[test]
fn firewall_custom_blocked_path() {
let cfg = FirewallVerifierConfig {
enabled: true,
blocked_paths: vec!["/data/secrets/*".to_string()],
blocked_env_vars: vec![],
exempt_tools: vec![],
};
let v = FirewallVerifier::new(&cfg);
let result = v.verify("read", &json!({"file_path": "/data/secrets/master.key"}));
assert!(
matches!(result, VerificationResult::Block { .. }),
"custom blocked path must be blocked"
);
}
#[test]
fn firewall_custom_blocked_env_var() {
let cfg = FirewallVerifierConfig {
enabled: true,
blocked_paths: vec![],
blocked_env_vars: vec!["MY_SECRET".to_string()],
exempt_tools: vec![],
};
let v = FirewallVerifier::new(&cfg);
let result = v.verify("shell", &json!({"command": "echo $MY_SECRET"}));
assert!(
matches!(result, VerificationResult::Block { .. }),
"custom blocked env var must be blocked"
);
}
#[test]
fn firewall_invalid_glob_is_skipped() {
let cfg = FirewallVerifierConfig {
enabled: true,
blocked_paths: vec!["[invalid-glob".to_string(), "/valid/path/*".to_string()],
blocked_env_vars: vec![],
exempt_tools: vec![],
};
let v = FirewallVerifier::new(&cfg);
let result = v.verify("read", &json!({"path": "/valid/path/file.txt"}));
assert!(matches!(result, VerificationResult::Block { .. }));
}
#[test]
fn firewall_config_default_deserialization() {
let cfg: FirewallVerifierConfig = toml::from_str("").unwrap();
assert!(cfg.enabled);
assert!(cfg.blocked_paths.is_empty());
assert!(cfg.blocked_env_vars.is_empty());
assert!(cfg.exempt_tools.is_empty());
}
}