Une consigne dans un fichier d’instructions oriente ce que l’agent essaie de faire, elle ne l’empêche de rien. Un hook PreToolUse, lui, s’exécute avant chaque appel d’outil et peut le refuser : voici comment en écrire un qui bloque les suppressions récursives.
Le principe est posé dans le billet sur les garde-fous d’un agent IA. Ici, la mécanique : ce que le script reçoit, ce qu’il renvoie, où le déclarer.
Ce que le hook reçoit
Claude Code lance le script et lui écrit un objet JSON sur l’entrée standard. Pour l’outil Bash, la documentation donne cet exemple (extrait) :
{
"session_id": "abc123",
"cwd": "/home/user/my-project",
"permission_mode": "default",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {
"command": "npm test",
"description": "Run test suite"
},
"tool_use_id": "toolu_01ABC123..."
}
La commande à juger est dans tool_input.command.
Le script
Python plutôt que bash et jq : le même script sert sous Linux, macOS et Windows. Il ne juge que l’outil Bash ; pour PowerShell, voir plus bas.
#!/usr/bin/env python3
"""Hook PreToolUse : refuse toute suppression récursive (rm -r, -rf, -R, --recursive)."""
import json
import re
import sys
RM_RECURSIF = re.compile(r"\brm\b[^;&|]*\s(-[a-zA-Z]*[rR][a-zA-Z]*|--recursive)\b")
# Lire le JSON en UTF-8 quel que soit l'encodage du poste (Python 3.7+).
sys.stdin.reconfigure(encoding="utf-8")
try:
commande = json.load(sys.stdin)["tool_input"]["command"]
if not isinstance(commande, str):
raise TypeError("command absent ou non textuel")
except (ValueError, KeyError, TypeError):
print("Entrée illisible : appel refusé par précaution.", file=sys.stderr)
sys.exit(2)
if RM_RECURSIF.search(commande):
print("Suppression récursive refusée par le hook : à lancer par l'utilisateur.", file=sys.stderr)
sys.exit(2)
sys.exit(0)
Le motif est volontairement large : il bloque aussi git rm -r. Un faux positif se contourne à la main, une suppression ne se rattrape pas toujours.
Code 2 bloque l’appel et transmet le texte de stderr à Claude ; code 0 sans sortie ne décide rien (détails dans le billet sur les garde-fous).
Le piège du code 1
Tout autre code est une erreur non bloquante : l’action continue. Un sys.exit(1) écrit « pour refuser » laisse donc passer la commande. Même chose si le hook dépasse son délai (600 secondes par défaut) ou ne démarre pas, par exemple quand python3 est introuvable.
Le réglage "onFailure": "block", à partir de Claude Code v2.1.295, fait de chacun de ces échecs, code 1 compris, l’équivalent d’un code 2 : l’appel est refusé. Il figure dans l’exemple de déclaration ci-dessous.
Demander plutôt que refuser
Au lieu du code 2, le hook peut sortir en 0 et écrire un objet JSON sur stdout :
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "ask",
"permissionDecisionReason": "Suppression récursive : confirmation humaine requise"
}
}
deny refuse l’appel, ask demande confirmation, allow saute la demande ; les règles deny et ask des permissions restent évaluées quoi que renvoie le hook.
Où le déclarer
Dans un fichier de réglages, utilisateur ou projet (lequel choisir) :
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "python3",
"args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/bloquer_rm_recursif.py"],
"onFailure": "block"
}
]
}
]
}
}
Le matcher Bash est une chaîne exacte : seul l’outil Bash déclenche le script. Avec args, le hook passe en forme exec : pas de shell, chaque élément est transmis tel quel, espaces compris. C’est la forme recommandée dès qu’un chemin utilise ${CLAUDE_PROJECT_DIR}, la racine du projet. Sous Windows, command doit désigner un vrai exécutable, python.exe par exemple. La commande /hooks liste ensuite les hooks actifs et leur fichier d’origine.
Tester sans Claude
Le script ne lit que stdin : on lui envoie un faux appel.
echo '{"tool_name":"Bash","tool_input":{"command":"/bin/rm -rf build/"}}' \
| python3 .claude/hooks/bloquer_rm_recursif.py; echo "code=$?"
Attendu : le message sur stderr et code=2. Avec ls -la, code=0.
Hook et règle deny, ensemble
D’après la page des permissions, la règle deny Bash(rm *) arrête rm -rf build/, mais ni /bin/rm -rf build/ ni bash -c 'rm -rf build/'. Le hook, qui lit tout le texte, attrape ces deux formes, et plus largement les formes courantes, pas toutes (guillemets, variables, find -delete, autres langages). À l’inverse, la règle deny tient même si le script manque ou plante. On cumule, avec les règles posées dès l’installation.
Sous Windows, les commandes peuvent passer par l’outil PowerShell, et sans Git Bash l’outil Bash n’existe même pas : un hook limité à Bash ne s’y déclenche jamais. Ajoutez alors une règle deny :
{
"permissions": {
"deny": ["PowerShell(Remove-Item *)"]
}
}
Elle refuse toute suppression par Remove-Item, récursive ou non.
Ce qu’il faut retenir
- Le hook lit un JSON sur stdin ; pour Bash, la commande est dans
tool_input.command. - Par défaut, seul le code 2 bloque ; avec
"onFailure": "block", un échec ou un code 1 bloque aussi. - En code 0,
permissionDecisionpeut valoirdeny,askouallow. - Forme exec avec
argset${CLAUDE_PROJECT_DIR}pour un chemin sans souci d’espaces. - Hook et règle deny se complètent : l’un lit tout le texte, l’autre tient si le script tombe.
In English
What the hook receives on stdin, how it refuses (exit code 2 or a JSON decision), where to declare it, and why exit code 1 lets the command through. A complete example on a recursive deletion: a deny rule on Bash(rm *) sees neither /bin/rm nor bash -c, whereas a hook reading the full command text catches both.
Sources & vérification
Relevé sur : Documentation Claude Code, relevé 2026-10
- Claude Code, Hooks reference code.claude.com
- Claude Code, Configure permissions code.claude.com
- Claude Code, Settings files and precedence code.claude.com
