Exit Zero: quem testa os gates que vigiam os agentes de código
Passei onze semanas construindo guardrails determinísticos em volta de agentes de código e o resto do tempo tentando provar que eles eram falsos. O teste de mutação encontrou um gate que imprimia ⛔ FALHOU na tela e saía com exit 0, e ele era um dos dois gates que eu estava prestes a ligar num cliente.
Esta é a versão condensada. O relato completo, com todas as tabelas, está no blog: https://sozodata.com.br/pt-BR/blog/exit-zero-quem-testa-os-gates-dos-agentes
Os números emprestados
Em 11 de setembro, em um dos produtos que esta plataforma governa, fechei 59 commits e um diff de 207.859 linhas adicionadas. Dessas linhas, 184.770 são 22 snapshots de migração de ORM gerados por ferramenta. O que escrevi de fato naquele dia foram 16.783 linhas de TypeScript (7.762 de código de produção e 9.021 de teste) e cerca de 3.400 de documentação. Artefatos que nenhum humano lê inflaram a manchete em doze vezes.
Volume não indica qualidade, e os dados publicados vão na mesma direção:
- A METR fez um ensaio controlado randomizado com 16 desenvolvedores open-source experientes, em 246 tarefas reais. Eles estimaram que tinham sido acelerados em 20%; a medição mostrou que ficaram 19% mais lentos.
- A GitClear analisou mais de 600 milhões de commits e encontrou, de 2023 para 2026, um aumento de 81% nos blocos de código duplicados.
- O DORA descreve a IA como amplificador: o retorno depende da qualidade da plataforma interna em volta da ferramenta.
- Ben Sghaier e colegas fixaram o modelo e variaram só o harness ao longo de 35 releases de uma CLI, e rastrearam as oscilações de qualidade até mudanças de harness. O título do paper resume: Don't Blame the Large Language Model.
Se o modelo não é a variável que decide e o harness é o que está sob engenharia, quem testa o harness?
Medindo a lacuna no GitHub
Em abril, Birgitta Böckeler publicou Harness engineering for coding agent users (martinfowler.com) e listou pontos em aberto. Um deles: não existe equivalente a cobertura de código para avaliar o próprio harness.
Varri pela API do GitHub os nove repositórios mais estrelados da área (cerca de 600 mil estrelas somadas em 24/09) com uma pergunta: o harness testa a si mesmo?
| Repositório | Estrelas | Testes junto a hooks | Mutação do harness |
|---|---|---|---|
| obra/superpowers | 291.202 | 2 | nenhuma |
| github/spec-kit | 138.776 | 7 | nenhuma |
| ruvnet/ruflo | 73.218 | 34 | nenhuma |
| bmad-code-org/BMAD-METHOD | 53.425 | 0 | nenhuma |
| SuperClaude-Org/SuperClaude | 23.906 | 0 | nenhuma |
| diet103/…-infrastructure-showcase | 10.029 | 0 | nenhuma |
| buildermethods/agent-os | 5.444 | 0 | nenhuma |
| disler/claude-code-hooks-mastery | 3.926 | 1 | nenhuma |
| karanb192/claude-code-hooks | 524 | 21 | nenhuma |
Todos esses repositórios pedem ao agente que obedeça aos controles, e nenhum deles verifica se os controles continuam funcionando. O mais bem testado é o menor: o karanb192/claude-code-hooks entrega um teste por plugin e é o vizinho mais próximo do que descrevo aqui.
Mecanismo 1: gate que ninguém chama é só um documento
A plataforma é uma camada de SDLC interna distribuída como plugin: 22 agentes, 54 skills, 19 hooks, 33 gates e cerca de 7.600 linhas de shell, em 140 commits.
A documentação dizia quais gates eram obrigatórios, um catálogo à parte dizia o que cada um checava e um terceiro arquivo dizia em que nível cada um era cobrado. Os três eram prosa mantida à mão, e os três se desalinharam.
A correção foi um registry, um único JSON que é a autoridade sobre quais gates existem. Toda entrada responde a três perguntas: o que ele roda (runner), onde está armado (gatilho.onde, um evento real num manifesto real) e o que prova que ele morde (harness_cases). A cada commit, um checador confere o registry contra a realidade: se um runner está listado e não está armado, está armado e não está listado, ou cita casos que não existem, o commit falha.
O tipo de gatilho orfao quer dizer que o controle existe, está testado e hoje não está ligado a nada. Três das 33 entradas são órfãs. É dormência declarada, com a razão registrada, em vez de um hook parado numa pasta com cara de cobrado.
A regra por baixo: skip ≠ pass. Um gate nunca fica verde sem ter checado. Ferramenta ausente conta como falha, nunca como pulo.
Mecanismo 2: 237 casos, uma pasta para cada
Um gate é um script de shell que lê alguma coisa e decide, o que o torna testável. O formato é burro de propósito: uma pasta por caso e um arquivo por asserção.
casos/anti-destrutivo/bloqueia-drop-de-tabela/
runner → hooks/anti-destrutivo.sh
arg → psql -c '<DDL destrutivo em uma tabela>'
exit → 2
espera_stderr → DDL destrutivo
motivo → guard-rail #1: derrubar tabela nunca passa.
Não há framework de teste, porque o harness roda em repositório de cliente cuja stack ele não controla (Java, .NET, PHP, Node, Bun, Python). Pasta e código de saída são a única coisa universal.
O stderr é asserido junto com o código de saída, e essa é a restrição mais valiosa do formato. Um script que quebra num argumento inesperado também sai com código diferente de zero. Se você aferir só o código, "o gate bloqueou o comando perigoso" e "o gate está quebrado" dão o mesmo verde.
Mecanismo 3: quem testa os testes
237 casos verdes provam que os gates se comportam corretamente em 237 entradas, mas não provam que algum caso perceberia se um gate parasse de funcionar. Isso pede outro mecanismo: quebrar o runner de propósito, uma mudança pequena por vez, e exigir que algum caso fique vermelho.
| Operador | O que simula |
|---|---|
exit 2 → exit 0 | o gate para de bloquear |
exit $FALHA → exit 0 | a postura para de bloquear |
fail=1 → fail=0 | a falha deixa de contar |
if ! … → if … | a condição perde a negação |
-eq→-ne · -le→-gt · -lt→-ge | a comparação inverte |
grep -q → grep -qv | o casamento inverte |
block "…" → : "…" | a chamada de bloqueio é neutralizada |
O candidato óbvio a décimo primeiro operador, && → ||, foi medido e reprovado: gerou 35 dos 51 sobreviventes da primeira rodada, quase todos em linhas de guarda de dependência opcional. Um relatório que ninguém lê pertence à mesma família de falha de um gate permanentemente amarelo.
O que os mutantes acharam. O problema estava em duas linhas do orquestrador principal: as atribuições fail=1 de dois gates em postura de bloqueio. Com elas zeradas, o runner imprime ⛔ FALHOU e sai com 0. Esses dois gates eram justamente os que estavam na fila para subir de warn para block num cliente. Todos os casos estavam verdes porque os exercitavam em postura de aviso, onde o fail=1 nunca é lido.
Os mutantes também acharam um ledger de commits que podia emudecer (uma comparação invertida fazia o hook parar de registrar commit novo sem avisar) e um ramo marcado como obrigatório no catálogo sem nenhum caso.
O bug dentro do ledger de sobreviventes. Alguns mutantes sobrevivem legitimamente e vão para um arquivo de sobreviventes declarados, com classe, razão e data. Qualquer sobrevivente fora dessa lista reprova a execução. Só que as entradas eram chaveadas por runner + número da linha, e números de linha mudam: em 15 de agosto, 3 de 12 entradas estavam desancoradas. Uma entrada deslocada gera ruído visível; já um sobrevivente real que cai num número herdado é aceito sem aviso. A chave passou a ser runner + operador + trecho de código.
A rodada desta semana. Em 20 de agosto, a rodada tinha dado 98 mortos e zero sobreviventes não declarados. Rodei de novo em 24 de setembro: 102 mortos, 11 declarados e dois sobreviventes não declarados, ambos em gates que nasceram depois de agosto. O gate de observabilidade continua verde quando o exit 2 vira exit 0. O gate de merge de PR continua verde quando a comparação que reconhece o timeout (-eq 124) é invertida. O zero não se manteve sozinho: os gates novos entraram com casos que passam, e só a mutação mostrou que esses casos não percebem o gate parar de bloquear.
Isto é o mais perto que tenho da métrica ausente que Böckeler aponta. É uma taxa de morte sobre um conjunto fixo de operadores, e não cobertura: diz que, para estas dez formas mecânicas de quebrar um controle, alguma coisa percebe, e não diz nada sobre a décima primeira.
Mecanismo 4: a regra que teve de merecer a entrada
O candidato era o export-sem-import, que acusa símbolo exportado no diff sem nenhum consumidor. Antes de escrever o gate, rodei uma sonda sobre o histórico de três repositórios, com o limiar (precisão ≥ 70%) registrado antes de olhar qualquer resultado.
Triei à mão as 17 acusações: treze verdadeiras, quatro prematuras (o consumidor chegou depois, em três delas no mesmo dia). A precisão por commit é 13/17 = 76%. Por tarefa, que é como o gate roda de verdade, é 13/14 = 93%.
O Tricorder do Google exige que um analisador fique abaixo de ~10% de falso positivo efetivo, porque acima disso os desenvolvedores o desligam. Contra esse teto, este gate reprova como checagem por commit e passa como checagem por tarefa. A unidade de medição é um parâmetro de projeto do gate, e escolhê-la equivale a escolher o limiar.
A maior parte do que medi, eu matei
| Candidato | Veredito |
|---|---|
| Dependência fora do lockfile | reprovado: o npm ci já pega |
| Guarda de repetição de ferramenta | reprovado: 1,31% de 49.712 chamadas |
| Gate anti-adulteração do harness | reprovado: 1 afrouxamento em 123 commits, declarado e justificado |
| Décimo primeiro operador de mutação | reprovado: 35 de 51 sobreviventes eram ruído dele |
| Poda de carga de contexto | reprovado: as candidatas eram as mais referenciadas |
| Gate de evidência visual | aprovado: 24% de 1.683 commits tocam UI |
Eu queria todas essas regras. Quatro das cinco reprovações vieram de medições rodadas justamente para justificar a construção.
Também achei, num levantamento contra a própria plataforma, dois arquivos de configuração que nenhum código lia. Um declarava off/warn/block para cada gate, e quem pusesse um gate em block ali acreditava ter endurecido o pipeline sem ter mudado nada. O outro dizia que o gate lia dali os comandos de lint e teste, quando o runner trazia hardcoded os do npm, e um repositório 100% Bun com 95,51% de cobertura ficava permanentemente vermelho. Um arquivo de configuração que nenhum código lê acaba virando uma mentira com schema.
Três vezes em que a medição errou
Num único dia de agosto, três medições produziram números confiantes e errados:
- Um laço de shell quebrou num nome de diretório com espaço e produziu 594 hits, com a conclusão "esta regra vai afogar o time em ruído". A contagem verdadeira era zero.
- Contei
exit != 0como "bloqueou". A conclusão foi "as três formas de curinga estão bloqueadas", quando as três passavam direto. - Um caminho de raiz não resolvido para absoluto fez um gate acusar o próprio nome da plataforma.
As três sobreviveram porque o resultado era plausível. A medição que concorda com o que você já acreditava é a que você menos confere. Regra prática: antes de concluir algo a partir de um número, rode a contraprova, um caso que tem de dar o resultado oposto.
O que isto não demonstra
Não faço afirmação de eficácia: não medi se times que usam a plataforma entregam mais rápido ou melhor, e não há grupo de controle. A suíte reporta três gates sem nenhum caso e cinco com cobertura parcial. Todo n aqui é pequeno. E é a plataforma de uma pessoa, em dogfood: onze semanas, 140 commits.
O que vale roubar
- Escreva quais dos seus controles não estão ligados a nada, num campo do próprio arquivo que afirma que eles existem.
- Afira o stderr além do código de saída. Script quebrado e gate bloqueando produzem o mesmo código diferente de zero.
- Quebre um controle de propósito esta semana. Troque um
fail=1porfail=0e veja se alguma coisa fica vermelha. Se nada ficar, você aprendeu a coisa mais útil deste texto pelo preço de dez minutos. - Declare o limiar antes de olhar o resultado. Sem isso, todo número é post hoc e toda regra sobrevive.
- Deixe a medição matar coisas. Se suas medições só aprovam o que você já planejava construir, elas viraram cerimônia.
- Gate novo entra em postura de aviso. Um gate que bloqueia desde o primeiro dia fica vermelho o tempo todo, e até sexta alguém negocia a retirada dele.
Nada disto trata de IA. É a lição mais velha da disciplina chegando a um lugar novo: uma asserção que ninguém viu falhar não merece esse nome. O harness é código de produção: roda a cada commit, barra cada entrega e, quando falha, falha em silêncio e em verde.
Não achei publicado ninguém apontando um motor de mutação para o próprio harness. Se alguém já fez isso, eu gostaria de ter a referência.
Relato completo, com as fontes e as tabelas de medição: https://sozodata.com.br/pt-BR/blog/exit-zero-quem-testa-os-gates-dos-agentes
A plataforma, as medições e os achados são meus; redigi o texto com apoio de IA e depois o editei.