Skip to content

Two-phase rm detection to eliminate flag-ordering bypasses #2707

@Fieldnote-Echo

Description

@Fieldnote-Echo

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.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions