Aller au contenu principal
Retour au blog

Claude Code hooks : garde-fous déterministes pour Symfony

Un CLAUDE.md se négocie, un hook s’exécute. PreToolUse, PostToolUse, permissions et managed settings pour protéger .env, migrations et main en équipe PHP.

Flavien Métivier · 15 min de lecture
Sommaire · 12 parties

Ton CLAUDE.md contient sans doute « ne touche jamais au .env, ne modifie jamais une migration déjà mergée, ne force-push pas ». Elle est lue, elle pèse dans la décision du modèle, et elle est respectée presque toujours. « Presque toujours » ne suffit pas pour un fichier de secrets, une migration Doctrine déjà jouée en production ou un push forcé sur main. Une consigne en langage naturel reste une demande : elle entre en concurrence avec le reste du contexte, et rien ne garantit qu'elle l'emporte, surtout après une compaction.

Anthropic le formule sans détour dans le billet Steering Claude Code (18 juin 2026) : « A real guardrail needs to be deterministic, and the enforcement methods are hooks and permissions. » Et les managed settings y sont décrits comme le seul moyen d'imposer un garde-fou déterministe à toute l'organisation. Cet article monte, pour une équipe PHP et Symfony, un jeu de garde-fous concret, testé et déployable : secrets protégés, migrations commitées intouchables, push forcé refusé, PHP reformaté après chaque édition. Il dit aussi ce que ces garde-fous ne couvrent pas.

Pourquoi un CLAUDE.md ne suffit-il pas à protéger ton dépôt ?

Une règle dans CLAUDE.md est une consigne que le modèle peut ignorer ; un hook est un contrôle que le modèle ne peut pas ignorer. La doc de Claude Code prend précisément l'exemple de « Never edit .env » : dans CLAUDE.md, c'est une demande ; dans un hook PreToolUse, c'est une contrainte. Chaque mécanisme se situe sur cette échelle selon qui décide :

  • CLAUDE.md et .claude/rules : le modèle décide. Le bon endroit pour les conventions et le « pourquoi », pas pour une interdiction. Garde-le court, avec un responsable, et relis ses changements comme du code.
  • Permissions deny et ask : Claude Code décide, avant l'appel, quoi que le modèle ait « compris ». Leurs limites sur les commandes Bash sont détaillées dans l'article sur l'auto mode et la sandbox.
  • Hooks PreToolUse : ton code décide. Un script lit l'appel en JSON, interroge git ou le disque, et peut refuser. Les hooks ne coûtent pas de contexte, sauf par leur sortie.
  • Hooks PostToolUse : ils agissent après coup. Le fichier est déjà écrit : on formate ou on signale, on n'empêche pas.
  • Sandbox : confinement au niveau du système d'exploitation, seule couche qui tient quand du code arbitraire lit un fichier sans passer par un outil.
  • Managed settings : l'organisation décide. Les mêmes mécanismes, au plus haut rang de précédence, que ni l'utilisateur ni le projet ne peuvent surcharger.

Règle pratique : si l'enfreindre coûte un incident, ça ne va pas dans CLAUDE.md. Ça va dans permissions.deny si un motif suffit, dans un hook si la décision demande du code ou un état, et dans les managed settings si elle doit valoir pour tous.

Comment un hook PreToolUse bloque-t-il vraiment un appel ?

Un hook Claude Code est une commande exécutée automatiquement à un moment précis du cycle de l'agent, dont le code de sortie peut bloquer l'action. D'après la doc des hooks, il reçoit sur l'entrée standard un JSON avec cwd, tool_name et tool_input : tool_input.file_path, toujours absolu, pour Read, Edit et Write, et tool_input.command pour Bash. Le piège numéro un tient aux codes de sortie :

  • exit 0 : succès, l'appel continue.
  • exit 2 : blocage. Sur PreToolUse, l'appel est empêché et la sortie d'erreur est renvoyée à Claude, qui comprend pourquoi et change d'approche.
  • Tout autre code, y compris le 1 d'un script qui plante : erreur non bloquante, l'action continue. La doc le dit explicitement : pour une politique, utilise exit 2.

Un jq absent, un script non exécutable, un timeout : dans tous ces cas, la protection saute en silence. Depuis la v2.1.295, l'option onFailure: "block", sur les hooks de type command et http, traite ces échecs comme un exit 2. C'est le comportement fermé par défaut qu'il te faut sur tout hook de sécurité. Deux autres règles utiles : tous les hooks qui correspondent s'exécutent en parallèle, sans ordre garanti ; et quand plusieurs hooks divergent, la décision la plus stricte l'emporte, un hook ne pouvant jamais assouplir une règle deny des permissions. Enfin, le champ if filtre un hook avec la syntaxe des permissions, par exemple Bash(git push *), pour ne pas le lancer à chaque commande.

Comment bloquer la lecture du .env et des secrets Symfony ?

Avec un hook PreToolUse sur Read|Edit|Write, qui normalise le chemin pour neutraliser les ../ et laisse passer .env.dist et .env.example, qui sont de la documentation. Le test porte sur le nom du fichier, pas sur une sous-chaîne : un motif naïf comme *.env* bloquerait aussi src/Environment.php.

bash
#!/usr/bin/env bash
# protect-secrets.sh : PreToolUse (Read|Edit|Write)
set -euo pipefail
input=$(cat)
file=$(jq -r '.tool_input.file_path // empty' <<<"$input")
[[ -z "$file" ]] && exit 0
file=$(realpath -m -- "$file")      # neutralise ../
base=$(basename -- "$file")

deny() { echo "Bloqué : $1 ($file). Les secrets passent par bin/console secrets:set." >&2; exit 2; }

# .env, .env.local, .env.prod.local... mais pas .env.dist ni .env.example
if [[ "$base" =~ ^\.env(\..+)?$ && ! "$base" =~ ^\.env\.(dist|example)$ ]]; then deny "fichier d'environnement"; fi
# vault Symfony : clés de déchiffrement et secrets
if [[ "$file" == */config/secrets/* ]]; then deny "vault Symfony"; fi
exit 0

Double toujours ce hook par des règles deny dans les permissions (Read(./.env), Read(./config/secrets/**)) : elles ne dépendent d'aucun script et tiennent même si le hook plante ou est contourné. Le message d'erreur part directement chez Claude comme instruction : dis-lui quoi faire à la place.

Comment interdire la modification des migrations Doctrine commitées ?

Interdire tout le dossier migrations/ empêcherait de générer la migration suivante. Le script teste plutôt si le fichier existe déjà dans HEAD : une migration créée pendant la session reste éditable, une migration de l'historique partagé est refusée. C'est une approximation volontairement prudente : un fichier commité n'est pas forcément déjà exécuté en production, mais mieux vaut un faux positif qu'une migration réécrite après coup.

bash
#!/usr/bin/env bash
# lock-migrations.sh : PreToolUse (Edit|Write)
set -euo pipefail
input=$(cat)
file=$(jq -r '.tool_input.file_path // empty' <<<"$input")
cwd=$(jq -r '.cwd' <<<"$input")
[[ "$file" == */migrations/*.php ]] || exit 0

root=$(git -C "$cwd" rev-parse --show-toplevel)
rel=$(realpath -m --relative-to="$root" -- "$file")

if git -C "$root" cat-file -e "HEAD:$rel" 2>/dev/null; then
  echo "Bloqué : $rel est commitée. Crée une nouvelle migration avec bin/console make:migration." >&2
  exit 2
fi
exit 0

Comment refuser un push forcé ou un push direct sur main ?

Les règles de permission par préfixe ratent les variantes : Bash(git push --force *) n'attrape ni git push origin main -f, ni git push origin +main. Le hook suivant, branché avec if: "Bash(git push *)", refuse --force, -f y compris combiné (-fu) et le préfixe +, ainsi que tout push vers une branche protégée, y compris un git push nu depuis main. --force-with-lease reste permis sur une branche de fonctionnalité. Adapte la liste des branches protégées à ta politique.

bash
#!/usr/bin/env bash
# git-guard.sh : PreToolUse (Bash), filtré par if "Bash(git push *)"
set -euo pipefail
input=$(cat)
cmd=$(jq -r '.tool_input.command // empty' <<<"$input")
cwd=$(jq -r '.cwd' <<<"$input")
protected='(main|master|production)'

# --force, -f, -fu... et +refspec
if grep -Eq '(^|[[:space:]])(--force([[:space:]=]|$)|-[a-zA-Z]*f[a-zA-Z]*([[:space:]]|$))|[[:space:]]\+[^[:space:]]' <<<"$cmd"; then
  echo "Bloqué : push forcé interdit. Utilise --force-with-lease sur ta branche." >&2; exit 2
fi
# push explicite vers une branche protégée (main, HEAD:main, refs/heads/main)
if grep -Eq "(^|[[:space:]:]|refs/heads/)$protected([[:space:]]|\$)" <<<"$cmd"; then
  echo "Bloqué : push direct sur une branche protégée. Passe par une PR." >&2; exit 2
fi
# push sans refspec depuis une branche protégée
if [[ "$(git -C "$cwd" branch --show-current)" =~ ^$protected$ ]]; then
  echo "Bloqué : tu es sur une branche protégée. Crée une branche puis une PR." >&2; exit 2
fi
exit 0

Ce hook n'est qu'une première ligne. La vraie barrière contre le push forcé reste la protection de branche côté forge : un agent qui contournerait ton hook se heurtera quand même à GitHub ou GitLab.

Quel hook PostToolUse pour reformater le PHP ?

Ici, le hook ne protège pas, il fiabilise : plus besoin d'écrire « respecte PSR-12 » et de compter sur le modèle, le formateur passe après chaque édition. Si php-cs-fixer échoue, typiquement sur une erreur de syntaxe introduite, le hook sort en exit 2 et Claude reçoit l'erreur pour corriger. L'option --path-mode=intersection est indispensable quand ta configuration définit un finder, sinon le chemin passé en argument est ignoré.

bash
#!/usr/bin/env bash
# php-cs-fix.sh : PostToolUse (Edit|Write)
set -euo pipefail
input=$(cat)
file=$(jq -r '.tool_input.file_path // empty' <<<"$input")
cwd=$(jq -r '.cwd' <<<"$input")
[[ "$file" == *.php ]] || exit 0
[[ "$file" == */migrations/* || "$file" == */vendor/* || "$file" == */var/* ]] && exit 0

root=$(git -C "$cwd" rev-parse --show-toplevel)
rel=$(realpath -m --relative-to="$root" -- "$file")
cd "$root"
if ! out=$(docker compose exec -T php vendor/bin/php-cs-fixer fix \
      --config=.php-cs-fixer.dist.php --path-mode=intersection --quiet -- "$rel" 2>&1); then
  echo "php-cs-fixer a échoué sur $rel :" >&2
  echo "$out" | tail -n 20 >&2
  exit 2
fi
exit 0

Votre équipe utilise Claude Code ?

Formation intra sur vos projets réels : de la demi-journée à 2 jours, 6 devs max.

Voir les formats →

Ne mets pas onFailure: "block" sur ce hook-là : un docker compose momentanément indisponible ne doit pas figer toute l'édition. Le contrôle qualité complet reste le Stop hook PHPUnit et PHPStan présenté dans l'article sur la vérification d'abord. Si un formatage par fichier pèse sur les performances, déplace-le en fin de tour.

Comment tester ses hooks avant de les déployer ?

Un hook est un exécutable qui lit du JSON : pas besoin de lancer Claude pour le tester. Teste les deux côtés, ce qui doit être bloqué et ce qui doit passer, car les faux positifs détruisent autant la confiance que les faux négatifs. Un petit harness versionné, lancé en CI, vaut mieux que des essais manuels :

bash
#!/usr/bin/env bash
# test-hooks.sh : quitte en 1 au premier écart
set -u
H=$(dirname "$0"); P=$(git rev-parse --show-toplevel); fail=0
t() { # nom, script, json, code attendu
  out=$(printf '%s' "$3" | "$H/$2" 2>&1); rc=$?
  if [[ $rc -eq $4 ]]; then echo "OK   $1"; else echo "FAIL $1 rc=$rc attendu=$4 : $out"; fail=1; fi
}
t env-local  protect-secrets.sh "{\"tool_input\":{\"file_path\":\"$P/.env.local\"}}" 2
t env-dist   protect-secrets.sh "{\"tool_input\":{\"file_path\":\"$P/.env.dist\"}}" 0
t traversal  protect-secrets.sh "{\"tool_input\":{\"file_path\":\"$P/src/../.env\"}}" 2
t push-fu    git-guard.sh "{\"cwd\":\"$P\",\"tool_input\":{\"command\":\"git push -fu origin x\"}}" 2
t push-lease git-guard.sh "{\"cwd\":\"$P\",\"tool_input\":{\"command\":\"git push --force-with-lease origin feat/x\"}}" 0
t main-menu  git-guard.sh "{\"cwd\":\"$P\",\"tool_input\":{\"command\":\"git push origin fix/main-menu\"}}" 0
exit $fail

Ces scripts ont été exécutés sur dix-huit cas dans un conteneur Alpine (bash, jq, git), tous conformes. Pour vérifier le câblage réel, /hooks liste les hooks configurés par événement, claude doctor signale les entrées invalides, et un essai direct (« ajoute un commentaire dans .env.local ») doit renvoyer le message « Bloqué ». N'oublie pas chmod +x : un hook non exécutable ne tourne pas.

Que sont les managed settings et comment les déployer ?

Les managed settings sont une configuration déposée par l'organisation au niveau du système, qu'aucun fichier utilisateur ou projet ne peut surcharger. D'après la doc des managed settings, ils se livrent par un fichier managed-settings.json (/etc/claude-code/ sous Linux, /Library/Application Support/ClaudeCode/ sous macOS, C:\Program Files\ClaudeCode\ sous Windows), par des fragments dans un dossier managed-settings.d/ fusionnés par ordre alphabétique, par MDM ou depuis la console d'administration de claude.ai. Le fichier local reste modifiable par un développeur administrateur de sa machine : c'est la raison d'être du MDM. Les scripts vont dans /etc/claude-code/hooks/, propriété de root, pas dans le dépôt :

json
{
  "allowManagedHooksOnly": true,
  "allowManagedPermissionRulesOnly": true,
  "requiredMinimumVersion": "2.1.295",
  "permissions": {
    "disableBypassPermissionsMode": "disable",
    "deny": [
      "Read(./.env)",
      "Read(./config/secrets/**)",
      "Bash(git push --force *)"
    ],
    "allow": [
      "Bash(docker compose exec -T php vendor/bin/phpunit *)",
      "Bash(docker compose exec -T php vendor/bin/phpstan *)"
    ]
  },
  "sandbox": { "enabled": true, "failIfUnavailable": true },
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Read|Edit|Write",
        "hooks": [{ "type": "command", "command": "/etc/claude-code/hooks/protect-secrets.sh", "timeout": 10, "onFailure": "block" }]
      },
      {
        "matcher": "Edit|Write",
        "hooks": [{ "type": "command", "command": "/etc/claude-code/hooks/lock-migrations.sh", "timeout": 10, "onFailure": "block" }]
      },
      {
        "matcher": "Bash",
        "hooks": [{ "type": "command", "if": "Bash(git push *)", "command": "/etc/claude-code/hooks/git-guard.sh", "timeout": 10, "onFailure": "block" }]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [{ "type": "command", "command": "/etc/claude-code/hooks/php-cs-fix.sh", "timeout": 60 }]
      }
    ]
  }
}
  • allowManagedHooksOnly : seuls les hooks de la politique tournent ; ceux de l'utilisateur, du projet et des plugins sont bloqués, sauf plugins forcés par la politique. Sans cette clé, les hooks s'additionnent : un développeur ajoute les siens mais ne retire pas ceux de l'organisation. Contrepartie : les hooks propres à un projet tombent, et doivent migrer dans la politique ou passer par un plugin imposé.
  • allowManagedPermissionRulesOnly : ignore les règles allow, ask et deny des autres fichiers. Les autorisations d'équipe doivent alors figurer dans la politique, sinon les développeurs les perdent : prévois une phase de calibrage.
  • disableBypassPermissionsMode: "disable" : rejette --dangerously-skip-permissions.
  • sandbox.failIfUnavailable : sans lui, une sandbox qui ne démarre pas laisse les commandes s'exécuter sans isolation.
  • requiredMinimumVersion : garantit une version qui connaît onFailure.

Sur une machine pilote, /status affiche la source des réglages retenue, et un fichier de politique JSON invalide empêche Claude Code de démarrer : valide-le avec jq . avant de le pousser sur la flotte. Les plugins pouvant eux-mêmes embarquer des hooks, la gouvernance décrite dans l'article sur les Claude Code Mods complète ce dispositif.

Comment auditer les changements de configuration ?

L'événement ConfigChange se déclenche quand un fichier de configuration change pendant une session. Placé dans la politique, ce hook journalise chaque modification vers syslog, hors de portée de l'agent :

json
{
  "hooks": {
    "ConfigChange": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "jq -c '{ts: (now|todate), user: env.USER, source: .source, file: .file_path}' | logger -t claude-config-audit"
          }
        ]
      }
    ]
  }
}

Un exit 2 sur cet événement bloque le changement, sauf pour la configuration managée elle-même : tu peux donc refuser qu'une session ajoute une règle allow au .claude/settings.json du projet. Ses angles morts : il ne voit ni les changements faits hors session, ni ceux livrés par MDM ou par la console. Le reste de l'audit se fait côté infrastructure, en surveillant l'intégrité de /etc/claude-code/.

Et quand la règle demande du jugement ?

Certaines règles ne s'écrivent pas en expression régulière : « pas de commande Doctrine destructrice contre autre chose qu'une base locale » dépend de APP_ENV, de DATABASE_URL ou du conteneur ciblé. Un hook de type prompt envoie l'appel à un modèle qui répond {"ok": true} ou {"ok": false, "reason": "…"} ; sur PreToolUse, un refus bloque l'appel, et l'option continueOnBlock: true renvoie la raison à Claude pour qu'il s'adapte. Deux précautions : la décision redevient probabiliste, donc réserve ce type de hook au flou et double-le d'un deny dur sur ce qui est clair, comme schema:drop ; et les hooks de type agent, qui lancent un sous-agent, sont marqués expérimentaux par la doc, à éviter en production.

Que ne garantit pas un hook ?

  • Un hook n'est pas une sandbox : il inspecte l'appel que Claude annonce. Du code arbitraire (php -r, un script écrit dans /tmp, sh -c dans le conteneur) lit un fichier sans appeler Read. Seules la sandbox et les droits du conteneur ferment cette porte.
  • Le filtre if sur Bash est approximatif : la doc recommande les permissions plutôt qu'un hook pour un autoriser ou refuser strict.
  • La performance compte : un PreToolUse tourne avant chaque appel d'outil. Garde les scripts courts, sans réseau, avec un timeout serré.
  • Les faux positifs usent la confiance : un hook trop strict apprend à l'équipe à le contourner. Journalise chaque refus et revois les motifs chaque mois.
  • Les managed settings ne lient que Claude Code : un administrateur local peut éditer le fichier système, et un autre outil n'est pas concerné.

La défense en profondeur reste le bon message : règles deny, hooks, sandbox, puis protection de branche et CI côté forge, où personne ne peut contourner le contrôle depuis sa machine. C'est d'ailleurs le choix d'Anthropic pour sa propre revue de sécurité, et la logique de notre article sur la sécurisation d'un pipeline CI avec des agents.

Par où commencer en équipe ?

  • Ajouter aujourd'hui les règles deny sur .env et les secrets : dix minutes, aucun code.
  • Écrire les hooks avec un test par règle et un harness en CI, puis les poser en réglages de projet quelques jours pour mesurer les faux positifs.
  • Mettre onFailure: "block" sur tous les hooks de sécurité, jamais sur les hooks de confort.
  • Basculer le tout en managed settings, scripts propriété de root, et trancher sur allowManagedHooksOnly et allowManagedPermissionRulesOnly.
  • Garder la revue de sécurité et la protection de branche côté forge : le hook prévient l'erreur courante, il ne remplace pas le contrôle central.

Un CLAUDE.md explique le pourquoi, les hooks et les permissions imposent le jamais, les managed settings rendent l'ensemble non négociable pour toute l'équipe. Ce qui marche sur un poste doit devenir une convention du dépôt, puis une politique de l'organisation. Prochain article : quitter le poste pour la pull request, et industrialiser la revue de code assistée par IA avec /code-review et un fichier REVIEW.md.

Cet article vous a été utile ? Partagez-le.

Portrait de Flavien Métivier, fondateur de The Bearded Bear : barbe longue, lunettes, sur fond sombre

Écrit par

Flavien Métivier

Expert PHP/Symfony indépendant depuis 2014, ancien CTO de Kartable et d'Extrabat. Plus de 80 projets menés, de l'audit à la migration.

Mon parcours

À lire ensuite

Votre équipe utilise Claude Code ?

Formation intra sur vos projets réels : de la demi-journée à 2 jours, 6 devs max.

Newsletter Bear Bytes

Veille Claude Code, Symfony et PHP, retours d'expérience terrain et conseils techniques, directement dans votre boîte mail.