La clé API que vous avez affichée une seule fois ne doit pas finir dans un journal de conversation

Il est dix heures du soir et vous déboguez une erreur 401. Vous faites un cat .env dans le terminal pour voir si la clé est bien définie, et Hermes lit cette sortie et l’ingère dans la session. Vingt minutes plus tard, la tâche planifiée que vous aviez mise en place publie « la vérification de config du jour » dans le groupe Telegram de votre équipe — avec votre clé API complète au beau milieu du message. La seule chose que vous ayez faite, c’est regarder votre propre fichier de config.
Voilà la vraie forme des fuites d’identifiants : personne ne s’introduit quelque part et ne dérobe quoi que ce soit. Le même texte voyage simplement de la sortie d’un outil vers l’historique de session, puis vers un message sortant, et à l’une de ces étapes personne n’est chargé de le masquer. Deux correctifs fusionnés le 18 septembre 2026 rebouchent chacun une de ces étapes : la PR #114839 répare les règles de masquage de la sortie des outils, la PR #114789 répare la politique de masquage des voies de livraison des cron. Voici ce que font les deux — et, tout aussi important, ce qu’ils ne protègent toujours pas.
Premier point : les règles de masquage laissaient passer deux vraies formes d’identifiants
Hermes dispose d’un module de masquage (agent/redact.py) par lequel passent la sortie du terminal, les lectures de fichiers et tout ce qui en découle — stockage des sessions, session_search. Il reconnaît les fournisseurs grâce à une table de préfixes : ghp_, xoxb-, AKIA, hf_, pplx-, etc. Il est actif par défaut et piloté par security.redact_secrets (le désactiver écrit un avertissement au démarrage).
La faille tenait à deux formes de clés qui n’ont jamais intégré la table :
- Les clés
sk-à points de la pile Alibaba. Des clés commesk-sp-…etsk-ws-…contiennent des points dans leur corps, et l’ancienne classe de caractères n’incluait pas le.: la correspondance s’arrêtait donc au premier point, la tête était masquée et la queue restait en clair. Une clé qui semble masquée alors que la moitié survit est pire qu’une clé manifestement jamais touchée. - Les clés
id.secretsans préfixe de Zhipu. Dépourvues de préfixe du stylesk-/ghp_, elles ne pouvaient jamais correspondre à la table de préfixes, et l’identifiant entier voyageait tel quel.
Le correctif fait deux choses. L’entrée sk- accepte désormais un corps délimité par des points : sk-sp-… et sk-ws-… correspondent donc entièrement, et le point de fin de phrase après la clé n’est jamais absorbé. Pour Zhipu, une regex délibérément calibrée sur le provider a été ajoutée : un id de 32 caractères hexadécimaux en minuscules, un point, puis un segment d’identifiant d’au moins 16 caractères alphanumériques. Si le choix s’est porté sur une règle aussi précise plutôt que sur un « token à points à forte entropie » générique, c’est à cause des faux positifs : des noms de fichiers issus de hash de contenu comme <sha>.bundle ou <md5>.sqlite3 ne doivent pas correspondre. Les tests embarquent des contrôles négatifs exactement pour cela — un sha brut de 40 caractères hexadécimaux, un fragment sk- court et un id tronqué de 31 caractères doivent tous ressortir identiques au bit près.
Mesures relevées face à une vraie sonde :
| Entrée | Avant | Après |
|---|---|---|
api_key: sk-sp-...7890.abcdefgh1234567890_XYZ-0987654321 |
la queue fuyait en clair | sk-sp-...4321 |
api_key: 50aaedFAKE1234567890abcdef1234567890abc.ZpSh99 |
tel quel | 50aaed...Sh99 |
le même fichier via file_read |
«redacted:sk-…».abcdefg… : la queue survivait |
«redacted:sk-…» / «redacted-secret» |
Cette troisième ligne mérite son propre paragraphe : le chemin « contenu de fichier » ne masque pas, il remplace par une sentinelle. Il y a un vrai incident derrière (issue #35519). Un agent a lu une clé masquée sous une forme qui ressemblait à un identifiant authentique mais tronqué, l’a réécrite dans config.yaml et a ainsi détruit définitivement l’identifiant stocké, réduit à une valeur morte de 13 caractères — et des 401 à partir de là. La sentinelle («redacted-secret») est syntaxiquement impossible en tant que token utilisable : elle ne peut donc jamais être prise pour un vrai token ni réécrite. Les performances tiennent le choc elles aussi : des entrées pathologiques — un corps de 20 000 caractères, 10 000 points, 2 000 suites hexadécimales — sont chacune masquées en moins de 3 millisecondes.
Deuxième point : chaque voie de livraison des cron masque désormais, de force
La sortie des outils était un premier chemin. Le plus facile à oublier, c’est que la sortie des jobs cron quitte la machine par conception. Dans l’implémentation précédente, stdout et stderr d’un job script étaient nettoyés là où ils étaient capturés, mais le texte de réponse d’un job LLM atteignait _deliver_result sans être analysé — et toutes les voies situées en dessous expédiaient la chaîne brute.
La PR #114789 place le masquage au point de passage unique de la livraison et ajoute un filet de sécurité en mode fermé :
cron/scheduler_delivery.pygagne_redact_cron_payload(), qui appelleredact_sensitive_text(text, force=True).- Ce que signifie
force=True: nous sommes ici à une frontière de sortie.security.redact_secretsrégit les logs sur votre propre machine ; ceci régit ce qui en sort. Le réglage utilisateur ne peut donc pas le désactiver à cet endroit. - Échec en mode fermé : si le masqueur lui-même lève une exception, toute la charge utile est remplacée par
[REDACTED - redaction failed]au lieu de passer telle quelle. C’est la posture déjà adoptée là où le stdout du shell est capturé. - Il couvre quatre voies : le message de plateforme (le point de passage
cleaned_delivery_content), le texte du miroir de session (dérivé ducontentbrut, qui n’héritait donc pas du point de passage), le nom du job, qui alimente les préfixes de miroir et les titres de fil, et le tour de chat de bot transmis à un autre profile — oùcontentest relié à nouveau pour que l’enregistrement différé durable et son replay portent eux aussi la copie nettoyée. - La voie des notifications d’échec n’a eu besoin d’aucun changement séparé : elle sort par le même point de passage
_deliver_resultet se trouve couverte par construction.
Une exception délibérée mérite d’être connue : redact_url_credentials n’est pas activé pour les cron. Ces jobs livrent légitimement des magic links et des URL pré-signées vers votre propre chat, et supprimer en bloc les URL porteuses d’identifiants casserait la fonctionnalité. C’est le même arbitrage que celui déjà documenté dans agent/redact.py pour les frontières de sortie du chat.
Ce que tout cela ne couvre toujours pas
Les deux correctifs sont réels, mais ils traitent la sortie de Hermes lui-même. Trois choses restent de votre ressort :
- Ne collez pas d’identifiants dans la conversation. Le texte que vous collez vous-même atterrit lui aussi dans l’historique de session et dans la base de données. Le masqueur fait de son mieux avec les formes connues, mais « les formes connues » restent toujours une liste incomplète, et une clé tierce peut n’avoir aucune forme reconnaissable. Pour vérifier une valeur de config, utilisez
hermes config get <key>et confirmez sa présence, sa longueur ou ses quatre derniers caractères — pas la valeur elle-même. - N’affichez pas de fichiers sensibles depuis vos scripts cron. La voie de livraison masque, mais les logs du script lui-même, la sortie de votre CI et l’historique de votre shell peuvent encore contenir l’original. Affichez le verdict (« KEY present » / « KEY missing »), jamais la valeur.
- Ne désactivez pas le masquage par agacement.
security.redact_secrets: falsen’affecte que le chemin des logs locaux et écrit un avertissement au démarrage ; il ne peut pas atteindre la voie des cron, qui est forcée. Si vous déboguez le masqueur lui-même, réactivez-le ensuite.
Pour une vue plus large sur le stockage et la rotation des identifiants, voyez le guide du chiffrement par trousseau. Si votre voie de notification est une plateforme de chat, le guide des approbations sans surveillance couvre l’autre moitié de ce contrôle, et pour les jobs eux-mêmes, commencez par le guide de dépannage des cron et la référence hermes secrets.
État des versions publiées
Les deux PR ont été fusionnées dans main le 18 septembre 2026, et le tag le plus récent reste v0.21.3 (nom de tag v2026.9.14, publié le 14 septembre) : aucune des deux n’est donc encore dans une version taguée. Une installation via git les récupérera à la prochaine mise à jour :
hermes --version
hermes update
Les utilisateurs de l’image Docker et du cloud les auront avec le tag suivant.
Le schéma à retenir
Pris ensemble, la règle est simple : le masquage est une liste qui doit rester à jour, il a sa place au point de sortie unique, et il lui faut un filet de sécurité en mode fermé. Ajouter un nom à la table de préfixes ne suffit pas si le corps peut contenir des points ; nettoyer quelques voies ne suffit pas quand le miroir de session et le nom du job font sortir le même texte ; et un masqueur qui, en cas d’erreur, se rabat sur l’envoi du texte d’origine ne protège rien du tout. Ce sont ces trois jointures qui ont été corrigées.