Abgleich mit dem Vault-Stand 7f484f9
38 Datei(en) geändert. Erzeugt von .vault-tools/auto-push/werkzeuge_spiegel.py.
This commit is contained in:
1 parent
f5747605c7
commit
9e506ff588
38 files changed
+2325
-24
No files matched your search
@@ -0,0 +1,293 @@
|
||||
"""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)
|
||||
Reference in new issue
Block a user