INEMA.CLUBPROHooks do Claude Code

Hooks do Claude Code / Trilha 02

Trilha 02 · Guardrails

Guardrails:
pare o perigoso

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.

4aulas
~36minutos
intermediárionível

Trilha 02 · Aula 1 · Intermediário

A ideia de
uma trava

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

01 O que é uma trava

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:

  • exit 0 — tudo bem, pode rodar.
  • exit 2barra. O Claude não executa, e o que você imprimiu no 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?

02 O motivo volta pro Claude

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?

03 Um exemplo mínimo: barrar rm -rf

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.

04 exit 0 libera

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.

  • Termine sempre com exit 0 depois do bloco de checagem — mesmo que pareça óbvio.
  • Uma trava que só reage a um padrão e deixa tudo o resto passar é o comportamento certo: você quer travar o perigoso, não o resto do trabalho.

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.

Indo mais fundo

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

Sua primeira trava: bloquear rm -rf

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

Ler o que
vai rodar

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

01 O comando chega em tool_input.command

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?

02 Extrair com jq -r

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'?

03 Casar padrões perigosos (grep/case)

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.

04 Falso positivo e aspas: cuidado com escape

Ler o comando bem tem duas armadilhas comuns:

  • Falso positivo — um 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.
  • Nunca reinterpole o comando num shell novo — pegar $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

Barrar um git push --force

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 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

Casos que
valem a pena

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

01 rm -rf e caminhos fora do projeto

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/?

02 push/force na branch principal

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.

03 Tocar .env e segredos

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ê?

04 Editar arquivos protegidos, de forma geral

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:

  • Evento: PreToolUse.
  • Matcher: Edit|Write (pega edição ou criação de arquivo).
  • Checagem: comparar 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

Proteger o .env

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

Allow, ask,
deny — e higiene

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

01 Além do exit 2: JSON de decisão

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?

02 permissionDecision: allow, ask, deny

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" } }
  • allow — libera, sem perguntar (útil pra baixar o atrito num caso que você já confia).
  • deny — barra, equivalente ao exit 2, só que com motivo estruturado.
  • ask — pausa e pede confirmação ao humano. Use quando o certo depende do contexto que só você tem.

Teste-se

Qual valor de permissionDecision PAUSA a ação e pede confirmação ao humano, em vez de decidir sozinho?

03 fail-open: nunca trave por um bug seu

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.

04 Hook é poder: quoting, validar, não vazar segredo

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:

  • Quoting — sempre "$var" entre aspas; nunca reinterpole tool_input direto num shell novo.
  • Valide caminhos — cheque que um file_path não sai do projeto (sem ../ escapando pra fora).
  • Proteja segredos.env, credenciais, .ssh, .git, chaves privadas nunca devem aparecer em log nenhum, nem no motivo impresso.
  • Fail-open — como no passo anterior: bug seu não pode virar trava permanente.

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.

Indo mais fundo

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

Capstone: um guard que fala allow/ask/deny

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ê.