Files
Pascal Michel 9e506ff588 Abgleich mit dem Vault-Stand 7f484f9
38 Datei(en) geändert.
Erzeugt von .vault-tools/auto-push/werkzeuge_spiegel.py.
2026-11-02 03:00:03 +01:00

294 lines
11 KiB
Python

"""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.<n>, 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 <Distro> (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 <pid> 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)