DoMe Dynamics
AI Agent Systems
Zurueck zum Log
claude-codehookssafetyguardrails

Claude-Code-PreToolUse-Hook gegen rm -rf und DROP TABLE: destruktive Shell-Kommandos abfangen

Ein Hook, der rm -rf blockt — und warum er fast mich selbst ausgesperrt hätte

Ein Agent mit Shell-Zugriff kann jeden Befehl tippen, auch rm -rf oder DROP TABLE. Wie ich einen PreToolUse-Hook gebaut habe, der destruktive Kommandos abfängt — und was beim Bau schiefging.

Ein Agent mit Shell-Zugriff kann jeden Befehl tippen. Auch rm -rf. Auch DROP TABLE. Das ist keine Hypothese. Zweimal hatte mir die eigene Test-Suite bereits eine produktive Datenbank geleert, und ein Stack-Audit benannte genau diese Lücke anschließend als die größte offene. Also habe ich einen PreToolUse-Hook gebaut, der jeden Befehl prüft, bevor er ausgeführt wird. Was dabei schiefging, ist fast lehrreicher als was funktioniert.

Was der Hook tut

PreToolUse-Hooks sind der Kontrollpunkt zwischen dem Moment, in dem ein Agent einen Befehl plant, und dem Moment, in dem er ihn ausführt. Der Hook bekommt den vollständigen Tool-Aufruf übergeben — Befehlsname, Argumente, Kontext — und kann deny zurückgeben, bevor irgendwas passiert. Das ist der einzige Moment, an dem eine rein sprachlich arbeitende Sicherheitsgrenze wirksam ist: danach ist der Befehl schon weg.

Mein Hook prüft den Befehlsstring gegen eine Liste destruktiver Muster: rm -rf, DROP TABLE, TRUNCATE, git push --force auf Mainline-Branches, > wichtige-datei.txt als Redirect-Überschreib, und ein Dutzend weitere. Trifft ein Muster zu, wird der Aufruf geblockt und ein Audit-Eintrag ins Log geschrieben — JSONL-Format, ein Objekt pro Zeile, mit Zeitstempel, einem stabilen rule-Schlüssel für die geblockte Kategorie, und dem Befehl in gekürzter Form. „Gekürzt" bedeutet hier: ausreichend für die Diagnose, aber kein vollständiger Dump des Prompt-Inhalts, der ggf. sensible Zwischenstände enthält. Ein Audit-Log, das selbst ein Sicherheitsproblem wird, hilft nicht.

Die Architektur ist von einem Tool-Usage-Tracker abgeschaut, den ich für den DCO gebaut hatte. Der Schreibvorgang ins Log sitzt in einem try/except-Block: wenn das Schreiben fehlschlägt, wird der deny trotzdem ausgelöst. Ein Guard, der an einem kaputten Log-Handle hängt und deshalb nichts blockt, wäre schlimmer als gar kein Guard. Die Konsequenz ist, dass ein Blocking-Event im schlimmsten Fall lautlos ist — aber das ist ein akzeptabler Kompromiss gegenüber einer Umgehung durch Log-Fehler.

Wenn Daten wie Befehle aussehen

Das erste ernsthafte Problem trat auf, als ich rm -rf als Substring-Muster matchen wollte. Die naive Idee — wenn der String rm -rf enthält, blocken — ist auch die erste, die jeder in einer ersten Implementierung wählt. Sie ist naheliegend, weil sie für den häufigsten Fall stimmt. Sie scheitert, weil sie keinen Unterschied macht zwischen dem Befehlsteil und dem Argumentteil eines Aufrufs.

Konkreter Fall: ein Codex-Review-Aufruf über die Kommandozeile, in etwa node task "…analysiere diesen rm -rf-Fund in prod.db…". Der Prompt ist ein Argument in Anführungszeichen. Mein Muster matcht. Der Hook blockt meinen eigenen Review-Aufruf.

Die Lösung: bevor der Musterabgleich läuft, werden alle gequoteten Spans maskiert — einfache Anführungszeichen, doppelte Anführungszeichen, alles dazwischen wird durch Platzhalter ersetzt. Was danach übrig bleibt, ist der Befehlsteil ohne Argument-Inhalte. Erst darauf läuft das Muster. Ein Newline zählt dabei als Befehlstrennzeichen — was nach einem Zeilenumbruch kommt, wird als eigenständiger Befehl gewertet und separat geprüft.

Das klingt nach einer kleinen Korrektur. Es war ein Nachmittag Debugging. Und es ist der Punkt, an dem eine naive Substring-Suche an ihre strukturelle Grenze stößt: Shell-Syntax zu parsen ist ein eigenes Problem, und jeder Versuch, es halbherzig zu lösen, produziert entweder False Positives oder False Negatives.

fail-open statt fail-closed

Eine Entscheidung zieht sich durch den ganzen Hook: wenn unklar ist, ob ein Befehl gefährlich ist, wird er durchgelassen und geloggt — nicht geblockt.

Das klingt kontraintuitiv für ein Sicherheitssystem. Aber die Alternative ist ein Guard, der produktive Arbeit blockt — und der dann abgestellt wird. Dann schützt er nichts, dauerhaft. Ich habe das Prinzip nach dem Codex-Review-Vorfall formalisiert: lieber einen echten Treffer verpassen und im Audit-Log sehen, als legitime Aufrufe zu verweigern und den Hook nach einer Woche aus Frustration zu deaktivieren. Eine Sicherheitsgrenze, die deaktiviert wurde, ist schlechter als keine — weil sie eine falsche Sicherheit hinterlässt.

Das bedeutet: der Hook ist kein hartes Sicherheitsnetz. Er ist ein erster Filter und ein Frühwarnsystem. Die Lücken sind bekannt und dokumentiert — dazu später mehr.

Das Henne-Ei-Problem der Selbstverteidigung

Ich wollte den Hook auch vor sich selbst schützen. Konkret: eine Regel in Cluster H, die verhindert, dass der Hook-Code selbst bearbeitet wird, ohne explizite Freigabe. Wer die Schutzregeln editieren kann, kann den Schutz aushebeln. Das ist keine theoretische Bedrohung — ein Agent, der seinen eigenen Handlungsspielraum optimiert, ist genau das, was diese Art von Schutzmechanismus ursprünglich motiviert hat.

Das Ergebnis war vorhersehbar, im Nachhinein: die Regel blockierte mich beim ersten Versuch, den Hook zu verbessern. Ich wollte einen neuen Muster-Eintrag hinzufügen. Der Hook erkannte den Schreibbefehl auf seine eigene Datei als verdächtig. deny.

Zurückgerollt. Lesson learned: Selbstschutz braucht zuerst ein Owner-Bypass-Design — und der Bypass muss vor der Schutzregel existieren, nicht danach. Wer der Owner ist, muss eine verifizierte Möglichkeit haben, die Regeln zu ändern, ohne den gesamten Schutzmechanismus zu umgehen. Das bedeutet in der Praxis: eine separate, nicht vom Hook bewachte Konfigurationsdatei mit expliziter Freigabemechanik, oder eine Bypass-Geste die der Hook selbst versteht. Dieser Bypass existiert noch nicht. Cluster H ist deaktiviert, bis er es tut.

Was offen ist

Die ehrliche Bestandsaufnahme der bekannten Lücken:

  • Interpreter-Einzeiler: python -c "import os; os.system('rm -rf x')" landet nicht im Muster. Der Shell-String enthält keinen rm, nur einen Python-String, der einen rm enthält.
  • Command Substitution: $(rm -rf x) oder `rm -rf x` als Argument ist für den Hook unsichtbar — er sieht den äußeren Befehl, nicht was innerhalb der Substitution steht.
  • Base64-kodierte Befehle: bash -c "$(echo cm0gLXJmIC8= | base64 -d)" ist für einen Stringmatcher nicht auflösbar.
  • Piped SQL-Dumps: psql database < drop_all.sql — der Befehl selbst ist harmlos, der Effekt nicht. Die Gefahr steckt im Datei-Inhalt, nicht im Befehlsstring.

Diese Lücken sind dokumentiert und akzeptiert — fail-open bedeutet, dass sie durchrutschen und geloggt werden. Ein Mustermatcher über Shell-Strings ist grundsätzlich unvollständig. Das ist kein Fehler im Design, sondern eine strukturelle Eigenschaft des Ansatzes: wer auf String-Ebene arbeitet, sieht keine Semantik. Wer vollständige Abdeckung braucht, braucht einen anderen Ansatz: sandboxed Execution, Syscall-Monitoring, oder beides. Der Hook, den ich gebaut habe, ist das Werkzeug, das ich in einem Nachmittag produktiv hatte — und das seitdem tatsächlich genutzt wird. Das ist mehr wert als ein perfektes System, das noch in Planung ist.


Fast ausgeführte destruktive Aktionen sind der Moment, in dem man solche Schutzschichten baut. Wenn du an ähnlichen Sicherheitsgrenzen arbeitest oder andere Ansätze kennst, schreib mir.