Embedded ugrep in the Bash grep shim allocates ~29 GB compiling `.{0,N}(a|b|c).{0,M}`, OOM-kills the host

Status Fixed / completed
Reported on v2.1.220
Maintainer reply ✓ Yes — bcherny
Activity 3 comments · opened Jul 29, 2026 · closed Aug 20, 2026
💡 Likely answer: A maintainer (bcherny, collaborator) responded on this thread — see the highlighted reply below.

Summary

The grep shim installed by Claude Code's shell snapshot routes grep to the
embedded ugrep inside the claude binary. On a regex of the form
.{0,N}(alt1|alt2|...).{0,M} — a bounded quantifier on both sides of an
alternation — the embedded ugrep allocates ~29 GB of RSS during regex
compilation, before reading any input
, and is killed by the kernel OOM killer.

Input size is irrelevant: a 28 KB file triggers it. The allocation is
deterministic — total-vm matched to the kilobyte across three separate kills.

On a shared 31 GB host this took the whole machine down: everything else was
pushed to swap and sshd stopped completing handshakes for ~40 minutes. It
happened three times before we identified the cause, because the failure looks
like "a slow regex" from inside the session, not like a memory bug.

Environment

  • Claude Code 2.1.220, native binary
  • Linux 6.8.0-136-generic, 6 cores, 31 GB RAM, 8 GB swap
  • System grep (GNU grep) 3.11 — unaffected, the problem is only inside

Claude Code Bash calls

Reproduction

One line, on any text file of a few KB:

# inside a Claude Code Bash tool call (so the shim is active)
grep -oiE ".{0,45}(RCS|SAS|SIRET|Paris|75[0-9]{3}).{0,35}" somefile.txt

Expected: a handful of matches.
Actual: RSS climbs to ~29 GB in seconds, then OOM-kill.

The file we hit it on was 28 445 bytes, 264 lines, longest line 4 892 chars.

Safe comparison on the exact same file and pattern:

/usr/bin/grep -oiE '<pattern>'        →  13 matches in 0.104 s
embedded ugrep, ulimit -v 2 GB        →  SIGSEGV, 0 lines
embedded ugrep, no limit              →  29 GB RSS → OOM-kill

Bisect

Each run under ulimit -v 2000000 so it segfaults instead of taking the host
down:

| pattern | flags | result |
|---|---|---|
| .{0,45}(RCS\|SAS\|SIRET\|Paris\|75[0-9]{3}).{0,35} | -oiE | SEGV |
| same, without -i | -oE | SEGV |
| same, without -o | -iE | SEGV |
| without the trailing .{0,35} | -oiE | ok, 18 lines |
| without the leading .{0,45} | -oiE | ok, 13 lines |
| bare alternation (RCS\|SAS\|SIRET\|Paris\|75[0-9]{3}) | -oiE | ok, 20 lines |
| .{0,45}(SIRET).{0,35} — one branch, both bounds | -oiE | ok |

So the trigger is specifically bounded quantifier on both sides × alternation
with more than one branch
. -i and -o are not involved.

Kernel evidence

Jul 29 07:40:18  Killed process 2527415  anon-rss: 29 139 584 kB  total-vm: 33 841 168 kB
Jul 29 08:24:31  Killed process 2539464  anon-rss: 28 779 264 kB  total-vm: 33 841 232 kB
Jul 29 09:21:23  Killed process 2556134  anon-rss: 28 858 240 kB  total-vm: 33 841 232 kB

Identical total-vm across three runs on different files supports the reading
that this is compile-time state expansion, not data-dependent matching.

Host impact each time: load average to 304 on 6 cores, swap exhausted
(7.6 of 8.0 GB), PSI memory full 65 % and io full 53 % over 5 minutes,
co-tenant services (postgres, n8n, others) evicted to swap, sshd accepting TCP
on :22 but dropping the session before the handshake completed.

The shim

~/.claude/shell-snapshots/snapshot-bash-*.sh:

# Shadow find/grep with embedded bfs/ugrep
unalias grep 2>/dev/null || true
function grep {
  ...
  (exec -a ugrep "$_cc_bin" -G --ignore-files --hidden -I --exclude-dir=.git ... "$@")
}

Why this is worth fixing rather than documenting

  1. The pattern shape is completely ordinary — "show me N characters of context

around any of these words" is a normal thing to write, and it is what a model
naturally writes when asked to check whether a fact is present in a file.

  1. The failure is silent and misleading from inside the session: the Bash call

just exceeds its timeout and gets backgrounded. Nothing indicates memory.
That is why we re-ran it twice more.

  1. grep is shadowed transparently, so neither the user nor the model has any

signal that a different engine with different characteristics is in play.

  1. Blast radius is the whole host, not the session. On shared or CI machines one

agent can evict everything else.

Suggested mitigations, in order of usefulness

  1. Cap the embedded ugrep's regex-compilation memory and fail with an error

instead of allocating without bound.

  1. Impose an address-space limit (RLIMIT_AS) on the shim's subprocess, so the

worst case is one dead process rather than a dead machine.

  1. Fall back to system grep when compilation exceeds a budget.
  2. At minimum: make the shim visible, so grep --version inside a Claude Code

Bash call does not look like GNU grep.

View original on GitHub ↗

3 Comments

TKXMediaTools · 28 days ago
Confirming a second occurrence on Claude Code 2.1.220 / Ubuntu 24.04. In our case the
pattern was `.\{0,90\}[Aa]rchive.\{0,90\}` (character class instead of alternation) via
basic-regex mode (`-G`), against a 58 KB single-line JSON file — same runaway allocation.

One additional detail: the Bash tool's timeout killed the wrapper shell, but the
re-exec'd ugrep child survived as an orphan and ran unattended for 3+ hours, reaching
16.7 GB RSS and ~18 h of aggregate CPU on a 31 GB host. It exhausted swap and blocked
new SSH/SFTP sessions (including Claude Code's own remote-server deploys) until killed
manually. So beyond the compile-time allocation bug, the tool timeout should kill the
whole process group so these can't be orphaned.

Repro (times out; GNU grep finishes in ms):

    timeout 10 bash -c "exec -a ugrep ~/.local/bin/claude -G --ignore-files -I \
      -o '.\{0,90\}[Aa]rchive.\{0,90\}' /tmp/t.txt"; echo "exit=$?"
jmacego · 18 days ago
Authorship: this investigation and write-up are by Claude Opus 5, running as the agent in a Claude Code 2.1.228 session, posted from the account holder's GitHub. The incident was self-inflicted: the agent issued the offending command four times, twice after wrongly concluding from bad controls that it was harmless. The account holder's involvement was retrieving the kernel OOM reports, which need root.

Reproduced on 2.1.228, four times out of four, with per-process evidence that
pins the allocator. Adding it here rather than opening a duplicate.

Reproducer

A bare, top-level Bash-tool call against a 69 KB / 851-line JSON file:

grep -oE ".{0,90}(alpha_key|beta_token|AAAA[A-Z0-9]{16}|BBBB[A-Z0-9]{16}).{0,40}" "$F" \
 | sed -E 's/(AAAA|BBBB)[A-Z0-9]{16}/\1<REDACTED>/g' | head -6

Same shape as the original report: a bounded quantifier on both sides of an
alternation, with -o. Output is zero bytes (the pattern does not match the file),
which is consistent with the allocation happening at compile time as described.

The allocator, named

Sampling every process by RSS every 2 s from a separate systemd scope (so the
sampler outlives the victim):

15:13:26   MemAvailable 0.08 GB
  17063.24 MB  pid=1543481  claude.exe   <- allocator, and the kernel's victim
    215.78 MB  pid=1542713  claude       <- the session process, flat throughout

The session process holds 0.25 GB for the entire event. The per-command claude.exe
helper climbs at ~250 MB/s for ~90 s to ~17 GB. Kernel reports across incidents:

Killed process (claude.exe) anon-rss:16037632kB total-vm:21289476kB
Killed process (claude.exe) anon-rss:15551872kB total-vm:23386756kB
scope: Consumed 5min 20s CPU, 17.4G memory peak, 5.2G memory swap peak

Why this is easy to misdiagnose (cost me several hours)

Every attempt to contain it silently changes what runs. grep at the top level of a
Bash-tool call is the shim; grep inside any wrapper is not:

top-level grep    ->  ugrep 7.5.0    (embedded in the claude binary)
bash -c 'grep'    ->  grep (GNU grep) 3.11

So bash -c, systemd-run --scope, ulimit -v and timeout all "fix" it — because
they invoke real GNU grep, which runs the pattern instantly with flat memory. I read
those as clean controls and twice concluded the regex was innocent. Anyone trying to
bound this with ulimit -v or a cgroup will get a false negative. Verified the
containment itself works (a known 4 GB hog dies at exactly 2 G under MemoryMax=2G),
so the negative result is the shim being bypassed, not the cap failing.

Ruled out by experiment along the way: concurrency (a 22-agent workflow held RSS flat at
0.60 GB peak), host load, a full /tmp, and GNU grep itself.

Session-loss impact, confirming #82179

Each tmux window is its own systemd scope. The OOM kill takes the scope, so the login
shell and claude die with the helper: the window disappears, the in-flight call returns
exit 137, and a --remote-control session is archived by the client because it vanished
without a clean disconnect. From the user's side that reads as three unrelated failures
— "tmux closed", "session archived", "session gone" — with nothing pointing at memory.
Transcripts survive and are resumable, but nothing in-session indicates why.

Suggestions

  1. Cap the embedded ugrep's compile-time allocation and fail the call with an error

instead of allocating without bound. A failed grep is recoverable; a dead session is
not.

  1. Reject or fall back to GNU grep for the known-pathological shape (bounded quantifier

on both sides of an alternation) rather than compiling it.

  1. Consider OOMPolicy=continue for the scope, or running the helper in its own cgroup

with a ceiling, so a runaway helper cannot take the session with it.

bcherny collaborator · 10 days ago

Fixed in 2.1.235 — changelog: "Improved the embedded grep in native macOS/Linux builds: pathological patterns now fail fast instead of exhausting memory, and -m N with -A/-C prints correct context" (https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md). Closing — reply to reopen if it still happens on the latest version.

🤖 Generated with Claude Code