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

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

Summary

Inside an interactive session, the grep shell-snapshot shim (the
claude-as-ugrep function in ~/.claude/shell-snapshots/snapshot-zsh-*.sh)
returns a wrong exit code in one specific, reproducible combination:

-v is 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 grep for identical

arguments and identical input. -q should only suppress output, never change
the verdict; redirecting stdout should never change it either.

  • Actual: with -v plus 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 -v cells: 49 wrong. A single rule predicts all 160 with zero misses:

wrong iff -v AND output suppressed.

  • 320 non--v cells: 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/null and -q trigger 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
  • grep function defined at lines 4504-4519 of

~/.claude/shell-snapshots/snapshot-zsh-*.sh

View original on GitHub ↗