[Bug] Bash tool grep silently returns nothing on text files containing stray NUL bytes (bundled ugrep -I skips them)

Resolved 💬 3 comments Opened May 6, 2026 by nick-y-snyk Closed Jun 21, 2026

Summary

The grep shell-function wrapper that Claude Code injects into every Bash-tool snapshot calls bundled ugrep with -I (skip binary). ugrep classifies any file containing one or more NUL bytes as binary, so a single stray NUL — even in an otherwise UTF-8 text file — makes grep produce zero output and exit 1, with no error message. From the model/agent's perspective the file looks empty or unmatched, which leads to wrong conclusions and silent retries.

I hit this against Claude Code's own shell-snapshot file, which contained a literal NUL byte captured from a zsh plugin (jeffreytse/zsh-vi-mode, function zvm_escape_non_printed_characters). The snapshot is detected as text by file(1) (Unicode text, UTF-8 text, with very long lines) but ugrep treats it as binary and -I then skips it.

Reproduction

Minimal:

printf 'hello\0world\nmatch line\n' > /tmp/nul-text

# Inside Claude Code's Bash tool (uses the wrapper):
grep match /tmp/nul-text
# → exit 1, no output

# System grep (BSD/GNU) on the same file:
/usr/bin/grep match /tmp/nul-text
# → "Binary file /tmp/nul-text matches", exit 0

# With explicit text override the wrapper-bypassed call works:
command grep --binary-files=text match /tmp/nul-text
# → "match line", exit 0

Real-world hit (Claude's own shell snapshot, containing a single NUL inherited from a zsh plugin function):

$ grep -c 'function' ~/.claude/shell-snapshots/<snap>.sh
exit=1, zero output

$ /usr/bin/grep -c 'function' ~/.claude/shell-snapshots/<snap>.sh
91

Expected

grep against a UTF-8 text file with a stray NUL byte returns matching lines, like GNU and BSD grep do by default (they print Binary file X matches but still exit 0). At minimum, no silent zero-output result.

Actual

The wrapper invocation silently skips the file because of -I, returning exit 1 and no output. No diagnostic is printed, so it looks like there are simply no matches.

Diagnosis

The wrapper installed in the shell snapshot looks like:

function grep {
  local _cc_bin="${CLAUDE_CODE_EXECPATH:-}"
  if [[ \! -x $_cc_bin ]]; then command grep "$@"; return; fi
  ...
  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 "$@"
}

-I tells ugrep to skip files detected as binary, and ugrep's binary detection trips on a single NUL byte. This is the documented ugrep behavior, but combined with how easily NULs can leak into otherwise-text files (shell snapshots, build logs, plugin sources, terminfo dumps, etc.) it causes silent failures.

The snapshot in my case got its NUL from zsh-vi-mode's zvm_escape_non_printed_characters function, whose source contains literal control characters (NUL, DEL) used to match unprintable input. zsh's functions builtin dumps the body verbatim, and Claude Code's snapshot writer copies it through unchanged. Any zsh plugin using the same pattern will reproduce this.

Suggested fixes (any one)

  1. Drop -I from the wrapper — match GNU grep's default behavior (still print Binary file X matches, exit 0).
  2. Replace -I with --binary-files=text, so a stray NUL doesn't suppress matches in otherwise-text files.
  3. Sanitize NUL bytes when writing the shell snapshot (strip or escape), so at minimum Claude's own snapshot files never trigger this.
  4. On binary skip, emit a stderr warning so the agent sees ugrep: skipped binary file X instead of silent empty output.

(1) or (2) is lowest-risk; (3) is orthogonal and worth doing regardless.

Workaround

command grep ...                         # bypass the function
/usr/bin/grep ...                        # bypass via absolute path
command grep --binary-files=text ...     # treat as text via system grep

Note: \grep does NOT bypass — backslash disables alias expansion only, not function lookup.

I also gated the offending plugin out of Claude Code sessions in my own dotfiles:

if [[ -z $CLAUDECODE ]]; then
  zinit light jeffreytse/zsh-vi-mode
fi

That fixes it for me but the underlying wrapper behavior is the real bug.

Environment

  • Claude Code 2.1.129 (native macOS arm64 build, Homebrew cask claude-code@latest)
  • macOS Darwin 25.4.0 (arm64)
  • Shell: zsh with zinit + jeffreytse/zsh-vi-mode (commit 08bd1c0)
  • CLAUDECODE=1, CLAUDE_CODE_EXECPATH=/opt/homebrew/Caskroom/claude-code@latest/2.1.129/claude

View original on GitHub ↗

This issue has 3 comments on GitHub. Read the full discussion on GitHub ↗