AI tried to fix this
Added command blocklist and allowlist policy checks.
The AI-assisted change was a real security patch, but it left the same advisory reachable.
Missed: Treated an empty allowlist as a pass-through policy.
How AI contributed: Incomplete remediationPublished Mar 5, 2026 in
qhkm/zeptoclawAll 5 findings in this repository
Summary zeptoclaw implements a allowlist combined with a blocklist to prevent malicious shell commands in src/security/shell.rs. However, even in the Strict mode, attackers can completely bypass all the guards from allowlist and blocklist: · to bypass the allowlist, command injection is enough, such as ;, $() etc. · to bypass the REGEX_BLOCKED_PATTERNS, argument injection is enough, such as the python3 -P -c "..." · to bypass the LITERAL_BLOCKED_PATTERNS, file name wildcards can do the work, such as cat /etc/pass[w]d Details In code src/security/shell.rs#L218-L243, one can see the allowlist only checks the first token and thus makes command injection possible. !self.allowlist.is_empty() makes the empty allowlist overlook the allowlist check, if it is in ShellAllowlistMode::Strict mode, empty allowlist should direct reject all the commands. As the code in src/security/shell.rs#L18-L70, we can find the REGEX_BLOCKED_PATTERNS only apply \s+ in between the command and arguments, making argument injection possible, and the LITERAL_BLOCKED_PATTERNS just uses specific file name, totally overlooking the file name wildcards: PoC Impact Unauthorized command execution. Credit @zpbrent
The allowlist added in src/security/shell.rs compared only the first whitespace-delimited token of the command, so arguments and filename globs still reached blocked commands, and the is_empty() short-circuit made an empty allowlist accept everything even in Strict mode. The fix allows intervening flags in inline-code patterns such as python -P -c and python -Bc, and matches wildcard filenames with literal-path regexes.
Case facts
The highlighted steps are this advisory: the incomplete AI patch and the later commit that closed it.
This advisoryGHSA-5WP8-Q9MX-8JX8
AI tried to fix this
The AI-assisted change was a real security patch, but it left the same advisory reachable.
Missed: Treated an empty allowlist as a pass-through policy.
Fixed again
This is the patch that actually stops the same attack path.
--- a/src/security/shell.rs+++ b/src/security/shell.rs@@ -66,6 +66,18 @@ const LITERAL_BLOCKED_PATTERNS: &[&str] = &[ ".kube/config", ]; +/// Controls allowlist enforcement behaviour.+#[derive(Debug, Clone, PartialEq, Default)]+pub enum ShellAllowlistMode {+ /// Allowlist disabled — blocklist only (current behaviour, this is the default)+ #[default]+ Off,+ /// Log a warning if the first token is not in the allowlist, but proceed+ Warn,+ /// Block execution if the first token is not in the allowlist+ Strict,+}+ /// Configuration for shell command security. #[derive(Debug, Clone)] pub struct ShellSecurityConfig {@@ -75,6 +87,11 @@ pub struct ShellSecurityConfig { literal_patterns: Vec<String>, /// Whether to enable security checks (can be disabled for trusted environments) pub enabled: bool,+ /// Commands that are explicitly allowed (first token / executable name).+ /// Only used when `allowlist_mode` is `Warn` or `Strict`.+ pub allowlist: Vec<String>,+ /// Allowlist enforcement mode.+ pub allowlist_mode: ShellAllowlistMode, } impl Default for ShellSecurityConfig {@@ -104,6 +121,8 @@ impl ShellSecurityConfig { compiled_patterns, literal_patterns, enabled: true,+ allowlist: Vec::new(),+ allowlist_mode: ShellAllowlistMode::Off, } } @@ -116,6 +135,8 @@ impl ShellSecurityConfig { compiled_patterns: Vec::new(), literal_patterns: Vec::new(), enabled: false,+ allowlist: Vec::new(),+ allowlist_mode: ShellAllowlistMode::Off, } } @@ -133,6 +154,16 @@ impl ShellSecurityConfig { self } + /// Set the command allowlist and enforcement mode.+ ///+ /// The allowlist matches the first token (executable name) of the command.+ /// Example: `with_allowlist(vec!["git", "cargo", "ls"], ShellAllowlistMode::Strict)`+ pub fn with_allowlist(mut self, allowlist: Vec<&str>, mode: ShellAllowlistMode) -> Self {+ self.allowlist = allowlist.into_iter().map(|s| s.to_lowercase()).collect();+ self.allowlist_mode = mode;+ self+ }+ /// Check if a command is allowed. /// /// Returns `Ok(())` if the command is safe to execute,@@ -181,6 +212,35 @@ impl ShellSecurityConfig { } } + // Allowlist check (runs after blocklist)+ if self.allowlist_mode != ShellAllowlistMode::Off && !self.allowlist.is_empty() {+ let first_token = command+ .split_whitespace()+ .next()+ .unwrap_or("")+ .to_lowercase();+ // Strip path prefix (e.g. /usr/bin/git -> git)+ let executable = first_token.rsplit('/').next().unwrap_or(&first_token);+ if !self.allowlist.contains(&executable.to_string()) {+ match self.allowlist_mode {+ ShellAllowlistMode::Strict => {+ return Err(ZeptoError::SecurityViolation(format!(+ "Command '{}' not in allowlist",+ executable+ )));+ }+ ShellAllowlistMode::Warn => {+ tracing::warn!(+ command = %command,+ executable = %executable,+ "Command not in allowlist"+ );+ }+ ShellAllowlistMode::Off => {} // unreachable+ }+ }+ }+ Ok(()) } }@@ -422,4 +482,67 @@ mod tests { assert!(config.validate_command("node app.js").is_ok()); assert!(config.validate_command("ruby script.rb").is_ok()); }++ // ==================== ALLOWLIST TESTS ====================++ #[test]+ fn test_allowlist_off_passes_any_command() {+ let config = ShellSecurityConfig::new(); // Off by default+ // Any safe command passes even without being in allowlist+ assert!(config.validate_command("git status").is_ok());+ assert!(config.validate_command("cargo build").is_ok());+ assert!(config.validate_command("python script.py").is_ok());+ }+--- a/src/security/shell.rs+++ b/src/security/shell.rs@@ -129,10 +129,18 @@ const REGEX_BLOCKED_PATTERNS: &[&str] = &[ r"fork\s*\(\s*\)", // Encoded/indirect execution (common blocklist bypasses) r"base64\s+(-d|--decode)",- r"python[23]?\s+-c\s+",- r"perl\s+-e\s+",- r"ruby\s+-e\s+",- r"node\s+-e\s+",+ // Match python/perl/ruby/node with inline code flags, allowing intervening+ // flags (e.g. `python3 -P -c '...'` or `python3 -Bc '...'`).+ // GHSA-5wp8-q9mx-8jx8: previous pattern `python[23]?\s+-c\s+` was bypassed+ // by inserting extra flags between the command and -c.+ // The pattern now matches:+ // - `python3 -c '...'` (standalone -c)+ // - `python3 -P -c '...'` (extra flags before -c)+ // - `python3 -Bc '...'` (combined flag ending in c)+ r"python[23]?\s+.*-[A-Za-z]*c[\s=]",+ r"perl\s+.*-[A-Za-z]*e[\s=]",+ r"ruby\s+.*-[A-Za-z]*e[\s=]",+ r"node\s+.*-[A-Za-z]*e[\s=]", r"\beval\s+", r"xargs\s+.*sh\b", r"xargs\s+.*bash\b",@@ -166,6 +174,37 @@ const LITERAL_BLOCKED_PATTERNS: &[&str] = &[ ".zeptoclaw/config.yaml", ]; +/// Convert a command string that may contain shell glob characters into a regex+/// that can match the *literal* path the glob would expand to.+///+/// `?` → `.` (any single char), `*` → `.*`, `[` / `]` stripped,+/// all other regex-special characters escaped.+fn build_glob_regex(command: &str) -> Option<Regex> {+ let mut pat = String::with_capacity(command.len() + 16);+ // Skip wildcard-only tokens (e.g. `*`, `??`) to avoid matching every literal.+ let mut has_literal = false;+ for ch in command.chars() {+ match ch {+ '?' => pat.push('.'),+ '*' => pat.push_str(".*"),+ '[' | ']' => {} // strip brackets (contents become literal)+ c if ".+^${}()|\\".contains(c) => {+ has_literal = true;+ pat.push('\\');+ pat.push(c);+ }+ c => {+ has_literal = true;+ pat.push(c);+ }+ }+ }+ if !has_literal {+ return None;+ }+ Regex::new(&pat).ok()+}+ /// Controls allowlist enforcement behaviour. #[derive(Debug, Clone, PartialEq, Default)] pub enum ShellAllowlistMode {@@ -297,9 +336,29 @@ impl ShellSecurityConfig { } } - // Check literal patterns+ // Check literal patterns.+ // Strip shell glob characters so that e.g. `/etc/pass[w]d` still matches+ // the literal `/etc/passwd`. See GHSA-5wp8-q9mx-8jx8.+ //+ // Heuristic 1: Remove brackets — `pass[w]d` → `passwd`+ // Heuristic 2: For each token containing glob chars, build a regex+ // (`?` → `.`, `*` → `.*`, brackets stripped) and check+ // if any literal matches that expanded pattern.+ let deglobbed: String = command_lower+ .chars()+ .filter(|c| !matches!(c, '[' | ']' | '*' | '?'))+ .collect();+ // Pre-compile glob regexes for tokens that contain glob characters+ let glob_token_regexes: Vec<Regex> = command_lower+ .split_whitespace()+ .filter(|tok| tok.chars().any(|c| matches!(c, '?' | '*' | '[')))+ .filter_map(build_glob_regex)+ .collect(); for literal in &self.literal_patterns {- if command_lower.contains(literal) {+ let matched = command_lower.contains(literal)+ || deglobbed.contains(literal)+ || glob_token_regexes.iter().any(|re| re.is_match(literal));+ if matched { log_audit_event( AuditCategory::ShellSecurity, AuditSeverity::Critical,@@ -314,8 +373,40 @@ impl ShellSecurityConfig { } } - // Allowlist check (runs after blocklist)- if self.allowlist_mode != ShellAllowlistMode::Off && !self.allowlist.is_empty() {+ // Allowlist check (runs after blocklist).+ // GHSA-5wp8-q9mx-8jx8: Previously only checked the first token, so+ // `git status; python -c '...'` would pass if `git` was allowlisted.+ // Now we also detect shell metacharacters that enable command chaining.+ if self.allowlist_mode != ShellAllowlistMode::Off {+ // Detect command-chaining metacharacters. If the command contains+ // any of these, the first-token allowlist check is meaningless+ // because subsequent commands can be anything.+ let has_chaining_metachar = command_lower+ .chars()+ .any(|c| matches!(c, ';' | '|' | '&' | '`' | '\n'))+ || command_lower.contains("$(");++ if has_chaining_metachar {+ match self.allowlist_mode {+ ShellAllowlistMode::Strict => {+ return Err(ZeptoError::SecurityViolation(+ "Command blocked: contains shell metacharacters that bypass allowlist"+ .to_string(),AI-assisted change ef0cd2d23cffaa2fa7ac789a318ee9667179c48aa9944e0f78e7c0d508ef6317 · Fix 6f91fcdded7e06163c8d8c9170e9e8dbf3381a67d9b44eda6d68f5bbaeff8fdd