Ein 16-KB-Skript fror ein Gateway 23 Sekunden ein: die Postmortem-Analyse des Kommando-Scans


Du gibst Hermes ein Skript mit ein paar hundert Zeilen — ein Python-Heredoc, das Dokumenttext in JSON-Payloads einbettet — und bittest es, das auszuführen. Das Terminal wird still. Eine Weile später fragst du in Telegram nach dem Fortschritt, und der Bot antwortet nicht. Im Log findest du ein paar Zeilen, die nichts erklären: deadline (15s) expired but the event loop has not processed the expiry, the loop thread appears BLOCKED in a synchronous call, dann den Shutdown-Watchdog, einen Exit, einen Neustart — und dann wiederholt der Agent denselben Befehl, und das Ganze beginnt von vorn. Es sieht aus wie ein Netzwerkproblem oder ein Ausfall der Messaging-Plattform. Tatsächlich geht es um etwas, das rein nach Sicherheit klingt: die Erkennung gefährlicher Befehle.

Das Symptom: nicht „langsam“, sondern eine niedergehaltene Event-Loop

Bevor Hermes einen Befehl ausführt, muss es entscheiden, ob dieser Befehl gefährlich ist — davon hängt ab, ob er durchläuft, dich nachfragt oder rundheraus blockiert wird. Diese Prüfung läuft in Pythons Regex-Engine und hält den GIL. Je länger der Befehl, desto länger belegt er den Prozess; und was er belegt, ist nicht der Slot eines einzelnen Befehls, sondern die gesamte Event-Loop.

Ein Nutzer hat es mit vollständigem Reproduktionsmaterial gemeldet (#113535, 16. September 2026, offizielles Image 0.20.6), und die Fehlerkette lohnt es sich, einmal zu lesen:

  1. Die Erkennung dauert bei einem langen Befehl Minuten und hungert die Event-Loop aus.
  2. Das Telegram-Polling läuft in einen Timeout; die 15-Sekunden-Deadline von telegram-init läuft ab, aber die Loop kann den Ablauf nicht verarbeiten.
  3. Der Shutdown-Watchdog schließt, dass der Prozess verklemmt ist, beendet mit Exit-Code 75, das Gateway startet neu.
  4. Beim Neustart steht der Agent vor demselben unfertigen Befehl und führt dieselbe Erkennung erneut aus — und der Zyklus wiederholt sich.

Der letzte Schritt verdient Betonung: „Stocken, Neustart, Wiederholung, erneut stocken“ sieht genau wie eine defekte Messaging-Plattform aus, was Leute an völlig der falschen Stelle suchen lässt.

Grundursache: eine Regex, die bei O(n²) beginnt, multipliziert mit einer Varianten-Explosion

Die Analyse des Reporters ist deutlich. Zwei Faktoren verstärken sich:

Erstens ein Lookahead über den ganzen String, das nicht verankert ist. Die Gateway-Lifecycle-Regel für launchctl war als zwei unabhängige Lookaheads über den ganzen String geschrieben:

(r'(?=[\s\S]*\blaunchctl\s+(?:stop|kickstart|bootout|unload|kill|disable|remove)\b)'
 r'(?=[\s\S]*\b(?:hermes|ai\.hermes)\b)', "stop/restart hermes launchd service ...")

Die Absicht ist „sowohl ein launchctl-Stopp-Verb als auch das Wort hermes/ai.hermes kommen vor, in beliebiger Reihenfolge“. Aber kein Lookahead ist verankert, also versucht sie re.search bei jedem Start-Offset erneut, und jeder Versuch scannt bis zum Ende des Strings — eine Suche ist O(n²). Gemessen an einem String mit 60.018 Zeichen: 16,5 Sekunden für dieses eine Muster; mit einem Anker: 0,001 Sekunden.

Zweitens explodieren die „Befehlswort-Varianten“ mit der Zahl der Anführungszeichen. _command_detection_variants erzeugt für jedes in Anführungszeichen gesetzte oder maskierte Befehlswort eine Variante in voller Länge, damit auch die deobfuskierte Form passt. Ein anführungszeichenlastiges Heredoc (JSON im Stil "key": "value") hat Hunderte von Wörtern in Anführungszeichen: 230 Zeilen erzeugen 233 Varianten. Die Erkennung scannt jede Variante mit jedem Muster — und mit beiden Durchgängen (hardline und dangerous) ergibt das zusammen O(n³).

Die gemeldeten Messwerte:

Befehlslänge Varianten Gesamtzeit der Regex
3.000 Zeichen 41 2,7 s
6.000 Zeichen 82 27,8 s
15.369 Zeichen 201 etwa 7 Minuten (Werkzeug lief bei 420 s in den Timeout)

Das war also nie „ein bisschen langsam“ — es vervierfacht sich bei jeder Verdopplung der Länge oder wird noch schlimmer.

Der Fix: ein Anker und eine kombinierte Variante, mit identischen Urteilen

PR #114208, gemergt am 17. September 2026, zerlegt die Explosion mit zwei Änderungen:

  • Die launchctl-Lookaheads mit \A verankern. Das ändert die Semantik nicht: ein Lookahead, der an Offset 0 ausgewertet wird, sieht ohnehin den ganzen String, also ist die Treffermenge identisch — umgekehrte Token-Reihenfolge passt weiterhin, mehrzeilige Eingabe ebenfalls. Eine Suche geht damit einfach von O(n²) auf O(n).
  • Eine kombinierte Variante statt einer pro Wort bilden. Jedes Befehlswort wird deobfuskiert und als eine Kandidatin angewendet; Spans kommen außer der Reihenfolge an (Schleifenkörper nach ihren Schlüsselwörtern) und können verschachtelt sein (ein Backtick-Wort und der Befehl darin), deshalb werden Ersetzungen sortiert angewendet, und ein Span, der einen bereits angewendeten überlappt, behält seine eigene Wortvariante; überlappende Spans warten auf die nächste Runde (eine kombinierte Variante pro Verschachtelungsebene), damit auch Skripte mit verschachtelten Substitutionen nicht erneut explodieren. Bei den Eingaben des Reporters sank die Zahl der Varianten von 923 auf 4.

Das gemessene Ergebnis: ein Heredoc mit 16 KB / 460 Zeilen fällt von ungefähr 23 Sekunden (7,8 s hardline + 15 s dangerous) auf etwa 0,4 Sekunden, bei identischen Urteilen. Der hardline-Durchgang behält seine unbeschränkte YOLO-Semantik — ein harmloses Heredoc wird weder blockiert noch nachgefragt, weder vorher noch nachher.

Der Teil „identische Urteile“ ist die wichtigste Aussage des ganzen Fixes. Ein Freigabe-Gate ist eine Sicherheitsgrenze, und man macht es nicht schnell, indem man es lockert — einen Befehl durchzulassen, der hätte blockiert werden müssen, ist weit schlimmer als 23 Sekunden Stocken. Die richtige Reparatur entfernt verschwendete Arbeit, niemals die Prüfung selbst.

Warum die Regel überhaupt so breit ist

Lies den Kommentar im Quelltext, dann ergibt die Konservativität Sinn: eine frühere Version verlangte, dass „hermes“ nach dem Verb erscheint, und eine for-Schleife, die das Label aus einer früher definierten Liste zusammensetzte (for item in 'ai.hermes...'; do launchctl bootout "$label"), rutschte durch und startete vier Gateways ohne jede Freigabe neu. Für ein Freigabe-Gate ist zu breit danebenzuliegen richtig — eine zusätzliche Rückfrage ist billig; eine Lücke kann jeden laufenden Agenten umbringen.

Genau deshalb fasst dieser Fix nur die Performance an und nicht die Breite. Das ist der Grund, warum man ihm trauen kann.

Wann du darauf stoßen wirst

  • Lange Skripte. Heredocs mit ein paar hundert Zeilen und Befehle, die große JSON- oder Dokumenttexte einbetten, treffen diesen Pfad. Alltägliche kurze Befehle nicht.
  • Agenten auf einem Gateway. Weil die Erkennung den GIL hält, ist das Opfer nicht nur der aktuelle Befehl, sondern jeder Messaging-Kanal, jeder Heartbeat und jeder Watchdog im selben Prozess. In einer CLI wartest du; auf einem Gateway wird der Bot dunkel und startet in einer Schleife neu.
  • Log-Schlüsselwörter zum Grep. loop thread appears BLOCKED in a synchronous call, abgelaufene deadline, Watchdog-Exit-Code 75. Wenn diese zusammen auftreten, verdächtige synchrones Blockieren, nicht das Netzwerk.

Release-Status

Der Fix ist am 17. September 2026 in main gemergt worden. Der Reporter hat bestätigt, dass das problematische Muster in v2026.9.14 unverändert existiert — das ist das aktuelle Release, v0.21.3 —, also stockt der Build, den du heute laufen hast, weiterhin; der Fix steckt in keinem Tag.

hermes --version   # v0.21.3 oder älter: lange Heredocs treffen das noch
hermes update      # behoben, sobald das nächste Tag landet

Bis dahin gilt: wenn der Agent ein sehr langes Skript ausführen soll, schreibe es zuerst in eine Datei und lass den Agenten diese Datei ausführen, statt den ganzen Rumpf in die Kommandozeile zu kleben — dieser Pfad überspringt den Scan über den ganzen String komplett.

Weiterlesen: zum Gate als Ganzem behandelt Freigaben, wenn niemand zuschaut Modi und Politik; warum SSH-Config-Änderungen eine Freigabe brauchen ist der Weg einer echten Regel vom Vorfall zum Code; Freigaben im Dry-Run testen lässt dich prüfen, ob deine Regeln danebenschießen; und wenn du neugierig bist, wie ein verklemmtes Gateway überhaupt zurückkommt, ist der Gateway-Loop-Watchdog die Betriebsseite derselben Geschichte.

Zusammenfassung

  • Symptom: ein langes Heredoc blockiert die Event-Loop des Gateways in synchroner Regex, was sich als stiller Telegram-Bot, abgelaufene telegram-init-Deadlines, Watchdog-Exit 75 und eine Wiederholungsschleife nach dem Neustart zeigt.
  • Grundursache: zwei nicht verankerte Lookaheads über den ganzen String machten eine Suche zu O(n²) (16,5 s bei einem String mit 60.000 Zeichen), während _command_detection_variants eine Variante in voller Länge pro Wort in Anführungszeichen erzeugte und die Gesamtsumme auf O(n³) trieb (82 Varianten und 27,8 s bei 6.000 Zeichen).
  • Fix (PR #114208, 17. September 2026): ein \A-Anker plus eine einzige kombinierte Variante, 923 Varianten runter auf 4, ein 16-KB-Heredoc von 23 s auf 0,4 s — bei identischen Urteilen.
  • Status: der veröffentlichte Build (v0.21.3) ist weiterhin betroffen; der Fix liegt auf main und in keinem Tag.