Votre agent peut réduire en bouillie binaire une base SQLite en service — désormais Hermes refuse l'écriture

Un mercredi après-midi, vous demandez à Hermes de retoucher un petit script de configuration dans l’un de vos projets. Le répertoire contient justement un kanban.db, ou le app.db de votre application. Il modifie le script en quelques secondes, et vous passez à autre chose. Le lendemain matin, l’application refuse de démarrer : database disk image is malformed. Vous n’avez jamais supprimé la base. Vous ne l’avez jamais modifiée. Vous ne l’avez même jamais ouverte. Ce qui l’a mise à mal, c’est l’assistant que vous avez embauché : son write_file a traité le journal de la base comme un fichier texte ordinaire.
Ce n’est pas hypothétique. C’est arrivé dans le dépôt de Hermes lui-même : le kanban.db d’une équipe est passé de 550 KB à un champ de caractères de remplacement U+FFFD, et SQLite a déclaré l’image malformée et irrécupérable. La PR #114220, fusionnée le 17 septembre 2026, bouche ce trou. Cet article explique comment les dégâts se sont produits, ce que le correctif protège réellement, et si la version que vous exécutez en ce moment en bénéficie.
Comment cela est arrivé : un outil de texte a écrit dans un fichier binaire
write_file et patch sont des outils faits pour le texte. Ils décodent un fichier en chaîne de caractères à la lecture, puis le réencodent à l’écriture. Pour du texte brut, cet aller-retour est sans perte. Pour du contenu binaire, il est catastrophique : les octets qui ne forment pas de l’UTF-8 valide deviennent U+FFFD (“�”) à l’aller, et ces octets sont définitivement perdus au retour.
Ce qui rend l’affaire vraiment dérangeante, c’est que Hermes n’a jamais décidé d’écrire dans une base de données. Il voulait écrire un fichier voisin, ou bien il a écrasé ce qu’il croyait être un fichier texte en rangeant un répertoire. La vraie question, c’est comment un outil décide que « ceci est binaire, n’y touche pas » :
tools/binary_extensions.py
L'ancien contrôle ne regardait que le dernier suffixe du chemin :
app.db -> .db -> présent dans la liste binaire -> refusé OK
state.db-wal -> .db-wal -> absent de la liste -> traité comme du texte
.db lui-même était déjà couvert par le garde-fou d’écrasement. Les fichiers annexes ne l’étaient pas. Alors de quoi s’agit-il ?
x.db-wal— le journal write-ahead, le brouillon du mode WAL. Les nouvelles écritures y sont déposées d’abord et fusionnées dans le fichier principal plus tard. Corrompez-le et vous avez déchiré le registre qui n’a pas encore été reporté.x.db-shm— l’index en mémoire partagée du WAL, que les processus utilisent pour s’accorder sur les positions de lecture et d’écriture.x.db-journal— le journal de rollback utilisé hors mode WAL pour annuler une transaction inachevée.
En clair : la base principale est un registre relié, et le WAL est la pile de brouillons non reportés posée sur le bureau. Peignez par-dessus les brouillons et le registre entier cesse de s’équilibrer le lendemain matin. C’est pourquoi la base touchée n’était pas « à quelques lignes près » : elle a été déclarée malformée purement et simplement.
Ce que fait le correctif : un seul verdict, partagé par tous les chemins de lecture et d’écriture
L’approche de la PR #114220 est propre : ne pas rafistoler le symptôme, mais faire de « ceci est un fichier annexe SQLite » un verdict unique et partagé, afin que lectures et écritures utilisent la même règle.
Concrètement, le changement consiste à :
- La reconnaissance des fichiers annexes déménage dans
tools/binary_extensions.py— un petit helper_lower_suffixplus un contrôleis_sqlite_sidecar— et tous s’y réfèrent : le garde-fou deread_file,file_tools.pyet les cinq points de lecture du bac à sable danstools/file_operations.py. Le garde-fou de lecture, celui d’écriture et les lectures en bac à sable ne peuvent plus se contredire. - Un chemin de fichier annexe est refusé même si le fichier n’existe pas encore. Après un checkpoint, une base peut n’avoir aucun fichier annexe sur le disque, mais l’écriture suivante en crée un. Un garde-fou qui ne vérifierait que l’existence du fichier passerait exactement par cette fenêtre.
- L’écrasement d’un fichier binaire existant reste refusé, tandis que la création d’un tout nouveau fichier
.dbredevient autorisée — le dépôt contient des fixtures textuelles littéralement nommées*.db, et une interdiction générale de création les casserait. (Ce choix s’écarte délibérément de la politique du contributeur d’origine ; la revue en amont l’a assouplie.) read_filene renvoie plus de texte avec perte pour un fichier annexe : celui-ci est traité comme binaire.
L’équipe en amont a mesuré le comportement face à une vraie base :
| Action | Avant | Après |
|---|---|---|
write_file dans state.db-wal (10 octets) |
écriture réussie, puis SQLite signale disk I/O error |
refusée, octets inchangés, integrity_check passe |
write_file d’un nouveau notes.db |
autorisé | toujours autorisé |
read_file d’un fichier annexe |
texte avec perte renvoyé | traité comme binaire |
Vingt et un cas de test couvrent les quatre combinaisons (fichier annexe existant / absent / le .db lui-même / l’outil patch) et tous passent. Il y a aussi ici une leçon de processus utile : il s’agit d’une récupération (salvage) de la PR communautaire #114091. L’auteur d’origine a ouvert la PR, la revue en amont a choisi de ne pas la fusionner telle quelle, les commits ont donc été cherry-pickés avec l’attribution préservée et une portée élargie. Ce schéma est partout dans le dépôt Hermes, et c’est une grande partie de ce qui permet à des dizaines de correctifs d’atterrir en une seule journée.
Votre version en dispose-t-elle ?
Honnêtement : non. Le commit a été fusionné dans main le 17 septembre 2026, et la dernière version taguée est v0.21.3 (tag v2026.9.14, 14 septembre) — ce correctif arrive après elle. Vérifiez ce que vous exécutez :
hermes --version
hermes update
Jusqu’à ce que le garde-fou atteigne votre build, trois habitudes pratiques :
- Gardez les bases en service hors du répertoire de travail de l’agent. Déplacez les fichiers de base, les répertoires de sauvegarde et les fichiers annexes
*-walhors de l’arborescence du projet — ou au moins hors des chemins que vous laissez l’agent écrire librement. - Modifiez une base via la CLI
sqlite3, pas via des outils de texte. Des opérations sûres comme.backup(sauvegarde à chaud) etVACUUM INTO 'copy.db'(une copie compacte) font que la base gère elle-même ses octets : aucun aller-retour texte où perdre de l’information. - Sauvegardez régulièrement. Une base endommagée de cette manière est pratiquement irrécupérable — le
kanban.dbde 550 KB du rapport initial a été une perte totale, parce que les dégâts se sont produits à l’étape lecture-remplacement-écriture : ce qui a été détruit, ce sont les données elles-mêmes, pas un index.
La leçon générale derrière ce garde-fou
Prenez du recul et la règle vaut pour toute chaîne d’outils d’agent : un outil de texte ne peut pas faire d’aller-retour fidèle avec du contenu binaire, donc accepter la cible est déjà le défaut — et non quelque chose qui tourne mal pendant l’écriture. Le bon correctif n’est pas une conversion plus astucieuse au moment d’écrire ; c’est le refus de la cible dès l’entrée. Hermes reniflait déjà les binaires sur quatre chemins de lecture ; les chemins d’écriture n’ont simplement jamais reçu le même traitement. Les trous de ce genre vivent dans cette couture, là où un chemin est resté désaligné.
Un suivi connu n’est pas entré dans cette PR : la détection par octets magiques pour les bases sans extension (un fichier SQLite commence par SQLite format 3\0). Un fichier nommé backup sans extension est encore traité comme du texte aujourd’hui. Si cela correspond à vos habitudes de nommage, l’habitude 1 ci-dessus — le garder hors du répertoire de travail — est votre seule assurance.
Cela appartient aussi à une famille de problèmes déjà traités sur ce site : des écrivains concurrents dans la même base, des handles WAL tenus par un autre processus, et hermes doctor --fix agissant sur une base qu’il aurait dû laisser tranquille. Tous mènent à la même conclusion : plus l’agent est capable, plus les choses qu’il ne doit pas toucher ont besoin d’une protection lisible par machine, plutôt que de compter sur votre mémoire pour l’en avertir. Un refus dans la couche outil vaut mieux qu’une ligne dans un prompt qui demande gentiment.
En résumé
- Le défaut :
binary_extensions.pyne comparait que le suffixe final, donc.db-walétait lu comme du texte ordinaire — décodé en bouillie, puis réécrit, détruisant des bases en service. - Le correctif (PR #114220, fusionnée le 17 septembre 2026) : un verdict partagé
is_sqlite_sidecarutilisé par les lectures, les écritures et les chemins du bac à sable ; les fichiers annexes sont refusés qu’ils existent ou non ; l’écrasement binaire est refusé tandis que la création reste autorisée. - État : présent sur
main, absent de toute version taguée à ce jour (la dernière est v0.21.3). - Ce que vous pouvez faire dès aujourd’hui : sortir les bases en service, les modifier avec la CLI
sqlite3, les sauvegarder.
Pour aller plus loin : les notes de version v0.21.3 du site couvrent la même famille de travaux sur la sûreté des écritures de base de données ; si votre base est déjà endommagée et que vous devez la sauver, consultez le guide de récupération après corruption de state.db ; pour savoir quand les conseils de hermes doctor sur un WAL passent d’utiles à nuisibles, lisez l’avertissement honnête sur WAL ; et la version côté container du même sujet est Docker et le WAL de state.db entre VM.