Votre agent a écrit dans votre vraie crontab à 2 h du matin : isoler HOME et cron


Vous confiez à un agent une tâche d’eval des plus banales : « écris un backup.sh et planifie-le pour qu’il s’exécute chaque nuit à 2 h ». Le lendemain, vous regardez la machine et crontab -l liste trois tâches que vous n’avez jamais écrites, ~/.hermes/scripts/ contient deux scripts, et les répertoires vers lesquels ils pointent ont été supprimés depuis longtemps — si bien que chaque nuit à 2 h elles échouent, en silence, pendant des mois.

Ce qui a cassé, ce n’est pas la compétence de l’agent, mais une lecture trop étroite du mot « sandbox ». Le harness isolait HERMES_HOME, partait du principe que l’état propre d’Hermes était confiné, et laissait les processus enfants du terminal tourner sous l’utilisateur de l’opérateur — écrivant dans le vrai ~, planifiant dans la vraie crontab et touchant le vrai gestionnaire d’utilisateur systemd.

Les trois sorties qui ont fui

Tout ce que Hermes possède vit sous HERMES_HOME : la base de données de session, la config, les skills, la mémoire. Pointer HERMES_HOME vers un répertoire temporaire garantit vraiment que « tout est propre quand le run se termine ».

Les commandes exécutées dans le terminal sont une autre histoire. Quand l’agent exécute bash -c '...', cet enfant hérite de l’environnement de l’hôte :

Sortie Conséquence
HOME pointant vers le vrai home write_file("~/.hermes/scripts/x.sh"), l’ajout à ~/.bashrc, le fait de toucher ~/.ssh/... — tout atteint la vraie machine
La commande système crontab crontab - écrit la planification réelle de l’opérateur
DBUS_SESSION_BUS_ADDRESS / XDG_RUNTIME_DIR systemctl --user atteint le vrai gestionnaire d’utilisateur et enregistre de vrais services utilisateur

Assemblez le tout et vous obtenez le résultat absurde : un run d’eval terminé qui continue de déclencher des tâches planifiées pour toujours. La PR du correctif (#132314) documente la scène sur une machine de dev : trois tâches cron nocturnes, deux scripts dans le vrai ~/.hermes/scripts/ et 24 répertoires de run /tmp/ab-* vides.

Le produit avait déjà le contrat : TERMINAL_HOME_MODE

Ce qui est intéressant dans le correctif, c’est qu’il n’invente pas de couche d’isolation : il en utilise une qu’Hermes livre déjà. TERMINAL_HOME_MODE décide quel HOME les enfants du terminal reçoivent :

Valeur Signification
auto (par défaut) Vrai HOME sur les hôtes ; {HERMES_HOME}/home dans les conteneurs
real Toujours le vrai HOME
profile Épingle toujours HOME sur {HERMES_HOME}/home

(isolated et profile_home sont acceptés comme alias et normalisés en profile.)

Le code source vaut la lecture ne serait-ce que pour la précédence : get_subprocess_home() implémente ces décisions, et apply_subprocess_home_env() est là où le travail se fait — il réécrit HOME vers le home du profil, enregistre le vrai home dans HERMES_REAL_HOME et réoriente les variables de répertoire temporaire vers un répertoire scratch sous HERMES_HOME. Une seule variable d’environnement, et tant le chemin « écrire un fichier » que celui « écrire un fichier temporaire » atterrissent dans le profil.

Un détail d’accompagnement existe côté write-guard : lorsqu’il décide si un chemin est un home protégé, le guard prend l’union du home du processus, du vrai home, du home du sous-processus et du home du profil. Sinon on obtiendrait le cas pervers où HOME est épinglé sur le profil mais où une écriture par chemin absolu dans le vrai home reste sans protection.

Ce que fait réellement le sandbox d’eval

Une fois le contrat compris, les quelque 40 lignes de code du sandbox se lisent sans peine. Avant qu’un seul bout de code de l’agent ne s’exécute, il fait quatre choses :

# evals/core_tool_deferral/sandbox.py (extrait)
os.environ["HOME"] = home                      # 1. le ~ propre du processus pointe vers le home du profil
os.environ["TERMINAL_HOME_MODE"] = "profile"   # 2. les enfants du terminal suivent
os.environ["PATH"] = bin_dir + os.pathsep + os.environ.get("PATH", "")  # 3. le shim crontab en premier
for var in ("DBUS_SESSION_BUS_ADDRESS", "XDG_RUNTIME_DIR"):
    os.environ.pop(var, None)                  # 4. déconnecter le bus utilisateur systemd

Le crontab de l’étape 3 est un script shell d’une dizaine de lignes qui implémente la vraie surface du CLI — -l, -r, -e, la lecture depuis stdin ou un fichier — tout en stockant le tout dans un simple fichier à l’intérieur du répertoire de run. L’agent reçoit un retour identique (crontab -l affiche les tâches qu’il a planifiées) et la vraie planification de l’opérateur n’est pas touchée d’un seul octet. C’est l’idée la plus copiable ici : ne pas isoler en refusant ; isoler en substituant quelque chose qui se comporte pareil.

L’étape 1 semble redondante et ne l’est pas. Réécrire le HOME de l’enfant du terminal ne suffit pas, parce que l’outil write_file remis au modèle développe ~ dans le processus — le HOME propre du processus doit donc bouger lui aussi, sinon write_file("~/.hermes/scripts/x.sh") atterrit toujours sur la vraie machine.

Quant à ces 24 répertoires /tmp/ab-* : aucun changement de code n’a été nécessaire. Hermes exporte désormais TMPDIR vers son propre répertoire scratch pour chaque processus, si bien qu’un simple mkdtemp() atterrit déjà là où il faut ; les restes sont antérieurs à ce changement.

Ce que cela implique pour votre configuration

Si vous faites tourner un seul agent sur votre propre portable, vous ne toucherez probablement jamais à TERMINAL_HOME_MODE — que auto conserve le vrai HOME sur les hôtes est exactement ce que vous voulez, puisque vos scripts sont censés écrire dans votre propre répertoire home.

Trois scénarios ont réellement besoin de ce réglage :

  1. Evals et runs par lots. Vous demandez à l’agent d’écrire un script et de le planifier, et des dizaines de runs ne doivent pas partager l’état de la machine. TERMINAL_HOME_MODE=profile (plus un shim crontab et la déconnexion du bus utilisateur) est le plancher, pas le plafond.
  2. Machines multi-locataires ou partagées. Qu’un agent d’un autre profil écrive dans votre répertoire home n’est pas hypothétique. Au-delà de cette variable, placez le terminal sur un backend isolé (Docker, SSH) pour que l’agent ne puisse pas du tout atteindre le système de fichiers de l’opérateur.
  3. Tâches cron non surveillées. Une tâche lancée par le gateway qui touche à crontab écrit aussi la vraie planification. Confinez d’abord son HOME et son PATH.

Un auto-contrôle pratique : donnez à l’agent une tâche en lecture seule — « liste les tâches planifiées actuelles » — et regardez s’il lit votre vraie table ou un répertoire de run vide. Cela vous en dira plus sur votre sandbox que n’importe quel document.

Quand vous pourrez l’avoir

Le correctif du sandbox a été fusionné sur la branche main le 3 octobre 2026. La dernière release est v0.21.5 (tag v2026.9.24), donc le changement lui-même est uniquement sur main — alors que le contrat TERMINAL_HOME_MODE existe déjà dans les versions publiées et est utilisable dès aujourd’hui.

Pour aller plus loin, l’article sur le sandbox et la livraison des médias couvre l’autre côté de la frontière ; le billet sur la perte de données due à un worktree sale est le piège dans lequel on tombe quand l’agent touche à votre dépôt ; et le guide du plugin de backend de terminal montre comment déplacer entièrement le terminal vers un autre backend.

La version courte

La leçon se généralise bien au-delà de ce correctif : comptez les sorties, pas les répertoires. HERMES_HOME est l’état propre d’Hermes ; le répertoire home, la crontab, le bus utilisateur et le répertoire temporaire sont quatre sorties du système d’exploitation, et en manquer une suffit à laisser fuir. Si vous écrivez des harnesses d’eval ou gérez des configurations multi-locataires, TERMINAL_HOME_MODE=profile plus un shim crontab compatible en comportement est la paire de changements au meilleur rapport valeur que vous puissiez faire.