Hooks pour le développement Apple : des patterns qui sauvent le projet
Une session Claude Code pointée sur un projet iOS dispose d’une portée qu’un projet Python ou web générique n’a pas. L’agent peut exécuter xcodebuild et xcrun via son outil Bash. Il peut lire et modifier les fichiers .pbxproj (par défaut une property list ASCII à l’ancienne, parfois XML ou JSON après conversion par les outils, et tout aussi fatale à corrompre dans n’importe lequel de ces formats). Il détient les identités de signature du développeur du seul fait qu’il s’exécute sur la machine du développeur. Il peut effacer un simulateur. Il peut reconstruire un projet avec le mauvais scheme. Il peut commiter et pousser. Le protocole ne contrôle rien de tout cela : le système de fichiers du développeur est le système de fichiers de l’agent, et le drapeau --dangerously-skip-permissions de Claude Code se trouve à une touche d’une automatisation totale.
L’atténuation n’est pas « faire confiance à l’agent ». L’atténuation, ce sont les hooks : des scripts shell déterministes que l’hôte exécute aux frontières du cycle de vie (PreToolUse, PostToolUse, UserPromptSubmit, SessionStart, Stop).1 Les hooks ralentissent l’agent sur les entrées dangereuses, valident les sorties destructrices et conditionnent l’achèvement à un build vert. Ils constituent la primitive de sécurité porteuse que tout développeur iOS faisant tourner un agent devrait configurer avant que l’agent n’exécute quoi que ce soit.
Quatre patterns de hooks méritent leur place dans les projets iOS. Ils relèvent du framework, pas du projet spécifique ; les apps du cluster (Return, Get Bananas, Reps, Water, Ace Citizenship) en font toutes tourner des variantes. Chaque pattern nomme un mode d’échec réel, un script concret et l’événement de cycle de vie qui délimite le rayon d’impact.
TL;DR
- Quatre patterns de hooks qui comptent en iOS : validation
.pbxproj(PostToolUse, renvoie les erreurs à l’agent), filtrage des commandes bash dangereuses (PreToolUse, bloque avant exécution), porte de sortie sur build vert via Stop, hygiène de l’état des simulateurs (Stop). - Les codes de sortie des hooks comptent, et leur comportement diffère selon l’événement.
exit 2bloque l’action proposée surPreToolUse(l’outil ne s’exécute jamais) ; surPostToolUse, il ne peut pas bloquer (l’outil s’est déjà exécuté) mais il fait remonter stderr à l’agent qui peut alors réparer ou annuler ; surStop, il empêche l’agent de conclure la session.exit 0autorise.exit 1journalise généralement mais ne bloque pas.1 - Les scripts de hooks vivent dans
.claude/hooks/*.shdu dépôt, référencés par chemin relatif depuis.claude/settings.json. La revue de code s’applique. - L’autorité de l’agent est l’autorité du développeur. Les hooks sont la manière dont le développeur redécoupe cette autorité en un ensemble délibéré d’actions approuvées.
Pattern numéro un : validation .pbxproj à chaque édition
Le fichier projet Xcode est le seul fichier qu’un agent mute régulièrement et qui présente le ratio rayon-d’impact-par-ligne le plus élevé. Une seule mauvaise accolade dans project.pbxproj casse silencieusement le build pour toute l’équipe. L’erreur de build apparaît à la prochaine invocation xcodebuild, pas au moment de l’édition, si bien que l’agent affirme typiquement que le changement a fonctionné avant que la cassure ne se manifeste.
Le hook lance plutil -lint sur toute écriture .pbxproj. PostToolUse ne peut pas bloquer l’écriture elle-même (le fichier est déjà sur disque au moment où le hook se déclenche), mais exit 2 fait remonter immédiatement l’erreur de validation à l’agent comme un échec d’appel d’outil : l’agent lit l’échec, sait que le fichier est cassé, et peut annuler ou réparer avant que la session ne continue :
#!/bin/bash
# .claude/hooks/post-write-pbxproj.sh
# Runs after every Edit or Write tool call. Exits 2 to surface the
# validation failure to the agent so it can revert/repair the broken
# .pbxproj before the session moves on. (PostToolUse cannot prevent
# the write itself; it can only feed the error back.)
INPUT=$(cat)
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
if [[ "$FILE" != *.pbxproj ]]; then
exit 0
fi
if ! plutil -lint "$FILE" >/dev/null 2>&1; then
echo "ERROR: $FILE failed plutil -lint after write" >&2
echo "The Xcode project file is structurally broken. Revert and try again." >&2
exit 2
fi
exit 0
plutil -lint détecte les cassures structurelles : accolades ou parenthèses déséquilibrées, points-virgules manquants, jetons plist invalides, imbrication XML cassée.2 Il ne détecte pas les erreurs de sémantique Xcode comme un UUID malformé qui se trouve être du texte plist syntaxiquement valide, ou une phase de build référençant un fichier manquant. Celles-ci produisent des erreurs de build ordinaires que l’agent peut déboguer normalement. La porte plutil attrape la classe d’échec catastrophique de parsing ; les erreurs sémantiques retombent jusqu’au build lui-même.
La configuration du hook dans .claude/settings.json (notez $CLAUDE_PROJECT_DIR entre guillemets pour les chemins contenant des espaces) :
{
"hooks": {
"PostToolUse": [{
"matcher": "Write|Edit",
"hooks": [{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/post-write-pbxproj.sh"
}]
}]
}
}
Le matcher déclenche le hook uniquement sur les appels d’outils Write et Edit ; la première action du script consiste à court-circuiter sur les chemins qui ne sont pas .pbxproj. Le coût de l’exécution à chaque Edit est négligeable parce que le filtre de chemin est la première vérification.
Pattern numéro deux : filtrer les commandes Bash destructrices avant qu’elles ne s’exécutent
xcrun simctl erase efface les données d’un simulateur. xcodebuild archive invoque la signature et peut produire des artefacts signés que le développeur n’avait pas l’intention de générer. git push --force réécrit l’historique. L’agent y a accès à toutes via son outil Bash. Un hook PreToolUse sur Bash met en correspondance le motif de la commande proposée et décide s’il faut procéder.
La forme :
#!/bin/bash
# .claude/hooks/pre-bash-xcode.sh
# Runs before every Bash tool call. Exits 2 (blocking) on irreversible
# Xcode/signing/git operations the developer hasn't explicitly approved.
# PreToolUse hooks CAN block: exit 2 prevents the tool from running.
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
case "$COMMAND" in
*"simctl erase"*)
echo "ERROR: simulator erase requires explicit human approval" >&2
echo "Tell the developer what you wanted to erase and why; let them run it." >&2
exit 2
;;
*"xcodebuild archive"*|*"xcodebuild -exportArchive"*)
echo "ERROR: xcodebuild archive/export invokes signing; requires human approval" >&2
exit 2
;;
*"git push --force"*|*"git push -f"*)
echo "ERROR: force-push rewrites history; requires human approval" >&2
exit 2
;;
*"rm -rf"*)
echo "ERROR: rm -rf requires explicit approval" >&2
exit 2
;;
esac
exit 0
Le hook est un switch sur des sous-chaînes de motif de commande. La classe bloquante est la classe d’actions irréversibles : effacement, signature, force-push, suppression récursive. Les opérations réversibles (builds réguliers, tests, commits Git sans force) passent à travers.
Un raffinement courant consiste à autoriser certaines commandes lorsque le développeur a explicitement opté pour cela via une variable d’environnement ou un drapeau dans la conversation. Par exemple : xcodebuild archive pourrait être autorisé si CLAUDE_ALLOW_ARCHIVE=1 est dans l’environnement, défini par le développeur avant la session pour une tâche d’archivage spécifique. Le hook lit la variable d’environnement et contourne le blocage :
*"xcodebuild archive"*)
if [[ "${CLAUDE_ALLOW_ARCHIVE:-0}" == "1" ]]; then
exit 0
fi
echo "ERROR: xcodebuild archive requires CLAUDE_ALLOW_ARCHIVE=1" >&2
exit 2
;;
Le pattern : refus par défaut sur la classe irréversible, soupape d’opt-in pour les cas que le développeur veut confier à l’agent.
Pattern numéro trois : un hook Stop qui conditionne l’achèvement à un build vert
L’agent aime déclarer une tâche terminée quand la conversation semble résolue. Sans porte, « terminé » peut signifier « j’ai édité les fichiers et le chat est dans un état cohérent » plutôt que « le build compile toujours ». Le hook Stop est l’endroit où imposer le bon sens.
#!/bin/bash
# .claude/hooks/stop-build-check.sh
# Runs when the agent tries to stop. Exits 2 if the build is broken,
# which prevents the session from concluding until the agent fixes it.
cd "$CLAUDE_PROJECT_DIR" || exit 0
# Hard-code project, scheme, and destination per repo. Do not rely on
# auto-discovery: workspaces, multiple projects, or shared-vs-user
# schemes all break naive heuristics.
PROJECT="MyApp.xcodeproj"
SCHEME="MyApp"
DESTINATION="platform=iOS Simulator,name=iPhone 17 Pro"
LOG=/tmp/claude-stop-build.log
if ! xcodebuild -project "$PROJECT" -scheme "$SCHEME" \
-configuration Debug \
-destination "$DESTINATION" \
build > "$LOG" 2>&1; then
echo "ERROR: build failed; cannot stop with a broken build" >&2
echo "See $LOG for the full xcodebuild output." >&2
tail -50 "$LOG" >&2
exit 2
fi
exit 0
Coder en dur PROJECT, SCHEME et DESTINATION est la bonne forme pour les hooks commités au dépôt : les valeurs ne dérivent jamais, les workspaces et les dépôts multi-projets fonctionnent sans ajustements par machine, et un système de build CI utilisant le même hook peut échanger la destination via une variable d’environnement. La détection automatique (ls *.xcodeproj, xcodebuild -list | awk) fonctionne pour le cas solo le plus simple mais échoue sur les projets enracinés dans un .xcworkspace, sur les dépôts contenant plusieurs fichiers .xcodeproj, et sur la distinction scheme partagé versus utilisateur. La chaîne de destination suit la syntaxe platform=...,name=... documentée par xcodebuild ;3 elle doit correspondre à un simulateur que la machine du développeur possède réellement, sinon le hook échoue pour des raisons d’environnement plutôt que de code.
Deux décisions produit que prend le hook :
Stop bloque le signal « j’ai terminé » de l’agent, pas celui de l’humain. Le développeur peut toujours faire Ctrl+C, fermer le terminal ou outrepasser. Le hook est un garde-fou contre l’optimisme de l’agent, pas un verrou imposé à l’humain.
Le hook lance un vrai build, pas une vérification syntaxique. swift build sur un projet spécifique iOS saute les étapes de compilation propres à iOS ; seul xcodebuild prouve que la cible iOS compile. Le coût, c’est le temps de build lui-même (10-60 secondes sur la plupart des projets) ; la valeur, c’est d’attraper à chaque fois le cas du build cassé marqué comme terminé.
Pattern numéro quatre : hygiène de l’état des simulateurs
Après une longue session d’agent, les simulateurs peuvent s’accumuler : simulateurs démarrés que l’agent a oublié d’arrêter, anciennes installations d’app qui mettent en cache un état périmé, données d’exécution qui survivent entre les sessions et produisent des bugs irreproductibles. Un hook Stop peut faire le ménage.
#!/bin/bash
# .claude/hooks/stop-simulator-cleanup.sh
# Soft cleanup: shuts down booted simulators we don't need anymore.
# Does NOT erase data; does NOT block. Logs only.
BOOTED=$(xcrun simctl list devices booted 2>/dev/null | grep -E "Booted" | wc -l | xargs)
if (( BOOTED > 0 )); then
echo "[hook] $BOOTED booted simulators at session end; consider shutdown" >&2
# Uncomment to auto-shutdown:
# xcrun simctl shutdown all 2>/dev/null
fi
exit 0
La forme est non bloquante par conception : le hook signale l’état mais n’agit pas tant que le développeur n’a pas décommenté la ligne d’arrêt. La raison en est que la prochaine session de l’agent pourrait vouloir conserver le simulateur démarré (démarrage à froid plus rapide quand le simulateur tourne déjà). La décision est propre à chaque développeur : si les simulateurs s’accumulent sur plusieurs sessions et que le coût est réel, décommentez l’arrêt ; sinon, laissez-le comme signal de journal.
Une variante plus agressive efface les simulateurs entre les sessions, mais cela bascule dans le territoire des opérations destructrices du Pattern numéro deux. L’effacement appartient au blocage PreToolUse, pas à l’automatisation Stop.
Ce que les hooks ne résolvent pas
Les quatre patterns ci-dessus constituent l’ensemble de travail, pas l’image complète. Trois classes d’échecs que les hooks ne peuvent pas attraper :
Les bugs de logique dans le code de l’agent. Un hook valide la structure, pas la sémantique. L’agent peut écrire une classe @Model qui compile, passe le lint du fichier projet, build vert, et reste sémantiquement incorrecte (une migration manquante, une contrainte d’unicité cassée, une relation SwiftData sans inverse). La justesse logique vit dans les tests, la revue de code et les yeux du développeur ; les hooks sont là pour les préoccupations structurelles et de cycle de vie.
La dérive lente de la qualité de l’agent. Chaque hook individuel arrête une classe d’échec à la première rencontre, mais la dérive cumulée sur de nombreuses sessions (code progressivement plus brouillon, tests progressivement plus faibles, instructions CLAUDE.md progressivement obsolètes) n’est pas ce que les hooks mesurent. C’est un problème de revue de session, pas un problème de hook.
Les violations de frontière de confiance en dehors de la surface d’outils de l’agent. Un hook sur Bash et Edit couvre le chemin courant. Un hook sur chaque outil MCP que l’agent peut appeler nécessite des matchers par outil ; certains serveurs MCP exposent des dizaines voire des centaines d’outils (XcodeBuildMCP en annonce environ 80) et écrire un hook par outil n’est pas réaliste. Le bon pattern là-dedans est de cadrer l’accès aux serveurs MCP (.mcp.json au niveau projet, flux d’approbation à la première utilisation) plutôt que de hooker chaque outil individuel, et d’accepter que l’agent opérant ses serveurs MCP fasse partie de son autorité sanctionnée.
La relation entre les hooks et la posture de confiance plus large est traitée dans Le dépôt ne devrait pas voter sur sa propre confiance : la confiance est un invariant d’ordre de chargement, pas une vérification en aval. Les hooks sont des gardes en aval sur les actions que prend un agent déjà digne de confiance ; ils ne remplacent pas la décision en amont quant à savoir si l’agent devrait être digne de confiance du tout.
Ce que je construirais différemment
Trois patterns que les apps du cluster livrent ou auraient souhaité livrer.
Les scripts de hooks dans le contrôle de version avec le reste du projet. Les scripts de hooks vivent dans .claude/hooks/*.sh du dépôt. Le .claude/settings.json les référence par chemin relatif. L’équipe obtient les mêmes filets de sécurité sur toutes les machines, la revue de code s’applique aux changements de hooks, et l’intégration d’un nouveau développeur devient un git clone plutôt qu’un exercice de copier-coller. Les hooks à portée utilisateur dans ~/.claude/settings.json sont la mauvaise granularité pour le filtrage propre au projet.
Un hook SessionStart qui affiche la configuration des hooks actifs. Les hooks sont silencieux jusqu’à leur déclenchement. Un hook SessionStart qui s’exécute au début de chaque session Claude Code et affiche « Hooks actifs : pbxproj-validation, dangerous-bash-gate, build-check-on-stop » rappelle au développeur (et à l’agent) quels gardes tournent. Le coût est d’une ligne sur stderr par session ; la valeur est que personne ne développe sans savoir que le filet de sécurité est là.
Un journal d’audit au niveau du dépôt des appels d’outils de l’agent. Un hook PostToolUse qui ajoute chaque appel d’outil (avec horodatage, nom d’outil, arguments) à un fichier JSONL dans .claude/logs/ (gitignoré). Le journal répond à « qu’a fait l’agent dans cette session ? » avec une requête jq plutôt qu’un défilement de l’historique du chat. Le hook ajoute quelques millisecondes par appel d’outil et produit des données d’audit durables que le développeur peut grep quand quelque chose tourne mal.
Quand les hooks sont la mauvaise réponse
Deux cas où la couche hook est le mauvais endroit pour résoudre le problème.
Les serveurs MCP de l’agent eux-mêmes. Un mauvais serveur MCP qui fait la mauvaise chose n’est pas un problème de hook ; c’est un problème de serveur MCP. Le correctif consiste à cadrer quels serveurs MCP le projet accepte (revue .mcp.json, approbation à la première utilisation cadrée par projet) et à lire le code source du serveur s’il est ouvert. Un hook sur chaque appel d’outil MCP ajoute du surcoût sans répondre à la question de la confiance.
Les agents qui tournent sans surveillance. La posture complète des hooks suppose qu’un développeur est à proximité de la session et peut interpréter un hook en échec. Un agent qui tourne en CI sans humain dans la boucle a besoin d’une posture différente : cadrage MCP plus strict, jeux d’outils plus étroits, un modèle de confiance différent. Les hooks seuls ne font pas le pont entre le développement supervisé et l’automatisation non supervisée ; cet écart est intentionnel.
Ce que ce pattern signifie pour les apps iOS qui livrent sur iOS 26+
Trois enseignements.
-
Refus par défaut sur les opérations irréversibles, validation des fichiers à rayon d’impact structurel, conditionnement de l’achèvement à un build vert. Trois événements de cycle de vie de hook (
PreToolUse,PostToolUse,Stop), quatre patterns, couvrant les modes d’échec iOS courants. L’ensemble combiné est suffisamment petit pour être écrit en un après-midi et suffisamment durable pour survivre à n’importe quel agent ou modèle spécifique. -
Le code de sortie compte, et il diffère selon l’événement.
exit 2bloque l’action surPreToolUse(l’outil ne s’exécute jamais) ; surPostToolUse, il ne peut pas bloquer (l’outil s’est déjà exécuté) mais il fait remonter stderr à l’agent qui peut alors réparer ou annuler ; surStop, il empêche l’agent de conclure la session.exit 1ne bloque pas sur la plupart des événements. Testez chaque hook avec un cas délibérément en échec avant de vous y fier. -
Les hooks délimitent l’autorité. Ils ne l’octroient pas. La portée de l’agent dans la machine du développeur est tout ce que l’OS permet à la session de terminal du développeur de faire. Les hooks permettent au développeur de découper des actions spécifiques hors de cette autorité et d’exiger une approbation explicite. La valeur par défaut est ce que l’OS accorde ; le but des hooks est de rendre cette valeur par défaut plus petite, pas plus grande.
L’ensemble du cluster Apple Ecosystem : les App Intents typés pour la surface Apple Intelligence ; les serveurs MCP pour la surface agent ; la question de routage entre les deux ; les Foundation Models pour les fonctionnalités LLM on-device dans l’app ; la distinction LLM runtime versus tooling ; la synthèse des trois surfaces ; le pattern de source unique de vérité ; les Deux serveurs MCP pour l’intégration Xcode qui s’associe à ces hooks ; les Live Activities pour la machine d’état de l’écran verrouillé iOS ; le contrat runtime watchOS sur l’Apple Watch ; les internes SwiftUI pour le substrat du framework ; le modèle mental spatial de RealityKit pour les scènes visionOS ; la discipline de schéma SwiftData pour la persistance ; les patterns Liquid Glass pour la couche visuelle ; la livraison multi-plateforme pour la portée multi-appareils. Le hub se trouve à la Série Écosystème Apple. Pour un contexte plus large iOS-avec-agents-IA, consultez le guide iOS Agent Development.
FAQ
Que sont les hooks Claude Code et pourquoi importent-ils pour le développement iOS ?
Les hooks Claude Code sont des scripts shell déterministes qui s’exécutent à des événements de cycle de vie (PreToolUse, PostToolUse, UserPromptSubmit, SessionStart, Stop). Pour le développement iOS, ils délimitent l’autorité de l’agent sur les opérations destructrices : effacement de simulateur, signature de code, mutations de fichier projet, force-push. Sans hooks, l’agent dispose de toute l’autorité de la machine du développeur ; avec les hooks, certaines actions dangereuses spécifiques requièrent une approbation explicite.
Quels événements de hook un développeur iOS devrait-il prioriser ?
PreToolUse sur Bash pour bloquer les commandes destructrices (simctl erase, xcodebuild archive, git push --force). PostToolUse sur Edit/Write pour valider l’intégrité de .pbxproj. Stop pour conditionner sur un build vert. SessionStart pour journaliser la configuration des hooks actifs. Les quatre ensemble attrapent les échecs d’agent les plus courants spécifiques à iOS.
Quelle est la différence entre les codes de sortie 0, 1 et 2 ?
exit 0 autorise l’action et procède. exit 2 se comporte différemment selon l’événement : sur PreToolUse, il bloque l’action proposée (l’outil ne s’exécute jamais) ; sur PostToolUse, il ne peut pas bloquer parce que l’outil s’est déjà exécuté, mais il fait remonter stderr à l’agent qui peut alors réparer ou annuler ; sur Stop, il empêche l’agent de conclure la session. exit 1 journalise une erreur mais ne bloque pas sur la plupart des événements de hook. Pour les patterns de sécurité qui doivent réellement empêcher une action avant qu’elle ne s’exécute, utilisez exit 2 sur PreToolUse. Pour la validation après une écriture destructrice, utilisez exit 2 sur PostToolUse afin de remonter l’échec à l’agent. Testez chaque hook avec une entrée délibérément en échec pour confirmer qu’il se comporte comme prévu pour l’événement spécifique.
Où devraient vivre les scripts de hooks ?
Dans .claude/hooks/*.sh à la racine du projet, avec .claude/settings.json qui les référence par chemin relatif. Sous contrôle de version et soumis à la revue de code aux côtés du reste du projet. Les hooks à portée utilisateur dans ~/.claude/settings.json fonctionnent aussi mais constituent la mauvaise granularité pour le filtrage iOS propre au projet.
Les hooks remplacent-ils le besoin de revue de code ?
Non. Les hooks attrapent les erreurs structurelles (fichiers projet cassés, bash dangereux, builds cassés) avant qu’elles ne soient livrées. La revue de code attrape les erreurs sémantiques (bugs logiques, migrations manquantes, tests faibles). Les deux couches se complètent : les hooks rendent l’agent plus sûr à déployer sur la boucle interne, la revue de code maintient la sortie de l’agent honnête à la frontière.
Références
-
Anthropic, « Claude Code reference: Hooks ». Événements de cycle de vie (
PreToolUse,PostToolUse,UserPromptSubmit,SessionStart,Stop), syntaxe des matchers, forme des commandes, et rôle des codes de sortie.exit 2se comporte différemment selon l’événement : surPreToolUseetStop, il bloque l’action ; surPostToolUse, il ne peut pas bloquer (l’outil s’est déjà exécuté) mais il fait remonter stderr à l’agent.exit 0autorise ;exit 1journalise généralement mais ne bloque pas. L’analyse de l’auteur dans Quand l’LLM vit dans votre app versus dans votre tooling couvre la posture de confiance LLM runtime-versus-tooling que les hooks opérationnalisent. ↩↩ -
Apple, page de manuel
plutil(1). Le drapeau-lintvalide la syntaxe des property lists pour les formats ASCII à l’ancienne, XML et binaire. Il détecte les cassures au niveau du parsing mais ne vérifie pas les sémantiques propres à Xcode comme les références de phases de build ou la validité des UUID au sein du graphe du projet. ↩ -
Apple Developer, « xcodebuild Destination Specifier » et la page de manuel
xcodebuild. La syntaxe-destination 'platform=...,name=...'est la manière canonique d’épingler une cible de build ; les environnements CI surchargent le nom du simulateur via des variables d’environnement ou une détection scriptée de la disponibilité des appareils. ↩