[Bug] Bash tool stdin redirect detection fails on quoted arguments containing `<`
Bug Description
Bash tool: quote-unaware stdin-redirect detection leaves stdin open, hangs commands via shell dotfiles
Summary
The Bash tool decides whether to append < /dev/null to the eval'd command by testing the raw command string with a regex that does not understand quoting. A literal < inside a quoted argument — for example a Co-Authored-By: Name <email> trailer in git commit -m — matches the regex, so the tool skips < /dev/null and the child inherits the harness's open unix socket as stdin. Any program in the command chain that reads stdin then blocks until the 2-minute timeout, and the intended command never runs.
In my setup the blocking program comes from a common dotfiles pattern: a cd function that runs ls (aliased to eza) after every directory change. eza blocks on read() when stdin is an open socket. Every cd <repo> && git commit -m "... <..." therefore hangs for 2 minutes with no commit created. Claude Code's own commit-trailer convention (Co-Authored-By: Claude <noreply@anthropic.com>) triggers the bug, so the default commit flow hangs for any user with an auto-ls cd wrapper.
Environment
- Claude Code 2.1.223 (native binary, macOS arm64)
- macOS 27.0 (Darwin 27.0.0)
- Wrapper shell:
/bin/zsh(user login shell: fish) eza0.x via Homebrew, wired into zsh dotfiles asls
Reproduction
Dotfiles (captured into the shell snapshot):
cd () { builtin cd "$@" && ls }
ls () { eza --group-directories-first --classify --icons $@ }
Then let the agent run, via the Bash tool:
cd /path/to/repo && git commit --allow-empty -m "msg" -m "Co-Authored-By: Claude <noreply@anthropic.com>"
Result: hangs ~2 minutes, exits 143/144, no commit. git never starts.
Minimal trigger without git — any command string containing a space followed by <, plus a cd:
cd /some/dir && basename "a <b"
Observed process state during the hang
claude (parent)
└─ /bin/zsh -c source <snapshot> ... eval 'cd ... && git commit ...' (alive, waiting)
└─ eza --group-directories-first --classify --icons (blocked in read())
lsofon the hung wrapper: fd 0 is an open unix socket.sampleon theezaprocess: single thread parked inread().- No
gitprocess ever appears.
Root cause (from the 2.1.223 binary)
The wrapper builder decides stdin handling from the raw command text:
Identifiers below are renamed for readability (the shipped bundle is minified):
// stdin-redirect detection — quote-unaware
function hasStdinRedirect(cmd){ return /(?:^|[\s;&|])<(?![<(])\s*\S+/.test(cmd) }
// heredoc detection
function hasHeredoc(cmd){ ... return /<<-?\s*(?:(['"]?)(\w+)\1|\\(\w+))/.test(cmd) }
// "command does not read stdin"
function stdinUnused(cmd){ if(hasHeredoc(cmd)) return false; if(hasStdinRedirect(cmd)) return false; return true }
// append " < /dev/null" only when stdinUnused is true
function buildEvalTarget(cmd, unused){ ...; return unused ? `${quoted} < /dev/null` : quoted }
The stdin-redirect regex runs on the whole command string without parsing quotes. -m "fix parser <" or -m "Co-Authored-By: Claude <noreply@anthropic.com>" contains < followed by a non-space character, so the regex matches, the command is classified as reading stdin, and < /dev/null is dropped. The child then inherits the harness socket as stdin.
Note the binary already embeds tree-sitter-bash and uses it elsewhere for command analysis (heredoc_redirect, file_redirect node handling in the permission path); only this stdin decision falls back to a regex.
Verification matrix (same session, fd 0 of the spawned shell via lsof -a -p $$ -d 0)
| Command string | fd 0 |
| --- | --- |
| plain command | /dev/null |
| basename "a<b" (no space before <) | /dev/null |
| basename "a <b" (space before <) | unix socket |
| git -C repo commit -m "msg <" (no cd) | unix socket — but succeeds, git ignores stdin |
eza behaviour verified outside the harness: exits immediately with stdin /dev/null or closed; blocks indefinitely (timeout 124) with stdin a held-open pipe.
Rebuilding the same argv with printf '\x3c' so no literal < appears in the command text makes the identical git invocation succeed — confirming the decision keys on the command text, not on what the shell executes.
Impact
- Any quoted argument containing
<disables the< /dev/nullguard. - Combined with a stdin-reading program early in the chain (auto-ls
cdwrappers are widespread in dotfiles; fzf/zoxide hooks are similar candi…
Note: Content was truncated.
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗