TL;DR Pon
"if": "Bash(git commit *)"en la entrada del hook y Claude Code no lanza el proceso cuando el comando no casa. Es la misma sintaxisTool(patrón)de tus reglas de permisos, una sola regla por entrada, y con el nombre de la tool dentro ya no hace faltamatcher. Cuidado con dónde lo pones:ifsolo se evalúa en los cinco eventos de tool. En unStopo unSessionStart, ese hook no se salta una vez, deja de existir, y sin--debug hooksno te enteras.
Tienes un hook de PreToolUse sobre Bash. Se dispara con cada llamada: cada ls, cada cat, cada echo de dos palabras. El script lee el JSON, ve que no le toca y sale con 0. Has arrancado un proceso entero para no hacer nada, y lo repites decenas de veces por sesión.
Hay un campo que corta eso antes de que ocurra.
Resultado, de una sesión real con dos comandos seguidos:
> ejecuta git status --short y luego echo hola
# hook.log
{"hook_event_name":"PreToolUse","tool_name":"Bash",
"tool_input":{"command":"git status --short", ...}}
# Una entrada. El echo no llegó a lanzar nada.
Cómo funciona
if vive en la entrada del hook, al lado de type y command. Lleva una regla de permisos con la sintaxis Tool(patrón) que ya usas en tu allowlist, la misma de las reglas deny, allow y ask.
Claude Code la evalúa cuando decide qué hooks casan con la llamada, antes de lanzar el proceso. Si la regla no casa, no hay fork, no hay intérprete arrancando y no hay stdin que escribir. La diferencia con un script que mira el JSON y sale pronto no es de milisegundos: es que uno de los dos no llega a existir.
Una regla por entrada. No hay && ni ||. Dos condiciones son dos entradas.
Cómo montarlo
1. Filtra por comando
{
"hooks": {
"PreToolUse": [
{
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/pre-commit.sh",
"if": "Bash(git commit *)"
}
]
}
]
}
}
Fíjate en lo que no hay: matcher. Con el nombre de la tool dentro del if, el matcher deja de hacer falta.
2. Filtra por ruta
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/lint.sh",
"if": "Write(src/**)"
}
Para Write y Edit el patrón casa contra la ruta del archivo, relativa a tu directorio de trabajo. Un Write sobre src/a.ts lanza el hook; uno sobre b.ts en la raíz, no. Desde la versión 2.1.214 src/** significa el src de la raíz y lo que cuelga de él; si quieres cualquier carpeta llamada src a cualquier profundidad, el patrón es **/src/**.
3. Sin paréntesis, toda la tool
{ "type": "command", "command": "...", "if": "Bash" }
Sin contenido de regla, if casa con todas las llamadas a esa tool. Útil para escribir el filtro entero en la entrada y no repartirlo entre matcher e if.
Los cinco eventos donde if se evalúa
if solo se evalúa en cinco eventos: PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest y PermissionDenied. Son los que traen una llamada a una tool contra la que casar.
En cualquier otro (Stop, SessionStart, Notification, PreCompact) la entrada no se salta esta vez y ya. Se descarta entera, siempre, y en silencio. Tu hook está escrito, el JSON es válido, y no se ejecuta nunca.
Para verlo hay que pedirlo. Arranca la sesión con --debug hooks y busca la línea en el log de esa sesión:
grep "if condition" ~/.claude/debug/<id-de-sesión>.txt
2026-09-10T19:04:47.314Z [DEBUG] Hook if condition "Bash(git *)" cannot be evaluated for non-tool event Stop
Si tu hook de Stop no dispara, mira si le has puesto un if antes de revisar el script.
if filtra, no protege
Con Bash el filtro es de mejor esfuerzo. Claude Code descompone el comando para ver qué se ejecuta de verdad, y cuando no puede saberlo, lanza el hook igual: $TOOL git push dispara un if de Bash(git *) porque no hay forma de saber qué expande esa variable. Un patrón con más que el nombre del comando también dispara ante $(), backticks o variables.
La documentación lo deja escrito: como el filtro es de mejor esfuerzo, para imponer un allow o un deny de verdad usa el sistema de permisos, no un hook. if es una herramienta de rendimiento.
Referencia
| Pieza | Valor |
|---|---|
| Eventos donde se evalúa | PreToolUse · PostToolUse · PostToolUseFailure · PermissionRequest · PermissionDenied |
| Sintaxis | Una regla de permisos: Bash(git *), Edit(*.ts), Write(src/**), o el nombre pelado Bash |
| Combinaciones | Ninguna. Una regla por entrada; dos condiciones, dos entradas |
| Dónde vale | Cualquier origen de hooks: settings.json de usuario, proyecto y local, hooks/hooks.json de plugin, y frontmatter de skill y de subagente |
| Tipos de hook | command, http, mcp_tool, prompt, agent |
Documentación oficial: Intercept and control agent behavior with hooks
No lo confundas con las reglas condicionales de .claude/rules/: aquellas deciden qué entra en tu contexto, esta decide si se lanza un proceso. Es la pieza que le faltaba a los hooks de toda la vida, donde el matcher solo sabe filtrar por nombre de tool. Y es la misma idea del matcher estrecho que auto-aprueba solo el plan, un punto más fina: allí acotas por tool, aquí por lo que esa tool va a hacer.
Requisitos
- Verificado en Claude Code 2.1.267. El cambio de
src/**es de la 2.1.214.