====== 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í.