Shell grep shim: inline `grep -v` returns an inverted exit code when output is suppressed (-q or >/dev/null) — answers a file-level question instead of line-level
Summary
Inside an interactive session, the grep shell-snapshot shim (theclaude-as-ugrep function in ~/.claude/shell-snapshots/snapshot-zsh-*.sh)
returns a wrong exit code in one specific, reproducible combination:
-vis present AND the output is suppressed — either via-q, or by redirecting stdout to/dev/null.
In that case the exit code answers a file-level question instead of a
line-level one: it returns 0 iff at least one input source contains zero
matching lines, else 1. Real grep returns 0 iff at least one line fails
to match.
This is silent, and it fails in both directions — false "no match" on non-empty
input, and false "match" on empty input. grep -q is the standard shell idiom
for "does this condition hold", so any agent- or user-typed inline conditional
built on grep -qv can silently invert.
I checked the existing ugrep shim issues (#81916, #84234, #76056, #77230,
#74143, #73718, #54394) — those are hangs, OOMs, regex-parse and--ignore-files pattern issues. I did not find one reporting an inverted exit
code, so filing separately.
Reproduction
printf 'PR1 MERGED\nPR2 none\nPR3 none\n' > /tmp/f
# 1. suppressed output + -v -> WRONG
grep -qvE 'MERGED|CLOSED' /tmp/f ; echo "shim = $?" # 1 <-- wrong
command grep -qvE 'MERGED|CLOSED' /tmp/f ; echo "truth = $?" # 0
# 2. same, no -q, just >/dev/null -> also WRONG (so it is not about -q)
grep -vE 'MERGED|CLOSED' /tmp/f >/dev/null ; echo "shim = $?" # 1 <-- wrong
command grep -vE 'MERGED|CLOSED' /tmp/f >/dev/null ; echo "truth = $?" # 0
# 3. identical command with output NOT suppressed -> CORRECT
grep -vE 'MERGED|CLOSED' /tmp/f ; echo "shim = $?" # prints 2 lines, exit 0
# 4. opposite direction, false PRESENCE on empty input
: > /tmp/e
grep -qv MERGED /tmp/e ; echo "shim = $?" # 0 <-- wrong
command grep -qv MERGED /tmp/e ; echo "truth = $?" # 1
# 5. the file-level question leaking out: two files, one with no match at all
printf 'a\nb\n' > /tmp/g
grep -qv MERGED /tmp/f /tmp/g ; echo "shim = $?" # 0 (because /tmp/g has no match)
command grep -qv MERGED /tmp/f /tmp/g ; echo "truth = $?" # 0
grep -qv MERGED /tmp/f ; echo "shim = $?" # 1 <-- wrong
Expected vs actual
- Expected: the shim's exit code matches
command grepfor identical
arguments and identical input. -q should only suppress output, never change
the verdict; redirecting stdout should never change it either.
- Actual: with
-vplus suppressed output, the exit code reports a
file-level "some input has no match" verdict.
Scope (measured, 480 cells against command grep as ground truth)
- 160
-vcells: 49 wrong. A single rule predicts all 160 with zero misses:
wrong iff -v AND output suppressed.
- 320 non-
-vcells: 0 wrong. Not implicated: `-c -l -L -n -i -w -m1 -o -E
-F, -q on its own, single-token vs |`-alternation patterns, matching
none/some/every line.
- Input source is NOT the variable. stdin pipe, a single file argument, and
multiple file arguments all behave identically. (I initially suspected "stdin
pipes are unreliable because the shim carries file-search flags" — that theory
is false.)
- Only
/dev/nulland-qtrigger it. stdout to a terminal, a pipe, a
regular file, or a closed fd all give correct exit codes.
- Child processes are unaffected, as expected for a shell function:
command -v grep is /usr/bin/grep with correct exit codes inside
bash script.sh, inside a harness-fired PreToolUse hook (verified by
registering a probe hook and reading its log), and — worth noting — inside
bash -c '...' and zsh -c '...' typed inline.
Workarounds
command grep and /usr/bin/grep both behave correctly. Note that \grep does
not escape the shim: in zsh a backslash suppresses aliases, not functions,
which makes it a misleading-looking workaround.
The shim's passthrough list (-*-filter*, -*-pager*, -*-view*,-*-format-open*, -*-config*, ---*, -@*, -*-save-config*, -[Zz]*,-[!-]*[Zz]*, --null, --null-data) does reach the real binary, so adding-Z incidentally fixes the exit code — but -Z/-z change grep's semantics,
so it is not a usable workaround.
Suggested fix
Either make the suppressed-output path compute the same line-level verdict as
the visible path, or add -v to the shim's passthrough list so inverted matches
go to the system binary.
Environment
- Claude Code 2.1.239
- macOS, Darwin 25.5.0, arm64, zsh
grepfunction defined at lines 4504-4519 of
~/.claude/shell-snapshots/snapshot-zsh-*.sh