Bash-tool grep shadow (2.1.245): present string returns exit 1 — measured repro, embedded ugrep 7.8.4, session-scoped workaround

Status Open
Reported on v2.1.245
Maintainer reply None cached
Activity 0 comments · opened Aug 25, 2026

Related: #88279, #83326, #80103 report the same silent-narrowing class; #69736 requests the opt-out. Filing separately to pin a version-stamped measurement on the current release — exit codes, embedded-engine identity, and a session-scoped workaround. Happy to have this folded into #88279 if triage prefers.

Environment

  • Claude Code 2.1.245 (npm install of @anthropic-ai/claude-code), native claude.exe, Linux x86_64, bash
  • Embedded engine, asked directly (the shadow re-execs the CLI under argv0=ugrep):
$ (exec -a ugrep <path-to-claude-binary> --version) | head -1
ugrep 7.8.4 x86_64-pc-linux-gnu +sse2; -P:pcre2jit; -z:zlib,bzip2,zstd,brotli,7z,tar/pax/cpio/zip

What happens

Every Bash-tool shell sources a session snapshot (~/.claude/shell-snapshots/snapshot-bash-*.sh) that defines grep as a shell function. Unless the arguments contain one of a short list of ugrep-only flags (those route to command grep), the function re-execs the claude binary as ugrep with this prepended flag set:

-G --ignore-files --hidden -I --exclude-dir=.git --exclude-dir=.svn --exclude-dir=.hg --exclude-dir=.bzr --exclude-dir=.jj --exclude-dir=.sl

--ignore-files silently drops matches in gitignored files; -I silently drops matches in binary-classified files. Nothing on the default path discloses either.

<details>
<summary>Injected function as dumped by <code>type grep</code> in a live Bash-tool shell</summary>

grep ()
{
    local _cc_a;
    for _cc_a in ${1+"$@"};
    do
        case "$_cc_a" in
            -*-filter* | -*-pager* | -*-view* | -*-format-open* | -*-config* | ---* | -@* | -*-save-config* | -[Zz]* | -[!-]*[Zz]* | --null | --null-data)
                command grep ${1+"$@"};
                return
            ;;
        esac;
    done;
    local _cc_bin="${CLAUDE_CODE_EXECPATH:-}";
    [[ -x $_cc_bin ]] || _cc_bin=$HOME/.local/bin/claude;
    if [[ ! -x $_cc_bin ]]; then
        command grep ${1+"$@"};
        return;
    fi;
    ( 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 ${1+"$@"} )
}

(zsh/Windows branches elided; they use ARGV0=ugrep instead of exec -a.)
</details>

Repro

mkdir repo && cd repo && git init -q
printf 'The NEEDLE is in the visible file.\n'    > visible.txt
printf 'The NEEDLE is in the gitignored file.\n' > secret.txt
printf 'secret.txt\n' > .gitignore
python3 -c 'open("data.bin","wb").write(b"NEEDLE in binary file\x00\x01\x02trailing")'

Measured on 2.1.245, grep = the injected shadow, command grep = GNU grep 3.12:

| command | output | exit |
|---|---|---|
| command grep -rn NEEDLE . | visible.txt:1, secret.txt:1, data.bin: binary file matches | 0 |
| shadow flag set, same search | visible.txt:1 only | 0 |
| command grep -rn gitignored . | secret.txt:1 | 0 |
| shadow flag set, same search | (nothing) | 1 |

Expected

grep in the Bash tool behaves like grep — or the divergence is disclosed (a stderr notice, documentation, or the opt-out requested in #69736).

Actual

A string physically present in the repo returns exit 1 / empty output (gitignored file), indistinguishable from true absence; binary-classified files are silently skipped as a second channel (-I).

Notes

  • The shadow functions are not exported (no BASH_FUNC_* in the tool-shell env): child scripts get real grep; only top-level Bash-tool command lines are affected.
  • The snapshot also contains an rg shadow, but unlike grep/find it is guarded: if ! (unalias rg 2>/dev/null; command -v rg) >/dev/null 2>&1; then function rg {…}; fi — the embedded ripgrep is only a fallback for systems without rg (here type rg/usr/bin/rg, the real one). grep/find get no such guard and shadow even when the real binaries exist, although the less surprising pattern already exists in the same file. pkill is wrapped unconditionally.
  • Session-scoped workaround: append unset -f grep find 2>/dev/null || true to the end of the live ~/.claude/shell-snapshots/snapshot-bash-*.sh; the snapshot is re-sourced on every Bash-tool call and the last definition wins, restoring /usr/bin/grep and /usr/bin/find for the rest of the session. Verified in a fresh shell sourcing the snapshot: type grep goes from grep is a function to grep is /usr/bin/grep. Does not survive into new sessions.

View original on GitHub ↗