====== Jak mi grep vyčerpal RAM a jak jsem tomu zabránil ====== //Vytvořeno: **17.8.2026** | Aktualizováno: **~~LASTMOD~~**// Při práci s [[ai:platformy:claude-code:start|Claude Code]] a [[ai:platformy:opencode:start|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: * regulární výraz s omezeným opakováním široké třídy znaků vícekrát v jednom vzoru, například ''.\{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; * roura, jejíž poslední článek skončí dřív, typicky ''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: - pravidlo pro agenty v ''AGENTS.md'' nebo ''CLAUDE.md''; - watchdog ''ugrep-watchdog.sh'', který problematický proces najde v ''/proc'', ukončí a zapíše důkaz do trvalého logu; - 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í ===== [[https://github.com/anthropics/claude-code/issues/86942|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. [[https://github.com/anthropics/claude-code/issues/69189|Claude Code #69189]] uvádí 21,2 GB RSS za 70 sekund nad vstupem o 7 281 bajtech. [[https://aident.ai/blog/fix-claude-code-runaway-child-oom|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 < --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 * ''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. * Sledování celkové paměti je černá skříňka, ne automatická oprava: neukončuje subagenty ani jiné procesy. * Watchdog úmyslně používá ''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. * Guard neuvidí regex složený až za běhu v proměnné. Právě proto zůstává watchdog povinnou záchrannou sítí. * Meze vycházejí z měření na Alpine / ARM64. Na jiném stroji nejprve spusť ''UGWD_DRYRUN=1'' a podle logu zvaž úpravu. ===== 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 ===== * [[https://github.com/anthropics/claude-code/issues/86942|Claude Code #86942 – runaway ugrep a OOM]] * [[https://github.com/anthropics/claude-code/issues/69189|Claude Code #69189 – vysoká spotřeba paměti wrapperu grep]] * [[https://aident.ai/blog/fix-claude-code-runaway-child-oom|aident.ai – runaway child OOM v Claude Code]] ===== 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 [[https://github.com/anthropics/claude-code/issues/86942|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í.