Problem
The rm -rf detection pattern (DET_EXEC_DESTRUCT_RM_RF) in both pattern.py and policy_rails.py requires flags to appear immediately after rm\s+. This misses two bypass classes:
Intervening flags:
rm --no-preserve-root -rf / # MISS
rm --no-preserve-root --recursive --force / # MISS
Post-argument flags:
rm / -rf # MISS
rm /etc -r -f # MISS
GNU coreutils rm (tested: v9.10) accepts flags after positional arguments by default. This is standard GNU getopt behavior when POSIXLY_CORRECT is unset, which is the case on virtually all mainstream Linux distributions (Ubuntu, Debian, Fedora, Arch, RHEL). When POSIXLY_CORRECT is set, -rf after a positional argument is treated as a filename — but this is not the default anywhere relevant.
The has_recursive_force variable in policy_rails.py (line 87-93) gates the catastrophic-delete rail, so the rail shares the same blind spot.
Source: pattern.py lines 73-76, policy_rails.py lines 87-93.
Threat model
An LLM emitting a tool call to execute a shell command. The command text is the executable corpus scanned by both analyzers. The bypass requires no encoding tricks or indirect dispatch — just a flag ordering that GNU rm accepts normally.
Decision rationale
A two-phase imperative check (anchor on \brm\b, then scan the remainder for recursive+force flags independently) eliminates all flag-ordering bypasses without introducing new false positives. The shell operator boundary ([^;|&]*) prevents cross-command matching. The imperative approach is preferred over regex lookaheads for readability and independent testability.
Proposed fix
Create defense_in_depth/_detection_helpers.py (not utils.py — that module's scope is extraction/normalization):
def _is_rm_rf(text: str) -> bool:
"""Detect rm with both recursive and force flags in any position."""
match = re.search(r"\brm\b([^;|&]*)", text, re.IGNORECASE)
if not match:
return False
remainder = match.group(1)
has_recursive = bool(
re.search(r"(?:-[^\s]*[rR]|--recursive)\b", remainder, re.IGNORECASE)
)
has_force = bool(
re.search(r"(?:-[^\s]*f|--force)\b", remainder, re.IGNORECASE)
)
return has_recursive and has_force
PatternSecurityAnalyzer: Call _is_rm_rf as a pre-loop check in security_risk() with early return to HIGH. Remove DET_EXEC_DESTRUCT_RM_RF from the compiled pattern list. Log the detector ID for telemetry.
PolicyRailSecurityAnalyzer: Replace has_recursive_force with _is_rm_rf(seg). The catastrophic-delete rail gains the fix automatically. sudo rm --no-preserve-root -rf / is caught because \brm\b matches within sudo rm ....
Test plan
Helper function:
rm -rf /, rm -Rf /, rm -r -f /, rm --recursive --force /, rm --force --recursive / — detect
rm --no-preserve-root -rf /, rm / -rf, rm /etc -r -f — detect (currently missed)
sudo rm --no-preserve-root -rf / — detects
rm -r /tmp/file (no force), rm -f file (no recursive), rm file (plain) — NOT detect
rm -r /tmp; echo -f — NOT detect (shell operator boundary)
Integration: Both analyzers return HIGH for all bypass variants. Catastrophic-delete rail fires for critical paths with bypass variants.
Regression: All current rm tests continue to pass.
Out of scope
- Shell quoting bypasses (
r"m" -rf /) — requires shell parser
- Variable indirection (
x=rm; $x -rf /) — requires sandboxing
- New destructive-command patterns — covered by expanded pattern coverage issue
Context
Identified in adversarial security review (2026-04-03) of PR #2472. Priority 2 in the red-team fix list.
Problem
The
rm -rfdetection pattern (DET_EXEC_DESTRUCT_RM_RF) in bothpattern.pyandpolicy_rails.pyrequires flags to appear immediately afterrm\s+. This misses two bypass classes:Intervening flags:
Post-argument flags:
GNU coreutils
rm(tested: v9.10) accepts flags after positional arguments by default. This is standard GNU getopt behavior whenPOSIXLY_CORRECTis unset, which is the case on virtually all mainstream Linux distributions (Ubuntu, Debian, Fedora, Arch, RHEL). WhenPOSIXLY_CORRECTis set,-rfafter a positional argument is treated as a filename — but this is not the default anywhere relevant.The
has_recursive_forcevariable inpolicy_rails.py(line 87-93) gates the catastrophic-delete rail, so the rail shares the same blind spot.Source:
pattern.pylines 73-76,policy_rails.pylines 87-93.Threat model
An LLM emitting a tool call to execute a shell command. The command text is the executable corpus scanned by both analyzers. The bypass requires no encoding tricks or indirect dispatch — just a flag ordering that GNU rm accepts normally.
Decision rationale
A two-phase imperative check (anchor on
\brm\b, then scan the remainder for recursive+force flags independently) eliminates all flag-ordering bypasses without introducing new false positives. The shell operator boundary ([^;|&]*) prevents cross-command matching. The imperative approach is preferred over regex lookaheads for readability and independent testability.Proposed fix
Create
defense_in_depth/_detection_helpers.py(notutils.py— that module's scope is extraction/normalization):PatternSecurityAnalyzer: Call
_is_rm_rfas a pre-loop check insecurity_risk()with early return toHIGH. RemoveDET_EXEC_DESTRUCT_RM_RFfrom the compiled pattern list. Log the detector ID for telemetry.PolicyRailSecurityAnalyzer: Replace
has_recursive_forcewith_is_rm_rf(seg). The catastrophic-delete rail gains the fix automatically.sudo rm --no-preserve-root -rf /is caught because\brm\bmatches withinsudo rm ....Test plan
Helper function:
rm -rf /,rm -Rf /,rm -r -f /,rm --recursive --force /,rm --force --recursive /— detectrm --no-preserve-root -rf /,rm / -rf,rm /etc -r -f— detect (currently missed)sudo rm --no-preserve-root -rf /— detectsrm -r /tmp/file(no force),rm -f file(no recursive),rm file(plain) — NOT detectrm -r /tmp; echo -f— NOT detect (shell operator boundary)Integration: Both analyzers return
HIGHfor all bypass variants. Catastrophic-delete rail fires for critical paths with bypass variants.Regression: All current
rmtests continue to pass.Out of scope
r"m" -rf /) — requires shell parserx=rm; $x -rf /) — requires sandboxingContext
Identified in adversarial security review (2026-04-03) of PR #2472. Priority 2 in the red-team fix list.