Un archivo que tu agent dejó en Docker puede ejecutarse como root en tu portátil

Le pides al agent que compile algo dentro de un sandbox de Docker. Instala dependencias, compila, empaqueta y deja el artefacto en /workspace/dist/, un bind mount que en realidad es un directorio de tu máquina. Al día siguiente ejecutas ese artefacto en local y arranca como root, escribiendo donde le apetece.
El mecanismo es de lo más mundano. Los contenedores Docker corren como root por defecto, así que los archivos que escriben en un bind mount quedan con owner root en el host. Si alguno de esos archivos lleva bits setuid/setgid o file capabilities, ejecutarlo en el host se lleva consigo la escalada de privilegios. El nosuid del montaje, el --cap-drop ALL y no-new-privileges del contenedor no sirven de nada aquí: limitan los procesos de dentro del contenedor, no los bits de permiso de un archivo que está en tu sistema de archivos.
Por qué el hardening del contenedor no cruza con el archivo
| Hardening | Qué limita | ¿Aplica a un archivo del host? |
|---|---|---|
--cap-drop ALL |
Procesos dentro del contenedor | No |
Montaje nosuid |
La vista que el contenedor tiene de sus montajes | No |
no-new-privileges |
El exec dentro del contenedor |
No |
El modo del propio archivo (u+s, g+s, file caps) |
A cualquiera que lo ejecute | Sí — cruza la frontera |
La última fila lo resume todo. setuid es un atributo del archivo, no del contenedor. Cuando el contenedor escribe en un bind mount, los bits viajan con el archivo; borras el contenedor y los bits siguen ahí. La única defensa fiable es quitarlos del lado del host —chmod ug-s y limpiar las file capabilities— en lugar de confiar en que las opciones de montaje lo hagan por ti.
Qué ha cambiado en Hermes
El cambio vive en tools/environments/docker_ownership.py y el orden es deliberado: primero devolver la propiedad, luego quitar los bits.
- Orden. En el teardown, la devolución de propiedad corre primero: su
chownya limpia bits y capabilities en todo lo que es de root. Después corre una pasada de strip independiente, y cada paso tiene su propio presupuesto de 10 segundos para que un árbol enorme no deje sin tiempo a la devolución. - Cómo funciona el strip. Un contenedor desechable y endurecido ejecuta:
find -P <sandbox-binds> -xdev <prune-nested-mounts> \
-type f -perm /6000 -exec chmod ug-s {} +
No cambia ningún owner y no necesita stat, así que también sirve en imágenes que nunca incluyeron /bin/stat (bullseye-slim); funciona tanto con GNU findutils como con busybox.
- File capabilities.
findno puede inspeccionar xattrs. Cuando al contenedor se le concedióSETFCAP,ALL, o corre con--privileged, el mismo contenedor desechable vuelve a hacer chown de cada archivo regular a su propio uid (los uids se leen una vez, un recorrido por owner), lo que limpia las caps sin dejar detrás un owner distinto. Los argumentos base quitanSETFCAP, así que la ruta por defecto se queda en el strip barato. - Los sandboxes persistentes también quedan cubiertos. Ese era el hueco real de la pasada anterior (#141033): solo corría cuando se eliminaba un contenedor, y el sandbox persistente por defecto nunca se elimina. Ahora el strip también corre:
- cuando una sesión se desengancha de un contenedor persistente (
cleanup()en modo persist, en el hilo de teardown que se vacía en el atexit); - antes del primer comando tras reconectar;
- en
reap_orphan_containers, antes de eliminar un contenedor que ya ha salido.
- cuando una sesión se desengancha de un contenedor persistente (
- El alcance es estrecho. Los objetivos del strip salen de los montajes del contenedor en
docker inspect, y solo se conservan cuandorealpath(Source)cuelga de una raíz<sandbox>/dockerque el propio host deriva: su directorio de sandbox, o el por defecto de la raíz de Hermes y de cadaprofiles/*. Eso cubre a los profiles que comparten un contenedor. Las labels del contenedor y otros metadatos nunca amplían el conjunto, porque los args extra y losLABELde la imagen pueden fijarlos. - Montaje. Solo se montan esos directorios de sandbox, por su ruta resuelta, con
--mount type=bind(una fuente que falta se ignora, nunca se recrea con owner root). No usa--volumes-from, así que una ruta cambiada a mitad del recorrido solo puede llevar a archivos del sandbox. - Los fallos no son silenciosos. Un strip que no puede correr (falta la herramienta, salida distinta de cero, timeout) escribe un WARNING con la ruta del sandbox, una vez por sandbox.
Las partes que no cambiaron importan igual: find -P nunca sigue symlinks, los montajes anidados se podan, el entorno PATH/loader se resetea, --cap-drop ALL con solo CHOWN/DAC_OVERRIDE/FOWNER, no-new-privileges, nodos de dispositivo y FIFOs intactos, todo best-effort. El mecanismo entero está condicionado a Linux, a que Hermes no corra como root, a Docker no rootless y a que no haya userns-remap.
Revisa tu propio host (diez minutos)
Si tu build es anterior al arreglo, puedes comprobarlo y limpiarlo a mano. Primero saca las rutas reales de los montajes:
docker ps --format '{{.ID}} {{.Image}} {{.Names}}'
docker inspect <container> --format '{{range .Mounts}}{{.Source}} -> {{.Destination}}{{"\n"}}{{end}}'
Luego busca archivos privilegiados en el host:
find -P <that Source path> -xdev -type f -perm /6000 -print
Si sale algo, la exposición es real. Quítalo a mano:
find -P <that Source path> -xdev -type f -perm /6000 -exec chmod ug-s {} +
Después revisa las file capabilities (necesita las herramientas de libcap; muchos hosts slim no las tienen):
getcap -r <that Source path>
setcap -r <file path>
Estas son las formas de Linux: -perm /6000 de GNU find significa “cualquiera de estos bits activado”. En macOS con Docker Desktop el comportamiento del mapeo es distinto y los archivos del host normalmente son tuyos, así que la exposición es menor; las máquinas que importan son los servidores Linux de producción, los runners de CI y los servidores de desarrollo compartidos.
Tres decisiones que puedes tomar hoy
1. Espera a la actualización o limpia ahora. El cambio se fusionó a main el 18 de octubre de 2026, después de que la v0.22.2 saliera el 15 de octubre, así que la versión actual no lo tiene. Tu próximo hermes update lo recoge solo; no hay ningún flag que activar, viene por defecto.
2. A largo plazo: ejecuta el contenedor como tu usuario. Si el sandbox se dedica sobre todo a editar archivos montados en lugar de hacer apt install, esto elimina los archivos con owner root en origen:
terminal:
backend: docker
docker_run_as_host_user: true # default: false
El trade-off está documentado: el contenedor ya no puede escribir rutas de root como /root/.npm, así que si necesitas las dos cosas hace falta una imagen cuyo HOME pertenezca a un usuario no root. Más detalle en la sección de Docker de la documentación de configuración.
3. No ejecutes artefactos del sandbox en el host. Consejo independiente de la versión: el código en el que no confías pertenece a un contenedor o a una VM desechable. Quitar los bits evita accidentes, no builds maliciosas.
No es un parche aislado
#141033 seguido de #141086 deja una idea más amplia: una garantía de aislamiento solo cubre el interior de la frontera de aislamiento. En el momento en que un archivo cruza fuera del contenedor y aterriza en el sistema de archivos del host, todos los flags de hardening dejan de aplicar. Cualquier backend que permita a un agent escribir en tu máquina —bind mounts de Docker, hosts SSH remotos, directorios de sandbox en la nube sincronizados— tiene que responder a la misma pregunta: ¿qué permisos lleva este artefacto cuando llega al lado de confianza?
Si estás montando algo así, hay dos piezas relacionadas que merece la pena leer: compartir un contenedor Docker entre sesiones cubre la frontera de aislamiento cuando varios chats comparten contenedor, y cómo se entregan los medios del sandbox cubre el movimiento de artefactos a través de la frontera. ¿Es tu primera vez con Hermes? La guía de instalación explica cómo se activa cada backend.
Resumen
- Síntoma: los archivos escritos en un bind mount llegan al host con setuid/setgid o file capabilities, así que ejecutarlos escala privilegios.
- Causa: son atributos del archivo; el
nosuid, elcap-dropy elno-new-privilegesdel lado del contenedor no alcanzan al host. - Arreglo: una pasada de strip en el lado del host (
chmod ug-smás un cambio de owner para las capabilities) en el teardown, el detach, el reattach y el barrido de huérfanos, limitada a los directorios de bind del sandbox. - Hoy: localiza tus montajes con
docker inspect, compruébalo confind -perm /6000, espera la actualización; a largo plazo usadocker_run_as_host_usero mantén los artefactos no confiables fuera de la shell del host.