Un script de 16 KB a gelé un gateway pendant 23 secondes : l'autopsie du scan de commandes

Vous confiez à Hermes un script de quelques centaines de lignes — un heredoc Python qui intègre du texte de document dans des charges JSON — et vous lui demandez de l’exécuter. Le terminal devient silencieux. Un moment plus tard, vous demandez l’avancement dans Telegram et le bot ne répond pas. Dans le journal, vous trouvez une série de lignes qui n’expliquent rien : deadline (15s) expired but the event loop has not processed the expiry, the loop thread appears BLOCKED in a synchronous call, puis le watchdog d’arrêt, une sortie, un redémarrage — et ensuite l’agent relance la même commande et tout recommence. Cela ressemble à un problème de réseau ou à une défaillance de la plateforme de messagerie. En réalité, cela concerne quelque chose qui semble purement relever de la sécurité : la détection des commandes dangereuses.
Le symptôme : pas « lent », mais une boucle d’événements immobilisée
Avant d’exécuter une commande, Hermes doit décider si cette commande est dangereuse — ce qui détermine si elle passe sans encombre, vous demande confirmation, ou est bloquée d’emblée. Ce contrôle tourne dans le moteur de regex de Python et retient le GIL. Plus la commande est longue, plus il occupe le processus ; et ce qu’il occupe n’est pas le créneau d’une commande, mais la boucle d’événements entière.
Un utilisateur l’a signalé avec une reproduction complète (#113535, 16 septembre 2026, image officielle 0.20.6), et la chaîne de défaillance mérite d’être lue une fois :
- La détection prend des minutes sur une longue commande et affame la boucle d’événements.
- Le polling Telegram expire ; la deadline de 15 secondes de
telegram-initarrive à échéance mais la boucle ne peut pas traiter l’expiration. - Le watchdog d’arrêt conclut que le processus est coincé, sort avec le code 75, le gateway redémarre.
- Au redémarrage, l’agent retrouve la même commande inachevée et relance la même détection — et le cycle se répète.
Cette dernière étape mérite d’être soulignée : « blocage, redémarrage, nouvelle tentative, blocage à nouveau » ressemble exactement à une plateforme de messagerie en panne, ce qui envoie les gens chercher au mauvais endroit.
La cause racine : une regex qui part de O(n²), multipliée par une explosion de variantes
L’analyse du rapporteur est sans détour. Deux facteurs se cumulent :
Premier facteur : une lookahead non ancrée sur toute la chaîne. La règle launchctl du cycle de vie du gateway était écrite comme deux lookaheads indépendantes portant sur toute la chaîne :
(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 ...")
L’intention est « un verbe d’arrêt launchctl et le mot hermes/ai.hermes apparaissent tous les deux, dans n’importe quel ordre ». Mais aucune des deux lookaheads n’est ancrée, donc re.search les retente à chaque offset de départ, et chaque tentative rescanne jusqu’à la fin de la chaîne — une seule recherche est en O(n²). Mesuré sur une chaîne de 60 018 caractères : 16,5 secondes pour ce seul motif ; avec une ancre, 0,001 seconde.
Deuxième facteur : les « variantes de mots de commande » explosent avec le nombre de guillemets. _command_detection_variants produit une variante de pleine longueur pour chaque mot de commande entre guillemets ou échappé, afin de pouvoir correspondre à la forme désobfusquée. Un heredoc riche en guillemets (du JSON de style "key": "value") contient des centaines de mots entre guillemets : 230 lignes produisent 233 variantes. La détection scanne chaque variante avec chaque motif — et avec les deux passes (hardline et dangerous), le total est en O(n³).
Les mesures rapportées :
| Longueur de la commande | Variantes | Temps regex total |
|---|---|---|
| 3 000 caractères | 41 | 2,7 s |
| 6 000 caractères | 82 | 27,8 s |
| 15 369 caractères | 201 | environ 7 minutes (l’outil a expiré à 420 s) |
Ce n’était donc jamais « un peu lent » — c’est un quadruplement ou pire à chaque doublement de la longueur.
Le correctif : une ancre et une variante combinée, avec des verdicts identiques
La PR #114208, fusionnée le 17 septembre 2026, démonte l’explosion avec deux changements :
- Ancrer les lookaheads launchctl avec
\A. Cela ne change pas la sémantique : une lookahead évaluée à l’offset 0 voit déjà toute la chaîne, l’ensemble des correspondances est donc identique — un ordre de tokens inversé correspond toujours, une entrée multiligne correspond toujours. Une recherche passe simplement de O(n²) à O(n). - Construire une seule variante combinée au lieu d’une par mot. Chaque mot de commande est désobfusqué et appliqué comme un seul candidat ; les plages arrivent dans le désordre (les corps de boucle après leurs mots-clés) et peuvent s’imbriquer (un mot entre backticks et la commande qu’il contient), les remplacements sont donc appliqués triés, et une plage qui chevauche une plage déjà appliquée conserve sa propre variante par mot ; les plages qui se chevauchent attendent le tour suivant (une variante combinée par niveau d’imbrication), de sorte que les scripts à substitution imbriquée ne peuvent pas ré-exploser non plus. Sur l’entrée du rapporteur, le nombre de variantes est passé de 923 à 4.
Résultat mesuré : un heredoc de 16 KB / 460 lignes passe d’environ 23 secondes (7,8 s hardline + 15 s dangerous) à environ 0,4 seconde, avec des verdicts identiques. La passe hardline conserve sa sémantique YOLO sans bornes — un heredoc anodin n’est ni bloqué ni soumis à confirmation, avant comme après.
Ce « verdicts identiques » est l’affirmation la plus importante de tout le correctif. Une barrière d’approbation est une frontière de sécurité, et on ne la rend pas rapide en la desserrant — laisser passer une commande qui aurait dû être bloquée est bien pire que bloquer pendant 23 secondes. Le bon correctif supprime le travail inutile, jamais le contrôle lui-même.
Pourquoi la règle est si large au départ
Lisez le commentaire dans la source et le conservatisme devient logique : une version antérieure exigeait que « hermes » apparaisse après le verbe, et une boucle for qui assemblait le label à partir d’une liste définie plus haut (for item in 'ai.hermes...'; do launchctl bootout "$label") est passée au travers et a redémarré quatre gateways sans aucune approbation. Pour une barrière d’approbation, pécher par excès de largeur est correct — une demande de confirmation en trop ne coûte rien ; un oubli peut tuer tous les agents en cours.
C’est exactement pourquoi ce correctif ne touche qu’aux performances et pas à la largeur. C’est ce qui le rend fiable.
Quand vous le rencontrerez
- Les longs scripts. Les heredocs de quelques centaines de lignes et les commandes qui intègrent du gros JSON ou du texte de document empruntent ce chemin. Les commandes courtes du quotidien, non.
- Les agents sur un gateway. Comme la détection retient le GIL, la victime n’est pas seulement la commande en cours, mais chaque canal de messagerie, heartbeat et watchdog du même processus. Dans une CLI, vous attendez ; sur un gateway, le bot devient muet et redémarre en boucle.
- Les mots-clés de journal à rechercher.
loop thread appears BLOCKED in a synchronous call,deadlineexpirée, code de sortie 75 du watchdog. Quand ils apparaissent ensemble, soupçonnez un blocage synchrone, pas le réseau.
État des versions publiées
Le correctif a été fusionné dans main le 17 septembre 2026. Le rapporteur a confirmé que le motif fautif existe à l’identique dans v2026.9.14 — c’est la version actuelle, v0.21.3 — donc la version que vous exécutez aujourd’hui se bloque encore ; le correctif ne figure dans aucun tag pour l’instant.
hermes --version # v0.21.3 ou antérieure : les longs heredocs touchent encore ce chemin
hermes update # corrigé dès que le prochain tag arrive
En attendant, si vous avez besoin que l’agent exécute un script très long, écrivez-le d’abord dans un fichier et faites exécuter ce fichier par l’agent plutôt que de coller tout le corps sur la ligne de commande — ce chemin contourne entièrement le scan sur toute la chaîne.
Pour aller plus loin : pour la barrière dans son ensemble, les approbations quand personne ne surveille couvre les modes et la politique ; pourquoi les modifications de config SSH exigent une approbation est le parcours d’une vraie règle, de l’incident au code ; tester les approbations en dry-run vous permet de vérifier si vos règles se déclenchent à tort ; et si vous êtes curieux de savoir comment un gateway coincé revient à la vie, le watchdog de boucle du gateway est le versant opérationnel de la même histoire.
En résumé
- Symptôme : un long heredoc immobilise la boucle d’événements du gateway dans une regex synchrone, ce qui se manifeste par un bot Telegram muet, des deadlines
telegram-initexpirées, une sortie 75 du watchdog et une boucle de nouvelles tentatives après redémarrage. - Cause racine : deux lookaheads non ancrées portant sur toute la chaîne rendaient une recherche O(n²) (16,5 s sur une chaîne de 60 K caractères), tandis que
_command_detection_variantsproduisait une variante de pleine longueur par mot entre guillemets, portant le total à O(n³) (82 variantes et 27,8 s pour 6 000 caractères). - Correctif (PR #114208, 17 septembre 2026) : une ancre
\Aplus une variante combinée unique, 923 variantes ramenées à 4, un heredoc de 16 KB passant de 23 s à 0,4 s — avec des verdicts identiques. - État : la version publiée (v0.21.3) est encore touchée ; le correctif est sur
mainet dans aucun tag.