O backend de terminal estava fixado — então por que o Hermes continuava rodando no host?


Você instala o Hermes em uma máquina de desenvolvimento compartilhada. O time de TI grava terminal.backend: docker em /etc/hermes/config.yaml, com a intenção de que todo comando rode dentro de um contêiner em vez de no host. Um colega pede ao agente para instalar uma dependência, e ela cai no diretório home do host mesmo assim. A política parece aplicada. Não está.

“Configurado, autorizado e ainda assim ignorado” é a pior classe de bug, porque nada dá erro. Duas mudanças já mescladas (PR #145221 e #145222) corrigem exatamente isso: o escopo de terminal por sessão nunca lia o escopo gerenciado, então um backend fixado perdia para a configuração do próprio profile a cada turno.

Quem decide onde seus comandos rodam

terminal.backend escolhe o alvo de execução: local, docker, ssh, modal, daytona, vercel_sandbox ou um backend fornecido por plugin. Ele não é lido uma única vez na inicialização. No modo gateway, cada sessão monta seu próprio escopo de terminal na criação, empilhando várias fontes:

  1. os padrões embutidos;
  2. o .env do profile;
  3. o overlay de lançamento (valores passados na linha de comando);
  4. o config.yaml do profile.

Isso basta para um usuário único — mexa no seu próprio config.yaml e pronto. Mas as quatro camadas são controladas pelo usuário, e o escopo gerenciado existe justamente para ficar acima delas.

O que é o escopo gerenciado

O escopo gerenciado é o mecanismo de frota: um diretório de sistema que fixa um conjunto de valores de configuração e segredos para todos os usuários da máquina. Por padrão é /etc/hermes/:

/etc/hermes/
├── config.yaml     # camada de config gerenciada, ganha de ~/.hermes/config.yaml
└── .env            # camada de env gerenciada, ganha de ~/.hermes/.env e do shell

O diretório e os arquivos pertencem ao root (diretório 0755, arquivos 0644). As permissões de filesystem são o mecanismo de imposição: todo mundo pode ler, só um administrador pode escrever. O local pode ser movido com HERMES_MANAGED_DIR para contêineres e implantações fora de /etc — e essa variável precisa ser fixada pelo administrador (embutida no unit file ou na imagem). Se um usuário comum puder defini-la, ele aponta o escopo gerenciado para um diretório que é dele e a coisa toda desmorona.

Precedência, para as chaves que a camada gerenciada realmente especifica:

Camada config.yaml .env
1 /etc/hermes/config.yaml (gerenciado) /etc/hermes/.env (gerenciado)
2 ~/.hermes/config.yaml (usuário) ~/.hermes/.env (usuário)
3 padrões embutidos ambiente de shell pré-existente

A mesclagem é no nível da folha: fixar model.default não congela o resto de model.*. É isso que faz disso uma linha de base em vez de um cadeado sobre tudo.

A camada que faltava

load_config() — o caminho normal de configuração — aplica o overlay gerenciado. Então hermes config mostra o valor fixado corretamente. Mas o escopo de terminal de cada sessão é montado por outra função, build_profile_terminal_scope(), e ela lia apenas as quatro camadas acima. Nunca a gerenciada. O resultado:

  • o terminal.backend: docker do administrador aparece em hermes config;
  • a execução dos comandos continua usando o local do próprio profile.

Um divórcio entre exibição e comportamento assim é quase invisível pela interface de configuração. Ele apareceu em um plugin de sandbox que roda um hermes serve --isolated de profile único, fixa terminal.backend via HERMES_MANAGED_DIR e oculta o .env do profile: o plugin acreditava que os comandos rodavam dentro da sua fronteira; eles rodavam fora dela.

A correção é deliberadamente pequena: o mapeamento gerenciado de terminal: é mesclado por último, chave por chave, usando o mesmo deep-merge que load_config() usa. Três comportamentos merecem ser lembrados:

  • null não fixa nada. Um docker_env: null na camada gerenciada deixa o valor do profile intacto.
  • Mapeamentos são mesclados por folha, não como bloco. Uma entrada docker_env gerenciada preserva as outras entradas do profile.
  • Só um docker_image não nulo conta como fixado, e só ele marca a imagem como escolhida pelo administrador.

Sem nenhuma seção terminal: gerenciada, o escopo é montado exatamente como antes — nenhuma implantação existente muda de comportamento.

Duas armadilhas de .env atrás do mesmo bug

O cenário do sandbox expôs dois problemas menores, ambos sobre .env.

Um arquivo que existe mas não pode ser lido. O lançador do sandbox oculta o .env do profile (no Windows, uma deny list do MXC o torna ilegível) e passa as chaves permitidas como variáveis de ambiente. O arquivo continua existindo, então load_hermes_dotenv o abre, esbarra em PermissionError e o Hermes morre no import — antes de qualquer coisa rodar. A nova regra: quando, e somente quando, o escopo gerenciado fixa um terminal.backend não vazio, um .env de profile presente mas ilegível carrega como ausente. Nada é aplicado a partir dele, as chaves herdadas permanecem, e um aviso nomeia o arquivo uma vez por processo (não a cada turno). Sem o pin, o erro ainda se propaga, porque nesse caso o arquivo pode ser o único lugar onde o backend está escrito — engolir o erro seria pior.

O .env do projeto roubando chaves. O código antigo decidia se um .env do diretório do projeto podia preencher lacunas com base no fato de o .env do profile ter carregado com sucesso. Agora ele checa existência: enquanto o .env do profile estiver lá (legível ou não), o .env do projeto só pode preencher lacunas, nunca sobrescrever. As chaves injetadas pelo lançador sobrevivem a um .env do repositório.

Mais uma pequena: quando o .env existe mas não pode ser lido, /reload e o método reload.env não executam mais a passagem de remoção — caso contrário as chaves que o lançador acabou de injetar seriam apagadas.

Como um administrador fixa isso

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

Em um contêiner onde /etc é inconveniente, fixe o diretório no unit do serviço:

export HERMES_MANAGED_DIR=/opt/org/hermes-policy

As mudanças valem na próxima inicialização do Hermes. Um arquivo gerenciado malformado nunca bloqueia o boot — ele é registrado em log de forma bem visível e ignorado — então confirme com hermes doctor que as chaves fixadas foram realmente lidas.

Como um usuário descobre que está sob gestão

Três comandos somente de leitura:

hermes config        # o cabeçalho nomeia a fonte gerenciada e as chaves fixadas
hermes doctor        # diretório gerenciado resolvido e contagem de chaves fixadas
hermes config get terminal.backend

Tentar mudar uma chave fixada é recusado, com a fonte nomeada:

Cannot set 'terminal.backend': it is managed by your administrator
(/etc/hermes/config.yaml) and cannot be changed.

Se os comandos continuam caindo no host dentro de um contêiner, verifique nesta ordem: o que terminal.backend reporta em hermes config; se o diretório gerenciado que hermes doctor resolveu é o que você espera; e se o config.yaml do profile define a mesma chave. Para a lista completa de backends e como plugins de backend são instalados, veja o guia de plugins de backend de terminal; para os detalhes de segurança do backend docker (bits setuid removidos, por exemplo), veja o texto sobre o sandbox docker.

Status e limites

Os dois PRs (#145221 para o carregamento de .env na inicialização, #145222 para o escopo de terminal por turno) entraram na main em 2026-10-29 e já estão na v0.22.6 (2026-11-02): basta atualizar, sem precisar de uma instalação baseada na main.

Três limites vale conhecer: o .env gerenciado é legível por todos (0644), então não coloque segredos de alta sensibilidade nele; as ferramentas do próprio agente não são bloqueadas de forma rígida contra ler valores de ambiente gerenciados; e se um usuário puder escrever no diretório gerenciado (ou rodar o Hermes como root), o mecanismo é consultivo. Ele impede acidentes e edições casuais, não uma escalada de privilégios determinada — para isolamento de verdade, máquinas ou contêineres separados ainda vencem.