Bash tool: grep→ugrep shim exits 2 when one operand is missing even though another matched (GNU grep/POSIX -q exits 0)
Summary
The Bash tool's grep shim (the shell function installed via the shell snapshot, which execs $CLAUDE_CODE_EXECPATH -G with ARGV0=ugrep) returns exit status 2 when one operand file is missing, even though a line was selected in another operand. GNU grep returns 0 in that situation, and POSIX specifies that behaviour explicitly for -q:
If the -q option is specified, the exit status shall be zero if an input line is selected, even if an error was detected.
-s does not suppress it. The result is that a very common shell idiom — grepping a config file plus an optional *.local.* override — silently takes the wrong branch whenever an agent runs it, while a human running the identical line in their own shell gets the correct result.
Repro
Run these directly in Claude Code's Bash tool (not inside a bash script.sh — the shim is a shell function, so it does not apply in a non-interactive script, which is what makes this easy to misdiagnose):
mkdir -p /tmp/grepshim && echo needle > /tmp/grepshim/present.txt && rm -f /tmp/grepshim/missing.txt
grep -q needle /tmp/grepshim/present.txt /tmp/grepshim/missing.txt 2>/dev/null; echo "shim rc=$?"
command grep -q needle /tmp/grepshim/present.txt /tmp/grepshim/missing.txt 2>/dev/null; echo "GNU grep rc=$?"
grep -sq needle /tmp/grepshim/present.txt /tmp/grepshim/missing.txt; echo "shim -s rc=$?"
grep -q needle /tmp/grepshim/present.txt 2>/dev/null; echo "shim 1arg rc=$?"
Observed:
shim rc=2 <-- wrong
GNU grep rc=0
shim -s rc=2 <-- -s does not help
shim 1arg rc=0 <-- single operand is fine
Expected: the first line returns 0, matching command grep.
Operand order does not matter — GNU grep returns 0 whether the missing file is first or second.
Environment
- Claude Code 2.1.231
- Linux (Ubuntu, kernel 6.8)
- System grep: GNU grep 3.11 (
/usr/bin/grep) type grepresolves to the snapshot-installed shell function, not/usr/bin/grep
Why this matters
This is not cosmetic — it inverts a boolean that scripts branch on, and it fails silently. The real case I hit: a slash-command skill detects whether a repo enables a plugin with
grep -qh 'plugin@marketplace' .claude/settings.json .claude/settings.local.json 2>/dev/null
.claude/settings.local.json usually doesn't exist, so under the shim this always reported "not enabled". Every agent-run invocation of that command took the wrong code path and produced output in the wrong format. Nothing errored, nothing logged, and it was invisible to a human testing the same line by hand.
Any script doing grep -q <pattern> <file> <optional-override-file> is exposed — a widespread pattern for settings.local.json, .env.local, *.override.* and similar. Debugging it leads investigators to blame GNU grep or their own script (I did both) before thinking to run type grep.
Workaround
Read the operands through stdin, which bypasses the shim's operand handling and behaves identically under both engines:
cat .claude/settings.json .claude/settings.local.json 2>/dev/null | grep -q 'pattern'
Single-operand greps are also unaffected, so ORing separate invocations works too.
Suggested fix
Make the shim's exit status match GNU grep / POSIX: when -q is given and a line was selected, exit 0 regardless of per-file errors. More generally, a file-not-found error should not mask a successful match across the other operands.
Related
- #67361 — same shim, different failure mode (bare
execin subshell contexts).
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗