"""PreToolUse-Hook auf das Bash-Werkzeug: warnt vor vier bekannten Windows-Shell-Fallen, lehnt nie ab. Ersteller: Pascal Michel Erstellt: 2026-10-30 Entwurf: ENTWURF.md im selben Ordner · Doku: docs/code/shell_wache.md GELTUNGSBEREICH Gelesen wird tool_input.command der Hook-Eingabe auf stdin (Bytes, UTF-8 mit Ersatzzeichen). Geprüft werden genau vier Muster, ein Hinweis je getroffenem Muster, auch wenn es mehrfach trifft: [python-I] python/python.exe mit -I, aber ohne -X utf8 oder -X utf8=1. Optionen werden bis zum ersten Nicht-Optionswort gelesen; -c und -m beenden sie, auch im Bündel (-Ic); -W und -X nehmen ein Argument, angehängt oder als nächstes Wort. -X utf8=0 zählt nicht als utf8. [python3] python3, python3., mit oder ohne .exe. [bash-windows] bash/bash.exe ohne Pfad im Windows-Kontext: (a) hinter cmd /c, cmd //c, cmd.exe /c, powershell, pwsh bis zum Zeilenende, (b) als Python-Listenelement ["bash" oder ['bash.exe' im ganzen Befehl. Steht direkt davor / oder \\, bleibt es still. [pkill-f] pkill mit --full oder einem Bündel mit kleinem f, gelesen wie bei python; -u -U -g -G -P -s -t -F und --signal nehmen das nächste Wort als Argument. Auch in WSL und über ssh. Linux-Weiche: ein Wort wsl, ssh, docker, podman oder kubectl (mit oder ohne .exe). python3 und Muster (b) bleiben im ganzen Befehl still, sobald irgendwo eine Weiche steht; Muster (a) nur für ein bash hinter einer Weiche auf derselben Zeile. python -I und pkill kennen keine Weiche. Groß- und Kleinschreibung: cmd, powershell, pwsh, bash, python, Weichenwörter ohne Rücksicht darauf; pkill und alle Optionen genau. Ein Programmwort beginnt am Befehlsanfang oder nach Leerraum oder einem der Zeichen ; & | ( ` ' " = und darf einen Pfad vor sich tragen (außer bash). Ausgabe: ohne Befund nichts. Mit Befund genau ein JSON-Objekt {"hookSpecificOutput": {"hookEventName": "PreToolUse", "additionalContext": ...}}. Nie permissionDecision, nie decision, nie continue. Exit ist immer 0. Kaputte Eingabe (leer, kein JSON-Objekt, tool_input oder command fehlt oder falscher Typ, tool_name ungleich Bash) ist ein Befund [eingabe], ein interner Fehler ein Befund [intern]; leerer command bleibt still. BENANNTE LÜCKEN * Erwähnungen in Zeichenketten schlagen an, etwa grep "python -I" datei oder eine Commit-Nachricht. Der Hook kennt keine Shell-Grammatik. * Das PowerShell-Werkzeug ist nicht abgedeckt; dort ist jeder Befehl Windows-Kontext. Eine Erweiterung bräuchte eine eigene Kontextregel. * py (Python-Launcher), subprocess mit shell=True und Skriptdateien, die erst zur Laufzeit gelesen werden, sieht der Hook nicht. * Ein Befehl, der ein lokales python3 und zugleich ssh enthält, bleibt still (die Weiche gilt für den ganzen Befehl). * Workflow-Subagenten laufen mit derselben settings.json; der Hook wirkt dort mit. Ob ihr Kontext den Hinweis zeigt, belegt erst ein Lauf. * Backslashes in Heredocs werden nicht geprüft (ENTWURF.md, Frage 7). DIESER HOOK IST EIN FANGNETZ, KEIN NACHWEIS. Er erkennt Muster im Befehlstext, nicht, was der Befehl tut. Ein Fehlalarm kostet eine Zeile Kontext, keinen abgelehnten Befehl. Exit ist immer 0. Exit 2 wäre bei PreToolUse eine Ablehnung (Hilfetext von Claude Code 2.1.309), andere Codes erreichten das Modell nicht (ENTWURF.md, Frage 4). """ from __future__ import annotations import json import re import sys KOPFZEILE = "shell-wache warnt nur, der Befehl läuft trotzdem." HINWEISE = { "python-I": ( "[python-I] python -I ohne -X utf8: -I ignoriert PYTHONIOENCODING, Umlaute brechen " "unter cp1252. Nimm python -I -X utf8 (AI/Regelwerk/windows-shell.md, „Welche Shell, welches Python“)." ), "python3": ( "[python3] python3 ist unter Windows der Microsoft-Store-Stub. Nimm python " "(AI/Regelwerk/windows-shell.md, „Welche Shell, welches Python“)." ), "bash-windows": ( "[bash-windows] bash ohne Pfad aus cmd, PowerShell oder Python startet unter Windows " "den WSL-Starter. Git-Bash mit vollem Pfad C:\\Program Files\\Git\\bin\\bash.exe, " "WSL mit wsl -d (AI/Regelwerk/windows-shell.md, „Welche Shell, welches Python“)." ), "pkill-f": ( "[pkill-f] pkill -f trifft jede Befehlszeile mit dem Muster, auch die eigene Shell " "und fremde Läufe in WSL. Erst pgrep -af lesen, dann gezielt kill oder ein " "Muster wie \"[s]leep 7199\" (Erinnerung pkill-f-trifft-die-eigene-shell; " "AI/Regelwerk/windows-shell.md)." ), } # Ein Programmwort beginnt am Zeilen- oder Befehlsanfang oder nach einem dieser Zeichen. _START = r"(?:^|(?<=[\s;&|(`'\"=]))" _PFAD = r"(?:[^\s;&|()`'\"=]*[/\\])?" _ENDE = r"(?=$|[\s'\";)|&])" def _programm(name: str, pfad: bool = True) -> str: return _START + (_PFAD if pfad else "") + "(?:" + name + ")" + _ENDE _RE_PYTHON = re.compile(_programm(r"python(?:\.exe)?"), re.I | re.M) _RE_PYTHON3 = re.compile(_programm(r"python3(?:\.\d+)?(?:\.exe)?"), re.I | re.M) _RE_PKILL = re.compile(_programm("pkill"), re.M) _RE_WEICHE = re.compile(_programm(r"(?:wsl|ssh|docker|podman|kubectl)(?:\.exe)?"), re.I | re.M) _RE_BASH = re.compile(_programm(r"bash(?:\.exe)?", pfad=False), re.I | re.M) _RE_KONTEXT = re.compile( _programm(r"cmd(?:\.exe)?[ \t]+//?c|(?:powershell|pwsh)(?:\.exe)?"), re.I | re.M ) _RE_LISTE_BASH = re.compile(r"\[\s*[\"']bash(?:\.exe)?[\"']", re.I) # Buchstaben, deren Option das nächste Wort als Argument nimmt. _PKILL_MIT_ARGUMENT = "uUgGPstF" def lies_stdin() -> bytes: if sys.stdin is None or sys.stdin.isatty(): return b"" return sys.stdin.buffer.read() def lies_eingabe(roh: bytes) -> tuple[str | None, str]: """(Grund, Befehl): Grund ist None, wenn der Befehl geprüft werden kann.""" text = roh.decode("utf-8", "replace").lstrip("\ufeff") if not text.strip(): return "stdin leer", "" try: daten = json.loads(text) except ValueError: return "kein JSON", "" if not isinstance(daten, dict): return "kein JSON-Objekt", "" name = daten.get("tool_name") if name is not None and name != "Bash": return f"tool_name ist {name}, nicht Bash", "" eingabe = daten.get("tool_input") if not isinstance(eingabe, dict): return "tool_input fehlt oder ist kein Objekt", "" befehl = eingabe.get("command") if not isinstance(befehl, str): return "command fehlt oder ist keine Zeichenkette", "" return None, befehl def _rest_der_zeile(befehl: str, ab: int) -> list[str]: # Rest der Zeile hinter dem Programmwort, bis zum ersten ; & |, als Wörter ohne Anführungszeichen. rest = befehl[ab:].split("\n", 1)[0] rest = re.split(r"[;&|]", rest, maxsplit=1)[0].lstrip(" \t'\"") return [w.strip("'\")") for w in rest.split()] def python_i_ohne_utf8(woerter: list[str]) -> bool: ist_i = False utf8 = False i = 0 while i < len(woerter): w = woerter[i] i += 1 if not w.startswith("-") or w in ("-", "--"): break if w.startswith("--"): continue buendel = w[1:] ende = False for pos, zeichen in enumerate(buendel): if zeichen == "I": ist_i = True elif zeichen in "cm": ende = True break elif zeichen in "WX": arg = buendel[pos + 1:] if not arg and i < len(woerter): arg = woerter[i] i += 1 if zeichen == "X": utf8 = arg in ("utf8", "utf8=1") break if ende: break return ist_i and not utf8 def pkill_mit_f(woerter: list[str]) -> bool: i = 0 while i < len(woerter): w = woerter[i] i += 1 if not w.startswith("-") or w in ("-", "--"): break if w.startswith("--"): if w == "--full": return True if w == "--signal": i += 1 continue buendel = w[1:] for pos, zeichen in enumerate(buendel): if zeichen == "f": return True if zeichen in _PKILL_MIT_ARGUMENT: if pos == len(buendel) - 1: i += 1 break return False def erkenne_python_i(befehl: str) -> bool: return any(python_i_ohne_utf8(_rest_der_zeile(befehl, m.end())) for m in _RE_PYTHON.finditer(befehl)) def erkenne_python3(befehl: str) -> bool: if _RE_WEICHE.search(befehl): return False return _RE_PYTHON3.search(befehl) is not None def _bash_im_kontext(befehl: str) -> bool: for zeile in befehl.split("\n"): kontext = _RE_KONTEXT.search(zeile) if not kontext: continue rest = zeile[kontext.end():] weiche = _RE_WEICHE.search(rest) for bash in _RE_BASH.finditer(rest): if weiche is None or bash.start() < weiche.start(): return True return False def erkenne_bash_windows(befehl: str) -> bool: if _bash_im_kontext(befehl): return True if _RE_WEICHE.search(befehl): return False return _RE_LISTE_BASH.search(befehl) is not None def erkenne_pkill_f(befehl: str) -> bool: return any(pkill_mit_f(_rest_der_zeile(befehl, m.end())) for m in _RE_PKILL.finditer(befehl)) # Reihenfolge ist die Reihenfolge der Zeilen im Hinweis. DETEKTOREN = ( ("python-I", erkenne_python_i), ("python3", erkenne_python3), ("bash-windows", erkenne_bash_windows), ("pkill-f", erkenne_pkill_f), ) def baue_text(schluessel: list[str], grund: str | None, intern: list[str]) -> str: zeilen = [KOPFZEILE] zeilen += [HINWEISE[k] for k in schluessel] if grund is not None: zeilen.append(f"[eingabe] Hook-Eingabe unbrauchbar ({grund}), nichts geprüft.") if intern: zeilen.append( f"[intern] shell-wache ist abgestürzt ({', '.join(intern)}), Prüfung unvollständig." ) return "\n".join(zeilen) def melde(text: str) -> None: ausgabe = {"hookSpecificOutput": {"hookEventName": "PreToolUse", "additionalContext": text}} sys.stdout.write(json.dumps(ausgabe, ensure_ascii=True)) sys.stdout.flush() def main() -> None: schluessel: list[str] = [] intern: list[str] = [] grund: str | None = None try: grund, befehl = lies_eingabe(lies_stdin()) if grund is None and befehl.strip(): for name, detektor in DETEKTOREN: try: if detektor(befehl): schluessel.append(name) except Exception as fehler: intern.append(type(fehler).__name__) except BaseException as fehler: intern.append(type(fehler).__name__) if schluessel or grund is not None or intern: melde(baue_text(schluessel, grund, intern)) if __name__ == "__main__": try: main() except BaseException: # Letzte Schranke: Exit 0 ist Pflicht, sonst liest Claude Code stdout nicht. pass sys.exit(0)