Um script de 16 KB congelou um gateway por 23 segundos: o postmortem da varredura de comandos

Você entrega ao Hermes um script de algumas centenas de linhas — um heredoc em Python que embute texto de documento em payloads JSON — e pede que ele rode. O terminal fica quieto. Um tempo depois você pede progresso no Telegram e o bot não responde. No log você encontra um conjunto de linhas que não explicam nada: deadline (15s) expired but the event loop has not processed the expiry, the loop thread appears BLOCKED in a synchronous call, depois o watchdog de encerramento, uma saída, um reinício — e então o agente tenta o mesmo comando de novo e tudo recomeça. Parece um problema de rede ou uma falha da plataforma de mensagens. Na verdade, tem a ver com algo que soa puramente como segurança: a detecção de comandos perigosos.
O sintoma: não “lento”, mas um event loop preso
Antes de rodar um comando, o Hermes precisa decidir se aquele comando é perigoso — o que determina se ele passa direto, se pergunta a você ou se é bloqueado de imediato. Essa verificação roda no motor de regex do Python e segura o GIL. Quanto mais longo o comando, mais tempo ele ocupa o processo; e o que ele ocupa não é o espaço de um comando, mas o event loop inteiro.
Um usuário relatou isso com material de reprodução completo (#113535, 16 de setembro de 2026, imagem oficial 0.20.6), e vale a pena ler a cadeia de falha uma vez:
- A detecção leva minutos em um comando longo e deixa o event loop faminto.
- O polling do Telegram expira; o deadline de 15 segundos do
telegram-initexpira, mas o loop não consegue processar a expiração. - O watchdog de encerramento conclui que o processo travou, sai com código 75 e o gateway reinicia.
- No reinício, o agente encontra o mesmo comando inacabado e roda a mesma detecção outra vez — e o ciclo se repete.
Esse último passo merece ênfase: “travar, reiniciar, tentar de novo, travar de novo” parece exatamente uma plataforma de mensagens quebrada, o que manda as pessoas procurar no lugar completamente errado.
Causa raiz: uma regex começando em O(n²), multiplicada por uma explosão de variantes
A análise de quem relatou é direta. Dois fatores se somam:
Primeiro, um lookahead de string inteira sem âncora. A regra de ciclo de vida do gateway com launchctl foi escrita como dois lookaheads independentes sobre a string inteira:
(r'(?=[\s\S]*\blaunchctl\s+(?:stop|kickstart|bootout|unload|kill|disable|remove)\b)'
r'(?=[\s\S]*\b(?:hermes|ai\.hermes)\b)', "stop/restart hermes launchd service ...")
A intenção é “que apareçam tanto um verbo de parada do launchctl quanto a palavra hermes/ai.hermes, em qualquer ordem”. Mas nenhum dos lookaheads tem âncora, então o re.search os retenta em cada offset de início, e cada tentativa reescaneia até o fim da string — uma busca é O(n²). Medido em uma string de 60.018 caracteres: 16,5 segundos para esse único padrão; com uma âncora, 0,001 segundos.
Segundo, as “variantes de palavra de comando” explodem com o número de aspas. O _command_detection_variants produz uma variante de comprimento total para cada palavra de comando entre aspas ou escapada, para conseguir casar a forma desobfuscada. Um heredoc cheio de aspas (JSON no estilo "key": "value") tem centenas de palavras entre aspas: 230 linhas produzem 233 variantes. A detecção varre cada variante com cada padrão — e com as duas passadas (hardline e dangerous), o total é O(n³).
As medições relatadas:
| Comprimento do comando | Variantes | Tempo total de regex |
|---|---|---|
| 3.000 caracteres | 41 | 2,7 s |
| 6.000 caracteres | 82 | 27,8 s |
| 15.369 caracteres | 201 | cerca de 7 minutos (a ferramenta expirou em 420 s) |
Então isso nunca foi “um pouco lento” — é quadruplicar ou pior a cada dobra de comprimento.
A correção: uma âncora e uma variante combinada, com vereditos idênticos
O PR #114208, mesclado em 17 de setembro de 2026, desmonta a explosão com duas mudanças:
- Ancorar os lookaheads do launchctl com
\A. Isso não muda a semântica: um lookahead avaliado no offset 0 já vê a string inteira, então o conjunto de matches é idêntico — ordem de tokens invertida ainda casa, entrada multilinha ainda casa. Uma busca simplesmente passa de O(n²) para O(n). - Construir uma única variante combinada em vez de uma por palavra. Cada palavra de comando é desobfuscada e aplicada como um candidato; os intervalos chegam fora de ordem (corpos de laço depois das palavras-chave) e podem se aninhar (uma palavra entre crases e o comando dentro dela), então as substituições são aplicadas em ordem, e um intervalo que se sobrepõe a um já aplicado mantém a sua própria variante por palavra; intervalos sobrepostos esperam a rodada seguinte (uma variante combinada por nível de aninhamento), de modo que scripts com substituições aninhadas também não conseguem reexplodir. Na entrada de quem relatou, a contagem de variantes caiu de 923 para 4.
O resultado medido: um heredoc de 16 KB / 460 linhas cai de cerca de 23 segundos (7,8 s de hardline + 15 s de dangerous) para cerca de 0,4 segundos, com vereditos idênticos. A passada hardline mantém a sua semântica YOLO irrestrita — um heredoc inofensivo não é bloqueado nem gera prompt, nem antes nem depois.
Essa parte de “vereditos idênticos” é a afirmação mais importante de toda a correção. Um portão de aprovação é uma fronteira de segurança, e você não o torna rápido afrouxando-o — deixar passar um comando que deveria ter sido bloqueado é muito pior do que travar por 23 segundos. O conserto certo remove trabalho desperdiçado, nunca a verificação em si.
Por que a regra é tão ampla em primeiro lugar
Leia o comentário no código-fonte e o conservadorismo faz sentido: uma versão anterior exigia que “hermes” aparecesse depois do verbo, e um for-loop que montava o rótulo a partir de uma lista definida antes (for item in 'ai.hermes...'; do launchctl bootout "$label") passou batido e reiniciou quatro gateways sem nenhuma aprovação. Para um portão de aprovação, pecar pelo excesso de amplitude é o correto — um prompt a mais é barato; um erro pode matar todos os agentes em execução.
É exatamente por isso que esta correção mexe apenas na performance e não na amplitude. É por isso que ela pode ser confiada.
Quando você vai encontrar isso
- Scripts longos. Heredocs de algumas centenas de linhas e comandos que embutem JSON grande ou texto de documento batem nesse caminho. Comandos curtos do dia a dia, não.
- Agentes em um gateway. Como a detecção segura o GIL, a vítima não é só o comando atual, mas todos os canais de mensagens, heartbeats e watchdogs do mesmo processo. No CLI você espera; em um gateway o bot fica mudo e reinicia em laço.
- Palavras-chave de log para procurar.
loop thread appears BLOCKED in a synchronous call,deadlineexpirado, código de saída 75 do watchdog. Quando essas aparecem juntas, suspeite de bloqueio síncrono, não da rede.
Status de release
A correção entrou na main em 17 de setembro de 2026. Quem relatou confirmou que o padrão problemático existe inalterado na v2026.9.14 — que é a release atual, v0.21.3 — então a build que você roda hoje ainda trava; a correção não está em nenhuma tag.
hermes --version # v0.21.3 ou anterior: heredocs longos ainda batem aqui
hermes update # corrigido quando a próxima tag sair
Até lá, se você precisa que o agente rode um script muito longo, escreva-o primeiro em um arquivo e peça que o agente execute esse arquivo, em vez de colar o corpo inteiro na linha de comando — esse caminho pula a varredura de string inteira.
Leitura adicional: sobre o portão como um todo, aprovações quando ninguém está olhando cobre modos e políticas; por que edições no SSH config exigem aprovação é a jornada de uma regra real, do incidente ao código; testar aprovações em dry-run permite checar se as suas regras disparam por engano; e se você tem curiosidade sobre como um gateway travado volta a funcionar, o watchdog de laço do gateway é o lado de operações da mesma história.
Resumo
- Sintoma: um heredoc longo prende o event loop do gateway dentro de uma regex síncrona, aparecendo como um bot do Telegram silencioso, deadlines do
telegram-initexpirados, saída 75 do watchdog e um laço de retentativas após o reinício. - Causa raiz: dois lookaheads de string inteira sem âncora tornaram uma busca O(n²) (16,5 s em uma string de 60 mil caracteres), enquanto o
_command_detection_variantsproduzia uma variante de comprimento total por palavra entre aspas, levando o total a O(n³) (82 variantes e 27,8 s em 6.000 caracteres). - Correção (PR #114208, 17 de setembro de 2026): uma âncora
\Amais uma única variante combinada, 923 variantes reduzidas a 4, um heredoc de 16 KB de 23 s para 0,4 s — com vereditos idênticos. - Status: a build lançada (v0.21.3) ainda é afetada; a correção está na
maine não em uma tag.