Inline-shell rm check (2.1.288) asks in bypass mode on bash -c $'...' scripts that contain no rm

Status Open
Reported on v2.1.288
Maintainer reply None cached
Activity 3 comments · opened Oct 3, 2026

Summary

Since 2.1.288, a bash -c script written with ANSI-C quoting ($'...') that expands a variable inside a nested interpreter's code string (python3 -c "...", node -e "...") is stopped with:

This command passes a shell -c script that runs rm, and Claude Code could not check the script for dangerous removals. Approve only if you have read the script.

The script contains no rm. The prompt appears even with --dangerously-skip-permissions / bypassPermissions mode, and it cannot be approved by a permission rule. 2.1.287 runs the same commands without asking.

This looks like the same family as #99249 (which covers zsh -c), but this one is plain bash -c on Linux, and it fires in bypass mode.

Minimal repro

No hooks, no settings, no project:

cd /tmp
claude -p --dangerously-skip-permissions --setting-sources project --output-format json \
  "Use the Bash tool exactly once to run this exact command verbatim: bash -c \$'python3 -c \"print(\$HOME)\"'"

On 2.1.288 the result has permission_denials of length 1, and the tool result is the message quoted above. On 2.1.287 the command runs and prints the home directory.

Results

Each row is one Bash tool call in bypass mode. All on 2.1.288 unless stated otherwise.

| Command | Result |
|---|---|
| bash -c $'python3 -c "print($HOME)"' | asks |
| bash -c $'S=1; python3 -c "print($S)"' | asks |
| bash -c $'S=1; node -e "console.log($S)"' | asks |
| bash -c $'S=/tmp; python3 -c "print(\"$S\")"' | asks |
| bash -c $'S=/tmp; python3 -c "print(\'$S\')"' | asks |
| bash -c $'S=1; python3 -c "print($S)"' on 2.1.287 | runs |
| bash -c 'S=1; python3 -c "print($S)"' (plain single quotes) | runs |
| bash -c 'S=/tmp; python3 -c "print('\''$S'\'')"' (plain single quotes) | runs |
| bash -c $'python3 -c "print(\'x\')"' (no variable) | runs |
| bash -c $'S=/tmp; sh -c "echo \'$S\'"' (nested shell, not interpreter) | runs |
| bash -c $'echo one\necho two' | runs |

So the trigger seems to need all three of these:

  • the -c script is ANSI-C quoted ($'...')
  • it contains a nested interpreter code string (python3 -c, node -e)
  • that string expands a shell variable

Plain single-quoting the same script avoids it.

Why it matters

printf '%q' emits $'...' for any string with a newline. So a PreToolUse hook that returns updatedInput wrapping the original command as <wrapper> -- /bin/bash -c "$(printf %q "$cmd")" (for example, to run each command inside a resource-limited scope) produces exactly this shape for any multi-line command. After upgrading to 2.1.288, sessions and subagents running in bypassPermissions mode began stopping on ordinary multi-line python3 -c commands with the "runs rm" prompt. Unattended subagents block until someone answers.

Expected

  • A script with no rm is not reported as one that "runs rm". If the check can't parse the script, the message should say so, not claim an rm that isn't there.
  • $'...' is ordinary bash quoting. Variable expansion inside a nested interpreter's code string should not make the script uncheckable.
  • bypassPermissions should skip this ask, or the docs should say that this safety check is immune to bypass mode.

Environment

  • Claude Code 2.1.288 (native install); regression from 2.1.287
  • Linux (WSL2), GNU bash 5.2.21, Python 3.12.3

Not claimed

  • Which internal branch fires. In the 2.1.288 bundle, scripts containing $' are routed to the inline-script check. Where that check can't finish, it returns this fixed "runs rm and could not be checked" reason (circuitBreaker: "dangerousRemoval", classifierApprovable: false) whether or not the script contains rm.

Related: #99249, #96300.

View original on GitHub ↗

3 Comments

indigoae · 11 days ago

More triggers for the same check, on macOS (Claude Code 2.1.288, bypassPermissions). These need no $'...' quoting and no nested interpreter, so plain single quotes don't always avoid it. 2.1.287 runs every command below without asking.

  1. A variable used as a command name after &&:

``bash
B=echo; true && $B x bash -c "echo a"
`
Asks. The same command with
true; in place of true &&` runs.

  1. $(...) inside a single-quoted sh -c script, with a variable argument:

``bash
L=/tmp/x; true && sh -c 'echo "$(date)" >> "$0"' $L
`
Asks. Without the
$(date) it runs: L=/tmp/x; true && sh -c 'echo "now" >> "$0"' $L`

  1. The same shape with a different $(...), inside a git repo:

``bash
D=.; true && bash -c 'echo "$(git diff --name-only)" "$0"' $D
`
Asks. The same script saved to
script.sh as echo "$(git diff --name-only)" "$1" and run as D=.; true && bash script.sh $D` runs.

A -c script with no $(...) passes even with mapfile and process substitution: D=.; true && bash -c 'mapfile -t F < <(git -C "$0" diff --name-only); for f in "${F[@]}"; do echo "$f"; done' $D runs.

The pairs differ only in && vs ;, in the presence of a $(...), or in passing the script with -c rather than as a file. The trigger looks like a split of the command line that ignores quoting and treats a $WORD that follows a $(...), or that sits behind a variable whose value is uncertain, as a possible command name that could be rm.

Under a PermissionRequest hook that denies prompts raised by subagents, each of these is refused outright, so unattended subagents fail on them.

Related: #99249 (zsh -c).

Martin-Stangl · 8 days ago

Two more triggers of the same check, both without $'...' and without rm. Linux, Claude Code 2.1.288 and 2.1.289 (the check is unchanged in 2.1.289). Each line is one Bash tool call:

  1. \n inside a quoted printf format:

for v in a b; do bash -c 'printf "%s\n" "$0"' "$v"; done
Asks. The same command with "%s" in place of "%s\n" runs.

  1. A $-word argument right after a ) that is inside quoted code:

for f in a.log b.log; do grep -E '(warn|error)' "$f"; bash -c 'echo next'; done
Asks. The same command with a plain file name in place of "$f" runs. awk '{print $1}' "$f" and python3 -c '...print(x)' "$x" behave the same way.

This matches the cause suggested in the comment above. Reading the 2.1.288/2.1.289 bundle, the "runs rm" test splits the statement a second time with the quote characters removed, at ; & | ( ) { } !, backtick, $( and newline. A segment that starts with a word beginning with $ counts as a possible rm. Before that split, the statement-level path also turns every \n, \t, \r escape into a real newline, inside quoted strings too. So "%s\n" "$0" becomes a segment "$0", and (warn|error)' "$f" becomes a segment "$f". In both cases the shell can never run that word as a command.

Suggested direction: apply the escape-to-newline step only where the shell creates a newline ($'...'), and run the "$ word in command position" test on a quote-aware split. A real rm (for example bash -c "rm -rf $v") is still caught that way.

Moving the script to a file avoids the prompt. That is not a fix: it only bypasses the check. The prompt cannot be approved by a rule, so it blocks unattended runs.

willmcginnis · 8 days ago

A similar false-positive prompt occurred on Claude Code 2.1.292 (macOS, bypassPermissions mode) with the command below. It came from a background general-purpose subagent, which matters for the unattended-runs point Martin-Stangl raised above.

The subagent submitted this command as one Bash tool call:

for v in 4294967295 4294967296 18446744073709551615; do bash -c 'x=$1; if [[ $x =~ ^[0-9]+$ && $x -le 4294967295 ]]; then echo "ACCEPT $x -> $(printf %08x "$x" 2>&1)"; else echo "refuse $x"; fi' _ $v; done

There is no rm anywhere in it. Although the session was in bypass permissions mode, a modal appeared in the main session's UI reading: "This shell -c script runs rm and could not be checked. Do you want to proceed? 1. Yes 2. No". The subagent stayed blocked until the prompt was dismissed by hand (Esc).

I have not reproduced this occurrence or reduced it to a minimal trigger. Applying the split Martin-Stangl describes above, the likely segment is the && $x -le ... inside [[ ... ]], which starts with a $ word, the same pattern as indigoae's true && $B case. The command uses no $'...', no nested interpreter, no printf "%s\n" and no outer &&.

Static inspection of the installed 2.1.292 binary finds the same short prompt text ("This shell -c script runs rm and could not be checked"), behavior: "ask", decisionReason.type: "safetyCheck" and inline_shell_unchecked. The short reason is what the modal shows, whether or not the script contains rm. This supports a connection to the check discussed here but does not establish which branch this call took.

Related: #99894 (also bypass mode, macOS, 2.1.291), #99249, #99630.