Bash tool omits `< /dev/null` when user command has any `<`/`<<` redirect, leaking parent stdin to children
Summary
The Bash tool's outer invocation conditionally omits the safety < /dev/null redirect when the user-supplied command contains any stdin redirection (< or <<). This is presumably intended to preserve a user-requested stdin, but it has the side effect that subsequent commands in the same script, plus any children they spawn, inherit the bash process's own stdin — which in agent / SDK contexts is the IPC channel back to the harness (a unix-domain socket). Children that block on a read(2) (e.g. an interactive y/N confirmation prompt) then hang forever.
In a human-driven Claude Code session the inherited stdin is a TTY, so the prompt is at worst rude. In Claude Agent SDK / stream-json sessions the inherited stdin is an open-but-silent socket — no EOF ever arrives — and the bash subprocess wedges indefinitely.
Reproduction
Inside any active Claude Code / Agent SDK session, run two Bash tool calls back to back and inspect each one's outer cmdline.
Call A — no inner redirect:
pid=$$; ps -o cmd= -p $pid
Resulting outer cmdline (note the trailing < /dev/null wrapping the eval):
/bin/bash -c source /home/.../snapshot-bash-….sh 2>/dev/null || true && \
shopt -u extglob 2>/dev/null || true && \
eval 'pid=$$; ps -o cmd= -p $pid' < /dev/null && pwd -P >| /tmp/claude-….-cwd
Call B — one inner redirect (< or <<):
cat > /tmp/x << 'INNER'
hello
INNER
echo "stdin: $(readlink /proc/$$/fd/0)"
echo "child stdin: $(bash -c 'readlink /proc/$$/fd/0')"
Resulting outer cmdline (note: < /dev/null is gone):
/bin/bash -c source /home/.../snapshot-bash-….sh 2>/dev/null || true && \
shopt -u extglob 2>/dev/null || true && \
eval 'cat > /tmp/x << ...' && pwd -P >| /tmp/claude-….-cwd
And the script's own output confirms the inheritance:
stdin: socket:[4184551]
child stdin: socket:[4184551]
Both the outer bash and any child it spawns inherit a unix-domain socket as fd 0. There is no EOF on this socket; a child that blocks on read(2) will wait forever.
Real-world impact
The shape that triggered the original investigation: an agent following a "capture verbatim output, then tear down the test env" runbook writes a single Bash call along these lines —
cat > /tmp/step-1.txt << 'INNER'
…verbatim slash-command output…
INNER
cat > /tmp/step-2.txt << 'INNER'
…verbatim agent reply…
INNER
echo "Outputs saved."
brev delete gr-test-manager 2>&1 | tail -3
brev delete (no --yes / --force available) prints a y/N confirmation prompt for an existing instance and read(2)s stdin. Because the heredocs at the top of the script suppressed the outer < /dev/null, brev inherits the SDK's IPC socket and hangs. The SDK's 10-minute Bash timeout fires SIGTERM at bash, but bash is in Ss waiting for its foreground pipeline (brev | tail) to complete, so the signal is queued until the pipeline finishes — which never happens. No SIGKILL escalation, no direct kill of brev. We observed three independent sessions wedge on this exact shape in a single day; each took an operator with sudo to recover.
The same kernel-stack fingerprint shows up on the hung brev:
[<0>] unix_stream_data_wait+0x121/0x1b0
[<0>] unix_stream_read_generic+0x345/0xae0
[<0>] unix_stream_recvmsg+0x8d/0xa0
[<0>] sock_recvmsg+0xde/0xf0
…
brev is just an example — the same pattern hits apt install (without -y), gh interactive flows, npm uninstall confirmation, anything that prompts.
Why per-command redirects don't justify dropping the outer wrap
cat > file << 'INNER' … INNER redirects bash's stdin only for the duration of cat. Once cat returns, control returns to the outer bash whose own fd 0 is unchanged. The next command inherits the outer fd 0, which — without the omitted wrap — is whatever the SDK passed. A whole-script stdin redirect (bash -c '…' < file) is the only inner shape that would actually conflict with an outer < /dev/null, and even then the outer wrap is what makes the safety guarantee predictable.
What would fix it
Either of:
- Always pass
< /dev/nullat the outer bash invocation, regardless of inner redirects. Inner per-command redirects continue to work — they're applied per-command after the outer redirect. - Force-close fd 0 at the outer bash if it's a socket / non-EOF-reachable handle (i.e. specifically defend against the SDK-IPC inheritance case even when a human invocation might want stdin).
(1) is simpler and matches the unsurprising semantics agents already assume. If there's a real use case for a Bash tool call that wants to read parent stdin, that should be opt-in (a tool-call parameter), not auto-detected from the presence of a redirect anywhere in the script.
Version
Observed on Claude Code 2.1.126 (binary: @anthropic-ai/claude-agent-sdk-linux-x64 0.2.126), Linux x86_64. Used via the Agent SDK / ACP, but the heuristic lives in the shared claude binary so CLI sessions are affected too — just less catastrophically, because a TTY parent stdin lets a human type y and unblock.
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗