Un hook è un comando che Claude Code esegue in un punto fisso del suo ciclo di vita. Il valore non è l’automazione: è che l’azione avviene sempre, invece di dipendere dalla decisione di un modello. Quando la regola è «questo file non si tocca» o «questo comando non si esegue», un hook la impone; una riga nel system prompt la suggerisce.
Dove si blocca
Il punto è PreToolUse, che scatta prima dell’esecuzione del tool. PostToolUse serve a osservare, non a impedire: quando scatta, il tool è già stato eseguito e non c’è niente da annullare.
La configurazione ha tre livelli di annidamento: l’evento, un gruppo con matcher che filtra per nome del tool, e i singoli handler. Il matcher accetta un nome esatto come Bash, un elenco separato da pipe come Edit|Write, oppure una regex; ed è case-sensitive, il che spiega buona parte degli hook che «non partono».
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/protect-files.sh" }
]
}
]
}
}
Lo script riceve l’evento come JSON su stdin e legge quello che gli serve, per esempio tool_input.file_path. Due dettagli operativi: su macOS e Linux deve essere eseguibile, e se il progetto gira anche su Windows conviene normalizzare i separatori backslash prima di confrontare i percorsi, altrimenti i pattern non corrispondono mai.
Il contratto di uscita
Qui sta il modo tipico di sbagliare. Exit 0 lascia proseguire l’azione, e se stdout contiene JSON valido viene letto. Exit 2 è l’errore bloccante: su PreToolUse blocca la chiamata, su UserPromptSubmit blocca ed elimina il prompt, su Stop impedisce a Claude di fermarsi, su TaskCompleted impedisce di chiudere il task, su ConfigChange blocca il cambio di configurazione. Qualunque altro codice non blocca: exit 1, il codice di errore convenzionale su Unix, viene trattato come errore non bloccante e l’azione prosegue. Se l’hook deve imporre una policy, deve uscire con 2.
L’alternativa esplicita è il JSON su stdout, che dice la stessa cosa in modo leggibile e con una motivazione:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Destructive command blocked by hook"
}
}
permissionDecision vale allow, deny o ask, e permissionDecisionReason viene mostrata all’utente e restituita al modello, che così sa perché e non riprova la stessa strada. Sugli eventi che possono bloccare, exit 2 vince comunque: nemmeno un allow in JSON lo ribalta.
Quando la decisione richiede giudizio invece di una regola, esistono gli hook di tipo prompt — l’input dell’evento va a un modello, Haiku per default, che risponde con un verdetto e una motivazione — e, sperimentali, quelli di tipo agent, che possono leggere file ed eseguire comandi prima di decidere. Per i flussi di produzione la documentazione consiglia comunque i command hook.
Gli stessi hook dal Claude Agent SDK
Nel Claude Agent SDK gli hook sono callback registrate nelle opzioni, con lo stesso vocabolario di eventi e lo stesso formato di uscita. Restituire un oggetto vuoto significa «nessuna decisione, prosegui».
async def protect_env_files(input_data, tool_use_id, context):
file_path = input_data["tool_input"].get("file_path", "")
if file_path.split("/")[-1] == ".env":
return {
"hookSpecificOutput": {
"hookEventName": input_data["hook_event_name"],
"permissionDecision": "deny",
"permissionDecisionReason": "Cannot modify .env files",
}
}
return {}
options = ClaudeAgentOptions(
hooks={"PreToolUse": [HookMatcher(matcher="Write|Edit", hooks=[protect_env_files])]}
)
Tre regole di composizione. Gli hook che corrispondono girano in parallelo e l’ordine di completamento non è deterministico, quindi ognuno deve decidere da solo, senza contare sul fatto che un altro abbia già girato. La precedenza fra esiti è deny, poi defer, poi ask, poi allow: un solo deny blocca a prescindere dagli altri. E se più hook riscrivono gli argomenti con updatedInput vince l’ultimo che finisce, che è un altro modo per dire di non farlo. Cattura le eccezioni dentro la callback, perché un’eccezione non gestita può interrompere l’agente.
Quello che un hook non può fare
Gli hook PreToolUse scattano prima del controllo della modalità di permesso, in ogni modalità: un deny restituito da un hook ferma il tool anche in bypassPermissions e anche con --dangerously-skip-permissions. È esattamente per questo che sono il posto giusto dove mettere una policy che l’utente non deve poter aggirare cambiando modalità.
Il contrario non vale: «Hooks can tighten restrictions but not loosen them past what permission rules allow.» Un allow da hook non annulla una regola di deny, non sopprime la richiesta di conferma per i tool MCP marcati come interattivi e non autorizza rimozioni sui percorsi critici.
Un ultimo confine, spesso confuso. In modalità non interattiva con -p non esiste un prompt di permesso da intercettare, quindi PermissionRequest non ti serve: le decisioni automatiche vanno su PreToolUse. E lato API esiste una cosa diversa con un nome simile, gli Inference hooks: un’organizzazione Enterprise instrada ogni richiesta governata verso il proprio server di sicurezza, che risponde allow o deny, e «a denied request never reaches the model». Oggi l’unico evento è prompt, il verdetto ha un timeout configurabile (5 secondi per default) e un’impostazione di failure handling decide cosa succede quando quel server non risponde. Non riscrive e non redige: solo allow o deny.