Bundled ugrep OOMs the host: -E with two bounded {0,N} intervals explodes DFA construction to multiple GB

Status Closed — duplicate
Reported on v2.1.170
Maintainer reply None cached
Activity 21 comments · opened Jun 10, 2026 · closed Aug 17, 2026

What happened

Claude Code's bundled ugrep (the CLI re-execs its own binary as ugrep, argv0=ugrep) allocates unbounded memory during regex compilation when run with -E (POSIX extended regex) on a pattern containing two bounded .{0,N} intervals. It reaches multiple GB of RSS before scanning any input and is OOM-killed (or SIGSEGVs under an address-space limit).

On a 12 GB Linux host this repeatedly killed whole Claude Code sessions. Captured processes reached 4.3 GB and 8.0 GB RSS; the kernel OOM-killer took down the session each time. The triggering process was a real Grep/search invocation of the form:

ugrep -E -o '.{0,40}(session-corpus|short-term-recall|...).{0,30}' <a 74 KB file>

The 74 KB file is irrelevant — the process dies in DFA construction, before reading data.

Minimal reproduction (verified)

# point a name "ugrep" at the bundled binary (adjust the version string)
ln -sf ~/.local/share/claude/versions/2.1.170 /tmp/ugrep
printf 'x\n' > /tmp/f

# BAD: RSS climbs to the 2.5 GB cap, then SIGSEGV (exit 139), zero output
( ulimit -v 2500000; /tmp/ugrep -E -o '.{0,40}abc.{0,30}' /tmp/f )

# GOOD: identical pattern under PCRE2 -> ~8 MB, instant, exits cleanly
( ulimit -v 2500000; /tmp/ugrep -P -o '.{0,40}abc.{0,30}' /tmp/f )

Narrowing:

  • Two bounded intervals are required: .{0,40}abc.{0,30} explodes; a single .{0,80}abc is fine.
  • -o and alternation are not required.
  • Independent of input size/content (a 1-byte file still SIGSEGVs) — confirming the blow-up is in regex/DFA compilation, not scanning.
  • Measured: -E peaks at the imposed cap (~2.37 GB) and SIGSEGVs after ~15–27 s of CPU; -P, -G, and -F all finish instantly at ~8 MB.

Expected

Bounded memory: compile the ERE efficiently, or cap memory and exit with an error.

Actual

Multi-GB allocation during regex compilation → OOM-kill / SIGSEGV. On a real host this silently exhausts RAM and kills the Claude Code session running the search.

Environment

  • Claude Code 2.1.170 (native install), bundled ugrep, Node v24 SEA
  • Linux (Fedora 44, x86-64), 12 GB RAM

Suggested fix

Use -P (PCRE2) for Grep patterns containing .{0,N} intervals, and/or bound ugrep's memory, and/or guard the Grep tool's pattern construction against multi-interval POSIX-ERE patterns.

View original on GitHub ↗

12 Comments

interkelstar · 2 months ago

Reproduced independently on a second host — confirming the OOM is in-process (CLI V8 heap), not the grep child, plus two mitigations that work.

Environment: Claude Code (ccd-cli) 2.1.185, Linux (Fedora 44), AMD Zen1 KVM VPS, 12 GB RAM + 8 GB zram swap, system ripgrep 14.1.1 present.

What happened: during ordinary code-research a session's claude/ccd-cli process ballooned to anon-rss 4.84 GB / total-vm 10.7 GB and was killed by the kernel OOM-killer. Because the matcher runs in-process, the bloat is in the CLI's own V8 heap (consistent with #54394) — so capping an external rg/ugrep child does nothing; the victim is the CLI itself.

System-wide impact before the kill (the part that makes it brutal): ~21 GB pushed through zram swap (pswpout ≈ 3.74M pages), kswapd0 pinned, and PSI memory full avg300 = 32% — i.e. the entire system was stalled on memory reclaim ~1/3 of the time for several minutes. Load average hit ~25 on 6 cores that were 97% idle with steal = 0; cockpit / ssh / tmux were unusable until the OOM finally fired.

Field signature for anyone else hitting this: high load average + idle CPU + steal 0 + kswapd hot + swap in use while RAM looks free → it's this, not the hypervisor/host.

Mitigations that worked for us:

  1. USE_BUILTIN_RIPGREP=0 — routes the Grep tool through system ripgrep (non-backtracking, memory-bounded). Avoids the explosion entirely and is 5–10× faster. This is the practical fix available today (requires system rg on PATH).
  2. systemd-oomd with PSI/swap kill confined to user.slice (ManagedOOMMemoryPressureLimit=50%, ManagedOOMSwap=kill) as a backstop — converts the multi-minute whole-box meltdown into a fast single-session kill. The session still dies, so it's damage-control, not a fix.

Ask: please move the embedded matcher off POSIX-ERE DFA construction for the .{0,N}…{0,M} case — ugrep -P (PCRE2), RE2, or the Rust regex crate all avoid the multi-GB compile. As filed, code-research can OOM the host, which shouldn't be possible from a search tool.

ilyyeees · 1 month ago

Confirming the same failure family on Claude Code 2.1.199, Ubuntu 24.04 bare-metal.

Environment:

  • Claude Code: 2.1.199 native install
  • OS: Ubuntu 24.04, bare-metal Linux desktop
  • Kernel: 6.17.0-35-generic
  • RAM: about 24 GB, swap: 8 GB
  • Shell: zsh
  • Workload: multiple Claude sessions/subagents doing code

Observed behavior:

  • The kernel OOM killer killed 2.1.199 processes, with one observed at anon-rss:21407232kB.
  • Other OOM tables showed several concurrent 2.1.199 workers at multi-GB RSS.
  • Those workers were adjacent to head / sort pipeline processes, matching search commands generated by Claude.
  • The parent Claude sessions and MCP servers were not the large consumers in the OOM table.

Pattern shape:

  • The OOM windows included model-authored grep commands using bounded context extraction, for example:
  • grep -aoE ".{0,90}innerHTML=.{0,90}" file.js | head
  • grep -oE ".{0,N}...{0,M}" ... | head
  • grep -oiE ... | sort -u | head
  • This matches the . {0,N} ... . {0,M} / bounded interval family described here.
  • In my case the path was reached through Claude Code's zsh shell-snapshot grep shim, which re-execed the Claude binary as bundled ugrep:
  • ARGV0=ugrep "$_cc_bin" -G --ignore-files --hidden -I ...
  • So the visible process was 2.1.199, not a normal system grep process.

Mitigation verified:

  • Launching normal sessions with:
  • claude --allowedTools Bash,Grep,Glob ...
  • prevented new shell snapshots from installing the embedded ugrep / bfs shell shim.
  • New snapshots contain zero:
  • Shadow find/grep
  • ARGV0=ugrep
  • exec -a ugrep
  • After sourcing the new snapshot:
  • grep is an alias for grep --color=auto
  • find is /usr/bin/find
  • Memory stayed stable after restart, with active Claude parent processes around 400-500 MB and no runaway 2.1.199 -G --ignore-files ... worker.

This looks consistent with the same underlying bundled matcher problem: bounded interval regexes that are harmless under system GNU grep or ripgrep can balloon inside Claude Code's bundled search path, and parallel sessions/subagents multiply the blast radius.

andersmolausson · 1 month ago

Confirming this on v2.1.207 (native Linux build), and adding two data points: the BRE path (-G, the shim's default for plain grep) is affected too, not just -E, and inside an LXC container the failure mode is worse than an OOM kill — it's a full container freeze.

Production incident (2026-07-11): an agent session ran what looked like ordinary grep commands with bounded repeats (grep -o '.\{0,200\}pattern.\{0,80\}' file.js) against a 187 KB minified JS file. The shim routed them to the embedded ugrep, which filled the container's entire 4 GiB cgroup in under two minutes. Because the allocation pressure came on gradually, the kernel never OOM-killed anything — the container sat pinned at memory.max in reclaim livelock (40–70 % CPU, host iowait 30–40 %), completely unresponsive to SSH/console. Only pct reboot recovered it. This happened twice in 20 minutes before we found the cause.

Measured comparison (same 122 KB synthetic text file, same pattern .\{0,200\}data_for_model.\{0,80\}):

| binary | peak RSS | result |
|---|---|---|
| GNU grep 3.x | 10.2 MB | completes in 0.17 s |
| embedded ugrep 7.5.0 (exec -a ugrep claude -G) | 2 565 MB | SIGSEGV at a 3 GB rlimit, ~3 s |

Repro:

python3 -c "print('\n'.join('x%d: { data_for_model: \"y-%d-block\" },' % (i,i) for i in range(1200)))" > /tmp/repro.js
# via the Bash-tool shim (or: exec -a ugrep ~/.local/bin/claude -G ...):
grep -o '.\{0,200\}data_for_model.\{0,80\}' /tmp/repro.js   # multi-GB allocation
command grep -o '.\{0,200\}data_for_model.\{0,80\}' /tmp/repro.js  # fine, ~10 MB

Embedded ugrep identifies as ugrep 7.5.0 x86_64-pc-linux-gnu.

Mitigations we've deployed (may help others until this is fixed):

  • systemctl set-property user.slice MemoryMax=3G MemorySwapMax=256M in the containers where agents run — the runaway ugrep gets OOM-killed (exit 137) instead of livelocking the container.
  • A CLAUDE.md rule to use command grep for any pattern containing {m,n} repeats.

Given that the model has no way to know grep isn't GNU grep, a cap or a fallback-to-command grep on patterns with counted repeats seems warranted; an opt-out for the shadowing (#69736) would also do.

qiyun-kxc · 1 month ago

Independent confirmation — this is still present in both current channels:

  • stable 2.1.205 (build 2026-07-08) and latest 2.1.212 (build 2026-07-16) reproduce with near-identical growth curves (~130-150 MiB/s native anonymous RSS).
  • Our production incident: the Bash tool's shell-snapshot grep() wrapper silently routed a bounded-repeat regex (.{100}worker.{0,30}必須.{0,150}) over long-line JSONL into the embedded ugrep; one process reached 3.87 GiB anon RSS on an 8 GiB host → global OOM, and a second run with swap present caused ~19 min of thrashing requiring a manual console reboot.
  • Consistent with the DFA-construction diagnosis here: our synthetic fixture yields zero matches and growth starts immediately.
  • One forensics trap worth noting: a trailing | head masks the failure — the scanning ugrep is OOM-killed but the pipeline exit status becomes head's 0, so tool output looks like a clean empty result. Also exec -a ugrep only changes argv[0]; the kernel comm still shows the Claude version string, which misdirects OOM postmortems.

Bounded synthetic repro (fully synthetic fixture, 512 MiB cgroup cap, RSS sampler, exits with the scan's real status): https://github.com/qiyun-kxc/claude-embedded-ugrep-oom-repro

Workaround we run in production: CLAUDE_ENV_FILE executes unset -f grep before each Bash-tool command (verified: grep falls back to /usr/bin/grep), plus command grep discipline and a cgroup MemoryMax on the Claude Code service.

Wren (Claude Fable 5); investigation: Crane and Rowan (OpenAI Codex GPT-5.6 sol)

hibbes · 1 month ago

Different failure mode from the same shim: when CLAUDE_CODE_EXECPATH is not a
ugrep-capable binary, grep and find fail silently instead of OOMing

Adding a data point that sits next to this issue rather than another confirmation
of the OOM. Same grep() wrapper, opposite symptom, and I would argue the silent
variant is the more dangerous of the two.

Setup, stated up front because it is unsupported: this machine is a Core2Duo
that cannot run the Bun-compiled binary (SIGILL, no AVX), so I run the extracted
JS bundle of 2.1.212 under Node through a small loader
(https://github.com/hibbes/claude-on-node). Consequently:

$ echo "$CLAUDE_CODE_EXECPATH"
/usr/bin/node

What the shim does with that. The generated snapshot contains the usual block:

function grep {
  local _cc_bin="${CLAUDE_CODE_EXECPATH:-}"
  [[ -x $_cc_bin ]] || _cc_bin=~/.local/bin/claude
  if [[ ! -x $_cc_bin ]]; then command grep ${1+"$@"}; return; fi
  ...
  (exec -a ugrep "$_cc_bin" -G --ignore-files --hidden -I --exclude-dir=.git ... ${1+"$@"})
}

The only guard is [[ -x $_cc_bin ]]. /usr/bin/node is executable, so the guard
passes, the shim execs Node with argv[0] rewritten to ugrep, and hands it
ugrep's options. Node rejects them one per line and exits 9. Nothing is ever
scanned.

$ printf 'alpha\nbeta\ngamma\n' > t.txt

$ grep beta t.txt
ugrep: bad option: -G
ugrep: bad option: --ignore-files
ugrep: bad option: --hidden
ugrep: bad option: -I
ugrep: bad option: --exclude-dir=.git
[...]
$ echo $?
9

$ /bin/grep beta t.txt
beta
$ echo $?
0

find breaks identically through the bfs branch (bfs: bad option: -S).

Why this is worse than an OOM. An OOM is loud. This is not. The errors go to
stderr and the exit code is 9, so anything written as grep -c pattern file
2>/dev/null
or find ... 2>/dev/null | wc -l yields an empty result that reads
exactly like "no matches" or "no such files". In one session the agent concluded
that a freshly synced Gentoo ebuild repository had zero changed files. The real
number was 1544, and the wrong answer was acted on before anything looked
suspicious. The | head masking described in #78700 is the same class of
problem; this variant does it on every invocation rather than only on
pathological regexes.

Worth noting separately: exec -a rewrites argv[0] to ugrep, so the error text
names a tool that is not present anywhere on the execution path. That cost me a
while during diagnosis.

Suggested hardening. At call time the wrapper cannot cheaply establish whether
$_cc_bin can act as ugrep or bfs. But the CLI knows this when it writes the
snapshot. Emitting the shadowing block only when the running executable actually
embeds those tools would close this at zero per-call cost, and it would give the
"do not shadow the system grep" request from #78700 a natural opt-out for any
install that does not carry them.

Mitigation, confirmed end to end here (essentially the CLAUDE_ENV_FILE
approach from #78700):

# ~/.claude/bash-env.sh
unset -f grep find 2>/dev/null || true
// ~/.claude/settings.json
"env": { "CLAUDE_ENV_FILE": "/home/USER/.claude/bash-env.sh" }

This works because the session environment script is injected after the snapshot
is sourced, so the unset strips the functions before the command runs.
Afterwards type grep reports /usr/bin/grep, the failing calls above return
correct results, and the repo scan that reported 0 reports 1544. For the record,
the snapshot shadows exactly two functions, grep and find, so that single
line is complete coverage.

I am not affected by the OOM itself, since no ugrep is ever reached on this
install.

*Investigated with Claude Code (Opus 4.8). All commands and outputs above are
from the actual session.*

wanderlust-n · 1 month ago

Independent confirmation on Claude Code 2.1.212, with two additions not
established in the original report: the same 1-byte reproducer OOMs clean
upstream ugrep 5.0.0, 7.5.0 and 7.8.2, and our incident shows
the re-exec'd matcher survived the Bash timeout for approximately 33
minutes. Upstream report: Genivia/ugrep#549.

Incident (2026-07-17, self-hosted server running live services). An agent-issued
tr ... | grep -oiE '.{0,70}dream.{0,90}' | sort -u | head -50 was routed
through the shell-snapshot grep() shim into the embedded ugrep (7.5.0
per --version) on a 7.6 GiB host. The kernel OOM record identifies the
large process as comm=claude.exe; the shell snapshot and process
invocation establish that this was the re-exec'd matcher with
argv[0]=ugrep, matching the process-identity trap described by
@qiyun-kxc. ~30 minutes of swap-thrash made the host unreachable
(DNS/VPN services starved) before the global OOM kill:

oom-kill:constraint=CONSTRAINT_NONE,...,global_oom,
  task_memcg=/user.slice/user-<uid>.slice/.../tmux-spawn-....scope,
  task=claude.exe,... total-vm:12848296kB, anon-rss:6654848kB

Our incident command also ended in | head -50, so the pipeline-status
masking described by @qiyun-kxc applies to our case as well.

Process-attribution clarification for this incident. In our task
table, the long-lived CLI session remained at approximately 216 MiB,
while the re-exec'd search process held approximately 11.15 GiB across
RSS and swap. This incident therefore involved a separate matcher process
using the Claude executable image, not growth in the long-lived session
process. This does not rule out unrelated session-heap bugs.

Upstream verification (new). We rebuilt clean ugrep from tags and ran
the 1-byte reproducer under a 512 MiB cgroup (MemorySwapMax=0):

| Build | Result | Time to 512 MiB |
|---|---|---:|
| Ubuntu 24.04 pkg 5.0.0 | OOM-kill | 3.9 s |
| upstream v7.5.0 (tag build) | OOM-kill | 3.9 s |
| upstream v7.8.2 (tag build) | OOM-kill | 5.7 s |

All three tests used the same printf 'x' fixture and the same
-E '.{0,70}dream.{0,90}' pattern. The bundling is not the cause; the
engine behavior is upstream. Filed with the narrowed reproducer as
Genivia/ugrep#NNN.

Additional controls agree with the OP's compile-time diagnosis:
either one-sided pattern completes at approximately 8 MiB;
.{0,8}dream.{0,8} completes; .{0,70}dream.{0,90} reaches 512 MiB on a
1-byte, zero-match file. -o, -i, input delivery method and match
presence do not affect the failure. -P with the identical pattern
completes at ~8 MiB (verified on the embedded build, which ships
-P:pcre2jit, and on the Ubuntu 5.0.0 package; our from-tag builds were
compiled without PCRE2 so -P could not be tested there).

Independent lifecycle amplifier: in our incident, the 120-second Bash
timeout returned while the pipeline continued as an unsupervised
background job for approximately 33 additional minutes. We reproduced
this behavior with a benign non-terminating command. This appears to
overlap #76056 and should be fixed independently of the regex bug.

Current mitigations:

  • Use /usr/bin/grep or command grep to bypass the injected shell

function.

  • Where appropriate, use -P for this pattern family.
  • Run agent sessions inside a cgroup as a damage-containment boundary,

for example: systemd-run --user --scope -p MemoryMax=4G -- claude

_Investigated with Claude Fable 5, Claude Opus 4.8, and GPT-5.6-sol; all runs were operator-verified._

yvonboulianne · 1 month ago

Independent confirmation on 2.1.217 (native Linux x86_64, installed 2026-07-21). Adding four things I don't see established yet in this thread: the quantified growth law (with a table you can regression-test without OOMing CI), the non-exploding neighbor patterns (which yield a precise danger predicate), child-side allocation evidence (relevant to which mitigations can work), and the upstream fix status (merged but unreleased — a version bump alone won't fix this today).

Growth law: ≈2^min(N,M), doubling per +1 on the smaller bound

Measured today on 2.1.217, exec -a ugrep <claude-binary> under systemd-run --scope -p MemoryMax=2G, peak RSS via /usr/bin/time -f %M, input = a 1-byte file (corpus-independent, this is DFA construction at pattern-compile time):

| pattern (-E, 1-byte input) | peak RSS |
|---|---|
| .{0,10}X.{0,10} | 9 MB |
| .{0,14}X.{0,14} | 56 MB |
| .{0,15}X.{0,15} | 110 MB |
| .{0,16}X.{0,16} | 219 MB |
| .{0,17}X.{0,17} | 444 MB |
| .{0,20}X.{0,20} | 2 GB cap hit, killed |

Clean doubling per +1 on the smaller bound. The context-grabbing patterns the model likes to write (.{0,60}word.{0,90}) are astronomically past any RAM size, which matches every incident report above. -G BRE (.\{0,20\}X.\{0,20\}) hits the 2 GB cap identically — confirming @andersmolausson.

CI-safe regression test: the {0,16} pair peaks at ~220 MB on a 1-byte file. No need to OOM anything to detect the pathology in CI.

What does not explode (same harness)

| pattern | peak RSS |
|---|---|
| .{0,200} (single bounded quantifier, however large) | 7 MB |
| .{0,200}X.{0,5} (asymmetric: min bound = 5) | 15 MB |
| [0-9]{0,60}X[a-f]{0,90} (two big bounds, disjoint classes) | 7 MB |
| -P '.{0,20}X.{0,20}' (PCRE2) | 7 MB |
| -P '.{0,60}X.{0,90}' (PCRE2, incident-sized) | 7 MB |

So the danger predicate is precise: two-plus bounded quantifiers over overlapping character classes, and cost is driven by min(N,M) — not by pattern size, corpus, or a single large bound. -P is immune even at incident-sized bounds (PCRE2 backtracks instead of building a DFA).

Where the memory sits: the re-exec'd child

In our reproductions the memcg scope contains only the re-exec'd argv0=ugrep child — the CLI is not inside it — and the child alone reaches the 2 GB cap. In-session capture while a shadowed grep ran: comm=2.1.217 cmd=ugrep -G --ignore-files --hidden -I ... (same process-identity trap described by @qiyun-kxc and @wanderlust-n: kernel OOM dumps show the claude version string as comm, so the blame lands on the CLI by mistake).

We lost two long-running agent sessions in 6 days this way (OOM-killed children at 15 GB and 27.8 GB RSS on a 32 GB host). This may well coexist with the in-process V8 growth @interkelstar captured, but for this family of incidents a child-side cap would have prevented every one of ours.

Upstream status: fixed on master, released nowhere

Genivia/ugrep#549 was closed completed on 2026-07-19 (DFA growth will be constrained to the VM opcode ceiling), but the latest ugrep release is still v7.8.2 (2026-05-17), which the upstream report lists as affected (5.0.0–7.8.2). Claude Code bundles 7.5.0. So today, no version bump fixes this — the shim needs its own guard.

Practical mitigations

Shim-side (one line, product): the snapshot grep() function could set ulimit -v before the re-exec. Given the child-side evidence above, this converts a host-freeze into a clean grep: killed failure. Routing risky patterns (≥2 bounded quantifiers, min-bound ≥ ~16) to -P, or to the already-embedded ripgrep, are stronger fixes.

User-side, works today (PreToolUse hook): we run this and it has already intercepted real model-authored killers. Intentionally coarse — a false positive costs a pattern rewrite, a false negative costs the host:

#!/usr/bin/env python3
# ~/.claude/hooks/grep-quantifier-guard.py — PreToolUse[Bash]
# Blocks Bash commands invoking (shadowed) grep with >=2 bounded quantifiers:
# the bundled ugrep's DFA needs ~2^min(N,M) states at pattern-compile time.
import json, re, sys
try:
    d = json.load(sys.stdin)
except Exception:
    sys.exit(0)
if d.get("tool_name") != "Bash":
    sys.exit(0)
cmd = (d.get("tool_input") or {}).get("command") or ""
if not re.search(r"(^|[\s|;&(`])\\?grep\s", cmd):
    sys.exit(0)
if re.search(r"(^|\s)-[A-Za-z]*[PF]", cmd):  # PCRE2 / fixed-strings: safe
    sys.exit(0)
quants = [max(int(g) for g in m.groups() if g)
          for m in re.finditer(r"\\?\{(\d+)?,?(\d+)?\\?\}", cmd)
          if any(m.groups())]
if len([q for q in quants if q >= 10]) >= 2:
    sys.stderr.write(
        "Blocked: >=2 bounded quantifiers explode the bundled ugrep DFA "
        "(~2^min(N,M) states; {0,20} twice already exceeds 2 GB before "
        "reading input). Safe rewrites: add -P (PCRE2, measured 7 MB on the "
        "same pattern); use `command grep` (GNU); or keep a single bounded "
        "quantifier / disjoint classes.\n")
    sys.exit(2)
sys.exit(0)
{ "hooks": { "PreToolUse": [ { "matcher": "Bash", "hooks": [ { "type": "command",
  "command": "python3 ~/.claude/hooks/grep-quantifier-guard.py", "timeout": 10 } ] } ] } }

(Caveats we verified so the hook doesn't over-block: child scripts (bash foo.sh, bash -c) get PATH grep — the shim function is not exported — and execvp wrappers (sudo/xargs/command grep) also bypass the shim. Only direct-position grep in the tool's shell, plus eval and keyword prefixes like time, route into ugrep.)

interkelstar · 1 month ago

Follow-up to my earlier comment: USE_BUILTIN_RIPGREP=0 closes only one of the two paths into the embedded matcher.

That variable covers the Grep tool. Our box kept OOMing after it, because the Bash tool reaches ugrep by a different road: the shell snapshot installs grep() / find() functions that re-exec the CLI as ugrep/bfs, so any model-authored grep inside a Bash command still hits the exploding matcher. On this host all 35 files in ~/.claude/shell-snapshots/ carried the shim. @qiyun-kxc and @hibbes already pointed at CLAUDE_ENV_FILE; this is the same idea stated as a complete recipe, since "I set USE_BUILTIN_RIPGREP=0 and still get killed" keeps coming up.

Workaround (verified on 2.1.216 / 2.1.217, Fedora 44):

  1. Create ~/.claude/bash-env.sh:
unset -f grep find 2>/dev/null || true
  1. Point CLAUDE_ENV_FILE at it, in ~/.claude/settings.json:
{ "env": { "USE_BUILTIN_RIPGREP": "0", "CLAUDE_ENV_FILE": "/home/USER/.claude/bash-env.sh" } }

A plain exported variable works too — verified by removing the key from settings.json and launching with CLAUDE_ENV_FILE=… claude -p …, which still reported the system grep. Useful if you provision boxes from a shell rc rather than editing JSON.

  1. Verify in a new session — the env file is sourced after the snapshot, so running sessions keep the shim:
type grep    # → grep is /usr/bin/grep
type find    # → find is /usr/bin/find

Both grep and find need unsetting: the snapshot shadows both, and the find shim has its own failure mode (@hibbes' silent-empty-results case).

If you would rather have Claude Code apply it for you, paste this into a session:

Claude Code routes Bash-tool grep/find through its embedded ugrep, which can OOM the
host on bounded-interval regexes (anthropics/claude-code#67021). Please close that path:

1. Create ~/.claude/bash-env.sh containing exactly:
       unset -f grep find 2>/dev/null || true
2. In ~/.claude/settings.json, add "CLAUDE_ENV_FILE" pointing at that file (absolute
   path) inside the "env" object, creating "env" if absent and keeping existing keys.
3. Tell me that only sessions started after this change are affected, then verify in a
   fresh session that `type grep` prints /usr/bin/grep and not a shell function.

This is containment, not a fix — the engine behaviour reported here (and upstream in Genivia/ugrep#549) is unchanged; it just stops Claude Code from routing searches into it. A cgroup cap (systemd-run --user --scope -p MemoryMax=4G -- claude) is still worth having as a backstop, as others noted above.

Helban · 1 month ago

Five weeks of field data from one box, posted because it bears on @yvonboulianne's shim-side ulimit -v proposal. A child-side cap would have contained every incident we recorded, and it is the only mitigation we tried that would have.

The box is WSL2 (Ubuntu) with 47 GiB assigned and 8 GiB swap, running Claude Code 2.1.214 through 2.1.218. Two OOM kills took it down with nothing left to attribute them to, so we put a systemd user timer on it: sample every minute, record /proc/<pid>/cmdline, ppid and the parent chain for any comm=claude.exe process above 8 GiB, SIGKILL above 12 GiB.

That caught 11 distinct runaway processes in 5 days (2026-07-18 to 2026-07-23, 15 alert rows, still reproducing on 2.1.218). Peak RSS per process: 8.6, 8.6, 12.9, 13.5, 14.1, 14.6, 15.7, 16.7, 17.2, 18.3, 18.6, 22.7 GiB.

Every one of them carries the shell snapshot's fixed flag prefix in argv: -G --ignore-files --hidden -I --exclude-dir=.git .... Not one arrived by another route. This is the native Linux build, which has no separate Grep tool, so the Bash shim is the only door into ugrep here and the opt-out asked for in #69736 would be a complete fix rather than a partial one. The bounds that showed up in the wild, all model-authored and none typed by the user: .{0,40}, .{0,60}, .{0,90}, .{0,120}, .{0,150}, .{0,180}, [^<>]{0,200}, .{0,300}, in both -E and -G form.

The growth rate decides which mitigations are even possible. Two consecutive samples 70 seconds apart, on 2026-07-23: 10.13 GiB, then 22.72 GiB. Roughly 180 MB/s sustained, searching a 569 KB single-line HTML file for .{0,180}(multiple accounts|more than one account|one account per|separate accounts).{0,180}. Alerting alone is worthless at that speed. Our first version only paged, and by the time a human read the notification the box was already thrashing, which is why the threshold kill went in.

The memory sits in the child, and killing the child is enough. The sampler line one minute before that breach reads OK: 10 claude process(es), largest 0.59 GiB. A minute later one argv0=ugrep child was at 10.13 GiB while the CLI parent that spawned it was still at 0.52 GiB. Killing the child recovered the machine every time and cost one search instead of the session. So ulimit -v in the snapshot's grep() before the re-exec would have turned all 11 of these into a clean Killed, with no host impact and no lost work.

Two things worth knowing if you are diagnosing this from outside. comm is the CLI version string for these processes, so dmesg and ps blame Claude Code itself, and the two OOM kills that started our investigation are still unattributed because nothing captured argv at the time. Capturing /proc/<pid>/cmdline at the threshold is the entire diagnosis.

The other one: the OOM is the loud failure. On the same box the same shim silently returns empty with exit 1 on a text file holding a single NUL byte (the -I default, #56644), which is indistinguishable from "no matches". That variant is the one that actually produced a wrong answer here.

We filed #78834 before finding this thread. Same bug, with an isolation table for the fixed-versus-variable bound question. Happy to close it as a duplicate of this one.

gobly2333 · 1 month ago

Independent confirmation on Claude Code 2.1.219 (Ubuntu 22.04 x86_64).

During an ordinary Bash tool call, Claude Code's shell snapshot shadowed grep and re-executed the Claude Code binary as the bundled
ugrep. A two-sided bounded-repeat ERE on a 9 KB text file produced no result for approximately 12 minutes.

The re-executed matcher reached approximately 3.33 GB anonymous RSS on a 3.8 GiB host, exhausted 2.3 GiB of swap, and triggered a
global OOM. The main Claude Code process was only approximately 152 MiB at the OOM snapshot. The resulting reclaim pressure stalled
unrelated interactive sessions and restarted systemd-journald via its watchdog.

Guarded reproduction using a fully synthetic fixture, a 512 MiB virtual-memory limit, and a five-second timeout:

  • Claude Code 2.1.219 bundled matcher: SIGSEGV after 2.97 seconds, max RSS 232072 KiB
  • Saved Claude Code 2.1.218 binary: SIGSEGV after 3.20 seconds, max RSS 234700 KiB
  • GNU grep with the same ERE: completed in 0.04 seconds, max RSS 3888 KiB

The upstream issue Genivia/ugrep#549 confirmed the DFA-construction blow-up and landed a patch on July 19:

https://github.com/Genivia/ugrep/issues/549
https://github.com/Genivia/ugrep/commit/94c30f7dfd668b0567ca6474f58f0f0f5ef758b1

No fixed upstream release is available yet. Since Claude Code 2.1.219 still contains the affected path, please consider vendoring the
upstream patch, or adding a shim-side complexity/resource limit or fallback until a patched ugrep release can be bundled.

interkelstar · 1 month ago
adding a shim-side complexity/resource limit or fallback until a patched ugrep release can be bundled.

shim-side already described here https://github.com/anthropics/claude-code/issues/67021#issuecomment-5049415197

gobly2333 · 1 month ago
> adding a shim-side complexity/resource limit or fallback until a patched ugrep release can be bundled. shim-side already described here https://github.com/anthropics/claude-code/issues/67021#issuecomment-5049415197

Thanks for the helpful pointer! We’ve already deployed a local workaround on our side. My comment was mainly intended to provide an independent confirmation and document that the affected path was still present in Claude Code 2.1.219.

Showing cached comments. Read the full discussion on GitHub ↗