Skip to content

Backends de bac à sable

源码版本v2026.7.20

Responsabilité

L'agent doit exécuter des commandes, lancer du code, sans laisser des commandes arbitraires s'abattre sur la machine hôte. L'outil (tool) terminal d'Hermes supporte 6 backends d'exécution, à choisir selon le compromis sécurité / isolation / coût : Local (exécution directe sur l'hôte), Docker (isolation par conteneur), SSH (machine distante), Singularity (conteneur HPC), Modal (bac à sable (sandbox) cloud, direct ou managed), Daytona (environnement de dev cloud). Durcissement du conteneur + isolation par espace de noms ; les commandes dangereuses sont en plus interceptées par un callback d'approbation.

Mot de conception

Pourquoi 6 backends plutôt qu'un seul ? Les scénarios d'exécution d'un agent sont trop dispersés : en dev on veut juste aller vite sur la machine locale, en production batch il faut l'isolation par conteneur, les utilisateurs HPC doivent passer par Singularity (Docker n'est pas disponible), et l'exécution cloud sans opérateur humain réclame un bac à sable à la seconde comme Modal. Aucun backend ne couvre tout. Hermes abstrait « exécuter » en une interface unifiée, les backends se branchent selon le contexte, et le callback d'approbation des commandes dangereuses est partagé entre backends. Le backend Docker doit vérifier spécifiquement si un bind mount expose un chemin de l'hôte, parce que le postulat de sécurité de l'isolation par conteneur est « le conteneur ne voit pas l'extérieur » — un docker run -v /:/host fait s'effondrer ce postulat ; Modal doit distinguer direct (credentials propres à l'utilisateur) et managed (passerelle (gateway) gérée), parce que le premier est facturé sur le compte de l'utilisateur et le second sur le quota du portail Nous, et les chemins de permissions et de facturation sont totalement différents. Ces logiques de détection ne sont pas écrites dans les backends mais factorisées dans tool_backend_helpers.py, pour découpler le choix du backend de l'évaluation du niveau de sécurité.

Fichiers clés

Flux de données

  1. L'agent décide d'exécuter une commande → tool_call terminaltools/terminal_tool.py.
  2. Sélection du backend selon la config du job (local/docker/modal/ssh/singularity/daytona) :
  3. Les commandes dangereuses sont interceptées par le callback d'approbation — la boucle principale utilise une approbation interactive ; le sous-agent est auto-deny par défaut (tools/delegate_tool.py:75), avec auto-approve opt-in pour cron / batch.
  4. La sortie de la commande est réinjectée dans la boucle principale ; les tâches longues peuvent être lancées en arrière-plan (tools/daemon_pool.py).

La déclaration de la liste des backends est simple : en haut de terminal_tool.py, un simple commentaire « A terminal tool that executes commands in local, Docker, Modal, SSH, Singularity, and Daytona environments. »

Le backend Docker détecte si le bind mount est un chemin de l'hôte en regardant le préfixe du spec : tout ce qui commence par /, ~, ./, ../, ou qui ressemble à un chemin Windows de disque C:\ (deuxième caractère deux-points), est considéré comme chemin de l'hôte, déclenche has_host_access=True, et l'exécution suit ensuite la branche de permissions plus stricte :

python
def _docker_volume_uses_host_path(volume_spec: str) -> bool:
    """Return True when a docker volume spec bind-mounts a host path."""
    if not isinstance(volume_spec, str):
        return False
    vol = volume_spec.strip()
    return bool(vol) and (
        vol.startswith(("/", "~", "./", "../")) or
        (len(vol) >= 3 and vol[1] == ":" and vol[2] in ("/", "\\"))
    )

La résolution Modal a trois positions : direct (propres credentials Modal de l'utilisateur), managed (portail Nous géré), auto (par défaut, fallback vers direct si managed indisponible). resolve_modal_backend_state centralise tout en un seul endroit ; si aucun backend n'est sélectionnable, elle renvoie None. has_direct_modal_credentials consulte à la fois les variables d'environnement MODAL_TOKEN_ID / MODAL_TOKEN_SECRET et le fichier ~/.modal.toml : il faut que les deux manquent pour que direct soit déclaré indisponible. managed_nous_tools_enabled interroge le compte Nous Portal ; en cas d'échec il échoue en mode fermé, sans jamais ouvrir par erreur le chemin managed suite à une erreur de requête.

Singularity est le cas spécial HPC — l'utilisateur ne peut pas installer Docker, il n'a qu'Apptainer/Singularity. _find_singularity_executable cherche d'abord apptainer puis singularity ; si aucun des deux n'est présent, elle lève une erreur qui indique où installer. Le répertoire scratch utilise en priorité /scratch (montage standard sur les clusters HPC) ; à défaut il retombe sur get_sandbox_dir() / "singularity". Le conteneur est lancé avec --containall --no-home et capability dropping — sur un cluster HPC partagé c'est obligatoire, sinon on pourrait voir les répertoires personnels des autres utilisateurs.

Limites et échecs

  • Faux négatif sur bind mount Docker : la fonction de détection traite les specs commençant par /, ~, ./, ../ comme chemins de l'hôte, mais un named volume (ex. mydata:/data) est laissé passer. Si un named volume est en réalité soutenu par un chemin de l'hôte (driver personnalisé), _docker_has_host_access ne le voit pas et le classe à tort en bas risque.
  • Repli (fallback) sur l'échec de Modal managed : en mode auto, si managed est indisponible on retombe sur direct ; mais si l'utilisateur n'a configuré que managed sans credentials direct, selected_backend devient None, et la sélection du backend échoue à l'exécution — au moment de créer le terminal l'erreur peut ne pas être claire.
  • Expansion du cache SIF Singularity : les images SIF font souvent plusieurs centaines de Mo à plusieurs Go ; un répertoire scratch sans stratégie de rotation finit par saturer le disque. Le /scratch d'un nœud HPC a en général une politique de nettoyage au niveau cluster, et quand les deux sont désalignés on se retrouve avec « le cluster a purgé le cache, mais l'agent croit encore que le SIF est là ».
  • Gigue réseau des backends distants : l'exécution de commandes SSH/Daytona passe par le réseau ; si la connexion tombe, la sortie du sous-processus en cours de marche est perdue. La réinjection de sortie suppose un retour synchrone en une fois, sans reprise ni retransmission, et les tâches longues sur backends distants sont donc les plus exposées.
  • Timeout et OOM : local/docker ont script_timeout, mais pour les backends distants le timeout doit être géré par le processus distant lui-même ; l'ordonnanceur local qui n'obtient pas de réponse reste bloqué, ou se fait marquer à tort comme mort par le TTL de bail de _run_job_script_with_claim_heartbeat.

Résumé

La couche bac à sable est « isolation d'exécution + contrôle des commandes dangereuses » : 6 backends couvrent de l'hôte local jusqu'au HPC et au cloud ; Docker détecte les bind mounts pour éviter une escalation de privilèges ; Modal distingue credentials direct/managed ; les commandes dangereuses passent toutes par un callback d'approbation. Les sous-agents et cron empruntent le chemin non interactif auto-deny / auto-approve.

Site d'apprentissage communautaire non officiel. Basé sur le code source de NousResearch/hermes-agent (licence MIT).