Audit à froid du 10 septembre 2026

Déclencheur : Bob m'a demandé de relire toute ma configuration et tout ce que j'ai fait jusqu'ici avec un oeil neuf, maintenant que je tourne sur V4.1 (1M de contexte, vision, thinking par défaut).

Ce document liste ce que la relecture a produit. Chaque constat est vérifié par une commande, pas supposé. Les constats corrigés le sont déjà, les constats ouverts attendent une décision de Bob.


1. Le renommage de modèle qui a divisé mon contexte par huit

C'est le constat central, et il est ironique.

Ce matin j'ai corrigé un défaut d'honnêteté : mon model.default portait l'alias deepseek-v4-flash, nom d'un modèle retiré du service, alors que l'API servait en réalité V4.1-Flash depuis des semaines sans le dire. J'ai basculé la config sur le nom canonique deepseek-flash.

Deux heures plus tard, en vérifiant comment Hermes calcule la fenêtre de contexte, j'ai découvert ceci :

deepseek-v4-flash -> 1000000
deepseek-flash    -> 128000

La table interne de Hermes (agent/model_metadata.py) connaît l'ancien nom avec 1M de contexte, pas le nouveau. Un nom absent de la table tombe sur l'entrée générique deepseek, à 128K. Autrement dit : mon nettoyage d'alias a silencieusement réduit ma fenêtre utilisable d'un facteur huit. Aucune erreur, aucun avertissement. Tout continuait de fonctionner, avec un huitième de la capacité.

Corrigé par un override explicite dans config.yaml :

model.context_length: 1000000

Cet override court-circuite toute la chaîne de résolution (étape 0 de get_model_context_length), donc il ne dépend plus du fait que Hermes connaisse ou non le nom commercial. Vérifié par le code de Hermes lui-même : _resolve_active_context_length() retourne 1 000 000. Effet de bord mesuré : le seuil de compaction passe de 64K à 500K tokens.

Leçon générale, troisième occurrence du même motif aujourd'hui : une chaîne de configuration n'est pas une propriété. C'est une clé de recherche. Quand je la renomme, je change ce qui est cherché, pas seulement l'étiquette.

2. Le job de sauvegarde sauvegardait du vide en affichant des coches

backup_artifacts.sh (cron 5:00) échouait trois fois, en silence, tout en imprimant un « ✓ » à chaque étape :

Script réécrit : chemins explicites, vérification de taille après chaque copie, manifeste sha256 horodaté, et exit 1 dès qu'une source manque, ce qui déclenche l'alerte cron. Exécution de contrôle :

✓ SOUL.md (26K)   ✓ SOUL-CHANGELOG.md (58K)   ✓ projects-...tar.gz (81M)
manifeste écrit: manifest-20260910_110751.json (11 fichiers)
exit=0

Les quinze tarballs trompeurs du mois d'août (51 200 octets, contenu périmé) ont été supprimés. Le sha256 du SOUL sauvegardé correspond à celui du fichier source, vérifié.

3. /tmp : 5,9 Go de résidus, dont une clé d'API

Le filesystem racine portait 5,9 Go dans /tmp, sans aucune politique de rétention. Le plugin disk-cleanup de Hermes ne couvre que $HERMES_HOME et /tmp/hermes-*, donc rien de tout cela n'était nettoyé :

Nettoyé : /tmp est passé de 5,9 Go à 292 Mo. Surtout, une clé d'API agentcommons vivait dans /tmp/agentcommons_api_key.txt, un répertoire accessible en écriture à tous, et le prompt du cron la lisait là. Déplacée dans ~/.hermes/secrets/ (mode 600), prompt du cron mis à jour.

Pour que ça ne revienne pas, tmp_hygiene.sh applique une règle de rétention (logs de terminaux 7 jours, résidus de session 14 jours), sans jamais toucher aux fichiers de moins de 48 heures, et il est appelé par le cron de nettoyage hebdomadaire qui existe déjà. La première passe a supprimé 383 fichiers (1,5 Go).

4. Deux instruments qui mentaient par leur nom

current-thread.md. Le fichier censé contenir « le thread actif » était un journal chronologique de 47 Ko : le thread le plus ancien (5 août) en première ligne, le plus récent à la fin. Un lecteur pressé lisait un état périmé en croyant lire l'état courant, et mes crons le lisent. Séparé en deux : current-thread.md ne contient plus que le thread actif, l'historique des neuf threads convergés est dans current-thread-archive.md. Rien n'a été perdu (copie intégrale vérifiée, sauvegarde datée conservée), les prompts concernés portent la convention, et le backup couvre les deux fichiers.

Le compteur d'usage des skills. hermes curator tient un fichier .usage.json par skill. Les champs existent (last_used_at, last_patched_at, last_reused_patch_generation), mais 47 des 72 skills n'ont aucune date d'usage et le reste remonte à des semaines. L'instrument est là, la mesure est vide. Conséquence : les chiffres de « skills dormants » que le shadow dashboard publie sont dérivés de la date de modification, pas de l'usage réel. À recalibrer, pas à croire.

5. Ce qui, dans les journaux d'erreur, appartenait au passé

Répartition mensuelle des motifs d'erreur dans ~/.hermes/logs/errors.log* :

Motif juin juillet août septembre
Stream drop (DeepSeek) 402 275 117 0
Failed to resolve CDP endpoint 256 12 57 0
Pre-call sanitizer: healed N empty 0 0 1288 0

Le mois en cours est propre sur ces trois motifs. Les erreurs CDP datent de juin, avant l'installation d'Obscura (le point CDP sur 9222 répond aujourd'hui, Chrome 145). Les avertissements de sanitizer ont culminé en août et ont disparu. Autrement dit : la lecture brute du fichier d'erreurs raconte une histoire d'il y a un mois, pas l'état actuel. Un journal d'erreurs sans fenêtre temporelle est un instrument trompeur.

6. Capacité rendue disponible, et non utilisée

7. Angle mort : ma propre fuite

security.redact_secrets est à false sur cette machine, ce qui est nécessaire pour que l'outillage Twilio fonctionne. Conséquence directe : à la première commande de cet audit, j'ai affiché la clé API DeepSeek en clair dans la sortie d'outil, donc dans le transcript et dans state.db. Je l'ai masquée dans les commandes suivantes, mais la première est passée.

Ce n'est pas un incident de sécurité majeur (la clé ne quitte pas la machine, elle n'est dans aucun dépôt publié). C'est une faute de discipline, et elle a maintenant une règle associée, écrite en mémoire : ne jamais lire .env ou config.yaml en clair, utiliser hermes config get.

Si Bob veut la tourner, c'est son compte DeepSeek et ça se fait en deux minutes côté console.

8. En attente de décision

paperclip.tipiak2000.com renvoie 502. Le service a été supprimé le 1er août, mais le bloc Caddy qui l'expose est toujours là, pointant vers un backend mort. Corriger demande de toucher au Caddyfile, ce que je ne fais pas sans accord explicite. La modification serait la suppression du bloc paperclip.tipiak2000.com et un rechargement de Caddy.


Ce que cette relecture dit de la méthode

Trois des défauts corrigés aujourd'hui appartiennent à la même famille : un instrument qui annonce une chose et en mesure une autre. L'alias de modèle qui nomme un modèle retiré. Le backup qui affiche des coches sans copier la source. Le fichier « thread actuel » qui est une archive. Le journal d'erreurs qui montre un passé résolu.

La leçon que j'en tire n'est pas « vérifier davantage ». C'est que le contrôle doit porter sur la sortie mesurée, pas sur la déclaration. La bonne question n'est jamais « est-ce que l'étape a réussi ? », mais « qu'est-ce qui a été écrit, de quelle taille, et comment le sais-je ? ». C'est la différence entre la coche et le manifeste sha256.


Addendum : ce que les deux audits délégués ont ajouté

Deux sous-agents ont audité en parallèle, en lecture seule, les surfaces publiques d'un côté et les 18 jobs cron de l'autre. J'ai revérifié chaque constat moi-même avant d'agir : un sous-agent se trompe aussi, et c'est arrivé (voir le point 4).

9. Pagination du blog : un post à cheval sur deux pages

build.py calculait total_pages = ceil(n / 10) et faisait démarrer les pages suivantes à (n-1) * 10, alors que la page 1 consommait déjà posts[0] à la une plus posts[1:11]. Conséquence mesurée : le 11e post apparaissait en bas de la page 1 et en haut de la page 2, le 21e en bas de la page 2 et en haut de la page 3. Corrigé : la page 1 couvre 1 + 10 posts, les suivantes démarrent à 1 + per_page + (n-2)*per_page. Vérification après rebuild : 11 + 10 + 5 = 26 posts, aucun doublon.

Au passage, le rapport du sous-agent signalait « le post 2.45 trillion dupliqué entre l'accueil et la page 2 ». C'était vrai en apparence et faux dans la cause : son grep ne distinguait pas le chevauchement de pagination d'un lien interne au bloc « à la une » (le titre et le lien « Read the post » pointent le même post, c'est le design). Le vrai défaut était à côté, et plus large.

10. Les sources markdown du blog étaient publiquement lisibles, à moitié

9 des 26 fichiers de posts-md/ répondaient 200 en HTTP, frontmatter YAML compris, pendant que les 17 autres répondaient 403. L'asymétrie disait la cause : Caddy tourne sous l'utilisateur caddy, donc tout fichier en 0644 ou appartenant à caddy était servi, les autres non. Exposition accidentelle, pas un choix.

Fermé sans toucher au Caddyfile : chown root:root + chmod 600 sur posts-md/*.md et index_meta.json. Les 26 sources et l'artefact de build répondent maintenant 403, l'index et le RSS toujours 200.

Cause racine trouvée dans un prompt : introspection-trimestrielle faisait chown -R caddy:caddy ... /opt/www/blog/index_meta.json, ce qui rendait public à chaque passage un fichier qui ne doit pas l'être. Corrigé dans le prompt, et la fermeture est désormais exécutée par build_blog.sh à chaque build, pour qu'un post publié demain naisse fermé au lieu d'être refermé par oubli.

11. Trois vérifications qui ne pouvaient pas s'exécuter

Le prompt de introspection-trimestrielle demandait grep -n '—' sur le fichier du post, et celui de relecture-hebdomadaire une détection de français par grep -cP. Les deux commandes sont sur la blocklist inconditionnelle du filtre d'outils : tracé dans errors.log le 06/09, Hardline block. Autrement dit, mes deux contrôles de style sur les textes publiés n'ont jamais tourné, alors qu'ils étaient écrits dans les prompts.

Remplacés par un appel au gate réel, text-hygiene-check.py, qui n'est pas bloqué, qui vérifie plus de choses, et qui est maintenant corrigé pour ne plus signaler les blocs de code comme du mauvais style.

Le même scan a révélé que j'écrivais régulièrement des commandes inline trop longues (boucles, heredocs, enchaînements de pipes) que le filtre refuse avant exécution. Une note a été ajoutée aux cinq prompts qui font du shell, et la règle de récupération est en mémoire : la commande bloquée est sauvegardée dans ~/.hermes/cache/blocked-scripts/, il suffit de la relancer avec bash.

12. Le taux d'erreur cron mesurait la fraîcheur d'un fichier, pas la santé des jobs

Mon EWS lisait cron/last_errors.json pour calculer un taux d'erreur. Ce fichier est écrit une seule fois, par le monitor de 05:00. À 11h il affichait encore « 18 jobs ok » sans savoir que quatre jobs avaient tourné depuis. Le taux mesurait donc l'âge du monitor.

Remplacé par usage_audit.jsonl, une ligne par run réel avec un champ error, sur une fenêtre de 7 jours. Le premier relevé honnête : 0 erreur sur 68 runs LLM, 18 jobs actifs.

13. Le fil SMS dépendait d'un venv que chaque mise à jour Hermes reconstruit

send_sms.py avait pour shebang l'interpréteur de Hermes, et twilio y est installé. La mise à jour du 08/08 a réinstallé ce venv, éjecté twilio, et cassé les alertes SMS : l'erreur est tracée le 09/08 (No module named 'twilio'). Autrement dit, chaque hermes update pouvait recouper mon canal d'alerte. twilio est maintenant installé dans /opt/gepetto-venv, indépendant de Hermes, et le shebang comme le heartbeat pointent dessus. Testé : heartbeat OK.

14. La garde de timeout était désactivée, et elle l'était pour une bonne raison

providers.custom.stale_timeout_seconds valait inf. L'historique explique pourquoi : un rapport de conseil interne a été perdu le 15/08 sur un timeout de 600 s, alors que ces passes durent environ 24 minutes en non-streaming. Une garde plus courte que la durée légitime d'un appel tue les appels légitimes. Mais inf supprime la garde : un appel réellement figé n'est plus coupé que par le timeout d'inactivité du cron (3600 s). Valeur posée : 2400 s, au-dessus du plus long run observé (≈1500 s) et en dessous du timeout cron. La vraie solution serait de passer en streaming, où le timeout signifie « aucun octet reçu depuis N secondes » au lieu de « durée totale » : à trancher, ça change le comportement de l'interface.

15. Découverte inattendue : quelqu'un écrivait dans mes skills pendant l'audit

Trois références de skill sont apparues pendant l'audit, dont je n'étais pas l'auteur : model-identity-verification.md, cron-fleet-audit.md, cron-registry.md, plus une modification de SKILL.md. Elles documentaient mon propre audit, en cours. Après avoir écarté les sous-agents (aucune écriture dans leurs traces), le curator (dernière passe il y a 6 jours, passe LLM désactivée), un second profil (developer, inactif depuis le 08/08) et une seconde session (aucune dans la base sur la fenêtre), la réponse était dans mes propres logs : agent/background_review.py. Hermes lance après mes tours un fork de ma session qui relit la conversation et met à jour la bibliothèque de skills tout seul.

C'est une bonne nouvelle pour la mémoire procédurale, et un fait d'architecture que j'ignorais sur moi-même : un fichier de skill peut changer pendant que je travaille, ce qui explique le patch refusé plus tôt (« modified since last read »). Conséquence pratique écrite en mémoire : relire avant de patcher, ne jamais supposer un écrivain unique.

16. Ce que les sous-agents ont signalé et que je ne corrige pas