[BUG] Memory leak causes repeated OOM kills in v2.1.224 (14.5GB and 21.3GB anon-rss, two incidents same day)

Status Open
Reported on v2.1.224
Maintainer reply None cached
Activity 7 comments · opened Aug 8, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report (please file separate reports for different bugs)
  • [x] I am using the latest version of Claude Code

What's Wrong?

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report
  • [x] I am using the latest version of Claude Code (was on 2.1.224 at time of incidents)

What's Wrong?

Claude Code (native install) was killed by the Linux OOM killer twice within the same session/day, consuming 14.5GB and 21.3GB of anonymous RSS respectively before being killed. This matches a long-running pattern of memory leak reports on this repo (#4953, #21403, #22042, #29576, #56335) but I could not find an existing report specifically for 2.1.224.

Environment

  • Claude Code version: 2.1.224
  • Install method: native
  • Platform: Fedora Linux (linux-x64)
  • Working directory at time of crash: agrodesk-v3 project (~50k line TypeScript/Supabase codebase)

Evidence (dmesg / journalctl -k)

Incident 1:
Out of memory: Killed process 1369580 (2.1.224) total-vm:14984888kB, anon-rss:14528788kB, file-rss:12kB, shmem-rss:0kB, UID:1000 pgtables:28776kB oom_score_adj:200
Incident 2:
Out of memory: Killed process 1404272 (2.1.224) total-vm:23374392kB, anon-rss:21333220kB, file-rss:16kB, shmem-rss:0kB, UID:1000 pgtables:42104kB oom_score_adj:200
Both incidents show the process running under the binary version string as process name (2.1.224), with oom_score_adj:200, making it a preferred OOM-kill target once RSS grows into this range.

Expected Behavior

Claude Code should not grow unbounded in memory during a normal working session. Memory usage should stay in a reasonable range (a few GB at most) even during long sessions with many file reads/tool calls, as noted in similar prior reports.

Actual Behavior

Process RSS grew to 14.5GB and 21.3GB respectively before being killed by the kernel OOM killer, on two separate occasions in the same day, both while working normally in a single project directory.

Additional Context

  • No unusual custom plugins/hooks in use beyond standard config.
  • Switching the update channel to stable (currently on 2.1.220) as a mitigation; have not yet confirmed whether the leak reproduces on this version.
  • Given the number of similar prior reports across many versions (#4953 at 120GB+, #21403 at 15-17GB, #22042 at 7.5GB, #29576 at 6.4GB), this appears to be a recurring, unresolved memory management issue in Claude Code rather than an isolated regression.

What Should Happen?

Both incidents show the process running under the binary version string as process name (2.1.224), with oom_score_adj:200, making it a preferred OOM-kill target once RSS grows into this range.

Expected Behavior

Claude Code should not grow unbounded in memory during a normal working session. Memory usage should stay in a reasonable range (a few GB at most) even during long sessions with many file reads/tool calls, as noted in similar prior reports.

Actual Behavior

Process RSS grew to 14.5GB and 21.3GB respectively before being killed by the kernel OOM killer, on two separate occasions in the same day, both while working normally in a single project directory.

Additional Context

  • No unusual custom plugins/hooks in use beyond standard config.
  • Switching the update channel to stable (currently on 2.1.220) as a mitigation; have not yet confirmed whether the leak reproduces on this version.
  • Given the number of similar prior reports across many versions (#4953 at 120GB+, #21403 at 15-17GB, #22042 at 7.5GB, #29576 at 6.4GB), this appears to be a recurring, unresolved memory management issue in Claude Code rather than an isolated regression.

Error Messages/Logs

Steps to Reproduce

Steps to Reproduce

No minimal deterministic reproduction found. The leak occurs during normal extended use — no specific trigger action identified. Observed pattern:

  1. Start a Claude Code session in a large TypeScript/Supabase project (~50k LOC, agrodesk-v3)
  2. Work normally for an extended period (multiple file reads, edits, tool calls)
  3. Memory usage grows unbounded over the session
  4. Process is eventually OOM-killed by the kernel (observed twice same day, at 14.5GB and 21.3GB anon-rss)

This matches the pattern described in #21403 and #4953, where the leak is also reported as gradual and tied to session length/tool call volume rather than a single reproducible action.

Claude Model

None

Is this a regression?

Yes, this worked in a previous version

Last Working Version

_No response_

Claude Code Version

2.1.224

Platform

Anthropic API

Operating System

macOS

Terminal/Shell

Terminal.app (macOS)

Additional Information

_No response_

View original on GitHub ↗

6 Comments

Kerrrberos · 22 days ago

Same signature here, with a deterministic partial reproduction and a version-correlation datapoint that may help narrow it.

Environment: native Linux build (the ~297 MB binary embedding Bun 1.4.0), Ubuntu, 15 GiB RAM host, long agentic sessions (background subagents, many tool calls). Kernel journal shows 13 OOM kills of Claude Code since Jul 16, victims = comm 2.1.210/211/212/220/221/224/224/224/224/225/226, each at 5–13 GiB anon-rss with oom_score_adj:200. Active sessions grow ~120–165 MB/min and die in 1.5–2 h; resuming an already-large conversation died in as little as 17 minutes.

Reproducible on demand (linear, version-flat): every byte of Bash-tool child stdout is retained ~2.1×, never reclaimed. Minimal repro (headless, cheap — the tool results are truncated before reaching the model, so almost no tokens):

# 10 files of 30 MB of text
mkdir -p /tmp/oomtest/data && for i in $(seq -w 1 10); do yes "filler $i" | head -c 30000000 > /tmp/oomtest/data/big$i.txt; done
cd /tmp/oomtest
systemd-run --user --scope -p MemoryMax=6G \
  claude --model haiku -p "Run the Bash tool exactly twenty times, sequentially: cat data/bigNN.txt for NN = 01..10, twice through. Do not use the Read tool. Do not skip any call even if outputs are truncated. Then reply DONE." \
  --allowedTools "Bash(cat:*)"
# watch the scope's memory.current: ~93 MB -> ~1.4 GB, linear while piping, no reclaim

Dose-response measured in capped scopes: 300 MB piped → ×1.67 · 600 MB → ×2.20 · 1800 MB → ×2.06 (peak 3.8 GB in ~40 s). The instance's on-disk transcript stays ~3 MB (truncated results) while the process holds gigabytes — the retention is on the raw pre-truncation pipe bytes, roughly two copies.

This repro is version-flat — identical peaks on 2.1.219 and 2.1.226 — so it is not, by itself, the whole story for the catastrophic real-workload deaths (their tool traffic is tens of MB, not GB). But the real-workload kills correlate hard with version: the same convergence workloads (same conversation, resumed across weeks) ran Jul 18–Aug 5 on 2.1.213–219 with zero kills, then died 8 times in 3 days once 2.1.220+ landed; two 2.1.217 instances on a second machine have zero kills to date. Something additional seems to have landed around 2.1.220. (The 2.1.221 changelog's memory mitigations — the 16 MB edit-cache bound, the resume-with-background-agents fix — did not stop it: 221, 224, 225 and 226 all died here.)

Refuted locally as the amplifier (one-variable probes in capped instances): streamed model output (4,000 lines, flat), 2 parallel Task/Agent subagents at small tool volume, headless --resume of a 13.4 MB transcript (-p errors "Prompt is too long", peak 469 MB), interactive TUI resume of the same (peak 565 MB), MCP servers (tiny RSS in every kill's task dump), transcript size on disk, session-env hook loops (#56335's mechanism — 18 dirs here), superlinear volume retention.

Mitigation we applied: pin to 2.1.219 + DISABLE_AUTOUPDATER=1, and keep large command output redirected to files instead of through tool results. Happy to provide the full kernel task dumps, the per-run CSVs, or to run instrumented builds/env-flag experiments on the affected machine.

Kerrrberos · 22 days ago

Correcting my comment above — the process that dies is the SUBAGENT, not the main session. This also invalidates the version-window claim I made, so please disregard that part.

I pinned to 2.1.219 and kept an external sampler on the main agent's pid. It got OOM-killed again — but the sampler shows the main agent was never the one growing. From the kernel's task dump for that kill:

| process | pid | comm | RSS |
|---|---|---|---|
| VICTIM | 2684085 | 2.1.219 | 13 101 MB |
| main agent (survived) | 2553031 | claude | 318 MB |
| MCP servers | 2553159/61 | node | 53–57 MB |
| sibling forks of the victim | 2684083/84 | 2.1.219 | 2 MB each |

The main session peaked at 480 MB anon over its whole life and never grew. The 13 GB process was a separate process spawned for a subagent (Agent/Task tool), exec'd via the versioned binary path — which is exactly why its comm is the version string.

That re-reads every kill I reported earlier. In all 13, the giant was comm=2.1.2XX and there was always a small comm=claude process alongside. I had assumed the small one was an unrelated older session; it was the main agent, and the giant was its subagent. So:

  • My "quiet on 2.1.213–219, dies on 2.1.220+" correlation is not evidence of a version regression — it more likely tracks how subagent-heavy the work was in each period. Kills happen on 2.1.219 too.
  • What does hold is the reproducible per-byte retention I posted above (~2.1× of Bash-tool child stdout, linear, no reclaim, identical on 219 and 226). It now has an obvious victim: a review/hunt subagent pipes far more through its tools than the main session does — it re-reads whole diffs and files — so it hits the ceiling first while the parent stays flat.

The operator I work with reached the same conclusion independently from the outside: no OOM kill here has ever occurred without subagents running, and every kill has had them.

Suggested repro shape (I have not minimised this yet): run a task that spawns two parallel subagents which each read a large diff and several large files in full, and watch the child claude processes rather than the parent — e.g. watch 'ps -eo pid,rss,comm --sort=-rss | head'. The parent stays flat; a child climbs.

If it helps, the same volume-based reproducer from my previous comment run inside a subagent rather than the main session should show the retention landing on the child process. Happy to run instrumented builds or specific experiments on this machine.

savilag · 22 days ago

I also had sub-agents, but the main process died as well. By the way, switching to stable solved the problem.

Kerrrberos · 22 days ago

Second correction — and this one settles the version question: it is NOT version-specific, and pinning does not help.

In my previous comments I reported a version correlation (kills on 2.1.220+, "quiet" on ≤219) and pinned to an older build as mitigation. That was wrong — an artifact of a small sample and a probabilistic trigger. New evidence:

I pinned to 2.1.217 (my oldest known-good) with auto-update disabled, and ran the exact workload (a convergence round spawning review+hunt subagents over a ~30-file diff) with an external sampler on every claude-family process. Two rounds were clean. The third round's hunt subagent — a separate comm=2.1.217 process, pid 2971073 — climbed to 7 GB in 31 s, then 13.4 GB, and the kernel OOM-killed it:

[2971073] ... 3350585 (rss pages) ... 200  2.1.217   ← 13.4 GB, killed
[2695083] ...   91592 (rss pages) ... 200  claude    ← main agent, 358 MB, survived

So, corrected conclusions:

  1. The leak is version-independent. Every version I have now tested has produced a kill: 2.1.210/211/212/220/221/224/225/226 and 219 and 217. The runaway ran comm=2.1.217 — the pinned version, verified against the versions directory, not a newer build. Pinning does not help.
  2. It is the SUBAGENT process that leaks, not the main session. The main agent stays flat (~350–480 MB) and survives the OOM; the kernel correctly kills the 13 GB subagent worker. The session dies only because the swap-storm around the runaway makes everything unresponsive.
  3. The trigger is probabilistic, tied to subagent tool volume. A subagent that reads a large diff plus many whole files can run away; most don't cross the threshold, so a few clean rounds masquerade as a fix. That is why the version correlation looked real.

Minimal repro shape (watch the child process, not the parent): run a task that dispatches 1–2 subagents each instructed to read a large diff and several large files in full, e.g.

watch -n1 'ps -eo pid,rss,comm --sort=-rss | head'

The parent (comm=claude) stays flat; a child worker (comm=<version>) climbs to OOM on the unlucky run. Growth rate observed: ~200 MB/s once it starts.

Workaround that actually works (since pinning does not): cap the subagent, not the version. The main agent is comm=claude and every subagent/worker is comm=<version-string>, so an external guard can kill just a comm=<version> process that crosses a memory ceiling, sparing the main agent and host; the failed subagent call is then retried. A cgroup MemoryMax around subagent spawns would be the clean equivalent if Claude Code exposed a spawn hook.

Happy to run instrumented builds or capture full task-dumps on this machine.

savilag · 22 days ago

You were right about the pinning, it didnt help. My SO says:

● ▎ TL;DR — the runaway comm=<version> process is not a subagent: it is Claude Code's own binary re-exec'd as the embedded ugrep 7.5.0 by the Bash tool's grep shim, whose regex compilation blows up exponentially
▎ (memory doubles per +1) on any pattern with two bounded repeats like [^.]{0,110}…[^.]{0,90}, allocating ~100–200 MB/s until the kernel kills it — reproducible on an empty input file, in every version, which is
▎ why pinning never helped.

----------------------

Third data point — same signature, but I traced the runaway to a concrete cause: the grep shell shim (embedded ugrep 7.5.0), not a subagent.

I hit an identical-looking OOM today and captured the full chain. Every observable in my case matches the report above — comm=<version> child, oom_score_adj=200, main agent flat and surviving, ~100–200 MB/s growth, version-independent — but the runaway was not a subagent worker. It was grep from a single Bash tool call, executed as ugrep by the shell-snapshot shim.

Environment: Claude Code 2.1.220, Fedora, kernel 7.1.6-201.fc44.x86_64, 32 GB RAM.

---

What the kernel recorded

Aug 08 22:35:58 kernel: erts_sched_4 invoked oom-killer: ... global_oom,
    task_memcg=/user.slice/user-1000.slice/user@1000.service/app.slice/ptyxis-spawn-<uuid>.scope,
    task=2.1.220, pid=937700
Aug 08 22:36:03 kernel: Out of memory: Killed process 937700 (2.1.220)
    total-vm:25451684kB, anon-rss:19546836kB, ... oom_score_adj:200

Task dump, relevant rows (rss in pages, last column = name):

[ 506276]  1000  506276  1557104    67566 ...  18016  200 claude     <- main session CLI, 264 MB, untouched
[ 937698]  1000  937698    61788        4 ...     28  200 sort
[ 937699]  1000  937699    57633        4 ...     27  200 head
[ 937700]  1000  937700  6362921  4886712 ... 1085472 200 2.1.220    <- 19.5 GB RSS + 4.2 GB swapped, killed

The adjacent PIDs are the giveaway. 937698/937699/937700 are one shell pipeline: sort, head, and the runaway. That pipeline appears exactly once in my session transcript:

grep -oE "[^.]{0,110}(harminc nap|hatvan nap|nyolc százalékpont|40 EUR|behajtási költség)[^.]{0,90}\." file.md | sort -u | head -6

A single Bash tool call. No subagent was running.

Why it shows up as comm=<version>

The Bash tool's shell snapshot (~/.claude/shell-snapshots/snapshot-bash-*.sh, lines ~178–208) shadows grep and find with functions that re-exec the Claude Code binary itself:

function grep {
  local _cc_bin="${CLAUDE_CODE_EXECPATH:-}"
  ...
  (exec -a ugrep "$_cc_bin" -G --ignore-files --hidden -I --exclude-dir=.git ... ${1+"$@"})
}

CLAUDE_CODE_EXECPATH is ~/.local/share/claude/versions/<version>, so the kernel sets comm to the version string, while exec -a sets argv[0] to ugrep. This means:

comm=<version> is not a subagent marker — it is "the Claude Code binary re-exec'd as an embedded tool" (ugrep / bfs / rg).

Anyone can confirm this in one second with a completely harmless grep from the Bash tool:

pid=994076 ppid=994073 RSS=11408KB comm=2.1.220 argv0=ugrep

A sampler built on ps -eo pid,rss,comm (as in the comment above) cannot distinguish these from a subagent worker — the distinguishing field is args, not comm. I suspect the "hunt subagent" that ran away was in fact a grep issued by that subagent: a subagent told to read a large diff and many files greps a lot.

The actual bug: exponential DFA construction in the embedded ugrep

It is not a slow leak tied to tool volume. It is regex compilation, and it happens with no input at all — an empty file reproduces it. The embedded ugrep is 7.5.0 (ugrep 7.5.0 x86_64-pc-linux-gnu +sse2; -P:pcre2jit).

Measured on a 0-byte input file, pattern [^.]{0,N}(a|b)[^.]{0,N}\., invoked exactly as the shim does:

CCBIN="${CLAUDE_CODE_EXECPATH}"
: > empty.txt
/usr/bin/time -f "%e s / %M kB" timeout 8 \
  bash -c 'exec -a ugrep "$0" -I -oE "$1" "$2"' "$CCBIN" '[^.]{0,16}(a|b)[^.]{0,16}\.' empty.txt

| N (both sides) | time | max RSS |
|---|---|---|
| 8 | 0.07 s | 16 MB |
| 10 | 0.14 s | 29 MB |
| 11 | 0.26 s | 48 MB |
| 12 | 0.51 s | 90 MB |
| 13 | 1.11 s | 179 MB |
| 14 | 2.51 s | 359 MB |
| 16 | >8 s (capped) | 1.05 GB |
| 20 | >8 s (capped) | 1.31 GB |

Time and memory roughly double per +1 in the interval bound. At the bounds in my real command ({0,110} and {0,90}) it simply allocates until the machine dies — mine reached 23.7 GB (19.5 RSS + 4.2 swapped) in ~210 s, i.e. ~110 MB/s, matching the ~200 MB/s reported above.

Notes on the trigger:

  • Two bounded repeats are needed. [^.]{0,110}(a|b)\. (one side only) finishes in 0.02 s / 10 MB.
  • Alternation is not required: [^.]{0,110}a[^.]{0,90}\. explodes too.
  • Input is irrelevant (empty file suffices), so it is compile-time, not matching.

Same pattern, same file, other engines:

| engine | result |
|---|---|
| embedded ugrep 7.5.0 (what grep actually runs) | never terminates, ~110–165 MB/s until OOM |
| GNU grep 3.12 (/usr/bin/grep) | 2.84 s / 284 MB, correct output |
| embedded ripgrep 14.1.1 (the built-in Grep tool's engine) | 0.02 s / 14 MB, correct output |

So the built-in Grep tool is unaffected; only the Bash-tool grep shim is.

This also explains the "probabilistic trigger" without invoking sample size: it is 100% deterministic given a {n,m}-style bounded repeat in a Bash grep, and 0% without one. It has nothing to do with how much the agent reads — only with whether the model happens to write such a regex. And it explains version-independence: ugrep is compiled into every build, so pinning cannot help.

Two things that turned a hung command into a dead machine

  1. The 120 s Bash timeout does not kill the process. My command was "moved to the background" at 22:34:35 (122 s after it started) and kept allocating for another 88 s. At a roughly constant rate that is ~14 GB at the timeout versus 23.7 GB at the kill — the detach added the last 40%.
  2. pkill -f "grep -oE" cannot match it. Because of exec -a ugrep, the runaway's cmdline is ugrep -G --ignore-files --hidden -I --exclude-dir=... -oE ... — the string "grep -oE" never appears. The session believed it had killed the process; nothing had. The correct kill is pkill -x <version-string> or matching on ugrep.

Finally, the session did not die because the CLI was killed — the kernel only killed the ugrep child. systemd tore down the whole terminal scope afterwards:

ptyxis-spawn-<uuid>.scope: The kernel OOM killer killed some processes in this unit.
ptyxis-spawn-<uuid>.scope: Stopping timed out. Killing.
ptyxis-spawn-<uuid>.scope: Failed with result 'oom-kill'.
ptyxis-spawn-<uuid>.scope: Consumed 6min 7.866s CPU time over 4h 43min ..., 18.9G memory peak, 4.4G memory swap peak.

That matches the "session dies from the swap storm around the runaway" observation.

How to check whether the earlier reports are the same bug

Cheap and decisive, in order:

  1. Add args to the sampler: ps -eo pid,rss,comm,args --sort=-rss | head. If the runaway's argv[0] is ugrep (or bfs), it is a shim invocation from a Bash tool call, not an agent worker.
  2. Read the OOM task dump for PID neighbours. A shell pipeline shows up as consecutive PIDs (sort, head, bash, ...). That is what settled it for me.
  3. Grep the session transcript for bounded repeats: grep -c '{0,' ~/.claude/projects/<slug>/<session>.jsonl, then look at the Bash commands around the kill timestamp.
  4. Run the standalone repro above. If an empty file plus {0,16} allocates 1 GB in 8 s, the cause is confirmed independently of any agent.

Suggested fixes (in order of value)

  1. Bound the embedded ugrep's regex compilation (a DFA/state budget with a clean error, the way ripgrep's regex-size limit behaves) — or route -E/-o patterns containing {n,m} to the system grep.
  2. Make the Bash tool's timeout terminate a runaway rather than detach it, or at least run backgrounded commands under a memory ceiling (systemd-run --scope -p MemoryMax=… where available).
  3. Make the shim discoverable: the fact that grep is not GNU grep is invisible until something behaves differently. It is also a correctness issue, not just performance — the same pattern returns matches with GNU grep and hangs forever with ugrep.

Workaround for users today: command grep / /usr/bin/grep bypasses the shim (shell functions are not inherited through exec, so timeout 5 grep … also runs the real GNU grep), and the built-in Grep tool is safe.

savilag · 22 days ago

A workaround that actually prevents these OOMs: block the pattern class before it runs.

Short recap of the cause, for anyone landing here: inside Claude Code's Bash tool grep is not GNU grep — the shell snapshot shadows it with a function that re-execs the Claude Code binary as its embedded ugrep 7.5.0 (exec -a ugrep "$CLAUDE_CODE_EXECPATH" …), which is why the runaway shows up as comm=<version>. That engine's regex compilation blows up exponentially when a pattern contains two bounded repeats ({n,m}) — memory and time roughly double per +1 in the bound, and it reproduces on a 0-byte input file:

{0,10}×2 → 29 MB    {0,12}×2 → 90 MB    {0,14}×2 → 359 MB    {0,16}×2 → 1.05 GB

At the bounds a model naturally writes for "N characters of context around a keyword" ([^.]{0,110}(a|b)[^.]{0,90}\.) it never terminates and allocates ~100–200 MB/s until the kernel OOM-kills it.

The model is the one writing these patterns, so a written instruction ("don't use two bounded repeats") is not enforcement — it has to be a gate. A PreToolUse hook on Bash does it.

~/.claude/settings.json (merge with your existing settings):

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "python3 ~/.claude/hooks/block-regex-bomb.py",
            "timeout": 10
          }
        ]
      }
    ]
  }
}

~/.claude/hooks/block-regex-bomb.py:

#!/usr/bin/env python3
"""PreToolUse guard: block Bash `grep` calls that would detonate the embedded ugrep.

Contract: read the PreToolUse JSON on stdin; print a deny decision and exit 0 to
block; print nothing and exit 0 to allow. Any internal error allows the command
(a broken guard must never wedge the session).
"""

import json
import re
import shlex
import sys

# `{n,m}` / `{n,}` ranges only. Exact counts (`{200}`) were measured as harmless.
RANGE = re.compile(r"\{\s*(\d+)\s*,\s*(\d*)\s*\}")
# Shim-routed grep word: not a path, not preceded by `command` / `builtin` / `\`.
SHIM_GREP = re.compile(r"(?:^|[\s;&|(])(?<![/\\])(?:e|f|z)?grep\b")
GREPS = {"grep", "egrep", "fgrep", "zgrep", "ugrep"}
BYPASS_WORDS = {"command", "builtin", "exec", "\\grep"}
OPERATORS = {"|", "||", "&&", ";", "&", ">", ">>", "<", "|&"}
MIN_RANGES = 2   # two bounded ranges are needed to detonate; one was measured safe
MIN_BOUND = 10   # {0,10}x2 costs 29 MB and finishes; the cliff is right above it


def bounds_in(text):
    return [int(m.group(2) or m.group(1)) for m in RANGE.finditer(text)]


def offending(command):
    """Return (invocation, bounds) for the first dangerous shim-grep, else None.

    Tokenizing with shlex is essential: a naive split on `|` would also split
    inside the pattern's own alternation, hiding the second bounded repeat.
    """
    try:
        lexer = shlex.shlex(command, posix=True, punctuation_chars=True)
        lexer.whitespace_split = True
        tokens = list(lexer)
    except Exception:
        # Unparseable (heredoc, odd quoting): fall back to a whole-command check.
        bounds = bounds_in(command)
        if SHIM_GREP.search(command) and len(bounds) >= MIN_RANGES and max(bounds) >= MIN_BOUND:
            return command.strip(), bounds
        return None

    i = 0
    while i < len(tokens):
        word, prev = tokens[i], (tokens[i - 1] if i else "")
        if not (word in GREPS and "/" not in word and prev not in BYPASS_WORDS):
            i += 1
            continue
        args, j = [], i + 1
        while j < len(tokens) and tokens[j] not in OPERATORS:
            args.append(tokens[j])
            j += 1
        bounds = bounds_in(" ".join(args))
        if len(bounds) >= MIN_RANGES and max(bounds) >= MIN_BOUND:
            return " ".join([word] + args)[:300], bounds
        i = j + 1
    return None


def main():
    try:
        payload = json.load(sys.stdin)
    except Exception:
        return
    if payload.get("tool_name") != "Bash":
        return
    command = (payload.get("tool_input") or {}).get("command") or ""
    if not isinstance(command, str):
        return
    found = offending(command)
    if not found:
        return
    segment, bounds = found
    reason = (
        "BLOCKED: this grep would run Claude Code's embedded ugrep (the Bash tool "
        "shadows `grep`), whose regex compilation blows up exponentially on two "
        f"bounded repeats. Found {len(bounds)} `{{n,m}}` ranges (largest bound "
        f"{max(bounds)}) in:\n\n    {segment[:300]}\n\n"
        "This is not a slow query — it allocates ~100-200 MB/s until the kernel "
        "OOM-kills it, and the Bash timeout only detaches it.\n\n"
        "Use one of these instead:\n"
        "  * /usr/bin/grep  (real GNU grep, immune)\n"
        "  * the built-in Grep tool (ripgrep, immune)\n"
        "  * python3 with re.finditer for context extraction\n\n"
        "/usr/bin/grep is NOT a drop-in for the shim, so mind these three:\n"
        "  1. `\\d` is a ugrep extension — GNU -E warns 'stray \\ before d' and does\n"
        "     NOT match. Use [0-9] or [[:digit:]]. (`\\s`, `\\w` work in both.)\n"
        "  2. No .gitignore/hidden filtering: `grep -r` descends into .git/,\n"
        "     node_modules/ and gitignored data. For tree-wide searches use the\n"
        "     built-in Grep tool instead of swapping the binary.\n"
        "  3. No -I by default: binary files produce 'Binary file … matches'.\n"
        "For a single named text file (the usual case here) none of this applies."
    )
    json.dump({"hookSpecificOutput": {
        "hookEventName": "PreToolUse",
        "permissionDecision": "deny",
        "permissionDecisionReason": reason,
    }}, sys.stdout)


if __name__ == "__main__":
    try:
        main()
    except Exception:
        pass

How it decides. It denies only when a shim-routed grep carries two or more {n,m} ranges with a bound ≥ 10. Explicit bypasses (/usr/bin/grep, command grep) pass through, so the model always has an escape hatch — and the deny message tells it which one to take, including the three ways GNU grep is not a drop-in replacement (\d unsupported, no .gitignore/hidden filtering on -r, no -I). The thresholds come from the measurements above: one bounded range is harmless, and {0,10} twice costs 29 MB and terminates.

Tested against the three commands that actually killed sessions on my machine (all blocked) and against commands that must not be blocked: grep -rn "word" dir/, grep -oE "<title>[^<]*</title>", a single {0,200} range, [0-9]{3,4}-style small bounds, /usr/bin/grep and command grep carrying a bomb pattern, and a Python heredoc containing {0,110}. Malformed stdin and non-Bash payloads pass through. Verified live — the hook took effect immediately, no restart needed, and normal greps keep working.

If you don't want a hook, the manual version of the same rule: never put two {n,m} repeats in a Bash-tool grep; use /usr/bin/grep, the built-in Grep tool, or Python for context extraction. And if one does get away from you, pkill -f "grep …" will not find it — exec -a ugrep rewrites argv[0], so the cmdline never contains "grep". Use pkill -x <version-string> (e.g. pkill -x 2.1.220) or pkill -f ugrep.

Showing cached comments. Read the full discussion on GitHub ↗