1

Claude Code: um hook de 20 linhas que impede o agente de editar seu .env

Quem usa o Claude Code no modo que aceita edições sem perguntar já deve ter pensado: "e se ele resolver mexer no .env?". Pedir isso no CLAUDE.md ajuda, mas é só uma instrução para o modelo, que pode ser esquecida. Um hook roda antes da ferramenta e o Claude Code respeita a resposta dele, então a regra deixa de depender da memória do modelo.

Como funciona

O evento PreToolUse dispara antes de cada chamada de ferramenta. O hook recebe um JSON pela entrada padrão (o caminho do arquivo vem em tool_input.file_path nas ferramentas Edit e Write; no MultiEdit, presente em algumas versões, o hook usa o mesmo campo) e pode responder com permissionDecision:

  • deny: bloqueia e mostra o motivo para o Claude;
  • ask: pede sua confirmação;
  • sem saída e exit 0: segue normalmente.

O script

Salve em .claude/hooks/guard-files.sh e rode chmod +x .claude/hooks/guard-files.sh (precisa do jq):

#!/usr/bin/env bash
set -uo pipefail
command -v jq >/dev/null || { echo "jq não instalado; bloqueando por segurança" >&2; exit 2; }
input=$(cat)
path=$(jq -r '.tool_input.file_path // empty' <<<"$input")
[ -z "$path" ] && exit 0
base=$(basename "$path")
decide() {
  jq -nc --arg d "$1" --arg r "$2" \
    '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:$d,permissionDecisionReason:$r}}'
  exit 0
}
case "$base" in
  .env.example|.env.sample|.env.template) ;;
  .env|.env.*|.envrc) decide deny "$base guarda segredos. Edite à mão; use .env.example." ;;
  *.pem|*.key|id_rsa|id_ed25519) decide deny "$base parece uma chave privada." ;;
  package-lock.json|pnpm-lock.yaml|yarn.lock) decide ask "$base deve mudar pelo gerenciador de pacotes." ;;
esac
exit 0

E registre em .claude/settings.json:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write|MultiEdit",
        "hooks": [
          { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/guard-files.sh" }
        ]
      }
    ]
  }
}

Testando sem abrir o Claude

Dá para simular a entrada:

echo '{"tool_input":{"file_path":"/proj/.env.local"}}' | .claude/hooks/guard-files.sh
# {"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny",...}}

echo '{"tool_input":{"file_path":"/proj/src/app.ts"}}' | .claude/hooks/guard-files.sh
# (nada) -> segue normal

Detalhes que pegam

Atualização (v2): O .envrc do direnv não casa com .env.* e costuma ter segredo, por isso entrou no padrão. E o matcher inclui MultiEdit, que em algumas versões do Claude Code também edita arquivos. (Valeu, plugandship!)

  1. Exit 2 vs JSON. Exit 2 bloqueia usando o stderr como motivo; para responder em JSON, saia com 0. Se o script quebrar com outro código, a ação não é bloqueada, por isso o teste do jq no topo sai com 2 (falha fechada).
  2. Isso só cobre escrita. O Claude ainda pode ler o .env com Read ou com cat no Bash. Para leitura, use também permissions.deny com Read(./.env) e Read(./.env.*) no settings.json, e um hook com matcher Bash se quiser barrar comandos.
  3. Proteja o próprio hook. Se o agente pode editar .claude/settings.json, ele pode desligar a regra. Vale incluir esse arquivo e a pasta .claude/hooks/ no deny.

Documentação oficial: https://docs.anthropic.com/en/docs/claude-code/hooks


Transparência: eu mantenho o Claude Code Power Kit, um pacote pago de hooks, agentes e configurações para Claude Code (inclui uma versão mais completa deste guard). Quem quiser só gerar um CLAUDE.md para o projeto tem um gerador grátis: https://claude-md-generator.surge.sh. O kit: https://pay.kiwify.com.br/ppuZGsg

Carregando publicação patrocinada...
1

Dois buracos pequenos no case. O .envrc do direnv não casa nem com .env nem com .env.*, e muita gente guarda segredo nele. E no matcher, dependendo da versão do Claude Code ainda existe o MultiEdit, que também manda file_path e passa direto pelo Edit|Write. Trocar pra Edit|Write|MultiEdit não custa nada.

1

Valeu demais, plugandship, as duas observações procedem!

  • .envrc entrou no padrão de bloqueio (.env|.env.*|.envrc), junto com o .env. Testei e agora ele é negado.
  • O matcher virou Edit|Write|MultiEdit. O script já lê tool_input.file_path, então com o MultiEdit mandando esse campo não precisa mudar mais nada.

Atualizei o post com as duas correções. Obrigado pela revisão!

0