grep -Z/--null is silently ignored by the bundled grep, breaking "grep -Z | xargs -0" pipelines
Summary
Claude Code replaces the system grep with a shell function (installed via the Bash-tool shell snapshot) that routes grep to a bundled ugrep-based search tool (the claude binary invoked with ARGV0=ugrep). That implementation's flag semantics differ from GNU grep: -Z / --null do not produce NUL-delimited output. As a result the extremely common GNU idiom grep -lZ ... | xargs -0 ... (and grep -rlZ) silently breaks — the output is newline-separated, so xargs -0 sees no NUL delimiters and passes the entire newline-joined file list as a single argument to the downstream command.
This is silent and hazardous. grep -rlZ PATTERN . | xargs -0 sed -i '...' does nothing useful (sed: can't read <blob>: No such file or directory); worse, ... | xargs -0 rm / mv / cp would act on one mashed-together "filename" rather than each file. Exit codes don't reliably surface the breakage.
This is the same class of problem as #62016 (rg -rn parsed as --replace=n): a bundled search tool whose flags don't match what the grep/rg name implies. This report is the grep -Z/--null instance, which is arguably more surprising because the command is literally named grep, so users and scripts reasonably expect GNU grep semantics.
Environment
- Claude Code: 2.1.178 (
CLAUDE_CODE_EXECPATH→versions/2.1.177) - OS: Linux · Shell: zsh
- The shell snapshot defines:
function grep {
local _cc_a
for _cc_a in "$@"; do
case "$_cc_a" in -*-filter*|-*-pager*|-*-view*|-*-format-open*|-*-config*|---*|-@*|-*-save-config*) command grep "$@"; return ;; esac
done
local _cc_bin="${CLAUDE_CODE_EXECPATH:-}"
[[ -x $_cc_bin ]] || _cc_bin=/path/to/claude
if [[ ! -x $_cc_bin ]]; then command grep "$@"; return; fi
ARGV0=ugrep "$_cc_bin" -G --ignore-files --hidden -I --exclude-dir=.git ... "$@"
}
The flag-passthrough guard only falls back to the real command grep for a fixed set of flags (--filter, --pager, --view, --format-open, --config, ---*, -@*, --save-config). -Z / --null / -z / --null-data are not in that list, so they reach the bundled implementation, which does not emit NUL delimiters.
Steps to reproduce (deterministic)
mkdir -p /tmp/t && cd /tmp/t
for n in f1 f2 f3; do printf 'OLD\n' > "$n.txt"; done
# Claude Code shim — WRONG: newline-separated, '-Z' ignored, './' stripped
grep -rlZ OLD . | od -c | head
# 0000000 f 1 . t x t \n f 3 . t x t \n f ...
# Real grep — CORRECT: NUL-separated, keeps './'
command grep -rlZ OLD . | od -c | head
# 0000000 . / f 3 . t x t \0 . / f 2 . t ...
# Hazard: the whole file list becomes ONE argument
grep -rlZ OLD . | xargs -0 -n1 echo ARG:
# ARG: f1.txt
# f3.txt
# f2.txt <-- expected THREE separate "ARG: <file>" lines
# Real-world failure
grep -rlZ OLD . | xargs -0 -r sed -i 's/OLD/NEW/g'
# sed: can't read f1.txt\nf3.txt\nf2.txt: No such file or directory (nothing replaced)
grep --null (long form) is equally ignored.
Expected vs actual
| | Expected (GNU grep) | Actual (Claude Code grep) |
|---|---|---|
| grep -lZ / --null output | NUL (\0) separated, ./ prefix kept | newline (\n) separated, ./ stripped |
| grep -lZ \| xargs -0 | one arg per file | whole list as a single arg |
Scope (tested)
- Broken:
grep -Z,grep --null(and presumably-z/--null-data). - Not broken:
find ... -print0— thefindshim does honor-print0(emits\0), sofind -print0 | xargs -0is a safe alternative.
Suggested fix (either)
- Add
-Z/--null/-z/--null-datato thegrepshim's passthrough guard so these invocations fall back to the realcommand grep(smallest, safest). - Implement NUL-delimited output (
-Z/--null) faithfully in the bundled tool.
Workarounds for users
command grep -Z .../\grep -Z ...//usr/bin/grep -Z ...find ... -print0 | xargs -0 ...(thefindshim honors-print0).
Related
- #62016 —
rg -rnparsed as--replace=n: same area (bundled search tool flag semantics ≠ thegrep/rgname's expected semantics).