Bash tool grep()/find() shims: bare exec when BASHPID != $$ replaces the executing subshell — compound commands/loops silently truncate
Summary
The grep() and find() shell functions that Claude Code injects into the Bash tool shell (re-exec'ing the bundled ugrep/bfs) use a bare exec whenever BASHPID != $$, on the assumption that a differing BASHPID means "I'm in a disposable subshell". That assumption is wrong whenever the subshell is still responsible for executing the rest of a statement sequence: the exec replaces that shell mid-sequence, so everything after the first grep/find silently never runs — loops die after one iteration, trailing commands vanish, the exit code looks clean, and no error is emitted.
This is almost certainly the true root cause behind #66804 ("injected grep() shell function kills the whole tool shell … compound commands silently truncate", currently labeled duplicate/stale and attributed to no-match — the kill is actually match-independent). Related shim issue: #74143.
Environment
- Claude Code v2.1.170, native Linux build (
CLAUDE_CODE_EXECPATH=~/.local/share/claude/versions/2.1.170) - Pop!_OS (Ubuntu-based), bash
- Also reproduced by us on plain compound commands; a macOS report with the same symptom exists (#66804)
Minimal repro (in the Bash tool)
Any construct where the command sequence itself runs in a subshell:
# 1. Pipeline over a loop — loop dies after the FIRST iteration:
for f in *.md; do grep -c "x" "$f"; done | cat
# expected: one count per file; actual: one line total
# 2. Explicit subshell — "after" never prints:
( grep -c "x" README.md; echo after )
# 3. Command substitution — "more" never captured:
out=$(grep -c "x" README.md; echo more); echo "$out"
If a PreToolUse hook rewrites commands via updatedInput into a wrapped form like ( CMD ) 2>&1 | sed … (a common output-scrubbing pattern), then every compound command is affected, not just user-written subshells — that's how we hit it dozens of times before root-causing.
Root cause
type grep inside the tool shell shows the injected function; the relevant branch:
if [[ $BASHPID != $$ ]]; then
exec -a ugrep "$_cc_bin" -G --ignore-files --hidden -I ... "$@"; # ← bare exec
else
( exec -a ugrep "$_cc_bin" -G ... "$@" ); # protected
fi
find() has the identical pattern (exec'ing bundled bfs).
BASHPID != $$ does not imply the current shell is disposable: in ( grep …; echo after ), in each pipeline segment, and in $( … ), the subshell still owes execution of the remaining statements. The bare exec replaces it, the remaining statements are dropped, and the compound's exit status becomes grep's own — so a no-match case even surfaces as a mysterious exit 1 with half the output missing (which is presumably why #66804 read as "kills on no-match").
Why it's nasty
- Silent: rc is grep's rc, stderr is empty, output just… stops.
- The model (and the user) sees a truncated result and draws wrong conclusions — in our case agents reported "loop completed" over 1/17 files.
- Only file-arg/consuming greps in non-final positions are affected;
grepas the last command or as its own pipeline segment works, so it looks intermittent.
Suggested fix
Always use the protected form — wrap the exec in a subshell in both branches, or simply drop the exec optimization:
ARGV0=ugrep "$_cc_bin" -G ... "$@"
return $?
The saved fork is not worth silently corrupting compound-command semantics.
Workaround (for anyone else hitting this)
unset -f grep find at the start of the command (or, if you run a PreToolUse command-rewrite hook, prepend it inside your wrapper subshell). Plain PATH grep/find behave correctly.