Um ficheiro que o seu agent deixou no Docker pode correr como root no seu portátil


Pede ao agent para construir algo dentro de um sandbox Docker. Ele instala dependências, compila, empacota e deixa o artefacto em /workspace/dist/ — um bind mount que é, na realidade, uma pasta na sua máquina. No dia seguinte corre esse artefacto localmente e ele executa como root, escrevendo onde lhe apetecer.

O mecanismo é banal. Os containers Docker correm como root por padrão, por isso os ficheiros que escrevem num bind mount ficam com dono root no host. Se algum desses ficheiros trouxer bits setuid/setgid ou file capabilities, executá-los no host leva a escalada de privilégios com eles. O mount nosuid do container, o --cap-drop ALL e o no-new-privileges não ajudam aqui: limitam processos dentro do container, não os bits de permissão de um ficheiro que está no seu sistema de ficheiros.

Porque o hardening do container não segue o ficheiro para casa

Hardening O que limita Aplica-se a um ficheiro no host?
--cap-drop ALL Processos dentro do container Não
mount nosuid A visão que o container tem dos seus mounts Não
no-new-privileges O exec dentro do container Não
O modo do próprio ficheiro (u+s, g+s, file caps) Qualquer pessoa que o execute Sim — atravessa a fronteira

A última linha é a história toda. O setuid é um atributo do ficheiro, não do container. Quando o container escreve num bind mount, os bits vão com o ficheiro; apague o container e os bits ficam lá. A única defesa fiável é removê-los do lado do host — chmod ug-s mais a limpeza das file capabilities — em vez de confiar nas opções de mount para o fazer.

O que o Hermes mudou

A alteração vive em tools/environments/docker_ownership.py e a ordem é deliberada: devolver a posse primeiro, remover os bits depois.

  • Ordem. No teardown, a devolução de posse corre primeiro — o seu chown já limpa bits e capabilities em tudo o que é do root. Uma passagem de remoção separada corre a seguir, e cada passo tem o seu próprio orçamento de 10 segundos, para que uma árvore enorme não deixe a devolução sem tempo.
  • Como a remoção funciona. Um container descartável endurecido corre:
find -P <sandbox-binds> -xdev <prune-nested-mounts> \
  -type f -perm /6000 -exec chmod ug-s {} +

Não muda dono nenhum e não precisa de stat, por isso também funciona em imagens que nunca trouxeram /bin/stat (bullseye-slim); testado em GNU findutils e busybox.

  • File capabilities. O find não consegue testar xattrs. Quando o container recebeu SETFCAP, ALL, ou corre --privileged, o mesmo descartável volta a dar a cada ficheiro regular a sua própria uid (uids lidas uma vez, uma passagem por dono), o que limpa as caps sem deixar um dono diferente para trás. Os argumentos base retiram SETFCAP, por isso o caminho por defeito fica na remoção barata.
  • Sandboxes persistentes também ficam cobertas. Era essa a falha real da passagem anterior (#141033): só corria quando um container era removido, e o sandbox persistente por defeito nunca é removido. A remoção passa agora a correr também:
    • quando uma session se desliga de um container persistente (cleanup() em modo persist, na thread de teardown drenada no atexit);
    • antes do primeiro comando depois de reconectar;
    • em reap_orphan_containers, antes de um container terminado ser removido.
  • O âmbito é estreito. Os alvos da remoção vêm dos mounts do container no docker inspect, mantidos apenas quando realpath(Source) está sob uma raiz <sandbox>/docker que o próprio host deriva — o seu diretório de sandbox, ou o por defeito da raiz do Hermes e de cada profiles/*. Isso cobre profiles que partilham um container. Labels de container e outra metadata nunca alargam o conjunto, porque args extra e LABELs da imagem os podem definir.
  • Montagem. Só esses diretórios de sandbox são montados, pelo seu caminho resolvido, com --mount type=bind (uma origem em falta é ignorada, nunca recriada com dono root). Não usa --volumes-from, por isso um caminho trocado a meio da passagem só pode levar a ficheiros do sandbox.
  • As falhas não são silenciosas. Uma remoção que não consegue correr (ferramenta em falta, saída diferente de zero, timeout) regista um WARNING com o caminho do sandbox, uma vez por sandbox.

As partes que não mudaram importam tanto: find -P nunca segue symlinks, mounts aninhados são podados, o env de PATH/loader é reposto, --cap-drop ALL com apenas CHOWN/DAC_OVERRIDE/FOWNER, no-new-privileges, device nodes e FIFOs intocados, best-effort no global. Todo o mecanismo está condicionado a Linux, Hermes sem root, Docker sem rootless e sem userns-remap.

Verifique o seu próprio host (dez minutos)

Se o seu build é anterior à correção, pode confirmar e limpar à mão. Primeiro, obtenha os caminhos reais dos mounts:

docker ps --format '{{.ID}}  {{.Image}}  {{.Names}}'
docker inspect <container> --format '{{range .Mounts}}{{.Source}} -> {{.Destination}}{{"\n"}}{{end}}'

Depois procure ficheiros privilegiados no host:

find -P <that Source path> -xdev -type f -perm /6000 -print

Se aparecer alguma coisa, há exposição real. Remova à mão:

find -P <that Source path> -xdev -type f -perm /6000 -exec chmod ug-s {} +

Depois verifique as file capabilities (precisa das ferramentas libcap; muitos hosts slim não as têm):

getcap -r <that Source path>
setcap -r <file path>

Estas formas são de Linux — o -perm /6000 do GNU find significa “qualquer um destes bits ligado”. No macOS com Docker Desktop o comportamento de mapeamento é diferente e os ficheiros no host são normalmente seus, por isso a exposição é menor; as máquinas que importam são caixas Linux de produção, runners de CI e servidores de dev partilhados.

Três decisões que pode tomar hoje

1. Espere pela atualização, ou limpe agora. A alteração foi mesclada na main a 18 de outubro de 2026 — depois de a v0.22.2 ter saído a 15 de outubro — por isso a release atual não a tem. O seu próximo hermes update apanha-a automaticamente; não há flag para ligar, está ativa por padrão.

2. A prazo: corra o container como o seu utilizador. Se o sandbox sobretudo edita ficheiros montados em vez de fazer apt install, isto elimina os ficheiros com dono root na origem:

terminal:
  backend: docker
  docker_run_as_host_user: true   # default: false

O trade-off está documentado: o container deixa de conseguir escrever em caminhos do root como /root/.npm, por isso, se precisar dos dois, precisa de uma imagem cujo HOME pertença a um utilizador não-root. Mais detalhe na secção Docker da documentação de configuração.

3. Não execute artefactos do sandbox no host. Conselho independente da versão: código em que não confia pertence a um container ou a uma VM descartável. Remover bits evita acidentes, não builds maliciosos.

Não é um patch isolado

#141033 seguido de #141086 mostra algo mais amplo: uma garantia de isolamento só cobre o interior da fronteira de isolamento. No momento em que um ficheiro sai do container e aterra no sistema de ficheiros do host, todas as flags de hardening deixam de se aplicar. Qualquer backend que deixe um agent escrever na sua máquina — bind mounts do Docker, hosts SSH remotos, diretórios de cloud sandbox sincronizados — tem de responder à mesma pergunta: que permissões traz este artefacto quando chega ao lado de confiança?

Se está a montar esse tipo de setup, há duas peças relacionadas que vale a pena ler: partilhar um container Docker entre sessions cobre a fronteira de isolamento quando vários chats partilham um container, e como os media do sandbox são entregues cobre a movimentação de artefactos através da fronteira. A montar o Hermes pela primeira vez? O guia de instalação mostra como cada backend é ativado.

Resumo

  • Sintoma: ficheiros escritos num bind mount chegam ao host com setuid/setgid ou file capabilities, por isso executá-los escala privilégios.
  • Causa: são atributos do ficheiro; nosuid, cap-drop e no-new-privileges do lado do container não chegam ao host.
  • Correção: uma passagem de remoção no host (chmod ug-s mais uma troca de dono para as capabilities) no teardown, no detach, no reattach e na limpeza de órfãos, limitada aos diretórios de bind do sandbox.
  • Hoje: encontre os seus mounts com docker inspect, faça a auto-verificação com find -perm /6000, espere pela atualização; a prazo use docker_run_as_host_user ou mantenha artefactos não confiáveis fora da shell do host.