From 0f562118924822f0cee2e2ed502e8be312200e61 Mon Sep 17 00:00:00 2001 From: Lawyered Date: Mon, 16 Feb 2026 22:33:29 -0500 Subject: [PATCH] fix(security): block single-ampersand command chaining bypass --- src/security/policy.rs | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/src/security/policy.rs b/src/security/policy.rs index 66591c2..be70110 100644 --- a/src/security/policy.rs +++ b/src/security/policy.rs @@ -158,6 +158,25 @@ fn skip_env_assignments(s: &str) -> &str { } } +/// Detect a single `&` operator (background/chain). `&&` is allowed. +/// +/// We treat any standalone `&` as unsafe in policy validation because it can +/// chain hidden sub-commands and escape foreground timeout expectations. +fn contains_single_ampersand(s: &str) -> bool { + let bytes = s.as_bytes(); + for (i, b) in bytes.iter().enumerate() { + if *b != b'&' { + continue; + } + let prev_is_amp = i > 0 && bytes[i - 1] == b'&'; + let next_is_amp = i + 1 < bytes.len() && bytes[i + 1] == b'&'; + if !prev_is_amp && !next_is_amp { + return true; + } + } + false +} + impl SecurityPolicy { /// Classify command risk. Any high-risk segment marks the whole command high. pub fn command_risk_level(&self, command: &str) -> CommandRiskLevel { @@ -165,7 +184,7 @@ impl SecurityPolicy { for sep in ["&&", "||"] { normalized = normalized.replace(sep, "\x00"); } - for sep in ['\n', ';', '|'] { + for sep in ['\n', ';', '|', '&'] { normalized = normalized.replace(sep, "\x00"); } @@ -339,6 +358,12 @@ impl SecurityPolicy { return false; } + // Block background command chaining (`&`), which can hide extra + // sub-commands and outlive timeout expectations. Keep `&&` allowed. + if contains_single_ampersand(command) { + return false; + } + // Split on command separators and validate each sub-command. // We collect segments by scanning for separator characters. let mut normalized = command.to_string(); @@ -933,6 +958,14 @@ mod tests { assert!(p.is_command_allowed("ls || echo fallback")); } + #[test] + fn command_injection_background_chain_blocked() { + let p = default_policy(); + assert!(!p.is_command_allowed("ls & rm -rf /")); + assert!(!p.is_command_allowed("ls&rm -rf /")); + assert!(!p.is_command_allowed("echo ok & python3 -c 'print(1)'")); + } + #[test] fn command_injection_redirect_blocked() { let p = default_policy();