Bash tool: injected grep() shell function kills the whole tool shell on no-match — compound commands silently truncate

Open 💬 0 comments Opened Jun 10, 2026 by jokdonohue

Environment

  • Claude Code 2.1.170 (native install, auto-updating)
  • macOS (Darwin 25.0.0), default shell /bin/bash
  • claude.ai subscription auth

Summary

The Bash tool's shell carries an injected grep shell function that re-execs the claude binary as embedded ugrep. When that function runs and finds no match (and in some pipeline/subshell uses), it terminates the entire tool shell, not just the grep. Everything after it in a compound command is silently dropped and the tool returns exit 1 with truncated output. || fallbacks do not fire because the shell itself dies.

The dangerous part is the failure shape: a no-match grep followed by nothing looks identical to "the command ran and found nothing." An agent doing shell forensics reads absence of output as absence of matches. In my case this masked audit results several times in one session before I caught it.

Minimal repro

Ask Claude to run each of these as a single Bash tool command:

grep -c zzz_no_match_zzz ~/.bashrc; echo "alive rc=$?"

Actual: prints 0 (grep's count), then the shell dies. alive never prints. Tool result is exit 1 with truncated output.
Expected: 0, then alive rc=1.

Controls that isolate the cause:

false; echo "alive rc=$?"                                   # survives -> not errexit
/usr/bin/grep -c zzz_no_match_zzz ~/.bashrc; echo "alive"   # survives -> function is the cause
unset -f grep; grep -c zzz ~/.bashrc; echo "alive"          # survives -> removing it cures

Also reproduced: grep -rln <pat> <dir> | head -20; echo "---" dies after the pipeline even when there were matches, and one case died despite an || echo guard on the grep — consistent with the shell being killed rather than a nonzero status propagating.

The injected function

type -a grep inside the tool shell:

grep is a function
grep ()
{
    local _cc_a;
    for _cc_a in "$@";
    do
        case "$_cc_a" in
            -*-filter* | -*-pager* | -*-view* | -*-format-open* | -*-config* | ---* | -@* | -*-save-config*)
                command grep "$@";
                return
            ;;
        esac;
    done;
    local _cc_bin="${CLAUDE_CODE_EXECPATH:-}";
    [[ -x $_cc_bin ]] || _cc_bin=/Users/<user>/.local/bin/claude;
    if [[ ! -x $_cc_bin ]]; then
        command grep "$@";
        return;
    fi;
    if [[ -n $ZSH_VERSION ]]; then
        ARGV0=ugrep "$_cc_bin" -G --ignore-files --hidden -I --exclude-dir=.git --exclude-dir=.svn --exclude-dir=.hg --exclude-dir=.bzr --exclude-dir=.jj --exclude-dir=.sl "$@";
    else
        if [[ "$OSTYPE" == "msys" ]] || [[ "$OSTYPE" == "cygwin" ]] || [[ "$OSTYPE" == "win32" ]]; then
            ARGV0=ugrep "$_cc_bin" -G --ignore-files --hidden -I --exclude-dir=.git --exclude-dir=.svn --exclude-dir=.hg --exclude-dir=.bzr --exclude-dir=.jj --exclude-dir=.sl "$@";
        else
            if [[ $BASHPID != $$ ]]; then
                exec -a ugrep "$_cc_bin" -G --ignore-files --hidden -I --exclude-dir=.git --exclude-dir=.svn --exclude-dir=.hg --exclude-dir=.bzr --exclude-dir=.jj --exclude-dir=.sl "$@";
            else
                ( exec -a ugrep "$_cc_bin" -G --ignore-files --hidden -I --exclude-dir=.git --exclude-dir=.svn --exclude-dir=.hg --exclude-dir=.bzr --exclude-dir=.jj --exclude-dir=.sl "$@" );
            fi;
        fi
    fi
}
grep is /usr/bin/grep

Possibly relevant: SHELLOPTS in the tool shell shows braceexpand:hashall:interactive-comments:monitor:onecmd — the anomalous onecmd (set -t) may be interacting with the function's exec/subshell paths, though false; echo surviving says onecmd alone is not the killer.

Scope note: the function is not exported, so hook commands and bash script.sh invocations are unaffected. Only inline Bash tool commands hit this.

USE_BUILTIN_RIPGREP=0 at session launch does not disable the function (type -t grep still returns function), and the dotfiles contain no set -e, traps, or grep aliases/functions.

Impact

  • Silent data loss in compound commands: results that exist are never produced, and the agent has no signal that later segments did not run.
  • The exit-1-with-truncated-output shape misattributes easily (sandbox kill, permission denial, timeout), so agents burn time on wrong theories.

Workarounds (all verified)

  • command grep or /usr/bin/grep in compound commands
  • Prefix unset -f grep;
  • End commands with a sentinel echo and treat its absence as "shell died," not "no matches"

View original on GitHub ↗