Aller au contenu
Développement medium

Claude Code : hooks pour automatiser lint, tests et garde-fous

12 min de lecture

Logo Claude Code - hooks pour automatiser lint et tests

Le rituel ruff + pytest repose sur votre vigilance : vous le lancez à la main, et un oubli casse la boucle. Les hooks Claude Code déclenchent des commandes shell autour des événements de la session (PreToolUse, PostToolUse, Stop, etc.). Vous pouvez exécuter ruff automatiquement après chaque Edit, bloquer rm -rf avant qu'il ne parte, ou notifier qu'une édition a laissé des erreurs de lint, sans dépendre de votre attention.

  • Configurer un hook PostToolUse qui lance ruff après chaque Edit/Write
  • Configurer un hook PreToolUse qui bloque une commande destructive
  • Choisir le bon événement et le bon matcher
  • Lire les codes de sortie pour informer, bloquer ou ignorer

Dans quel contexte utiliser cette fonctionnalité ?

Section intitulée « Dans quel contexte utiliser cette fonctionnalité ? »

Le point commun de ces quatre situations est le passage du conseil à la contrainte. Une consigne écrite dans un CLAUDE.md est du contexte : elle est lue, pondérée, parfois ignorée. Un hook est un processus lancé par l'outil lui-même, avec le même résultat à chaque exécution, y compris quand la session est longue et que le contexte s'est dilué.

  • Vous voulez un rituel déterministe, pas seulement conseillé
  • Vous partagez un projet avec une équipe et voulez garantir que le lint tourne
  • Vous voulez interdire systématiquement une commande (pas juste refuser en live)
  • Vous voulez notifier un événement (ex : un test cassé) sans bloquer

La doc officielle liste beaucoup d'événements. Pour un démarrage pragmatique, concentrez-vous sur ceux-ci :

ÉvénementSe déclencheExemple d'usage
PreToolUseAvant l'exécution d'un outil, peut bloquerInterdire rm -rf, bloquer les curl non whitelistés
PostToolUseAprès exécution réussie d'un outilLancer ruff check après Edit ou Write
PostToolUseFailureAprès échec d'un outilLoguer ou notifier un échec
StopQuand Claude termine son tourLancer pytest -q en fin de session
SessionStartAu démarrage d'une sessionAfficher l'état git courant

Les hooks vivent sous la clé hooks du settings.json. Chaque événement contient une liste d'entrées {matcher, hooks} : le premier niveau filtre, le second exécute. Cette imbrication surprend au début, mais elle permet d'attacher plusieurs commandes au même filtre et plusieurs filtres au même événement.

{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "uv run ruff check .",
"timeout": 30
}
]
}
]
}
}

Explication rapide :

  • matcher : filtre sur le nom de l'outil (Edit, Write, Bash, etc.). Edit|Write = l'un ou l'autre
  • type: "command" : exécuter une commande shell
  • command : la commande à lancer
  • timeout : optionnel, en secondes. Sans lui, une commande command dispose de 600 secondes, ce qui est très long pour un hook déclenché à chaque édition : fixez une valeur basse pour qu'une commande bloquée se manifeste vite

Objectif : deux hooks utiles immédiatement, lint automatique après édition et blocage systématique des commandes destructives.

Ce premier hook s'exécute après que l'outil a réussi, donc il n'empêche rien : il rapporte. La sortie de ruff revient dans la conversation, ce qui donne au modèle l'information nécessaire pour corriger de lui-même dans la foulée. C'est le cas d'usage le plus rentable pour démarrer, parce qu'il ne risque pas de bloquer votre session si la configuration est imparfaite.

  1. Éditez .claude/settings.json et ajoutez la section hooks

    {
    "$schema": "https://json.schemastore.org/claude-code-settings.json",
    "permissions": {
    "allow": ["Bash(uv run ruff check:*)", "Bash(uv run pytest:*)"],
    "deny": ["Bash(rm -rf:*)", "Read(./.env)"]
    },
    "hooks": {
    "PostToolUse": [
    {
    "matcher": "Edit|Write",
    "hooks": [
    {
    "type": "command",
    "command": "uv run ruff check .",
    "timeout": 30
    }
    ]
    }
    ]
    }
    }
  2. Validez la syntaxe JSON

    Fenêtre de terminal
    python3 -c "import json; json.load(open('.claude/settings.json'))" && echo "JSON valide"
  3. Testez dans une session

    Faites-lui modifier un fichier Python. Après l'Edit, ruff check . tourne automatiquement, et le résultat revient dans la conversation.

Un deny dans permissions couvre déjà rm -rf, mais un hook PreToolUse permet d'ajouter un message explicite et de centraliser la politique :

{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash -c 'input=$(cat); echo \"$input\" | grep -qE \"rm -rf|rm -fr\" && { echo \"rm -rf bloque par hook\" >&2; exit 2; } || exit 0'"
}
]
}
]
}
}

Ce qui se passe :

  • Le hook reçoit l'entrée de l'outil sur stdin (JSON)
  • S'il détecte rm -rf, il sort avec exit 2 et un message sur stderr
  • Exit code 2 = Claude voit ça comme un blocage et ne lance pas la commande

Une limite à connaître : ce filtre travaille sur le JSON brut, pas sur la commande analysée. Il déclenche donc aussi si la chaîne rm -rf apparaît ailleurs dans la charge utile, et il se laisse contourner par une écriture équivalente (rm --recursive --force, un alias, une variable). Traitez-le comme un garde-fou contre l'accident, pas comme une barrière contre un contournement délibéré : la vraie interdiction reste permissions.deny.

Le code de sortie est le seul canal par lequel votre script pilote la session : il n'y a pas de configuration « bloquant / non bloquant » dans le settings.json. Retenez surtout la distinction entre 2 et le reste, car c'est elle qui décide si Claude reçoit votre message ou si personne ne le lit. Retenez aussi que le flux compte autant que le code : stderr remonte à Claude sur un blocage, stdout sert à l'affichage.

Exit codeEffet
0Succès, le tour continue normalement
2Blocage explicite, stderr est renvoyé à Claude comme feedback
autreErreur non bloquante, signalée dans la session

Règle simple :

  • Vous voulez informer sans bloquer → exit 0 et écrire sur stdout
  • Vous voulez bloquer avec un message → exit 2 et écrire sur stderr

Le matcher décide sur quel outil votre hook se déclenche, et sa syntaxe change selon les caractères employés. Une valeur composée uniquement de lettres, chiffres et barres verticales est comparée littéralement ; dès qu'un autre caractère apparaît, la valeur devient une expression régulière non ancrée. La conséquence piège tout le monde une fois : Edit.* déclenche aussi sur NotebookEdit, il faut écrire ^Edit$ pour viser l'outil seul.

MatcherDéclenche sur
"*" ou omisTous les outils
"Edit"Uniquement l'outil Edit
"Edit|Write"Edit ou Write
"Bash"Toutes les commandes shell
"^Notebook"Regex : tous les outils commençant par Notebook

Quatre mécanismes se recouvrent partiellement, et les confondre produit des configurations qui ne s'appliquent jamais. Le critère de choix tient en une question : voulez-vous interdire, déclencher, enchaîner ou conseiller ? Une interdiction se pose dans les permissions, où elle est évaluée avant toute exécution ; un déclenchement automatique est un hook ; le reste relève du contexte fourni au modèle.

BesoinBon endroit
Interdire complètement une commandepermissions.deny dans settings.json
Déclencher automatiquement autour d'un événementHook
Enchaîner une procédure invoquée à la mainSkill
Conseiller une convention de codeRule ou CLAUDE.md

Les hooks sont le seul mécanisme déterministe : CLAUDE.md, rules et skills sont du contexte, les hooks sont du code exécuté.

Ces cinq symptômes se diagnostiquent tous de la même façon : élargir puis resserrer. Commencez par un matcher à "*" et une commande qui écrit simplement une trace ; si le hook se déclenche, le problème vient du filtre, pas de la configuration. La dernière ligne est la plus sournoise, car un hook mal quoté sort en 0 et laisse croire que la protection fonctionne.

SymptômeCause probableCorrection
Le hook ne se déclenche pasMauvais event ou matcher trop strictTester avec "matcher": "*" puis resserrer
timeout atteintCommande trop lente (ex : pytest complet après chaque Edit)Déclencher pytest sur Stop plutôt que sur chaque édition
Blocage non compris par ClaudeExit code autre que 2Utiliser exit 2 pour bloquer explicitement
Hook exécuté trop souventMatcher trop largePréciser "Edit|Write" plutôt que "*"
rm -rf passe quand mêmeHook shell mal quotéVérifier que la commande utilise bien stdin et un grep correct

Reprenez ces cinq points sur votre propre settings.json avant de considérer le sujet clos. Les deux derniers portent sur des choix de conception plutôt que sur la syntaxe : ce sont eux qui déterminent si vos hooks resteront utilisables dans six mois ou finiront désactivés parce qu'ils ralentissent chaque interaction.

  • J'ai au moins un hook PostToolUse fonctionnel sur Edit|Write
  • J'ai validé la syntaxe JSON de settings.json
  • J'ai testé un hook bloquant avec exit 2 et un message
  • Je sais où mettre un hook (automatisme) vs un skill (procédure manuelle)
  • Les hooks de validation lourde sont sur Stop, pas sur chaque Edit
  • Les hooks sont le seul garde-fou déterministe de Claude Code
  • PostToolUse + matcher Edit|Write = rituel lint automatique
  • PreToolUse + exit 2 = blocage avec message
  • Gardez les hooks rapides, sinon vous fatiguez la session
  • CLAUDE.md conseille, settings.json autorise, les hooks exécutent

Ce site vous est utile ?

Sachez que moins de 1% des lecteurs soutiennent ce site.

Je maintiens +700 guides gratuits, sans pub ni tracking. Un soutien, même symbolique, m'aide à couvrir l'hébergement et à garder ces ressources gratuites. Merci pour votre appui.

Le formulaire ne s'affiche pas ? Ouvrir Ko-fi dans un onglet.

Abonnez-vous et suivez mon actualité DevSecOps sur LinkedIn