Hooks do Claude Code / Trilha 02
Trilha 02 · Guardrails
Transforme "por favor não faça isso" numa trava de verdade: barrar comandos e arquivos perigosos antes de acontecer, decidir allow/ask/deny e proteger segredos — sem depender de o modelo lembrar.
Aulas desta trilha
PreToolUse e exit 2: o bloqueio mais simples que existe, e como o motivo volta pro Claude.
O comando chega em tool_input.command: extrair com jq, casar padrões perigosos e não se machucar com aspas.
rm -rf, push --force na main, tocar em .env e outros arquivos protegidos.
A decisão em JSON, fail-open pra nunca travar por bug seu, e as regras de higiene de um hook.
Trilha 02 · Aula 1 · Intermediário
Uma trava é só um hook de PreToolUse que decide, antes de a ferramenta rodar, se ela pode seguir. O sinal é o código de saída — e é tudo que você precisa pra começar.
↓ role para estudar
Uma trava é um hook de PreToolUse: ele roda antes da ferramenta e decide se ela pode seguir. O sinal é o código de saída:
stderr vira o motivo.Repare na diferença: a trava não "avisa depois" — ela impede antes. É a diferença entre um extintor e um relatório de incêndio. Nas próximas aulas você vai ensinar essa trava a ler o comando, cobrir os casos que doem de verdade, e falar allow/ask/deny — mas a base é só isto: exit 0 segue, exit 2 barra.
Teste-se
Qual código de saída BARRA a ação no PreToolUse?
Quando a trava barra, o Claude não fica no escuro: tudo que seu script imprime no stderr antes do exit 2 vira o motivo que ele lê. É a sua única chance de explicar por que — escreva pra ele, não pra você:
# ruim: não diz nada útil exit 2 # bom: o Claude sabe exatamente o que evitar da próxima vez echo "trava: rm -rf apaga tudo sem confirmação — use um caminho específico" >&2 exit 2
Pense na mensagem como uma instrução curta pro próprio Claude tentar de novo do jeito certo, não como um log de erro genérico. "bloqueado" diz pouco; "bloqueado: use rm arquivo.txt, não rm -rf pasta/" ensina.
Teste-se
O texto que vira o "motivo" mostrado ao Claude quando a trava barra sai por onde?
Junte as duas peças — ler o comando (mais sobre isso na próxima aula) e decidir com exit code — e você já tem uma trava de verdade:
# .claude/settings.json → PreToolUse, matcher "Bash"
jq -r '.tool_input.command' | grep -qE 'rm -rf' && {
echo "trava: rm -rf bloqueado" >&2
exit 2
}
exit 0
Leia de dentro pra fora: pega o command do stdin, procura o padrão rm -rf; se achar, imprime o motivo e barra; se não achar, o exit 0 no fim deixa seguir. Curto, e já resolve o acidente mais clássico.
Todo comando que não casa com o padrão perigoso precisa continuar normalmente — e quem garante isso é um exit 0 explícito no fim do script. Sem ele, o exit code do último comando rodado (o grep, por exemplo) pode vazar e confundir o Claude Code.
exit 0 depois do bloco de checagem — mesmo que pareça óbvio.Com isso, o ciclo fecha: ler o comando → comparar com o padrão → exit 2 (barra, com motivo) ou exit 0 (segue). É a menor trava que existe, e já é real — o Claude não pode simplesmente "decidir ignorar" ela.
Além do exit code, um hook PreToolUse pode devolver uma decisão mais rica em JSON pelo stdout (com allow/ask/deny e um motivo estruturado). É o assunto da Aula 4 desta trilha — comece pelo exit code, que já resolve a maioria dos casos.
Pratique agora 0/4 feito
Faça um hook barrar de verdade um rm -rf — testando num caminho descartável, sem chegar perto de nada importante. ~5 min.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "jq -r '.tool_input.command' | grep -qE 'rm -rf' && { echo 'trava: rm -rf bloqueado' >&2; exit 2; }; exit 0" }
]
}
]
}
}✓ Você tem uma trava real: rm -rf barrado antes de rodar, e tudo mais seguindo normal. Na próxima aula ela aprende a ler qualquer comando, não só este.
Trilha 02 · Aula 2 · Intermediário
Antes de decidir, o hook precisa ver o comando de verdade — ele chega pronto no JSON do stdin; cabe a você extraí-lo e comparar com o que é perigoso.
↓ role para estudar
Antes de rodar, o Claude entrega ao seu hook um JSON pela entrada padrão (stdin) com o contexto do momento. Alguns campos estão sempre presentes (session_id, cwd, hook_event_name); no PreToolUse, vêm também tool_name e o tool_input:
{
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": { "command": "rm -rf build/" },
"cwd": "/home/voce/projeto"
}
Repare: como a ferramenta ainda não rodou, não existe tool_response nesse momento — só a intenção. É exatamente essa intenção que sua trava examina antes de deixar (ou não) ela virar ação.
Teste-se
Onde chega o comando que o Bash está prestes a rodar?
Pra tirar o texto puro de dentro do JSON, você usa jq. A diferença entre usar -r ou não é o que separa um valor utilizável de um cheio de aspas:
# sem -r: devolve o valor JSON-encoded, com aspas $ jq '.tool_input.command' "rm -rf build/" # com -r (raw): devolve o texto cru, pronto pra comparar $ jq -r '.tool_input.command' rm -rf build/
Sem -r, as aspas continuam grudadas no valor e atrapalham qualquer comparação de texto. Guarde o hábito: em hook de shell, jq -r é praticamente sempre o que você quer.
Teste-se
Qual a diferença entre jq '.x' e jq -r '.x'?
Com o texto em mãos, comparar com vários padrões de uma vez é rotina. Duas formas equivalentes — escolha a que ler melhor no seu script:
# com grep -E (alternância "|") cmd=$(jq -r '.tool_input.command') echo "$cmd" | grep -qE 'rm -rf|git push --force|:\(\)\{ :' && exit 2 # com case (mais legível quando a lista cresce) case "$cmd" in *"rm -rf"*|*"push --force"*) echo "trava: padrão perigoso" >&2; exit 2 ;; esac exit 0
grep -E é mais curto pra poucos padrões; case fica mais legível quando a lista de padrões cresce e cada um merece um comentário. As duas fazem a mesma coisa: casar o comando com uma lista do que é perigoso.
Ler o comando bem tem duas armadilhas comuns:
grep largo demais pega coisa inocente (ex.: rm -rf dentro de um comentário ou de uma string). Prefira padrões específicos ao invés de uma palavra solta.$cmd e jogar de volta num eval ou noutro bash -c "$cmd" abre a porta pra injeção de comando. Sua trava só deve ler e comparar o texto, nunca executá-lo de novo.Sempre use "$cmd" entre aspas duplas ao manipular a variável (evita que espaços quebrem a comparação), e trate o valor como dado, não como código pra rodar.
Teste-se
Por que NÃO se deve reinterpolar $cmd num novo shell (ex.: eval "$cmd") dentro do próprio hook?
Pratique agora 0/4 feito
Um hook que lê o comando de verdade e barra um push forçado — o tipo de acidente que reescreve o histórico dos outros. ~5 min.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "jq -r '.tool_input.command' | grep -qE 'push.*(--force|-f)' && { echo 'trava: push --force bloqueado' >&2; exit 2; }; exit 0" }
]
}
]
}
}✓ A trava agora lê o comando antes de decidir — não é mais um padrão fixo isolado, é uma regra que examina o texto de verdade. (Repare que o padrão -f sozinho é largo — é o "falso positivo" do passo 04; ajuste-o se seu projeto usa -f pra outra coisa.)
Trilha 02 · Aula 3 · Intermediário
Você não precisa travar tudo — só os erros caros. Quatro casos que pagam a trava sozinhos, na primeira vez que evitam um estrago.
↓ role para estudar
O perigo real não é a palavra "rm -rf" isolada — é o alvo. Um rm -rf dentro da pasta build/ do projeto é chato de recuperar; um rm -rf /, rm -rf ~ ou rm -rf .. é catastrófico. Trave alvos amplos, não a sintaxe:
jq -r '.tool_input.command' | grep -qE 'rm -rf (/|~|\.\.)($| )' && exit 2
Uma checagem extra vale o esforço: comparar o caminho contra $CLAUDE_PROJECT_DIR (a raiz do projeto) garante que o alvo do comando não sai da pasta que você está de fato disposto a arriscar.
Teste-se
Por que travar rm -rf / ou rm -rf ~ é mais urgente que travar rm -rf build/?
git push --force reescreve o histórico remoto — se for na main ou master, o estrago é do time inteiro. Trave o combo específico (força + branch protegida), não só a força isolada:
cmd=$(jq -r '.tool_input.command')
echo "$cmd" | grep -qE -- '--force' && echo "$cmd" | grep -qE '\b(main|master)\b' &&
{ echo "trava: push --force direto na main/master" >&2; exit 2; }
exit 0
Note as duas condições combinadas: precisa ter força e apontar pra branch principal. Isso deixa passar um --force inofensivo numa branch de feature sua, e barra só o que ameaça o time.
Editar arquivos é outro evento: aqui o hook olha pra tool_input.file_path, não pra command. Barre a edição de segredos antes de acontecer:
# PreToolUse, matcher "Edit|Write"
file=$(jq -r '.tool_input.file_path')
case "$file" in
*.env|*.pem|*id_rsa*|*credentials*)
echo "trava: arquivo protegido ($file)" >&2; exit 2 ;;
esac
exit 0
Isso barra a criação e a edição — inclusive a "primeira vez" que alguém tentaria colar uma chave dentro de um .env versionado sem querer.
Teste-se
Pra travar a edição de um arquivo (e não um comando de terminal), qual campo do tool_input você lê?
A mesma técnica do passo anterior generaliza: qualquer arquivo que você não quer que mude sem revisão vira uma entrada na lista — package-lock.json, .github/workflows/*, o próprio .claude/settings.json. O molde é sempre:
PreToolUse.Edit|Write (pega edição ou criação de arquivo).tool_input.file_path com a sua lista de protegidos.Não existe lista perfeita de primeira. Comece pelos segredos (passo 03), depois adicione o que te morder — cada susto vira uma linha nova na trava.
Pratique agora 0/4 feito
Uma trava que barra qualquer edição ou criação de arquivo .env no projeto. ~5 min, e testável sem risco nenhum.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "jq -r '.tool_input.file_path' | grep -qE '\\.env$' && { echo 'trava: .env é protegido' >&2; exit 2; }; exit 0" }
]
}
]
}
}✓ Agora você trava por arquivo, não só por comando — o mesmo molde (matcher Edit|Write + file_path) protege qualquer arquivo sensível que você listar.
Trilha 02 · Aula 4 · Avançado
Vá além do exit 2: devolva uma decisão estruturada em JSON, falhe sempre pro lado seguro, e trate seu hook como o código de produção que ele é.
↓ role para estudar
Exit code é rápido, mas rústico: só barra ou não, e o motivo é um texto solto no stderr. Existe um segundo caminho — seu hook imprime um JSON de decisão no stdout, e sai com exit 0 mesmo quando está barrando:
{
"decision": "approve" | "block",
"reason": "string",
"hookSpecificOutput": {
"permissionDecision": "allow" | "deny" | "ask"
}
}
Note a virada: no caminho do exit code, exit 2 É a decisão. No caminho JSON, exit 0 é quase sempre a resposta — quem decide é o conteúdo impresso no stdout.
Teste-se
No caminho do JSON de decisão, com que código o hook normalmente sai — mesmo quando está barrando a ação?
Dentro de hookSpecificOutput, o campo permissionDecision tem três posturas — e um permissionDecisionReason pra explicar qual delas e por quê:
{ "hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "push --force na main não é permitido por aqui" } }
exit 2, só que com motivo estruturado.Teste-se
Qual valor de permissionDecision PAUSA a ação e pede confirmação ao humano, em vez de decidir sozinho?
Um hook é só um script — e scripts quebram (o jq falha, uma variável vem vazia, uma sintaxe erra). A pergunta que importa: quando seu próprio hook tem um bug, o que deve acontecer? A resposta certa é fail-open: liberar e avisar, nunca travar o projeto inteiro por um erro que não é do usuário.
# logo no topo do script set +e trap 'exit 0' ERR # … resto do hook …
O trap garante que, se algo dentro do script explodir, o hook ainda sai com exit 0 — o Claude segue trabalhando, e o bug fica pra você investigar depois, sem travar ninguém no meio do caminho. Fail-open não é desculpa pra escrever a trava com pouco cuidado — é uma rede de segurança para quando ela falhar de um jeito que você não previu.
Um hook roda com todas as suas permissões de usuário — não é uma sandbox. Trate-o como código de produção, com quatro regras de higiene:
"$var" entre aspas; nunca reinterpole tool_input direto num shell novo.file_path não sai do projeto (sem ../ escapando pra fora)..env, credenciais, .ssh, .git, chaves privadas nunca devem aparecer em log nenhum, nem no motivo impresso.Essas quatro regras cabem numa frase: um hook rápido, idempotente e sem vazar segredo — que fecha esta trilha e vira hábito daqui pra frente.
Nomes de ferramenta são case-sensitive (Bash, Edit, Read, Write), o timeout de um hook é em segundos (padrão generoso de 600s para comandos — vale encurtar), e hooks não podem ser interativos — todas essas gotchas empurram na mesma direção: escreva hooks curtos, previsíveis, e que falham de um jeito seguro.
Pratique agora 0/4 feito
Troque o exit 2 cru por uma decisão estruturada: o mesmo alvo da Aula 1 (rm -rf), agora devolvendo deny com motivo via JSON no stdout. ~7 min.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "cmd=$(jq -r '.tool_input.command'); echo \"$cmd\" | grep -qE 'rm -rf' && printf '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"trava: rm -rf bloqueado\"}}'; exit 0" }
]
}
]
}
}✓ Fim da trilha: você foi do exit 2 cru até um guard que fala a língua allow/ask/deny — a mesma trava, agora com uma decisão explícita e um motivo que o Claude realmente lê.