Root cause
AI change
fix(shell): use word-boundary matching for High-risk patterns to prevent false positives
Loading…
How AI contributed
Causal contributionA vulnerability was identified in nearai ironclaw up to 0.29.1. Affected is the function classify_command_risk of the file src/tools/builtin/shell.rs. Such manipulation leads to command injection. The attack may be launched remotely. The exploit is publicly available and might be used. The name of the patch is a1d7c3ba428ed575900469b207fb5668725f9a71. Applying a patch is advised to resolve this issue.
Root cause
fix(shell): use word-boundary matching for High-risk patterns to prevent false positives
Fix
fix(security): tool boundary checks (#4869)
Code comparison
--- a/src/tools/builtin/shell.rs+++ b/src/tools/builtin/shell.rs@@ -117,7 +117,7 @@ static NEVER_AUTO_APPROVE_PATTERNS: LazyLock<Vec<&'static str>> = LazyLock::new( "init 0", "init 6", "iptables",- "nft ",+ "nft", "useradd", "userdel", "passwd",@@ -139,7 +139,7 @@ static NEVER_AUTO_APPROVE_PATTERNS: LazyLock<Vec<&'static str>> = LazyLock::new( "DROP DATABASE", "TRUNCATE", "DELETE FROM",- "sudo ",+ "sudo", ] }); @@ -335,29 +335,20 @@ fn matches_command_pattern(segment: &str, pattern: &str) -> bool { /// Classify a shell command into a [`RiskLevel`]. ///-/// Classification rules (in order):-/// 1. **High** — any part of the command matches [`NEVER_AUTO_APPROVE_PATTERNS`]-/// (destructive / irreversible). Checked across the entire string so that a-/// dangerous sub-command in a pipeline is never missed.-/// 2. **Pipeline max** — the command is split on `|`, `&`, `;` and each segment-/// is classified independently; the overall risk is the **maximum** across all-/// segments, so `echo hello | cargo build` → Medium, not Low.-/// 3. Per-segment: Low if it matches [`LOW_RISK_PATTERNS`], Medium if it matches-/// [`MEDIUM_RISK_PATTERNS`], Medium for unknown commands (safer default).+/// The command is split on `|`, `&`, `;` and each segment is classified+/// independently; the overall risk is the **maximum** across all segments+/// so a dangerous sub-command in a pipeline is never missed.+///+/// Per-segment priority (highest wins):+/// 1. **High** — segment matches [`NEVER_AUTO_APPROVE_PATTERNS`] (destructive / irreversible).+/// 2. **Low** — segment matches [`LOW_RISK_PATTERNS`] (strictly read-only).+/// 3. **Medium** — segment matches [`MEDIUM_RISK_PATTERNS`] (reversible mutations).+/// 4. **Medium** — unknown commands default to Medium (safer than auto-approving). ///-/// Matching uses word-boundary rules (see [`matches_command_pattern`]) to prevent-/// false positives like `"lsblk"` matching the `"ls"` Low-risk prefix.+/// All matching uses word-boundary rules (see [`matches_command_pattern`]) to+/// prevent false positives like `"makeshutdownscript"` matching `"shutdown"` or+/// `"lsblk"` matching `"ls"`. pub fn classify_command_risk(command: &str) -> RiskLevel {- let lower = command.to_lowercase();-- // High wins over everything — check across the whole command string.- if NEVER_AUTO_APPROVE_PATTERNS- .iter()- .any(|p| lower.contains(&p.to_lowercase()))- {- return RiskLevel::High;- }- // For pipelines/chains, take the maximum risk across all segments. command .split(['|', '&', ';'])@@ -365,7 +356,12 @@ pub fn classify_command_risk(command: &str) -> RiskLevel { .filter(|s| !s.is_empty()) .map(|segment| { let seg_lower = segment.to_lowercase();- if LOW_RISK_PATTERNS+ if NEVER_AUTO_APPROVE_PATTERNS+ .iter()+ .any(|p| matches_command_pattern(&seg_lower, &p.to_lowercase()))+ {+ RiskLevel::High+ } else if LOW_RISK_PATTERNS .iter() .any(|p| matches_command_pattern(&seg_lower, p)) {@@ -1008,6 +1004,14 @@ mod tests { classify_command_risk("sudo apt install something"), RiskLevel::High );+ // Word-boundary: these contain High-risk pattern names as substrings but+ // must NOT be classified High (they are not the actual commands).+ assert_eq!(+ classify_command_risk("makeshutdownscript --help"),+ RiskLevel::Medium+ );+ assert_eq!(classify_command_risk("nftables-config"), RiskLevel::Medium);+ assert_eq!(classify_command_risk("passwdqc-check"), RiskLevel::Medium); } #[test]--- a/src/tools/builtin/shell.rs+++ b/src/tools/builtin/shell.rs@@ -337,6 +337,214 @@ fn matches_command_pattern(segment: &str, pattern: &str) -> bool { } } +fn shell_tokens(segment: &str) -> Vec<String> {+ let mut tokens = Vec::new();+ let mut current = String::new();+ let mut quote: Option<char> = None;+ let mut escaped = false;++ let mut chars = segment.chars().peekable();+ while let Some(ch) = chars.next() {+ if escaped {+ current.push(ch);+ escaped = false;+ continue;+ }++ if ch == '\\' && quote != Some('\'') {+ if chars.peek().is_some_and(|next| {+ next.is_whitespace() || matches!(next, '\'' | '"' | '\\' | '$' | '`')+ }) {+ escaped = true;+ } else {+ current.push(ch);+ }+ continue;+ }++ if let Some(q) = quote {+ if ch == q {+ quote = None;+ } else {+ current.push(ch);+ }+ continue;+ }++ match ch {+ '\'' | '"' => quote = Some(ch),+ c if c.is_whitespace() => {+ if !current.is_empty() {+ tokens.push(std::mem::take(&mut current));+ }+ }+ _ => current.push(ch),+ }+ }++ if !current.is_empty() {+ tokens.push(current);+ }++ tokens+}++fn command_basename(token: &str) -> &str {+ token.rsplit(['/', '\\']).next().unwrap_or(token)+}++fn is_env_assignment(token: &str) -> bool {+ token+ .split_once('=')+ .is_some_and(|(name, _)| !name.is_empty() && !name.contains('/') && !name.contains('\\'))+}++fn shell_script_arg(tokens: &[String], start: usize) -> Option<&str> {+ let mut idx = start;+ while idx < tokens.len() {+ let token = tokens[idx].as_str();+ if token == "-c" {+ return tokens.get(idx + 1).map(String::as_str);+ }+ if token.starts_with('-')+ && !token.starts_with("--")+ && token.contains('c')+ && token.len() > 2+ {+ return tokens.get(idx + 1).map(String::as_str);+ }+ idx += 1;+ }+ None+}++fn delegated_env_command(tokens: &[String]) -> Option<Vec<String>> {+ let mut idx = 1;+ while idx < tokens.len() {+ let token = tokens[idx].as_str();+ if token == "-S" || token == "--split-string" {+ return Some(+ tokens+ .get(idx + 1)+ .map_or_else(Vec::new, |script| shell_tokens(script)),+ );+ }+ if let Some(script) = token.strip_prefix("-S").filter(|script| !script.is_empty()) {+ return Some(shell_tokens(script));+ }+ if let Some(script) = token.strip_prefix("--split-string=") {+ return Some(shell_tokens(script));+ }+ if matches!(+ token,+ "-u" | "--unset"+ | "-C"+ | "--chdir"+ | "-P"+ | "-a"+ | "--argv0"+ | "--block-signal"+ | "--default-signal"+ | "--ignore-signal"+ ) {+ idx += 2;+ continue;+ }+ if token.starts_with('-') {Candidate b71d8f3f089154d80f8f865d2dcc9dd541807417a77a2c810f54f4cba6dd7e73 · Fix 4808cf98c778ce2934403843ddea52e7b6e15b291845171eb9ce903a72f2d0a6
Releases
Advisory references