Skip to content

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

Description

@emase-bot

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.
  2. 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.
  3. grep is shadowed transparently, so neither the user nor the model has any
    signal that a different engine with different characteristics is in play.
  4. 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.
  2. 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.
  3. Fall back to system grep when compilation exceeds a budget.
  4. At minimum: make the shim visible, so grep --version inside a Claude Code
    Bash call does not look like GNU grep.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions