Hook di Claude Code: quality gate deterministici per lint, test e sicurezza
Chiedere al modello di ricordarsi di formattare, di non lanciare comandi distruttivi o di far girare i test prima di dire «fatto» funziona… finché non funziona più. Le regole in `CLAUDE.md` sono suggerimenti probabilistici: sotto pressione di contesto il modello le dimentica. Gli hook di Claude Code spostano quelle garanzie fuori dal modello, in codice deterministico che gira sempre. In questo tutorial li uso per costruire quattro quality gate concreti, con codice reale e il contratto — stdin, exit code, output JSON — spiegato passo passo.
🪝 Perché gli hook cambiano le regole
Un hook è un comando shell che Claude Code esegue in automatico a un punto preciso del suo ciclo di vita. La differenza con una regola scritta in `CLAUDE.md` è sostanziale: la regola è un suggerimento probabilistico, il modello può seguirla o dimenticarla; l'hook è codice deterministico che gira sempre, con i tuoi permessi, indipendente dal modello e dal contesto.
Quando conviene? Per tutto ciò che deve accadere ogni volta, senza eccezioni: formattare dopo ogni modifica, impedire un `rm -rf`, non chiudere il turno con i test rossi. Sposti la garanzia dal «spero che l'AI se lo ricordi» al «lo impone lo script». Le regole restano perfette per stile e preferenze; gli hook sono per le invarianti che non puoi permetterti di violare.
Regola in CLAUDE.md
- Probabilistica: il modello la interpreta
- Può ignorarla sotto pressione di contesto
- Nessuna garanzia di esecuzione
- Ottima per stile, tono e preferenze
Hook
- Deterministico: gira sempre, come codice
- Indipendente dal modello e dal contesto
- Può bloccare l'azione con exit 2
- Ideale per policy, sicurezza e quality gate
Non sono alternativi: la regola guida, l'hook garantisce.
🗺️ Il ciclo di vita e i suoi eventi
Claude Code espone decine di eventi, ma per i quality gate ne bastano pochi, raggruppabili per cadenza. Una volta per sessione: `SessionStart` e `SessionEnd`. Una volta per turno: `UserPromptSubmit` e `Stop`. A ogni chiamata di tool: `PreToolUse` e `PostToolUse`. Ogni evento dichiara anche se può bloccare l'azione o solo aggiungere contesto.
La regola per scegliere è semplice: agisci il più a monte possibile. Vuoi impedire un'azione prima che accada? `PreToolUse`. Vuoi reagire a una modifica appena fatta? `PostToolUse`. Vuoi un gate di fine lavoro? `Stop`. Vuoi dare a Claude un briefing all'avvio? `SessionStart`. Il resto è dettaglio.
Pochi eventi coprono la quasi totalità dei quality gate: scegli quello più a monte.
🧬 Anatomia di un hook in settings.json
Gli hook si dichiarano in `settings.json`: quello utente in `~/.claude/` vale per tutte le tue sessioni, quello di progetto in `.claude/settings.json` si committa nel repo e vale per tutto il team. La struttura ha tre livelli di annidamento: l'evento, un matcher che filtra quando scatta, e uno o più handler — qui `type: "command"` con lo script. Il placeholder `$CLAUDE_PROJECT_DIR` punta alla radice del progetto, così il percorso resta portabile.
Il matcher aggancia il nome del tool: `Bash`, oppure una lista come `Edit|Write`, oppure una regex. Per i tool MCP il nome è `mcp__<server>__<tool>`, e per prenderli tutti da un server serve `mcp__<server>__.` (il `.` è obbligatorio: senza, viene trattato come stringa esatta e non aggancia nulla). Un matcher vuoto o `*` scatta su ogni occorrenza dell'evento.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/format.sh"
}
]
}
]
}
}Hooks reference · Claude Docs ↗
📥 L'input su stdin e gli exit code
Quando un hook parte, Claude Code gli passa su stdin un JSON con il contesto: `session_id`, `cwd`, `hook_event_name` e — sugli eventi tool — `tool_name` e `tool_input`. Lo leggi con `jq` e decidi. È tutto qui l'input: nessuna variabile magica, solo un oggetto JSON da interrogare.
Il canale di ritorno sono gli exit code: `0` significa via libera; `2` significa blocco, e lo `stderr` torna a Claude come feedback (su `PreToolUse` annulla la chiamata, su `Stop` impedisce a Claude di fermarsi, su `UserPromptSubmit` rifiuta il prompt); qualunque altro codice è un errore non bloccante. In alternativa, con `exit 0` puoi stampare su stdout un JSON strutturato per decisioni più fini — lo vediamo tra poco.
{
"session_id": "a1b2c3",
"cwd": "/home/me/coolsolution-blog",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {
"command": "rm -rf ./build"
}
}🧹 PostToolUse: format e lint a ogni modifica
Il primo gate utile: dopo ogni `Edit` o `Write`, riformatta e linta il solo file toccato. Registro un hook `PostToolUse` con matcher `Edit|Write` che chiama `format.sh`. Lo script legge il `file_path` dal `tool_input` con `jq`, poi lancia gli strumenti del progetto — nel repo uso yarn, quindi `prettier` ed `eslint` girano via yarn (su un progetto .NET sarebbe `dotnet format`).
Il valore è che Claude riceve il file già pulito: nessun diff di stile da rivedere, review più corte, storia git senza commit di sola formattazione. `PostToolUse` non può bloccare (il tool è già stato eseguito), quindi lo script termina sempre con `exit 0`; al massimo rimanda a Claude un messaggio.
#!/usr/bin/env bash
# riformatta e linta il file appena modificato da Claude
set -euo pipefail
# il path arriva nel JSON su stdin, dentro tool_input
file="$(jq -r '.tool_input.file_path // empty')"
[ -z "$file" ] && exit 0 # nessun file: niente da fare
case "$file" in
*.ts|*.tsx|*.js|*.jsx|*.css|*.md)
yarn prettier --write "$file"
yarn eslint --fix "$file" || true
;;
esac
exit 0 # PostToolUse non blocca: si prosegue🖥️ PostToolUse in azione
Ecco cosa vedi nel terminale quando Claude modifica un file: subito dopo l'`Edit`, l'hook scatta, `prettier` riformatta e `eslint --fix` corregge, poi `exit 0` e il turno prosegue senza interruzioni. È il pattern «format on save» portato dentro l'agente.
La differenza rispetto al tuo editor è che vale anche quando a salvare è Claude, non tu. Su un team con convenzioni rigide questo azzera un'intera categoria di commenti in review: nessuno discute più di virgolette, punti e virgola o import ordinati, perché il file arriva già conforme.
Il file torna a Claude già formattato e lintato: nessun diff di stile da rivedere.
🛡️ PreToolUse: negare l'irreparabile
`PostToolUse` reagisce dopo; per fermare qualcosa serve `PreToolUse`, che scatta prima dell'esecuzione e può annullarla. Il guard classico blocca i comandi distruttivi: lo script legge `.tool_input.command`, e se trova un `rm -rf` su glob larghi risponde con un JSON che porta `permissionDecision: "deny"` e una motivazione. Lo stesso schema protegge i segreti: nega gli `Edit` su `.env` o `.dev.vars`.
Due modi per bloccare: `exit 2` con un messaggio su `stderr` (semplice), oppure `exit 0` con su stdout un JSON `hookSpecificOutput.permissionDecision: "deny"` (strutturato). Il secondo è più espressivo: la `reason` associata a `deny` viene mostrata a Claude, che capisce il perché e corregge il tiro. Registro l'hook su `PreToolUse` con matcher `Bash|Edit|Write`.
#!/usr/bin/env bash
# nega comandi distruttivi e modifiche a file sensibili
set -euo pipefail
input="$(cat)" # tutto il JSON su stdin
tool="$(jq -r '.tool_name' <<<"$input")"
cmd="$(jq -r '.tool_input.command // empty' <<<"$input")"
path="$(jq -r '.tool_input.file_path // empty' <<<"$input")"
deny() {
jq -n --arg r "$1" '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: $r
}
}'
exit 0 # decisione via stdout JSON
}
# rm -rf su glob non circoscritti
[[ "$tool" == "Bash" && "$cmd" =~ rm[[:space:]]+-rf.*[*/] ]] \
&& deny "comando distruttivo 'rm -rf' bloccato dall'hook"
# modifiche a file di segreti
[[ "$path" == *.env || "$path" == *.dev.vars ]] \
&& deny "modifica a file sensibile ($path) non consentita"
exit 0 # nessuna corrispondenza: flusso normaleHooks reference · Claude Docs ↗
🚫 Il blocco visto da Claude
Dal lato di Claude il rifiuto non è un muro cieco: riceve la `reason`, capisce perché ed emenda la strategia. Nel terminale vedi il tool negato, la motivazione, e Claude che ripropone un comando circoscritto e sicuro al posto di quello bloccato.
È qui la forza dell'approccio: la policy vive nell'hook, non nella buona volontà del modello. È deterministica, versionata nel repo e identica per ogni agente che legge lo stesso `settings.json` — Claude, Codex, Copilot. Un solo punto di verità per le regole che non si negoziano.
La motivazione torna al modello, che corregge invece di insistere.
🚦 Stop: il quality gate sui test
L'evento `Stop` scatta quando Claude sta per finire il turno — il posto giusto per il gate più importante: non dichiarare fatto ciò che non passa i test. L'hook lancia `yarn test` (o `tsc --noEmit`, o `yarn lint`); se qualcosa è rosso risponde con `decision: "block"` e una `reason`, e Claude non si ferma: torna a lavorare.
Attenzione al loop: un gate `Stop` che blocca a oltranza terrebbe Claude in un ciclo infinito. Per questo lo script controlla il campo `stop_hook_active` (evita di rientrare in se stesso) e supera il gate con `exit 0` appena i test tornano verdi. È la trasposizione agentica del «la CI dev'essere verde prima del merge».
#!/usr/bin/env bash
# Stop gate: non chiudere il turno con i test rossi
set -uo pipefail
input="$(cat)"
# evita il loop: se l'hook gira già in risposta a se stesso, esci
[ "$(jq -r '.stop_hook_active // false' <<<"$input")" = "true" ] && exit 0
if yarn test --silent >/tmp/claude-test.log 2>&1; then
exit 0 # verde: Claude può fermarsi
fi
# rosso: blocca lo Stop e rimanda l'errore a Claude
tail -n 20 /tmp/claude-test.log >&2
jq -n '{
decision: "block",
reason: "yarn test è rosso: correggi i test prima di chiudere il turno."
}'Hooks reference · Claude Docs ↗
✅ Il gate che rimanda Claude al lavoro
In pratica: Claude annuncia «fatto», l'hook `Stop` esegue la suite, trova due test rossi e restituisce `block`. Il controllo torna a Claude, che corregge e rilancia — finché il gate diventa verde. Nessun turno si chiude con debito nascosto.
Il risultato è che «finito» smette di essere un'opinione del modello e diventa una proprietà verificata dalla suite di test. È la stessa disciplina della CI, ma applicata dentro il loop dell'agente invece che alla fine, quando il codice è già stato scritto e va rivisto a posteriori.
«Finito» lo decide la suite di test, non il modello.
🧠 SessionStart, sicurezza e debug
`SessionStart` fa l'opposto di un blocco: inietta contesto. Uno script che stampa branch corrente, file modificati e issue attiva, restituito come `additionalContext`, dà a Claude un briefing aggiornato a ogni avvio — senza incollarlo a mano. Lo stdout di `SessionStart` (come quello di `UserPromptSubmit`) entra direttamente nel contesto che Claude legge.
Due avvertenze finali. Sicurezza: gli hook eseguono comandi shell arbitrari con i tuoi permessi, quindi tratta `settings.json` come codice — revisiona ciò che aggiungi, non incollare hook di provenienza ignota. Debug: se un hook non parte, lancia `claude --debug` per vedere comando, input ed exit code; e ricorda che solo `exit 2` blocca (un `exit 1` passa silenziosamente come errore non bloccante).
#!/usr/bin/env bash
# briefing di progetto iniettato a ogni avvio di sessione
set -euo pipefail
branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo '-')"
changed="$(git status --porcelain 2>/dev/null | awk '{print $2}' | paste -sd, -)"
ctx="Branch corrente: ${branch}
File modificati: ${changed:-nessuno}"
jq -n --arg c "$ctx" '{
hookSpecificOutput: {
hookEventName: "SessionStart",
additionalContext: $c
}
}'🧭 In sintesi
Gli hook trasformano Claude Code da assistente persuadibile a sistema con garanzie. Quattro eventi coprono la gran parte dei casi: `SessionStart` per il contesto, `PreToolUse` per negare, `PostToolUse` per correggere, `Stop` per il gate finale sui test.
Il metodo è sempre lo stesso: script piccolo, `jq` per leggere l'input, `exit 2` o JSON su stdout per decidere, `settings.json` di progetto committato così la regola vale per tutti. Meno promemoria nel prompt, più invarianti nel repo.
- 01Scegli l'eventoPreToolUse per negare, PostToolUse per reagire, Stop per il gate, SessionStart per il contesto.
- 02Scrivi lo scriptUn .sh che legge il JSON su stdin con jq e decide.
- 03Registra in settings.jsonEvento, matcher sul nome del tool, handler command via $CLAUDE_PROJECT_DIR.
- 04Decidi l'esitoexit 0 prosegue, exit 2 blocca, stdout JSON per permissionDecision o decision.
- 05Debug con --debugclaude --debug mostra comando, input ed exit code dell'hook.
- 06Condividi col teamCommitta .claude/settings.json: stessa regola per ogni persona e ogni agente.
Domande frequenti su hook di Claude Code
Qual è la differenza tra un hook e una regola in CLAUDE.md?
Una regola in CLAUDE.md è un'istruzione che il modello interpreta e può dimenticare sotto pressione di contesto: è probabilistica. Un hook è un comando shell che Claude Code esegue sempre, in modo deterministico e indipendente dal modello. Le regole guidano stile e preferenze; gli hook garantiscono le invarianti (policy, sicurezza, quality gate).
Come blocco davvero un'azione con un hook?
In due modi. Con un exit code 2: l'azione viene bloccata e lo stderr torna a Claude come feedback (su PreToolUse annulla la chiamata, su Stop impedisce di fermarsi). Oppure con exit 0 e un JSON su stdout: per PreToolUse usi hookSpecificOutput.permissionDecision "deny", per Stop o PostToolUse il campo decision "block" con una reason. Attenzione: solo exit 2 blocca, un exit 1 è un errore non bloccante.
Gli hook rallentano Claude Code?
Solo se li scrivi lenti. La buona pratica è tenerli mirati: formattare e lintare il singolo file toccato (non l'intero repo), usare flag come --silent, e impostare un timeout ragionevole nell'handler. Un hook di format su un file impiega decine di millisecondi; un gate Stop che lancia l'intera suite di test è più pesante, ma scatta solo a fine turno.
Posso condividere gli hook con il team?
Sì. Gli hook definiti in .claude/settings.json vivono nel repository e valgono per chiunque lo cloni, agenti inclusi. Gli hook personali stanno invece in ~/.claude/settings.json e restano sulla tua macchina. Con $CLAUDE_PROJECT_DIR i percorsi degli script restano portabili tra le macchine del team.
Come faccio il debug di un hook che non scatta?
Lancia Claude Code con claude --debug: vedrai il comando eseguito, il JSON passato su stdin e l'exit code restituito. Le cause più comuni sono un matcher sbagliato (il nome del tool va scritto esatto, e per i tool MCP serve mcp__<server>__.*), lo script non eseguibile (chmod +x) o jq non installato.
Gli hook funzionano anche con i tool MCP?
Sì. Nei tool MCP il nome ha la forma mcp__<server>__<tool>, quindi un matcher come mcp__github__.* aggancia tutti i tool del server GitHub, e mcp__.__write. intercetta ogni operazione di scrittura di qualsiasi server. È il modo per applicare gli stessi gate anche agli strumenti esterni, non solo a Bash, Edit e Write.
Parliamone
Se questo tema ti riguarda, scrivimi: confrontarsi su codice e AI è sempre tempo speso bene.