- .claude/agents/planer.md - .claude/settings.json - .claude/workflows/executer-welle.js - .claude/workflows/plankritik.js - .claude/workflows/planung.js - .claude/workflows/quorum.js - .claude/workflows/verbrauch.js - .claude/workflows/welle-mit-pruefung.js - .vault-tools/shell-wache/ENTWURF.md - .vault-tools/shell-wache/docs/code/shell_wache.md - .vault-tools/shell-wache/docs/plans/2026-10-30-shell-wache/00-uebersicht.md - .vault-tools/shell-wache/docs/plans/2026-10-30-shell-wache/task-01-shell-wache.md - … und 34 weitere Erzeugt vom PostToolUse-Hook .vault-tools/auto-push/auto_push.py. Co-Authored-By: Claude Opus 5 <[email protected]>
304 lines
11 KiB
Python
304 lines
11 KiB
Python
"""Fährt die Angriffsliste gegen shell_wache.py, je Fall als eigener Prozess.
|
|
|
|
Ersteller: Pascal Michel
|
|
Erstellt: 2026-10-30
|
|
Entwurf: ENTWURF.md im selben Ordner
|
|
|
|
Je Fall: Exit 0, stdout leer oder genau ein JSON-Objekt mit
|
|
hookSpecificOutput.hookEventName == "PreToolUse", ohne permissionDecision, die
|
|
Schlüssel in eckigen Klammern am Zeilenanfang von additionalContext sind genau
|
|
die erwarteten, Laufzeit unter 2 s. Der Hook läuft mit python -I -X utf8 und ohne
|
|
PYTHONIOENCODING in der Umgebung, wie Claude Code ihn startet.
|
|
|
|
Grundlinie zuerst: Ein gesunder Befehl muss still sein. Scheitert sie, bricht der
|
|
Treiber ab, weil der Rest nichts misst.
|
|
|
|
Aufruf:
|
|
python -I -X utf8 .vault-tools/shell-wache/faelle_shell_wache.py [Hook-Pfad]
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
HIER = Path(__file__).resolve().parent
|
|
MAX_DAUER = 2.0
|
|
ZEITLIMIT = 10
|
|
KOPFZEILE = "shell-wache warnt nur, der Befehl läuft trotzdem."
|
|
|
|
# (Name, stdin als Bytes, erwartete Schlüssel[, Text, der im Hinweis stehen muss])
|
|
Fall = tuple
|
|
|
|
|
|
def eingabe(cmd: str) -> bytes:
|
|
return json.dumps({"tool_name": "Bash", "tool_input": {"command": cmd}}).encode("utf-8")
|
|
|
|
|
|
def roh(daten: object) -> bytes:
|
|
return json.dumps(daten).encode("utf-8")
|
|
|
|
|
|
def treffer(gruppe: str, key: str, befehle: list[str]) -> list[Fall]:
|
|
return [(f"{gruppe} | Treffer | {b!r}", eingabe(b), {key}) for b in befehle]
|
|
|
|
|
|
def still(gruppe: str, befehle: list[str]) -> list[Fall]:
|
|
return [(f"{gruppe} | still | {b!r}", eingabe(b), set()) for b in befehle]
|
|
|
|
|
|
GRUNDLINIE: list[Fall] = [
|
|
("GRUNDLINIE | gesunder Befehl bleibt still", eingabe("ls -la && python -I -X utf8 x.py"), set()),
|
|
("GRUNDLINIE | python3 wird gemeldet", eingabe("python3 x.py"), {"python3"}),
|
|
]
|
|
|
|
PY_I_TREFFER = [
|
|
"python -I x.py",
|
|
"python -IB x.py",
|
|
"python -BI x.py",
|
|
'python -Ic "print(1)"',
|
|
'python -I -c "x" -X utf8',
|
|
"python -I -X utf8=0 x.py",
|
|
"/c/Py/python.exe -I x.py",
|
|
'"C:/Py/python.exe" -I x.py',
|
|
"cd a && python -I x.py",
|
|
"PYTHONIOENCODING=utf-8 python -I x.py",
|
|
]
|
|
PY_I_STILL = [
|
|
"python -I -X utf8 x.py",
|
|
"python -IXutf8 x.py",
|
|
"python -IX utf8 x.py",
|
|
"python -X utf8 -I -B x.py",
|
|
"python -I -X utf8=1 x.py",
|
|
"python x.py -I",
|
|
'python -c "import x" -I',
|
|
"ipython -I",
|
|
"python -m pytest -I",
|
|
]
|
|
PY3_TREFFER = ["python3 x.py", "python3.12 -m venv v", "ls; python3 -V", "python3.exe x.py"]
|
|
PY3_STILL = [
|
|
"python x.py",
|
|
"python3-config --libs",
|
|
"wsl -d Debian -- python3 x.py",
|
|
"ssh h python3 x.py",
|
|
"docker exec c python3 x.py",
|
|
]
|
|
BASH_TREFFER = [
|
|
'cmd //c "bash x.sh"',
|
|
'cmd.exe /c bash -c "x"',
|
|
"CMD /C BASH x",
|
|
"powershell -c \"bash -lc 'x'\"",
|
|
'powershell.exe -NoProfile -Command "& bash x"',
|
|
'pwsh -c "bash x"',
|
|
"python -X utf8 -c \"import subprocess; subprocess.run(['bash', '-c', 'x'])\"",
|
|
"python x.py <<'EOF'\nsubprocess.run([\"bash.exe\", \"x\"])\nEOF",
|
|
]
|
|
BASH_STILL = [
|
|
'bash -c "x"',
|
|
"bash x.sh",
|
|
'cmd //c "C:/Program Files/Git/bin/bash.exe" x',
|
|
'cmd //c "C:\\Program Files\\Git\\bin\\bash.exe" x',
|
|
"powershell -c \"wsl -d Debian -- bash -lc 'x'\"",
|
|
'cmd /c "ssh.exe -o A=B root@h bash -s < f.sh"',
|
|
'powershell -c "Get-Content ~/.bashrc"',
|
|
'powershell -c "x"\nbash y.sh',
|
|
"ssh h python - <<'EOF'\nsubprocess.run(['bash','x'])\nEOF",
|
|
"python -c \"subprocess.run([GITBASH, '-c'])\"",
|
|
]
|
|
PKILL_TREFFER = [
|
|
"pkill -f x",
|
|
'pkill -TERM -f "[s]leep 1"',
|
|
"pkill -fx x",
|
|
"pkill --full x",
|
|
"pkill -u root -f x",
|
|
"sudo pkill -f x",
|
|
"wsl -d Debian -- bash -c 'a; pkill -f \"y\"'",
|
|
"/usr/bin/pkill -f x",
|
|
]
|
|
PKILL_STILL = ["pkill x", "pkill -TERM x", "pgrep -f x", "pkill -u fx x"]
|
|
|
|
FAELLE: list[Fall] = []
|
|
FAELLE += treffer("python-I", "python-I", PY_I_TREFFER) + still("python-I", PY_I_STILL)
|
|
FAELLE += treffer("python3", "python3", PY3_TREFFER) + still("python3", PY3_STILL)
|
|
FAELLE += treffer("bash-windows", "bash-windows", BASH_TREFFER) + still("bash-windows", BASH_STILL)
|
|
FAELLE += treffer("pkill-f", "pkill-f", PKILL_TREFFER) + still("pkill-f", PKILL_STILL)
|
|
|
|
# Mehrere Muster in einem Befehl
|
|
FAELLE += [
|
|
(
|
|
"mehrere | alle vier Muster",
|
|
eingabe("python -I x.py; python3 y.py; cmd /c bash z; pkill -f w"),
|
|
{"python-I", "python3", "bash-windows", "pkill-f"},
|
|
),
|
|
("mehrere | dasselbe Muster zweimal", eingabe("python -I a.py; python -I b.py"), {"python-I"}),
|
|
]
|
|
|
|
# Reihenfolge: je Muster am Anfang, am Ende, in Zeile 3
|
|
for _key, _treffer in (
|
|
("python-I", "python -I x.py"),
|
|
("python3", "python3 x.py"),
|
|
("bash-windows", "cmd /c bash x"),
|
|
("pkill-f", "pkill -f x"),
|
|
):
|
|
FAELLE += [
|
|
(f"reihenfolge | {_key} am Anfang", eingabe(f"{_treffer}; ls"), {_key}),
|
|
(f"reihenfolge | {_key} am Ende", eingabe(f"ls; {_treffer}"), {_key}),
|
|
(f"reihenfolge | {_key} in Zeile 3", eingabe(f"ls\npwd\n{_treffer}"), {_key}),
|
|
]
|
|
|
|
# Eingabe
|
|
FAELLE += [
|
|
("eingabe | stdin leer", b"", {"eingabe"}, "stdin leer"),
|
|
("eingabe | kein JSON", b"nicht json", {"eingabe"}, "kein JSON)"),
|
|
("eingabe | Liste", b"[]", {"eingabe"}, "kein JSON-Objekt"),
|
|
("eingabe | null", b"null", {"eingabe"}, "kein JSON-Objekt"),
|
|
("eingabe | leeres Objekt", b"{}", {"eingabe"}, "tool_input fehlt oder ist kein Objekt"),
|
|
("eingabe | tool_input fehlt", roh({"tool_name": "Bash"}), {"eingabe"},
|
|
"tool_input fehlt oder ist kein Objekt"),
|
|
("eingabe | tool_input null", roh({"tool_name": "Bash", "tool_input": None}), {"eingabe"},
|
|
"tool_input fehlt oder ist kein Objekt"),
|
|
("eingabe | tool_input Zeichenkette", roh({"tool_name": "Bash", "tool_input": "x"}), {"eingabe"},
|
|
"tool_input fehlt oder ist kein Objekt"),
|
|
("eingabe | command fehlt", roh({"tool_name": "Bash", "tool_input": {}}), {"eingabe"},
|
|
"command fehlt oder ist keine Zeichenkette"),
|
|
("eingabe | command null", roh({"tool_name": "Bash", "tool_input": {"command": None}}), {"eingabe"},
|
|
"command fehlt oder ist keine Zeichenkette"),
|
|
("eingabe | command Zahl", roh({"tool_name": "Bash", "tool_input": {"command": 5}}), {"eingabe"},
|
|
"command fehlt oder ist keine Zeichenkette"),
|
|
("eingabe | command Liste", roh({"tool_name": "Bash", "tool_input": {"command": ["ls"]}}), {"eingabe"},
|
|
"command fehlt oder ist keine Zeichenkette"),
|
|
("eingabe | ungueltiges UTF-8 im Befehl",
|
|
b'{"tool_name":"Bash","tool_input":{"command":"ls \xff"}}', set()),
|
|
("eingabe | tool_name PowerShell mit python3",
|
|
roh({"tool_name": "PowerShell", "tool_input": {"command": "python3 x"}}), {"eingabe"},
|
|
"tool_name ist PowerShell, nicht Bash"),
|
|
("eingabe | command leer", eingabe(""), set()),
|
|
("eingabe | command nur Leerraum", eingabe(" "), set()),
|
|
("eingabe | zusaetzliche Felder",
|
|
roh({"tool_name": "Bash", "tool_use_id": "t1", "session_id": "s",
|
|
"tool_input": {"command": "ls", "description": "d", "timeout": 5}}), set()),
|
|
]
|
|
|
|
|
|
def schluessel_von(text: str) -> set[str]:
|
|
return set(re.findall(r"^\[([^\]]+)\]", text, flags=re.M))
|
|
|
|
|
|
def hat_schluessel(objekt: object, name: str) -> bool:
|
|
if isinstance(objekt, dict):
|
|
return name in objekt or any(hat_schluessel(v, name) for v in objekt.values())
|
|
if isinstance(objekt, list):
|
|
return any(hat_schluessel(v, name) for v in objekt)
|
|
return False
|
|
|
|
|
|
def fahre(hook: Path, stdin: bytes) -> tuple[int, str, str, float]:
|
|
umgebung = {k: v for k, v in os.environ.items() if k != "PYTHONIOENCODING"}
|
|
start = time.perf_counter()
|
|
lauf = subprocess.run(
|
|
[sys.executable, "-I", "-X", "utf8", str(hook)],
|
|
input=stdin,
|
|
capture_output=True,
|
|
timeout=ZEITLIMIT,
|
|
env=umgebung,
|
|
)
|
|
dauer = time.perf_counter() - start
|
|
try:
|
|
out = lauf.stdout.decode("utf-8")
|
|
except UnicodeDecodeError as exc:
|
|
out = "UNGUELTIGES-UTF-8: %s" % exc
|
|
return lauf.returncode, out, lauf.stderr.decode("utf-8", errors="replace"), dauer
|
|
|
|
|
|
def pruefe(hook: Path, fall: Fall) -> list[str]:
|
|
_, stdin, erwartet = fall[:3]
|
|
muss_enthalten = fall[3] if len(fall) > 3 else None
|
|
try:
|
|
code, out, err, dauer = fahre(hook, stdin)
|
|
except subprocess.TimeoutExpired:
|
|
return [f"Zeitlimit {ZEITLIMIT} s ueberschritten"]
|
|
probleme: list[str] = []
|
|
if code != 0:
|
|
probleme.append(f"Exit {code} statt 0")
|
|
if dauer >= MAX_DAUER:
|
|
probleme.append(f"Laufzeit {dauer:.2f} s, Grenze {MAX_DAUER:.0f} s")
|
|
if err.strip():
|
|
probleme.append(f"stderr nicht leer: {err.strip()[:200]!r}")
|
|
if not erwartet:
|
|
if out != "":
|
|
probleme.append(f"stdout nicht leer: {out[:300]!r}")
|
|
return probleme
|
|
try:
|
|
daten = json.loads(out)
|
|
except ValueError:
|
|
return probleme + [f"stdout ist kein JSON: {out[:300]!r}"]
|
|
if not isinstance(daten, dict) or set(daten) != {"hookSpecificOutput"}:
|
|
return probleme + [f"Objekt hat nicht genau den Schluessel hookSpecificOutput: {out[:300]!r}"]
|
|
if hat_schluessel(daten, "permissionDecision"):
|
|
probleme.append("permissionDecision kommt vor")
|
|
innen = daten["hookSpecificOutput"]
|
|
if not isinstance(innen, dict) or innen.get("hookEventName") != "PreToolUse":
|
|
return probleme + ["hookEventName ist nicht PreToolUse"]
|
|
text = innen.get("additionalContext")
|
|
if not isinstance(text, str) or not text:
|
|
return probleme + ["additionalContext fehlt oder ist leer"]
|
|
if not text.startswith(KOPFZEILE + "\n"):
|
|
probleme.append(f"Kopfzeile fehlt: {text[:80]!r}")
|
|
gefunden = schluessel_von(text)
|
|
if gefunden != erwartet:
|
|
probleme.append(f"Schluessel {sorted(gefunden)} statt {sorted(erwartet)}")
|
|
if muss_enthalten and muss_enthalten not in text:
|
|
probleme.append(f"Hinweis ohne {muss_enthalten!r}: {text!r}")
|
|
return probleme
|
|
|
|
|
|
def main() -> int:
|
|
argumente = [a for a in sys.argv[1:] if not a.startswith("--")]
|
|
hook = Path(argumente[0]).resolve() if argumente else HIER / "shell_wache.py"
|
|
if not hook.is_file():
|
|
print(f"ABBRUCH: {hook} fehlt")
|
|
return 1
|
|
print(f"Hook: {hook}")
|
|
|
|
for fall in GRUNDLINIE:
|
|
try:
|
|
probleme = pruefe(hook, fall)
|
|
except Exception as exc:
|
|
probleme = [f"ABSTURZ im Treiber: {type(exc).__name__}: {exc}"]
|
|
print(f"{'OK' if not probleme else 'FEHLER':7} {fall[0]}" + "".join(f"\n {p}" for p in probleme))
|
|
if probleme:
|
|
print("ABBRUCH: Die Grundlinie ist nicht gruen. Der Rest misst nichts.")
|
|
return 1
|
|
|
|
ergebnisse: list[bool] = []
|
|
for fall in FAELLE:
|
|
try:
|
|
probleme = pruefe(hook, fall)
|
|
except Exception as exc:
|
|
probleme = [f"ABSTURZ im Treiber: {type(exc).__name__}: {exc}"]
|
|
ergebnisse.append(not probleme)
|
|
print(f"{'OK' if not probleme else 'FEHLER':7} {fall[0]}" + "".join(f"\n {p}" for p in probleme))
|
|
|
|
fehler = ergebnisse.count(False)
|
|
print()
|
|
print(f"{len(ergebnisse)} Faelle, {fehler} Fehler (zusaetzlich {len(GRUNDLINIE)} Grundlinie)")
|
|
return 0 if fehler == 0 else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
sys.stdout.reconfigure(encoding="utf-8")
|
|
except AttributeError:
|
|
pass
|
|
try:
|
|
code = main()
|
|
except Exception as exc:
|
|
print(f"FEHLER Treiber abgestuerzt: {type(exc).__name__}: {exc}")
|
|
code = 1
|
|
raise SystemExit(code)
|