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.
Nebezpečné jsou dva konkrétní tvary:
.\{0,80\}slovo.\{0,80\}; ugrep při překladu vzoru může alokovat stovky MB až jednotky GB ještě před čtením vstupu;mcpc … | grep … | head -2; pomocné vlákno ugrepu pak může zůstat na 100 % jednoho jádra, i když příkaz už agentovi vrátil správný výsledek.Ochrana má tři vrstvy:
AGENTS.md nebo CLAUDE.md;ugrep-watchdog.sh, který problematický proces najde v /proc, ukončí a zapíše důkaz do trvalého logu;grep-guard.py pro Claude Code, který známý paměťový vzor nepustí ke spuštění.
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.
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.
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
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.
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.
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.
#!/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
#!/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()
#!/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"
#!/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."
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í.
ugrep-watchdog --status ugrep-watchdog --once ugrep-watchdog --stop ugrep-watchdog --help
UGWD_DRYRUN=1 jen loguje a nic neukončí. Je vhodný pro první ověření na cizím stroji.UGWD_MEM_PCT určuje práh volné paměti včetně swapu pro záznam MEMLOW; výchozí je 15 %. UGWD_MEM_RELOG určuje opakování záznamu při trvajícím stavu; výchozí je 300 sekund.kill -9 pouze pro ugrep. Ugrep při čtení nemá co konzistentně uklízet; stejný postup se nemá bez rozmyslu rozšířit na jiné procesy.UGWD_DRYRUN=1 a podle logu zvaž úpravu."$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.
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í.