El backend del terminal estaba fijado, y entonces por qué Hermes seguía corriendo en el host

Instalas Hermes en una máquina de desarrollo compartida. El equipo de IT escribe terminal.backend: docker en /etc/hermes/config.yaml, con la intención de que cada comando corra dentro de un contenedor y no en el host. Un colega le pide al agente que instale una dependencia, y la dependencia termina en el home del host de todos modos. La política parece aplicada. No lo está.
“Configurado, con permisos, y aun así ignorado” es la peor clase de bug, porque nada da error. Dos cambios ya fusionados (PR #145221 y #145222) arreglan exactamente esto: el scope del terminal por sesión nunca leía el managed scope, así que un backend fijado perdía contra la configuración propia del profile en cada turno.
Quién decide dónde corren tus comandos
terminal.backend elige el destino de ejecución: local, docker, ssh, modal, daytona, vercel_sandbox, o un backend provisto por un plugin. No se lee una sola vez al arrancar. En modo gateway, cada sesión arma su propio scope de terminal en el momento de crearse, apilando varias fuentes:
- los valores por defecto integrados;
- el
.envdel profile; - el overlay de lanzamiento (valores pasados por línea de comandos);
- el
config.yamldel profile.
Con eso alcanza para un solo usuario: tocas tu propio config.yaml y listo. Pero las cuatro capas están bajo control del usuario, y el managed scope existe justamente para quedar por encima de ellas.
Qué es el managed scope
El managed scope es el mecanismo de flota: un directorio a nivel de sistema que fija un conjunto de valores de configuración y secretos para todos los usuarios de la máquina. Por defecto es /etc/hermes/:
/etc/hermes/
├── config.yaml # capa de configuración gestionada, gana sobre ~/.hermes/config.yaml
└── .env # capa de entorno gestionada, gana sobre ~/.hermes/.env y la shell
El directorio y los archivos son propiedad de root (directorio 0755, archivos 0644). Los permisos del sistema de archivos son el mecanismo de aplicación: todos pueden leerlos, nadie salvo un administrador puede escribirlos. La ubicación se puede mover con HERMES_MANAGED_DIR para contenedores y despliegues fuera de /etc, y esa variable la tiene que fijar el administrador (grabada en el unit file o en la imagen). Si un usuario normal puede definirla, puede apuntar el managed scope a un directorio propio y todo el esquema se derrumba.
Precedencia, para las claves que la capa gestionada realmente especifica:
| Capa | config.yaml | .env |
|---|---|---|
| 1 | /etc/hermes/config.yaml (gestionado) |
/etc/hermes/.env (gestionado) |
| 2 | ~/.hermes/config.yaml (usuario) |
~/.hermes/.env (usuario) |
| 3 | valores por defecto integrados | entorno de shell preexistente |
El merge es a nivel de hoja: fijar model.default no congela el resto de model.*. Eso es lo que lo vuelve una línea base y no un candado sobre todo.
La capa que faltaba
load_config(), la ruta normal de configuración, sí aplica el overlay gestionado. Por eso hermes config muestra correctamente el valor fijado. Pero el scope de terminal de cada sesión lo construye otra función, build_profile_terminal_scope(), y esta leía solo las cuatro capas de arriba. Nunca la gestionada. El resultado:
- el
terminal.backend: dockerdel administrador se ve enhermes config; - la ejecución de comandos sigue usando el
localpropio del profile.
Una división así entre lo que se muestra y lo que se hace es casi invisible desde la UI de configuración. Salió a la luz en un plugin de sandbox que corre un hermes serve --isolated de un solo profile, fija terminal.backend a través de HERMES_MANAGED_DIR y retiene el .env del profile: el plugin creía que los comandos corrían dentro de su frontera; corrían fuera de ella.
El arreglo es deliberadamente pequeño: el mapping gestionado de terminal: se fusiona al final, clave por clave, con el mismo deep-merge que usa load_config(). Tres comportamientos conviene recordar:
nullno fija nada.docker_env: nullen la capa gestionada deja intacto el valor del profile.- Los mappings se fusionan hoja por hoja, no como bloque. Una entrada gestionada de
docker_envconserva las demás entradas del profile. - Solo un
docker_imageno nulo cuenta como fijado, y solo eso marca la imagen como elegida por el administrador.
Sin una sección terminal: gestionada, el scope se construye exactamente como antes: ningún despliegue existente cambia de comportamiento.
Dos trampas de .env detrás del mismo bug
El escenario del sandbox dejó al descubierto dos problemas más acotados, ambos sobre .env.
Un archivo que existe pero no se puede leer. El launcher del sandbox retiene el .env del profile (en Windows una deny list de MXC lo vuelve ilegible) y pasa las claves permitidas como variables de entorno. El archivo sigue existiendo, así que load_hermes_dotenv lo abre, choca con un PermissionError y Hermes muere en el import, antes de que corra nada. La regla nueva: cuando, y solo cuando, el managed scope fija un terminal.backend no vacío, un .env de profile presente pero ilegible se carga como si no existiera. No se aplica nada de él, las claves heredadas se mantienen, y un warning nombra el archivo una vez por proceso (no una vez por turno). Sin el pin, el error sigue propagándose, porque ahí el archivo puede ser el único lugar donde está escrito el backend; tragárselo sería peor.
El .env del proyecto robando claves. El código viejo decidía si un .env del directorio del proyecto podía rellenar huecos según si el .env del profile había cargado bien. Ahora comprueba la existencia: mientras el .env del profile esté ahí (legible o no), el .env del proyecto solo puede rellenar huecos, nunca sobrescribir. Las claves inyectadas por el launcher sobreviven a un .env del repo.
Una más, chiquita: cuando el .env existe pero no se puede leer, /reload y el método reload.env ya no ejecutan su pasada de eliminación, porque si no se borrarían las claves que el launcher acaba de inyectar.
Cómo lo fija un administrador
sudo mkdir -p /etc/hermes
sudo chmod 0755 /etc/hermes
sudo tee /etc/hermes/config.yaml >/dev/null <<'YAML'
terminal:
backend: docker
docker_image: registry.example.com/hermes-sandbox:2026-10
docker_env:
NODE_OPTIONS: --max-old-space-size=2048
YAML
sudo chmod 0644 /etc/hermes/config.yaml
En un contenedor donde /etc es incómodo, fija el directorio en el service unit:
export HERMES_MANAGED_DIR=/opt/org/hermes-policy
Los cambios se aplican en el siguiente arranque de Hermes. Un archivo gestionado malformado nunca bloquea el arranque: se registra ruidosamente en el log y se ignora, así que confirma con hermes doctor que las claves fijadas se hayan tomado de verdad.
Cómo un usuario sabe que está gestionado
Tres comandos de solo lectura:
hermes config # la cabecera nombra la fuente gestionada y las claves fijadas
hermes doctor # directorio gestionado resuelto y recuento de claves fijadas
hermes config get terminal.backend
Si intentas cambiar una clave fijada, se rechaza nombrando la fuente:
Cannot set 'terminal.backend': it is managed by your administrator
(/etc/hermes/config.yaml) and cannot be changed.
Si los comandos siguen cayendo en el host dentro de un contenedor, revisa en este orden: qué reporta terminal.backend en hermes config; si el directorio gestionado que resolvió hermes doctor es el que esperas; y si el config.yaml del profile define la misma clave. Para la lista completa de backends y cómo se instalan los backends de plugin, mira la guía de plugins de backend de terminal; para los detalles de seguridad del backend docker (los bits setuid eliminados, por ejemplo), mira el análisis del sandbox docker.
Estado y límites
Los dos PRs (#145221 para la carga de .env al arranque, #145222 para el scope de terminal por turno) se fusionaron en main el 2026-10-29 y ya están en la v0.22.6 (2026-11-02): basta con actualizar, sin necesidad de una instalación basada en main.
Vale la pena conocer tres límites: el .env gestionado es legible por todos (0644), así que no pongas ahí secretos de alta sensibilidad; las herramientas del propio agente no están bloqueadas de forma dura para leer los valores gestionados del entorno; y si un usuario puede escribir en el directorio gestionado (o corre Hermes como root), el mecanismo es apenas consultivo. Frena accidentes y ediciones casuales, no una escalada de privilegios decidida: para aislamiento real, máquinas o contenedores separados siguen ganando.