Bon. J’ai passé une soirée sur les hooks de Claude Code, cette mécanique qui lance une commande à toi avant ou après chaque geste de l’agent. J’en ai posé un qui écrit « ATTENTION » avant chaque commande, histoire de voir si Claude en tient compte. Le hook a tourné, mon journal le prouve à la seconde près. Et Claude, interrogé juste après, a répondu : « RIEN VU ».
Ce n’est pas un bug. C’est écrit dans la doc, en toutes lettres, et presque personne ne le lit. Spoiler : un hook qui avertit ne parle qu’à toi. Le seul chiffre que Claude entend, c’est un code de sortie, et ce n’est pas celui qu’on croit. Voici la soirée dans l’ordre, avec les cinq sessions qui l’ont prouvé.
Étape 1 : un hook, c’est un fichier JSON et un script
Un hook, dans Claude Code, c’est une commande shell que l’outil exécute tout seul à un moment précis de la session : avant qu’un outil ne tourne, après, quand la session démarre, quand Claude a fini de répondre. Ce n’est pas un prompt. Le modèle n’est pas consulté, donc ça ne coûte aucun token. C’est du déterminisme au milieu d’un truc probabiliste, et c’est exactement pour ça que je voulais comprendre.
La configuration tient dans un fichier settings.json. Il y en a trois possibles : ~/.claude/settings.json pour tous tes projets, .claude/settings.json dans le projet (celui-là se commite), et .claude/settings.local.json pour le même projet mais hors git. J’ai pris le deuxième, dans un dossier vide créé pour l’occasion.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "python \"$CLAUDE_PROJECT_DIR/.claude/hooks/garde.py\""
}
]
}
]
}
}
Trois choses à lire là-dedans. PreToolUse est l’événement : « juste avant qu’un outil ne s’exécute ». matcher filtre l’outil, ici seulement Bash (le nom est exact et sensible à la casse ; Edit|Write en prendrait deux). Et command, c’est ce qui tourne. Tous les exemples de la doc utilisent jq pour lire le JSON ; sur mon Windows, jq n’est pas installé. Python, si.
Parce que oui, le hook reçoit quelque chose : Claude Code lui envoie un JSON sur l’entrée standard, avec le nom de l’outil et ses arguments. Pour Bash, la commande complète est dans tool_input.command. Mon script la lit, décide, et répond par un code de sortie. Le voici en entier, journal compris, parce que le journal est ce qui va sauver la soirée.
import json, sys, datetime
from pathlib import Path
entree = json.load(sys.stdin)
cmd = (entree.get("tool_input") or {}).get("command", "")
journal = Path(__file__).resolve().parents[2] / "hook-journal.txt"
def sortir(code, message=""):
with journal.open("a", encoding="utf-8") as f:
f.write(f"{datetime.datetime.now():%H:%M:%S} exit {code} cmd={cmd!r}\n")
if message:
print(message, file=sys.stderr)
sys.exit(code)
if "git push" in cmd and "# push-confirme" not in cmd:
sortir(2, "BLOQUE : git push est interdit ici. Si c'est voulu, "
"relance la commande en ajoutant ' # push-confirme' a la fin.")
if "avertis-moi" in cmd:
sortir(0, "ATTENTION (exit 0) : ce message est ecrit sur stderr par le hook. "
"Est-il lu par Claude ?")
if "erreur-un" in cmd:
sortir(1, "ERREUR (exit 1) : ce message est ecrit sur stderr avec le code 1.")
sortir(0)
Trois comportements, déclenchés par trois mots que je vais glisser dans des commandes. Un blocage sur git push, un avertissement qui sort avec le code 0, et une erreur qui sort avec le code 1. Chaque passage écrit une ligne dans hook-journal.txt, quoi qu’il arrive. Puis /hooks dans Claude Code pour vérifier que le hook est bien enregistré : le menu est en lecture seule, il liste, il ne modifie rien.
Étape 2 : le hook qui avertit, et le mot « RIEN VU »
Première session, en mode non interactif (claude -p, la commande passée en argument, la réponse récupérée en JSON). La consigne : lance exactement echo 'avertis-moi', puis dis-moi mot pour mot ce qu’un hook t’a signalé ; si tu n’as rien vu, réponds « RIEN VU ».

Réponse de Claude, deux tours, 6,4 secondes : RIEN VU. Et dans mon journal, à 18:49:23 : exit 0 cmd="echo 'avertis-moi'". Le hook a tourné. Il a écrit son avertissement sur la sortie d’erreur. Claude n’en a jamais eu connaissance.
J’ai d’abord cru à une erreur de ma part. Puis j’ai relu la référence des hooks, relevée le 25 août 2026.
La phrase est là, sans ambiguïté : « Stderr from a hook that exits 0 goes to the debug log only, never the transcript, and Claude never sees it. » La sortie d’erreur d’un hook qui sort en 0 va dans le journal de débogage, jamais dans la conversation, et Claude ne la voit jamais.
Pour la sortie standard, même régime pour la plupart des événements : « stdout is written to the debug log but not shown in the transcript ». Trois exceptions seulement, où ce qui est écrit devient du contexte que Claude lit : SessionStart, UserPromptSubmit et UserPromptExpansion.
Ça, franchement, personne ne me l’avait dit. Deux guides en français que j’avais lus avant de commencer écrivent que la sortie standard d’un hook « s’affiche » ou « sert à l’affichage ». La doc dit l’inverse pour un PreToolUse, et ma session aussi. Un hook d’avertissement, c’est un post-it collé sur ton écran à toi. L’agent ne le lit pas.
Étape 3 : exit 2, le seul chiffre que Claude entend
Deuxième session. Consigne : lance exactement git push origin main, et si c’est bloqué, rapporte le message mot pour mot puis arrête-toi. Mon dossier n’est même pas un dépôt git, donc la commande ne peut pas réussir de toute façon. Ce que je veux voir, c’est ce qui arrive à Claude avant git.

Cette fois il a tout reçu. Sa réponse cite le message tel quel, préfixé par le nom du hook : PreToolUse:Bash hook error: [python "$CLAUDE_PROJECT_DIR/.claude/hooks/garde.py"]: BLOQUE : git push est interdit ici. Si c'est voulu, relance la commande en ajoutant ' # push-confirme' a la fin. Puis il s’est arrêté, comme demandé. Journal : exit 2, à 18:49:32.
La règle est donc simple, et elle tient en un chiffre. Sur un événement qui peut bloquer, le code 2 bloque l’action et envoie ta sortie d’erreur à Claude comme motif. Rien d’autre ne le fait. La doc précise même qu’un JSON qui dirait « allow » sur la sortie standard ne peut pas passer par-dessus un code 2.
Et le code 1, alors ? C’est le code d’erreur conventionnel de tout programme Unix, celui que ton script rend quand il plante. Troisième session, echo 'erreur-un', même question. Réponse : RIEN VU, une fois de plus. Journal : exit 1, à 18:49:41, message écrit sur stderr. La commande a tourné quand même.
La doc le dit avec une franchise que j’apprécie : « Without valid JSON on stdout, Claude Code treats exit code 1 as a non-blocking error and proceeds with the action, even though 1 is the conventional Unix failure code. If your hook is meant to enforce a policy, use exit 2. »
Un guide en français de décembre 2025 enseigne pourtant « code 0 = autoriser, code différent de 0 = bloquer ». C’est faux, et ce n’est pas un détail : un hook de sécurité qui sort en 1 parce que son auteur a suivi ce guide laisse tout passer, en silence.

Même logique pour un hook qui n’a pas pu démarrer : chemin mal tapé dans le JSON, script introuvable, le shell rend 127, et Claude Code traite ça comme une erreur non bloquante. L’action passe.
La doc le formule exactement comme ça : « a mistyped path in settings.json leaves the gate silently disabled ». Un garde-fou dont le fichier a été déplacé n’existe plus, et rien ne te le dit à part une notice dans la transcription.
Étape 4 : le mot de passe, et la couche que je n’avais pas vue venir
Mon blocage sur git push a un défaut évident : parfois je veux pousser. Un blocage sec m’oblige à éditer le hook ; une permission permanente annule le hook. J’ai donc mis un mot de passe dans le message lui-même : « relance en ajoutant # push-confirme ». Le hook cherche cette chaîne et, s’il la trouve, laisse passer.
Ce n’est pas de la sécurité, un agent qui lit le message peut l’ajouter tout seul. C’est un accusé de réception : l’action ne passe qu’après qu’on a lu pourquoi elle était retenue.
Quatrième session, git push origin main # push-confirme. Journal : exit 0, le hook a laissé passer. Et la commande a quand même été refusée.
Pas par mon hook : par le classifieur du mode auto, qui a rendu ceci : « Permission for this action was denied by the Claude Code auto mode classifier. Reason: [Git Push to Default Branch] ». Un push direct sur la branche principale, il n’en veut pas, et mon commentaire n’y change rien.
Je n’avais pas prévu ça, et c’est la meilleure leçon de la soirée. Les hooks sont une couche parmi d’autres : les règles de permission, le classifieur du mode auto, et tes hooks se superposent, et chacune peut dire non. Cinquième session, même commande sur une branche essai-hooks : le hook laisse passer, le classifieur aussi, et git répond enfin, fatal: not a git repository. Là, le mot de passe a fait son travail.
Une subtilité relevée au passage dans le journal de débogage : un autre hook installé sur ma machine par un outil tiers réécrit les commandes avant exécution, et le mien a pourtant reçu la commande d’origine. Les hooks d’un même événement tournent en parallèle, chacun avec la même entrée, et Claude Code fusionne leurs réponses ensuite. La doc le confirme : un hook qui refuse n’empêche pas ses voisins de tourner.
Étape 5 : compter les événements, parce que personne n’a le même chiffre
Avant d’écrire, j’ai voulu savoir combien d’événements un hook peut écouter. Chaque page donne un nombre différent, et ce n’est pas de la négligence : la liste s’allonge à chaque version. Alors j’ai compté moi-même, sur la référence officielle, le 25 août 2026.
| Source | Date | Événements annoncés |
|---|---|---|
| Un guide FR | février 2026 | 18 |
| Un fil Reddit | 2026 | « tous les 23 » |
| Un tutoriel FR | juillet 2026 | « plus de 25 » |
| Un guide EN | 2026 | « les 30 » |
| Référence officielle, comptée à la main | 25 août 2026 | 31 |
Trente et un, de SessionStart à ElicitationResult. Tu n’as pas à les retenir. La doc les range en trois cadences : une fois par session (SessionStart, SessionEnd), une fois par tour (UserPromptSubmit, Stop), et à chaque appel d’outil (PreToolUse, PostToolUse). Pour un premier hook, PreToolUse couvre 90 % des besoins : c’est le seul moment où tu peux encore dire non.
Et attention au chiffre que tu liras ailleurs, y compris ici dans six mois. Une version de Claude Code ajoute un événement sans prévenir. Le mien, 2.1.202, ne connaît pas encore trois valeurs de Notification que la doc réserve aux versions 2.1.234 et suivantes.
Étape 6 : ce que j’ai gardé, et les pièges Windows
De la soirée, trois hooks survivent, tous avec le code 2 et tous sur PreToolUse. Le blocage de git push avec son mot de passe. Un blocage de --no-verify, l’option qui court-circuite le scan de secrets avant un commit : trois lignes de plus dans le même script, une chaîne à chercher, un message. Et l’ancien hook d’avertissement, converti en blocage, parce qu’un avertissement que l’agent ne lit pas ne sert à personne.
Côté Windows, deux choses m’ont coûté du temps. La première : un hook écrit en une ligne, sans champ args, tourne dans Git Bash si tu l’as, en PowerShell sinon. Mon "$CLAUDE_PROJECT_DIR/..." avec ses barres obliques marche parce que Git Bash est là.
La seconde : si tu veux du PowerShell exprès, il faut ajouter "shell": "powershell" dans le hook et écrire la variable $env:CLAUDE_PROJECT_DIR. La forme nue $CLAUDE_PROJECT_DIR, PowerShell la lit comme une variable locale vide, et ton chemin de script perd sa racine. La doc le dit ; je l’ai lu après.
Dernier réflexe, et il vaut pour tout ce qui précède : pour voir ce que fait vraiment un hook, lance claude --debug-file chemin.log. C’est là que j’ai trouvé mes messages « ATTENTION » que Claude ne voyait pas, avec la mention Hook PreToolUse:Bash (PreToolUse) success juste au-dessus. Le journal de débogage sait tout ; la conversation, presque rien.
Ce qu’un hook ne remplace pas
Un hook ne remplace pas les règles de permission. Celles-ci disent ce que Claude a le droit de faire ; le hook, lui, peut seulement resserrer. La doc est nette : un hook qui répond « allow » ne lève pas une règle de refus, mais un hook qui répond « deny » bloque même en mode bypassPermissions, même avec --dangerously-skip-permissions.
Autrement dit, quand l’agent ne demande plus la permission, le hook est le seul garde-fou qui ne dépend pas de son jugement. C’est ce qui manquait au décryptage du mode auto, et c’est pour ça que j’y renvoie.
Un hook n’est pas non plus un verrou fiable s’il est lent. Un hook de commande a dix minutes de délai par défaut ; passé ce délai, il est annulé et ne rend aucune décision, donc l’appel continue. « Don’t count on a stalled hook to act as a gate », dit la doc. Un script qui attend une réponse réseau n’est pas un garde-fou.
Hooks, skills, CLAUDE.md : les trois qu’on confond
J’ai mis du temps à démêler ça, alors voilà comme je me le suis expliqué. CLAUDE.md, c’est ce que Claude lit au démarrage : des consignes, chargées à chaque session, qui coûtent des tokens à chaque fois. Une skill, c’est une procédure rangée à part, chargée seulement quand elle sert. Un hook, c’est une commande qui tourne sans passer par le modèle, et qui peut dire non.
La différence qui compte : les deux premiers sont des conseils que Claude peut ignorer. Le troisième est une porte. Si tu veux qu’une règle soit toujours respectée, ce n’est ni un paragraphe dans CLAUDE.md ni une skill, c’est un hook avec un code 2. La page de référence sur Claude Code détaille la place de chacun.
Les questions que je me posais en commençant
Un hook consomme-t-il des tokens ?
Non, pour un hook de type command : c’est une commande shell, le modèle n’est pas appelé. Il existe aussi des hooks de type prompt et agent, qui font juger la situation par un modèle, et ceux-là coûtent. Je ne les ai pas testés.
Où mettre le fichier settings.json ?
Dans .claude/ à la racine du projet pour un hook lié au projet (il se commite), ou dans ~/.claude/ pour tous tes projets. Le menu /hooks liste ce qui est chargé et d’où ça vient.
Mon hook ne se déclenche pas, je regarde quoi ?
D’abord /hooks : s’il n’apparaît pas, le JSON est peut-être invalide (une virgule finale suffit). Ensuite le matcher, exact et sensible à la casse. Ensuite le journal de débogage avec --debug-file, qui montre l’exécution, le code de sortie et les sorties complètes.
Claude peut-il contourner un hook ?
Il ne peut pas empêcher un PreToolUse de tourner, même en mode auto. Il peut reformuler sa commande pour ne plus correspondre à ton test, comme il pourrait ajouter mon mot de passe. Un hook filtre des chaînes de caractères, il ne comprend pas les intentions. Pour un vrai interdit, la règle de permission reste l’outil.
Faut-il installer jq ?
Non. Les exemples officiels l’utilisent, mais n’importe quel langage qui lit l’entrée standard fait l’affaire. Sur Windows, Python est souvent déjà là, jq rarement.
Un hook peut-il modifier la commande au lieu de la bloquer ?
Oui, en sortant en 0 avec un JSON sur la sortie standard qui contient updatedInput. C’est ce que fait l’outil tiers de ma machine. Je n’ai pas construit le mien ainsi, la doc décrit le format.
Claude Code 2.1.202, Windows 11, Python 3.14, Git Bash. Un dossier vide avec .claude/settings.json et .claude/hooks/garde.py. Cinq sessions claude -p avec l’outil Bash autorisé et --debug-file, entre 6 et 18 secondes chacune. Chaque passage du hook écrit une ligne horodatée dans un journal, indépendante de ce que Claude rapporte. Référence des hooks relevée le 25 août 2026 sur code.claude.com, événements comptés à la main.
Pose un hook qui bloque, jamais un hook qui prévient. L’agent n’a pas d’yeux pour ton post-it.