Bloccare Claude Code sui file sensibili: PreToolUse hook per .env, chiavi e segreti
Ho un progetto con un `.env` che contiene chiavi di produzione. Se Claude Code lo apre "per leggere la config" e me lo cita in una risposta, il segreto è già uscito — dal file finisce nel contesto del modello, dalla risposta finisce nella cronologia. La cosa buona è che ho un modo semplice per impedirlo: un PreToolUse hook che intercetta ogni chiamata a `Read`/`Edit`/`Write`, guarda il path, e se non mi piace blocca il tool prima che venga eseguito. In questo articolo ti mostro l'hook che uso, come si aggancia in `settings.json` e perché exit code 2 è il dettaglio che fa la differenza.
🛡️ Perché serve un hook e non basta il .gitignore
Il `.gitignore` protegge la history di git, non l'agente. Claude Code, quando lavora su un repo, ha accesso al filesystem locale: se gli chiedi "leggimi la config del progetto", il tool `Read` non ha una lista implicita di file vietati — apre quello che trovi, `.env` incluso. Da lì il contenuto entra nel contesto del modello, e da quel momento può comparire in una risposta, in un log di sessione o in un artefatto condiviso.
La soluzione pulita non è "ricordarsi" di non chiedere quel file: è mettere un controllo deterministico davanti al tool. I PreToolUse hook di Claude Code servono proprio a questo — sono callback che l'agente esegue prima di ogni tool call, e possono decidere se lasciarla passare, aggiungere contesto o bloccarla del tutto. Non è AI che modera AI: è codice tuo, veloce e prevedibile, che si mette in mezzo tra il modello e il filesystem.
.gitignore
- Impedisce il commit del file
- Non impedisce a Claude di leggerlo
- Non blocca Edit o Write
- Zero segnale se qualcuno prova ad aprirlo
PreToolUse hook
- Intercetta ogni chiamata Read/Edit/Write
- Blocca prima che il tool parta (exit 2)
- Restituisce un messaggio che Claude legge
- Loggabile: sai chi ha tentato cosa
Il .gitignore protegge il repository remoto; l'hook protegge la sessione dell'agente. Sono strati diversi e servono entrambi.
⚙️ Come funziona un PreToolUse hook
Un hook è uno script (bash, python, node — quello che vuoi) che Claude Code invoca su un evento del ciclo di vita del tool. PreToolUse scatta prima che il tool venga eseguito. L'agente passa allo script un JSON su stdin con la descrizione della chiamata: nome del tool, input, contesto della sessione. Lo script decide con il suo exit code cosa succede: `0` = via libera, `2` = bloccato (Claude legge la stderr come motivazione), altri codici = errore trasparente.
Il vantaggio del contratto è che è testabile a mano: puoi inviare tu stesso un JSON di prova allo script e vedere cosa risponde, senza dover girare l'intero agente. E siccome è codice tuo, hai libertà totale: pattern matching sui path, whitelist di cartelle, integrazione con un secret scanner, log strutturato su file. La sola disciplina è tornare in fretta — l'hook gira in-line sulla tool call, quindi non è il posto per operazioni lente.
- 01Claude propone la tool callIl modello decide di chiamare Read su un path.
- 02PreToolUse riceve il JSONNome tool, path, input completo su stdin.
- 03Lo script decideRegex sul path, whitelist, secret scan — cosa vuoi tu.
- 04Exit code determina l'esito0 procede, 2 blocca con messaggio, altro è errore.
- 05Claude vede il bloccoLa stderr del blocco diventa contesto per la risposta successiva.
🧪 Lo script che uso: block-secrets.sh
Ecco lo script minimo che tengo nel mio profilo utente. Vive in `~/.claude/hooks/block-secrets.sh`, ha permessi di esecuzione, e fa una cosa sola: legge il JSON su stdin, estrae il path del file coinvolto dal tool `Read`/`Edit`/`Write`, e se il path matcha una lista di pattern sensibili esce con codice 2 e un messaggio esplicito su stderr. Nessun'altra ambizione: piccolo, veloce, leggibile in trenta secondi.
La lista di pattern è quella che ho trovato più utile nella pratica: `.env` e varianti (`.env.local`, `.env.production`), chiavi private (`.pem`, `.key`), token file (`.npmrc` con `_authToken`, `.pypirc`), cartelle segrete (`secrets/`, `.aws/credentials`, `.ssh/`). Non è esaustiva per costruzione — è la mia baseline, tu la estendi al tuo contesto: aggiungi ciò che nel tuo repo è veramente riservato, non fare finta che l'hook sia una firewall.
#!/usr/bin/env bash
# PreToolUse: blocca Read/Edit/Write su path sensibili.
set -euo pipefail
payload=$(cat)
tool=$(echo "$payload" | jq -r '.tool_name // empty')
path=$(echo "$payload" | jq -r '.tool_input.file_path // empty')
case "$tool" in Read|Edit|Write) : ;; *) exit 0 ;; esac
[ -z "$path" ] && exit 0
deny='(^|/)\.env($|\.|/)|\.pem$|\.key$|(^|/)secrets/|(^|/)\.aws/credentials$|(^|/)\.ssh/'
if [[ "$path" =~ $deny ]]; then
echo "blocked by PreToolUse hook: sensitive path '$path'" 1>&2
echo "ask the user before touching this file." 1>&2
exit 2
fi
exit 0Claude Code hooks · docs ufficiali ↗
🔌 Agganciarlo in settings.json
La registrazione dell'hook vive in `~/.claude/settings.json` (globale) o `.claude/settings.json` del progetto (locale, committabile). La struttura è la stessa: chiave `hooks.PreToolUse`, un array di regole, ognuna con un `matcher` (quale tool intercetti) e un `hooks[]` (cosa esegui). Io metto la regola globale così l'hook mi copre su tutti i progetti senza doverla replicare ovunque; se un repo ha bisogno di una whitelist speciale, aggiungo una regola locale che la estende.
Un solo dettaglio operativo: il `matcher` è una regex sui nomi dei tool. `"Read|Edit|Write"` copre le tre chiamate che possono aprire un file per contenuto; se sei paranoico includi anche `Bash` (per bloccare `cat .env`, ma lì la lista di comandi da bloccare è molto più lunga e conviene un hook dedicato). Il `command` è il path assoluto allo script — Claude Code gli passa il JSON su stdin, l'ambiente è quello della tua shell.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Read|Edit|Write",
"hooks": [
{
"type": "command",
"command": "$HOME/.claude/hooks/block-secrets.sh"
}
]
}
]
}
}🚦 Perché exit code 2 è il dettaglio giusto
Il contratto degli hook è preciso: `exit 0` fa passare la tool call, `exit 2` la blocca deterministicamente e feeda la tua stderr al modello come motivazione, qualsiasi altro codice è trattato come errore dello script e mostrato all'utente. Il 2 è il punto d'oro perché fa due cose insieme: impedisce la chiamata e comunica il perché a Claude, che nella risposta successiva non ritenta ciecamente ma spiega che il file è protetto.
In pratica vuol dire che la user experience regge: se Claude prova ad aprire `.env`, riceve indietro `blocked by PreToolUse hook: sensitive path '.env'`, e la risposta diventa qualcosa come "non posso aprire `.env` — è un file protetto dai tuoi hook, dimmi tu cosa mostrarti". Nessun crash, nessun loop, nessun "errore misterioso". Se usassi `exit 1` avresti solo rumore; usare `exit 2` è ciò che rende l'hook una policy leggibile dall'agente.
# Simula il JSON che Claude Code passerebbe
echo '{"tool_name":"Read","tool_input":{"file_path":".env"}}' \
| ~/.claude/hooks/block-secrets.sh
echo "exit=$?"
# → stderr: blocked by PreToolUse hook: sensitive path '.env'
# → exit=2
# Path innocuo: passa
echo '{"tool_name":"Read","tool_input":{"file_path":"README.md"}}' \
| ~/.claude/hooks/block-secrets.sh
echo "exit=$?"
# → exit=0⚠️ Errori da evitare
Il primo errore è usare l'hook come unica difesa. Un PreToolUse regge il tool `Read`, ma se Claude gira `Bash: cat .env` la difesa aggira il filtro sui file: per coprirla ti serve un hook dedicato al matcher `Bash` che intercetti il `command` completo e cerchi comandi rischiosi. Trattare l'hook come una barriera tra tante — insieme al `.gitignore`, ai `chmod 600` sui file sensibili e a un secret manager esterno — è l'unico modo per non farsi bucare dal caso limite.
Il secondo è metterci logica lenta. L'hook gira in-line: se chiama un servizio esterno, se scansiona 50 MB di file, se aspetta una API, ogni tool call di Claude paga quell'attesa. Tieni l'hook sotto i pochi millisecondi: se ti serve un secret scan pesante, spostalo in un PostToolUse asincrono o in un pre-commit git, non nel percorso caldo dell'agente.
- Non fidarti del solo hook: `.gitignore`, permessi filesystem e secret manager restano necessari.
- Copri anche Bash se vuoi bloccare `cat`, `grep`, `less` sui file sensibili — matcher a parte, logica diversa.
- Fallo veloce: niente rete, niente scan pesanti; l'hook è nel percorso critico.
- Loggalo: appendi un rigo su file quando blocchi, così scopri quali file l'agente prova ad aprire.
- Testa dopo ogni modifica: uno script rotto con `exit 1` genera solo rumore, con `exit 0` apre tutto.
✅ Checklist finale
Prima di lasciare Claude Code libero su un repo reale, faccio scorrere sempre la stessa checklist minima — sono cinque cose e non ne salto una, perché ognuna ha morso in un progetto in passato. È noiosa, ma è ciò che rende la sessione con un agente equivalente in sicurezza a una sessione tua sul terminale.
Se una qualsiasi voce non è verde, l'agente non parte. Vale in locale, vale in una skill che condivido con il team, vale doppio nei repo che toccano dati di produzione o di clienti. Il tempo che perdi a scriverla la prima volta lo recuperi la prima volta che l'hook blocca davvero qualcosa.
[ ] block-secrets.sh esiste e ha permessi di esecuzione
[ ] settings.json registra l'hook con matcher Read|Edit|Write
[ ] Test manuale: .env → exit 2, README.md → exit 0
[ ] .gitignore copre gli stessi path (difesa in profondità)
[ ] Un log-append opzionale ma consigliato: sai chi tenta cosaDomande frequenti su PreToolUse hook Claude Code
Cos'è un PreToolUse hook in Claude Code?
È una callback che Claude Code esegue prima di ogni chiamata a un tool (Read, Edit, Write, Bash, ecc.). Riceve su stdin un JSON con nome del tool e input, e decide con il suo exit code se la chiamata procede (0), viene bloccata con messaggio (2) o va in errore (altro codice). È il modo deterministico per mettere una policy tua tra il modello e il filesystem.
Perché non basta il .gitignore per proteggere .env dall'agente?
Il .gitignore impedisce il commit, non la lettura. Claude Code accede al filesystem locale: se il tool Read apre .env, il contenuto entra nel contesto del modello e da lì può finire in una risposta o in un log. L'hook interviene un passo prima: blocca la tool call, così il file non viene letto affatto.
Perché exit code 2 invece di 1?
Nel contratto degli hook, exit 2 è specifico: blocca la tool call e feeda la stderr dell'hook al modello come motivazione. Claude legge quel messaggio e nella risposta successiva spiega all'utente che il file è protetto invece di ritentare ciecamente. Exit 1 (e ogni altro codice) è trattato come errore dello script e non produce lo stesso comportamento di blocco pulito.
Devo mettere l'hook globale o per progetto?
Io lo metto globale in ~/.claude/settings.json così mi copre di default su ogni repo. Se un progetto ha bisogno di una whitelist speciale (es. leggere un .env.example dedicato) aggiungo una regola locale in .claude/settings.json del progetto, che viene committata e vale per tutto il team. La regola locale non sostituisce quella globale — si somma.
L'hook blocca anche cat .env dentro Bash?
Non con il matcher Read|Edit|Write mostrato in questo articolo — quello controlla solo i tool file. Per intercettare Bash serve una regola con matcher Bash e uno script che guardi il comando completo (tool_input.command) cercando pattern come cat .env, less .env, grep dentro cartelle segrete. Trattali come due hook distinti: la logica è diversa.
L'hook rallenta la sessione?
Solo quanto lo rendi tu lento. Lo script del post fa jq + regex e ritorna in millisecondi, sotto la soglia percepibile. Se vuoi integrare un secret scanner esterno o una chiamata di rete, non farlo qui: sei nel percorso critico di ogni tool call. Spostalo in un PostToolUse asincrono o in un pre-commit git, dove il tempo non pesa.
Parliamone
Se questo tema ti riguarda, scrivimi: confrontarsi su codice e AI è sempre tempo speso bene.