Inline-shell rm check (2.1.288) asks in bypass mode on bash -c $'...' scripts that contain no rm
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
-cscript 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
rmis not reported as one that "runs rm". If the check can't parse the script, the message should say so, not claim anrmthat 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 containsrm.
Related: #99249, #96300.
3 Comments
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.&&:``
bash
`B=echo; true && $B x bash -c "echo a"
true;Asks. The same command with
in place oftrue &&` runs.$(...)inside a single-quotedsh -cscript, with a variable argument:``
bash
`L=/tmp/x; true && sh -c 'echo "$(date)" >> "$0"' $L
$(date)Asks. Without the
it runs:L=/tmp/x; true && sh -c 'echo "now" >> "$0"' $L`$(...), inside a git repo:``
bash
`D=.; true && bash -c 'echo "$(git diff --name-only)" "$0"' $D
script.shAsks. The same script saved to
asecho "$(git diff --name-only)" "$1"and run asD=.; true && bash script.sh $D` runs.A
-cscript with no$(...)passes even withmapfileand process substitution:D=.; true && bash -c 'mapfile -t F < <(git -C "$0" diff --name-only); for f in "${F[@]}"; do echo "$f"; done' $Druns.The pairs differ only in
&&vs;, in the presence of a$(...), or in passing the script with-crather than as a file. The trigger looks like a split of the command line that ignores quoting and treats a$WORDthat follows a$(...), or that sits behind a variable whose value is uncertain, as a possible command name that could berm.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).Two more triggers of the same check, both without
$'...'and withoutrm. 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:\ninside 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.
$-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"andpython3 -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 possiblerm. Before that split, the statement-level path also turns every\n,\t,\rescape 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 realrm(for examplebash -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.
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-purposesubagent, which matters for the unattended-runs point Martin-Stangl raised above.The subagent submitted this command as one Bash tool call:
There is no
rmanywhere 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'strue && $Bcase. The command uses no$'...', no nested interpreter, noprintf "%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"andinline_shell_unchecked. The short reason is what the modal shows, whether or not the script containsrm. 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.