Obsah

Jak mi grep vyčerpal RAM a jak jsem tomu zabránil

Vytvořeno: 17.8.2026 | Aktualizováno: 17.08.2026 23:18

Při práci s Claude Code a OpenCode mi proces pojmenovaný ugrep skokově vyčerpal paměť a shodil vývojový kontejner. Ukázalo se, že inline grep v agentově shellu nebyl systémový grep, ale wrapper nad ugrepem; níže je kompletní, otestovaná ochrana bez závislosti na soukromém repozitáři.

Rychlý závěr

Nebezpečné jsou dva konkrétní tvary:

Ochrana má tři vrstvy:

  1. pravidlo pro agenty v AGENTS.md nebo CLAUDE.md;
  2. watchdog ugrep-watchdog.sh, který problematický proces najde v /proc, ukončí a zapíše důkaz do trvalého logu;
  3. volitelný PreToolUse hook grep-guard.py pro Claude Code, který známý paměťový vzor nepustí ke spuštění.

Co se stalo

Dne 17. 8. 2026 spadl vývojový kontejner. Po restartu se podařilo zachytit živý proces ugrepu, který zůstal po dokončené rouře mcpc … tools-call browser_take_screenshot … | grep -oE „[^ ]+\.png“ | head -2. Držel 100 % jednoho jádra přibližně 20 minut, ale jeho paměť byla plochá: VmRSS = VmHWM = 7 216 kB.

V jiné reprodukci na Alpine Linuxu / ARM64 vedl vzor .\{0,40\}ugrep.\{0,40\} k 663 MB RAM; při hodnotě 80 překročil 2,4 GB a dál rostl. Přímý důkaz, který z těchto mechanismů způsobil původní pád, po restartu neexistuje — procesy i jejich stav zanikly. Oba mechanismy ale byly samostatně naměřené a ochrana níže kryje oba.

Nezávislé potvrzení

Claude Code #86942 popisuje stejnou kombinaci Claude Code 2.1.233 a ugrepu 7.5.0: vzor s více omezenými opakováními široké třídy alokuje přibližně 235 MB za sekundu až do OOM. Claude Code #69189 uvádí 21,2 GB RSS za 70 sekund nad vstupem o 7 281 bajtech. aident.ai nezávisle potvrdil, že k alokaci dochází už nad prázdným souborem.

Na vlastním měření neplatí vysvětlení, že příčinu tvoří kombinace vynuceného -G a agentem přidaného -oE. Varianta -G s BRE i varianta -G -oE s ERE dosáhly shodně 663 MB. Rozhoduje tvar vzoru, ne použitá varianta regulárního výrazu.

Proč regulární výraz skokově zabere RAM

Zápis .\{0,80\}ugrep.\{0,80\} znamená: „najdi slovo ugrep a ukaž až 80 znaků před ním i za ním“. Ugrep si pro tento typ vyhledávání nejprve vytváří stavovou tabulku. Široká třída . se ve UTF-8 interně rozpadá na více bajtových variant; při více omezených opakováních se počet stavů násobí, ne sčítá.

Vzor Naměřená špičková paměť
ugrep.\{0,200\} — jednostranný kontext 1 MB
[0-9]\{0,80\}ugrep[0-9]\{0,80\} — úzká třída 1 MB
.\{0,20\}ugrep.\{0,20\} 9 MB
.\{0,30\}ugrep.\{0,30\} 43 MB
.\{0,40\}ugrep.\{0,40\} 663 MB
.\{0,20\}u.\{0,20\}g.\{0,20\} — tři opakování 1 438 MB
rekurzivní průchod stromu o 271 MB 8 MB / 0,5 s

Bezpečná mez na číslo neexistuje. Tři opakování s hodnotou 20 jsou horší než dvě s hodnotou 40. Nepoužívej proto více omezených opakování .\{…\} nebo [^…]\{…\} v jednom ugrep vzoru.

# Kontext jen za nalezeným slovem.
grep -o 'ugrep.\{0,120\}' soubor.txt
 
# PCRE použije jiný regex engine.
grep -P '.{0,80}ugrep.{0,80}' soubor.txt
 
# Ripgrep má pro stavovou tabulku vlastní strop.
rg -o '.{0,80}ugrep.{0,80}' soubor.txt

Pravidlo pro AI agenty

Tento blok patří do globálních instrukcí, které agent skutečně načítá: například ~/.config/opencode/AGENTS.md, případně ~/.claude/CLAUDE.md. Je důležitý i tam, kde nelze použít Claude Code hook.

## `grep` v příkazu je ugrep (vkládají ho Claude Code i OpenCode) — dva tvary položí stroj

- **Nejvýš jedno omezené opakování široké třídy (`.`, `[^…]`) ve vzoru.** `'.\{0,80\}x.\{0,80\}'` sežere gigabajty a bezpečná mez na to číslo neexistuje — paměť roste mocninou počtu opakování, ne velikostí N. Piš jednostranně `'x.\{0,120\}'`, nebo `grep -P`, nebo `rg -o`. Úzké třídy (`[0-9]\{0,80\}`) jsou v pořádku.
- **Nedávej `grep` doprostřed roury, jejíž konec skončí dřív** (typicky `mcpc … | grep … | head -2`) — po ugrepu pak občas zůstane viset vlákno na 100 % jádra. Piš `grep -m N` místo `| head`, nebo výstup přesměruj do souboru.

Co opravuje verze 1.1.0

Původní watchdog chránil pouze před ugrepem. Verze 1.1.0 navíc každých 30 sekund sleduje celkovou volnou paměť stroje, počítanou jako MemAvailable + SwapFree vůči MemTotal + SwapTotal. Při poklesu pod 15 % nikoho nezabíjí; pouze zapíše MEMLOW a pět největších procesů podle RSS. Po návratu nad práh zapíše MEMOK. To odliší problém ugrepu od jiné příčiny, například vlny více souběžných subagentů.

Skript také opravuje závod při současném startu: install.sh a nově otevřený shell mohly oba projít kontrolou pidfile dříve, než ho někdo zapsal. Výsledkem byly dva démoni; osiřelý navíc mohl při ukončení smazat pidfile živému. Verze 1.1.0 používá flock -n, uvolní pidfile jen tehdy, když obsahuje vlastní PID, a osiřelý démon se sám ukončí do jednoho kontrolního cyklu.

Instalace bez soukromého repozitáře

Následující čtyři soubory jsou kompletní verze nasazené a otestované na Alpine Linuxu / ARM64. Vytvoř adresář $HOME/tools/ugrep-watchdog, do něj ulož každý blok pod uvedeným názvem a potom spusť instalátor.

mkdir -p "$HOME/tools/ugrep-watchdog"
# Do této složky ulož čtyři následující soubory.
chmod +x "$HOME/tools/ugrep-watchdog/ugrep-watchdog.sh" \
  "$HOME/tools/ugrep-watchdog/install.sh" \
  "$HOME/tools/ugrep-watchdog/uninstall.sh"
 
# Watchdog + volitelný Claude Code hook.
"$HOME/tools/ugrep-watchdog/install.sh" --with-hook

Instalátor vytvoří symlink ~/.local/bin/ugrep-watchdog, přidá tichý bootstrap do ~/.bashrc, hned nastartuje démona a s parametrem –with-hook zapíše hook do ~/.claude/settings.json.

ugrep-watchdog.sh

#!/bin/sh
# ugrep-watchdog — hlídá zaseknuté a přerostlé procesy ugrep a zabíjí je,
# než položí celý stroj. Popis problému a důvody voleb: README.md ve stejné složce.
#
# POSIX sh (běží pod busybox ash na Alpine i pod bashem).
# Skript je záměrně soběstačný — nesahá na sousední soubory, aby fungoval i přes symlink.
 
set -u
 
VERSION="1.1.0"
PROG="ugrep-watchdog"
 
# ---------------------------------------------------------------- konfigurace
# Meze jde přebít proměnnou prostředí (hodí se na testy a na stroje s jiným profilem).
#   UGWD_CPU       vteřin procesorového času, po kterých je ugrep považován za zaseknutý
#   UGWD_RSS       MB rezidentní paměti, nad kterými je ugrep považován za přerostlý
#   UGWD_INTERVAL  vteřin mezi kontrolami v režimu démona
#   UGWD_DRYRUN    1 = nic nezabíjej, jen zapiš do logu (nácvik)
#   UGWD_STATE     složka pro pidfile a log
#   UGWD_MEM_PCT   pod kolika % volné paměti (RAM+swap) zapsat do logu stav stroje
#   UGWD_MEM_RELOG jak často opakovat zápis, dokud stav trvá (vteřiny)
#   UGWD_MEMINFO   náhrada za /proc/meminfo (jen pro testy)
CPU_LIMIT="${UGWD_CPU:-60}"
RSS_LIMIT="${UGWD_RSS:-500}"
INTERVAL="${UGWD_INTERVAL:-30}"
DRYRUN="${UGWD_DRYRUN:-0}"
MEM_PCT="${UGWD_MEM_PCT:-15}"
MEM_RELOG="${UGWD_MEM_RELOG:-300}"
MEMINFO="${UGWD_MEMINFO:-/proc/meminfo}"
 
STATE_DIR="${UGWD_STATE:-${XDG_STATE_HOME:-$HOME/.local/state}}"
PIDFILE="$STATE_DIR/$PROG.pid"
LOGFILE="$STATE_DIR/$PROG.log"
 
WATCH_NAMES="ugrep"
LOG_MAX_LINES=500
 
CLK_TCK="$(getconf CLK_TCK 2>/dev/null)"
case "$CLK_TCK" in ''|*[!0-9]*) CLK_TCK=100 ;; esac
 
now() { date '+%Y-%m-%d %H:%M:%S'; }
 
log() {
    mkdir -p "$STATE_DIR" 2>/dev/null
    printf '%s %s\n' "$(now)" "$*" >> "$LOGFILE"
}
 
rotate_log() {
    [ -f "$LOGFILE" ] || return 0
    lines=$(wc -l < "$LOGFILE" 2>/dev/null || echo 0)
    [ "$lines" -gt "$((LOG_MAX_LINES * 2))" ] || return 0
    tail -n "$LOG_MAX_LINES" "$LOGFILE" > "$LOGFILE.tmp" 2>/dev/null &&
        mv "$LOGFILE.tmp" "$LOGFILE"
}
 
argv0_basename() {
    _c=$(tr '\0' '\n' < "$1/cmdline" 2>/dev/null | head -n 1)
    [ -n "$_c" ] || return 1
    printf '%s' "${_c##*/}"
}
 
cmdline_oneline() {
    tr '\0' ' ' < "$1/cmdline" 2>/dev/null | cut -c1-240
}
 
cpu_seconds() {
    sed 's/.*) //' "$1/stat" 2>/dev/null |
        awk -v tck="$CLK_TCK" '{ if (NF >= 13) print int(($12 + $13) / tck); else print 0 }'
}
 
rss_mb() {
    awk '/^VmRSS:/ { print int($2 / 1024); found = 1 } END { if (!found) print 0 }' \
        "$1/status" 2>/dev/null
}
 
is_watched_name() {
    for _n in $WATCH_NAMES; do
        [ "$1" = "$_n" ] && return 0
    done
    return 1
}
 
# ------------------------------------------------------- sledování paměti stroje
mem_free_pct() {
    awk '/^MemTotal:/{mt=$2} /^MemAvailable:/{ma=$2} /^SwapTotal:/{st=$2} /^SwapFree:/{sf=$2}
         END { total = mt + st; avail = ma + sf;
               if (total <= 0) { print "100 0 0 0"; exit }
               printf "%d %d %d %d\n", avail*100/total, avail/1024, total/1024, ma/1024 }' \
        "$MEMINFO" 2>/dev/null
}
 
top_by_rss() {
    for p in /proc/[0-9]*; do
        r=$(awk '/^VmRSS:/{print $2; exit}' "$p/status" 2>/dev/null)
        case "${r:-}" in ''|*[!0-9]*) continue ;; esac
        c=$(tr '\0' ' ' < "$p/cmdline" 2>/dev/null | cut -c1-90)
        [ -n "$c" ] || c="[$(awk '/^Name:/{print $2}' "$p/status" 2>/dev/null)]"
        printf '%s %s %s\n' "$r" "${p#/proc/}" "$c"
    done | sort -rn | head -n 5 |
    while read -r rss pid cmd; do
        printf '%d MB  pid=%s  %s\n' "$((rss / 1024))" "$pid" "$cmd"
    done
}
 
MEM_STATE=ok
MEM_LAST_LOG=0
 
check_memory() {
    set -- $(mem_free_pct)
    pct="${1:-100}"; avail="${2:-0}"; total="${3:-0}"; ram="${4:-0}"
    case "$pct" in ''|*[!0-9]*) return 0 ;; esac
    now=$(date +%s)
 
    if [ "$pct" -lt "$MEM_PCT" ]; then
        if [ "$MEM_STATE" = ok ] || [ "$((now - MEM_LAST_LOG))" -ge "$MEM_RELOG" ]; then
            log "MEMLOW  volno ${pct}% — ${avail} MB z ${total} MB (RAM+swap), samotná RAM ${ram} MB"
            top_by_rss | while read -r line; do log "        $line"; done
            MEM_LAST_LOG="$now"
            rotate_log
        fi
        MEM_STATE=low
    else
        if [ "$MEM_STATE" = low ]; then
            log "MEMOK   volno zpět na ${pct}% — ${avail} MB z ${total} MB (RAM+swap)"
            MEM_STATE=ok
        fi
    fi
}
 
# ------------------------------------------------------------------- kontrola
scan() {
    _hits=0
    for p in /proc/[0-9]*; do
        [ -d "$p" ] || continue
        pid="${p#/proc/}"
        [ "$pid" = "$$" ] && continue
 
        name=$(argv0_basename "$p") || continue
        is_watched_name "$name" || continue
 
        cpu=$(cpu_seconds "$p"); : "${cpu:=0}"
        rss=$(rss_mb "$p");      : "${rss:=0}"
        case "$cpu" in ''|*[!0-9]*) cpu=0 ;; esac
        case "$rss" in ''|*[!0-9]*) rss=0 ;; esac
 
        reason=""
        [ "$cpu" -gt "$CPU_LIMIT" ] && reason="cpu>${CPU_LIMIT}s"
        if [ "$rss" -gt "$RSS_LIMIT" ]; then
            [ -n "$reason" ] && reason="$reason+"
            reason="${reason}rss>${RSS_LIMIT}MB"
        fi
        [ -n "$reason" ] || continue
 
        cmd=$(cmdline_oneline "$p")
        if [ "$DRYRUN" = "1" ]; then
            log "DRYRUN  pid=$pid duvod=$reason cpu=${cpu}s rss=${rss}MB :: $cmd"
        else
            kill -9 "$pid" 2>/dev/null
            log "KILL    pid=$pid duvod=$reason cpu=${cpu}s rss=${rss}MB :: $cmd"
        fi
        _hits=$((_hits + 1))
    done
    [ "$_hits" -gt 0 ] && rotate_log
    return 0
}
 
daemon_pid() {
    [ -f "$PIDFILE" ] || return 1
    _p=$(cat "$PIDFILE" 2>/dev/null)
    case "$_p" in ''|*[!0-9]*) return 1 ;; esac
    [ -d "/proc/$_p" ] || return 1
    tr '\0' ' ' < "/proc/$_p/cmdline" 2>/dev/null | grep -q "$PROG" || return 1
    printf '%s' "$_p"
}
 
release_pidfile() {
    [ "$(cat "$PIDFILE" 2>/dev/null)" = "$$" ] && rm -f "$PIDFILE"
    return 0
}
 
run_daemon() {
    mkdir -p "$STATE_DIR"
    echo "$$" > "$PIDFILE"
    trap 'release_pidfile; exit 0' INT TERM
    log "START   démon pid=$$ cpu=${CPU_LIMIT}s rss=${RSS_LIMIT}MB mem<${MEM_PCT}% interval=${INTERVAL}s dryrun=$DRYRUN v$VERSION"
    while :; do
        _owner=$(cat "$PIDFILE" 2>/dev/null)
        if [ "$_owner" != "$$" ]; then
            log "EXIT    osiřelý démon pid=$$ končí (pidfile drží ${_owner:-nikdo})"
            exit 0
        fi
        scan
        check_memory
        sleep "$INTERVAL"
    done
}
 
ensure_daemon() {
    mkdir -p "$STATE_DIR"
    if command -v flock >/dev/null 2>&1; then
        exec 9>"$STATE_DIR/$PROG.lock"
        flock -n -x 9 || return 0
    fi
 
    if daemon_pid >/dev/null 2>&1; then
        return 0
    fi
    rm -f "$PIDFILE"
    setsid "$SELF" --daemon 9>&- >/dev/null 2>&1 &
    _i=0
    while [ "$_i" -lt 20 ]; do
        daemon_pid >/dev/null 2>&1 && return 0
        sleep 0.1
        _i=$((_i + 1))
    done
    return 1
}
 
stop_daemon() {
    _p=$(daemon_pid) || { echo "$PROG: démon neběží"; return 0; }
    kill "$_p" 2>/dev/null
    rm -f "$PIDFILE"
    log "STOP    démon pid=$_p ukončen ručně"
    echo "$PROG: démon $_p ukončen"
}
 
status() {
    if _p=$(daemon_pid); then
        _up=$(cpu_seconds "/proc/$_p")
        echo "$PROG v$VERSION: BĚŽÍ (pid $_p, spotřeboval ${_up}s CPU celkem)"
    else
        echo "$PROG v$VERSION: NEBĚŽÍ"
    fi
    echo "  meze:  cpu > ${CPU_LIMIT}s  |  rss > ${RSS_LIMIT}MB   (kontrola každých ${INTERVAL}s)"
    set -- $(mem_free_pct)
    echo "  paměť: volno ${1:-?}% (${2:-?} MB z ${3:-?} MB vč. swapu), zápis do logu pod ${MEM_PCT}%"
    echo "  log:   $LOGFILE"
    _live=$(for p in /proc/[0-9]*; do
                n=$(argv0_basename "$p" 2>/dev/null) || continue
                is_watched_name "$n" && echo x
            done | wc -l)
    echo "  právě teď běží ugrep procesů: $_live"
    if [ -s "$LOGFILE" ]; then
        echo "  poslední zásahy:"
        grep -E ' (KILL|DRYRUN) ' "$LOGFILE" 2>/dev/null | tail -n 5 | sed 's/^/    /'
    fi
}
 
usage() {
    cat <<EOF
$PROG v$VERSION — hlídač zaseknutých a přerostlých procesů ugrep
 
Použití: $PROG <režim>
 
  --ensure-daemon   spustí démona, pokud ještě neběží (idempotentní; volá se z ~/.bashrc)
  --daemon          běží jako démon v popředí (interně volané přes setsid)
  --once            jedna kontrola a konec (hodí se na testy a do cronu)
  --status          stav démona, meze a poslední zásahy
  --stop            ukončí démona
  --help            tato nápověda
 
Proměnné prostředí:
  UGWD_CPU=$CPU_LIMIT        mez procesorového času ve vteřinách
  UGWD_RSS=$RSS_LIMIT       mez rezidentní paměti v MB
  UGWD_INTERVAL=$INTERVAL    prodleva mezi kontrolami ve vteřinách
  UGWD_DRYRUN=$DRYRUN        1 = jen zapisuj do logu, nezabíjej
  UGWD_MEM_PCT=$MEM_PCT       pod kolika % volné paměti (RAM+swap) zapsat stav stroje
  UGWD_MEM_RELOG=$MEM_RELOG    jak často zápis opakovat, dokud stav trvá
  UGWD_STATE=$STATE_DIR
EOF
}
 
SELF="$0"
case "$SELF" in
    /*) ;;
    *)  SELF="$(cd "$(dirname "$SELF")" && pwd)/$(basename "$SELF")" ;;
esac
while [ -L "$SELF" ]; do
    _t=$(readlink "$SELF")
    case "$_t" in
        /*) SELF="$_t" ;;
        *)  SELF="$(cd "$(dirname "$SELF")" && pwd)/$_t" ;;
    esac
done
 
case "${1:---help}" in
    --ensure-daemon) ensure_daemon ;;
    --daemon)        run_daemon ;;
    --once)          scan; check_memory ;;
    --status)        status ;;
    --stop)          stop_daemon ;;
    --version)       echo "$PROG $VERSION" ;;
    --help|-h)       usage ;;
    *)               echo "$PROG: neznámý režim '$1'" >&2; usage >&2; exit 2 ;;
esac

grep-guard.py

#!/usr/bin/env python3
"""grep-guard — PreToolUse hook, který nepustí ke spuštění příkaz s grep vzorem,
jenž by v ugrepu vyhodil paměť do stovek MB až GB.
 
Kontext a měření: README.md ve stejné složce, sekce 3.
 
Princip: ugrep si vzor převádí na stavovou tabulku JEŠTĚ PŘED čtením vstupu.
Omezené opakování široké třídy znaků (`.` nebo negované `[^…]`) násobí počet stavů,
takže dvě a víc takových opakování v jednom vzoru dá součin, ne součet.
 
Chová se konzervativně: při jakékoli nejistotě příkaz PUSTÍ.
Hlídač procesů je záchranná síť pod tímhle; guard nesmí být důvod, proč práce stojí.
"""
 
import json
import re
import shlex
import sys
 
STATE_LIMIT = 1000
SEPARATORS = {"|", "||", "&&", ";", "&", ")", "(", ">", ">>", "<"}
PCRE_FLAG = re.compile(r"^(?:-\w*P|--perl-regexp)$")
WIDE_BOUNDED = re.compile(
    r"(?:\.|\[\^[^\]]*\])"
    r"(?:\\)?\{"
    r"\s*(?:\d+\s*,\s*)?(\d+)\s*"
    r"(?:\\)?\}"
)
 
MESSAGE = """\
Zablokováno: tenhle grep vzor položí stroj.
 
Nalezeno {count} omezených opakování široké třídy znaků ({found}) — ugrep si z toho \
postaví tabulku o ~{states} stavech a sežere stovky MB až jednotky GB paměti \
ještě než začne číst vstup. Snížením čísla to nevyřešíš: paměť roste mocninou \
POČTU opakování, takže tři „malá" {{0,20}} jsou horší (1,4 GB) než dvě velká {{0,40}} (663 MB).
 
Napiš to jinak:
  • jednostranně — `grep -o 'slovo.\\{{0,120\\}}'` je bezpečné i při velkém N
  • `grep -P '...'` — PCRE tabulku nestaví
  • `rg -o '...'` — ripgrep má na velikost tabulky pevný strop
  • úzká třída místo tečky — `[0-9]\\{{0,80\\}}` oboustranně je v pořádku
 
Podrobnosti a měření: tento článek"""
 
 
def allow():
    print("{}")
    sys.exit(0)
 
 
HEREDOC = re.compile(r"<<-?\s*(['\"]?)(\w+)\1")
 
 
def strip_heredocs(command):
    out, terminator = [], None
    for line in command.split("\n"):
        if terminator is not None:
            if line.strip() == terminator:
                terminator = None
            continue
        match = HEREDOC.search(line)
        if match:
            terminator = match.group(2)
            line = HEREDOC.sub("", line)
        out.append(line)
    return "\n".join(out)
 
 
def is_grep_token(token):
    word = token.rsplit("|", 1)[-1].rsplit("/", 1)[-1]
    return word in ("grep", "ugrep")
 
 
def grep_argument_groups(command):
    tokens = shlex.split(strip_heredocs(command), posix=True)
    groups = []
    current = None
    for token in tokens:
        if current is not None:
            if token in SEPARATORS or any(s in token for s in ("|", ";", "&")):
                groups.append(current)
                current = None
            else:
                current.append(token)
                continue
        if is_grep_token(token):
            current = []
    if current is not None:
        groups.append(current)
    return groups
 
 
def analyze(command):
    if "grep" not in command:
        return None
 
    worst = None
    for args in grep_argument_groups(command):
        if any(PCRE_FLAG.match(a) for a in args):
            continue
 
        matches = []
        for arg in args:
            matches.extend(WIDE_BOUNDED.findall(arg))
        if len(matches) < 2:
            continue
 
        states = 1
        for n in matches:
            states *= int(n) + 1
            if states > 10 ** 9:
                break
        if states <= STATE_LIMIT:
            continue
 
        if worst is None or states > worst[1]:
            worst = (len(matches), states, matches)
 
    return worst
 
 
def main():
    try:
        data = json.load(sys.stdin)
    except Exception:
        allow()
 
    if data.get("tool_name") != "Bash":
        allow()
 
    command = data.get("tool_input", {}).get("command", "")
    if not isinstance(command, str) or not command:
        allow()
 
    try:
        verdict = analyze(command)
    except Exception:
        allow()
        return
 
    if verdict is None:
        allow()
 
    count, states, found = verdict
    message = MESSAGE.format(
        count=count,
        found=", ".join("{0," + n + "}" for n in found),
        states=f"{states:,}".replace(",", " "),
    )
    print(json.dumps({
        "hookSpecificOutput": {
            "hookEventName": "PreToolUse",
            "permissionDecision": "deny",
            "permissionDecisionReason": message,
        },
        "systemMessage": message,
    }, ensure_ascii=False))
    sys.exit(0)
 
 
if __name__ == "__main__":
    main()

install.sh

#!/bin/sh
# Instalace ugrep-watchdog. Idempotentní — klidně spouštěj opakovaně,
# každý krok se udělá jen když chybí.
 
set -eu
 
SRC_DIR="$(cd "$(dirname "$0")" && pwd)"
SCRIPT="$SRC_DIR/ugrep-watchdog.sh"
GUARD="$SRC_DIR/grep-guard.py"
BIN_DIR="$HOME/.local/bin"
LINK="$BIN_DIR/ugrep-watchdog"
BASHRC="$HOME/.bashrc"
SETTINGS="$HOME/.claude/settings.json"
MARK_BEGIN="# >>> ugrep-watchdog >>>"
MARK_END="# <<< ugrep-watchdog <<<"
 
WITH_HOOK=0
[ "${1:-}" = "--with-hook" ] && WITH_HOOK=1
 
[ -f "$SCRIPT" ] || { echo "CHYBA: nenalezen $SCRIPT" >&2; exit 1; }
 
echo "ugrep-watchdog — instalace"
echo "  zdroj: $SRC_DIR"
 
chmod +x "$SCRIPT" "$SRC_DIR/install.sh" "$SRC_DIR/uninstall.sh" 2>/dev/null || true
 
mkdir -p "$BIN_DIR"
if [ -L "$LINK" ] && [ "$(readlink "$LINK")" = "$SCRIPT" ]; then
    echo "  [=] symlink $LINK už ukazuje správně"
else
    ln -sf "$SCRIPT" "$LINK"
    echo "  [+] symlink $LINK -> $SCRIPT"
fi
 
case ":$PATH:" in
    *":$BIN_DIR:"*) ;;
    *) echo "  [!] POZOR: $BIN_DIR není v PATH — příkaz 'ugrep-watchdog' nepůjde zavolat jménem" ;;
esac
 
if [ -f "$BASHRC" ] && grep -qF "$MARK_BEGIN" "$BASHRC"; then
    echo "  [=] ~/.bashrc už blok obsahuje"
else
    {
        echo ""
        echo "$MARK_BEGIN"
        echo "# Hlídá zaseknuté/přerostlé procesy ugrep. Podrobnosti: článek Jak mi grep vyčerpal RAM a jak jsem tomu zabránil."
        echo '{ [ -x "$HOME/.local/bin/ugrep-watchdog" ] && "$HOME/.local/bin/ugrep-watchdog" --ensure-daemon; } >/dev/null 2>&1 || true'
        echo "$MARK_END"
    } >> "$BASHRC"
    echo "  [+] blok dopsán do $BASHRC"
fi
 
if "$SCRIPT" --ensure-daemon; then
    echo "  [+] démon běží"
else
    echo "  [!] démona se nepodařilo nastartovat — zkus ručně: $SCRIPT --daemon" >&2
fi
 
if [ "$WITH_HOOK" = "1" ]; then
    if [ ! -f "$GUARD" ]; then
        echo "  [!] nenalezen $GUARD — hook přeskočen" >&2
    else
        chmod +x "$GUARD" 2>/dev/null || true
        case "$SRC_DIR" in
            "$HOME"/*) GUARD_REF="\$HOME${SRC_DIR#$HOME}/grep-guard.py" ;;
            *)         GUARD_REF="$GUARD" ;;
        esac
        HOOK_CMD="python3 \"$GUARD_REF\""
        mkdir -p "$(dirname "$SETTINGS")"
        HOOK_CMD="$HOOK_CMD" SETTINGS="$SETTINGS" python3 - <<'PY'
import json, os, shutil, sys
 
path = os.environ["SETTINGS"]
cmd = os.environ["HOOK_CMD"]
 
try:
    with open(path) as f:
        settings = json.load(f)
except FileNotFoundError:
    settings = {}
except Exception as e:
    print(f"  [!] {path} nejde přečíst ({e}) — hook nezaregistrován", file=sys.stderr)
    sys.exit(1)
 
hooks = settings.setdefault("hooks", {})
pre = hooks.setdefault("PreToolUse", [])
 
if any("grep-guard.py" in h.get("command", "")
       for entry in pre for h in entry.get("hooks", [])):
    print("  [=] hook grep-guard už je v settings.json")
    sys.exit(0)
 
if os.path.exists(path):
    shutil.copy2(path, path + ".ugwd-backup")
 
pre.append({
    "matcher": "Bash",
    "hooks": [{"type": "command", "command": cmd, "timeout": 10}],
})
 
with open(path, "w") as f:
    json.dump(settings, f, indent=2, ensure_ascii=False)
    f.write("\n")
 
print(f"  [+] hook zaregistrován v {path}")
print(f"      {cmd}")
PY
        echo "  [i] Pro jistotu otevři novou session claude."
    fi
fi
 
echo ""
"$SCRIPT" --status
echo ""
if [ "$WITH_HOOK" = "0" ]; then
    echo "Tip: '$0 --with-hook' navíc zaregistruje PreToolUse guard proti nebezpečným grep vzorům."
fi
echo "Odinstalace: $SRC_DIR/uninstall.sh"

uninstall.sh

#!/bin/sh
# Odinstalace ugrep-watchdog. Log v ~/.local/state zůstává — je to černá skříňka,
# smaž ho ručně, až ho nebudeš potřebovat.
 
set -eu
 
SRC_DIR="$(cd "$(dirname "$0")" && pwd)"
SCRIPT="$SRC_DIR/ugrep-watchdog.sh"
LINK="$HOME/.local/bin/ugrep-watchdog"
BASHRC="$HOME/.bashrc"
MARK_BEGIN="# >>> ugrep-watchdog >>>"
MARK_END="# <<< ugrep-watchdog <<<"
 
echo "ugrep-watchdog — odinstalace"
 
[ -x "$SCRIPT" ] && "$SCRIPT" --stop || true
 
if [ -L "$LINK" ]; then
    rm -f "$LINK"
    echo "  [-] symlink $LINK smazán"
fi
 
if [ -f "$BASHRC" ] && grep -qF "$MARK_BEGIN" "$BASHRC"; then
    cp "$BASHRC" "$BASHRC.ugwd-backup"
    sed "/$MARK_BEGIN/,/$MARK_END/d" "$BASHRC.ugwd-backup" > "$BASHRC"
    echo "  [-] blok odebrán z $BASHRC (záloha: $BASHRC.ugwd-backup)"
fi
 
SETTINGS="$HOME/.claude/settings.json"
if [ -f "$SETTINGS" ]; then
    SETTINGS="$SETTINGS" python3 - <<'PY'
import json, os, shutil
 
path = os.environ["SETTINGS"]
try:
    with open(path) as f:
        settings = json.load(f)
except Exception:
    raise SystemExit(0)
 
pre = settings.get("hooks", {}).get("PreToolUse", [])
kept = [e for e in pre
        if not any("grep-guard.py" in h.get("command", "") for h in e.get("hooks", []))]
if len(kept) == len(pre):
    raise SystemExit(0)
 
shutil.copy2(path, path + ".ugwd-backup")
if kept:
    settings["hooks"]["PreToolUse"] = kept
else:
    settings["hooks"].pop("PreToolUse", None)
    if not settings["hooks"]:
        settings.pop("hooks", None)
 
with open(path, "w") as f:
    json.dump(settings, f, indent=2, ensure_ascii=False)
    f.write("\n")
print(f"  [-] hook grep-guard odebrán ze {path} (záloha: {path}.ugwd-backup)")
PY
fi
 
echo "  [i] log zůstává: ${XDG_STATE_HOME:-$HOME/.local/state}/ugrep-watchdog.log"
echo "Hotovo."

Ověření po instalaci

ugrep-watchdog --status
cat "$HOME/.local/state/ugrep-watchdog.log"

Očekávaný stav watchdogu je jeden běžící démon, kontrola každých 30 sekund a meze cpu > 60 s nebo rss > 500 MB. Stav navíc ukáže volnou paměť včetně swapu a práh pro forenzní zápis.

Byly ověřeny obě ugrep ochrany, záznam MEMLOW a MEMOK, omezení opakovaných zápisů, deset souběžných volání –ensure-daemon, odchod osiřelého démona i zachování pidfile živého démona. Guard má 29 testů: nebezpečné vzory blokuje, zatímco bezpečné jednostranné vzory, grep -P, rg, běžná rekurze, heredoc i zpráva commitu s ukázkou regexu procházejí.

Provoz a omezení

ugrep-watchdog --status
ugrep-watchdog --once
ugrep-watchdog --stop
ugrep-watchdog --help

Odinstalace

"$HOME/tools/ugrep-watchdog/uninstall.sh"

Odinstalátor zastaví démona, smaže symlink, odstraní jím vytvořený blok z ~/.bashrc a odebere pouze záznam grep-guard.py z ~/.claude/settings.json. Před úpravou konfiguračních souborů vytvoří zálohu s příponou .ugwd-backup. Log záměrně nechává zachovaný jako forenzní záznam.

Zdroje

Stav chyby v Claude Code

Jde o bug Claude Code, nikoli o běžné chování systémového grepu nebo jen o chybu v konkrétním agentním promptu. Claude Code do inline shellu vkládá wrapper, který spouští svůj vestavěný ugrep; právě tato kombinace dokáže při popsaném tvaru regulárního výrazu vyčerpat paměť hostitele.

Chyba je nahlášená v Claude Code issue #86942. Hlášení se týká stejné kombinace Claude Code 2.1.233 a ugrepu 7.5.0 a popisuje růst paměti až do OOM. Do opravy v Claude Code zůstává tento watchdog, guard a pravidla pro agenty pouze provozní mitigací.