Hooks fail-closed en Claude Code con onFailure block
onFailure: "block" es un campo de los hooks command y http de Claude Code, disponible desde la v2.1.295 (08/10/2026), que convierte un fallo del propio hook en un bloqueo de la acción. Los fallos que cubre son que el hook no arranque, que agote el timeout, que salga con un código inesperado o que devuelva JSON inválido. Sin este campo, un hook roto deja pasar todo.
Si tienes un hook PreToolUse que hace de guardia (impide un rm -rf, un git push --force o la lectura de un .env), esto te afecta: hasta ahora su seguridad dependía de que el script nunca fallase. Aquí vas a construir un guard en Python, registrarlo para que falle cerrado y comprobar que de verdad bloquea cuando se rompe.
Por qué un hook roto deja pasar el comando
Claude Code trata casi cualquier fallo de un hook como un error no bloqueante, así que la acción continúa. La documentación de hooks lo dice sin rodeos: en la mayoría de eventos, si un hook falla o agota su tiempo, Claude Code ejecuta la acción igualmente, "así que un hook de política con una ruta mal escrita o un script que se cuelga lo deja pasar todo".
La clave está en los códigos de salida. El 0 es éxito y se aplica el JSON que imprimas. El 2 es un bloqueo deliberado, y en PreToolUse Claude recibe tu stderr como motivo. Cualquier otro código es un error no bloqueante. La documentación avisa expresamente de que el 1, el código de error convencional en Unix, no bloquea: si tu hook impone una política, tiene que salir con 2.
Esta tabla resume qué pasa con un hook PreToolUse según cómo termine, con y sin el campo nuevo:
| Cómo termina el hook | Sin onFailure (valor "continue") | Con onFailure: "block" |
|---|---|---|
| Exit 0 sin salida | Sigue el flujo normal de permisos | Igual |
Exit 0 con permissionDecision: "deny" | Bloquea | Bloquea |
| Exit 2 | Bloquea | Bloquea |
| Exit 1 u otro código (excepción no capturada) | La herramienta se ejecuta | Bloquea |
Exit 1 con JSON que dice "allow" | La herramienta se ejecuta | Bloquea: cuenta como fallo |
| El ejecutable o el script no existe | La herramienta se ejecuta | Bloquea |
Supera su timeout | La herramienta se ejecuta | Bloquea (mensaje timed out) |
| JSON que no se puede parsear o no pasa el esquema | La herramienta se ejecuta | Bloquea |
| Hook HTTP: fallo de conexión o respuesta no 2xx | La acción continúa | Bloquea |
Un detalle que confunde: el texto plano en stdout de un hook command no cuenta como fallo. Solo cuenta la salida que empieza por { y no es un objeto JSON válido.
Paso 1: comprueba que tienes la versión que lo soporta
El campo exige Claude Code v2.1.295 o posterior, y en un equipo cada persona tiene que estar en esa versión. El changelog oficial lo añade en la 2.1.295 del 08/10/2026, y la 2.1.296 del 09/10/2026 mejora el log de depuración de hooks, que usarás en el paso 4.
claude --version
# Necesitas 2.1.295 o superior; 2.1.296 o superior para las líneas "Hooks:" del debug logLa documentación no dice qué hace una versión anterior con un campo que no conoce. No cuentes con que lo rechace: lo prudente es asumir que, en una versión antigua, tu hook sigue fallando en abierto sin avisar. Si compartes .claude/settings.json por git, incluye la versión mínima en el README o en el onboarding del repo.
Paso 2: un guard de Bash en Python que no disimula sus errores
Con onFailure: "block", lo correcto es dejar que el script reviente ante una entrada inesperada en lugar de capturar la excepción y salir con 0. Un try/except que termina en sys.exit(0) es justo el patrón que convierte un guard en decorado.
El ejemplo usa Python 3.12 y solo la biblioteca estándar, así que no hay nada que instalar. El script lee de stdin el JSON que Claude Code envía antes de cada llamada a Bash, saca el comando de tool_input.command y sale con 2 si coincide con un patrón prohibido. Guárdalo en .claude/hooks/guard_bash.py:
#!/usr/bin/env python3
# Bloquea comandos destructivos antes de que Claude Code los ejecute
import json
import re
import sys
PATRONES = [
r"\brm\s+-\w*[rR]", # rm recursivo
r"\bgit\s+reset\s+--hard\b",
r"\bgit\s+push\b.*\s(--force|-f)(\s|$)", # no afecta a --force-with-lease
]
entrada = json.load(sys.stdin) # JSON roto: excepción y exit 1
comando = entrada["tool_input"]["command"] # campo ausente: KeyError y exit 1
for patron in PATRONES:
if re.search(patron, comando):
print(f"Bloqueado por la política del repo: {patron}", file=sys.stderr)
sys.exit(2)Si ningún patrón coincide, el script termina con 0 y sin salida, y el comando pasa al flujo normal de permisos. Fíjate en las dos líneas comentadas: sin onFailure, cualquiera de esas excepciones dejaría pasar el comando. Con él, lo bloquean.
Ten en cuenta que una lista de expresiones regulares es una red de seguridad, no una frontera de seguridad. Un agente puede llegar al mismo efecto por caminos que tus patrones no contemplan (un script intermedio, un alias, otra herramienta). Combínalo con las reglas de permisos y con un sandbox si el agente tiene permiso de escritura sobre algo que te importe.
Paso 3: registra el hook en exec form, con timeout corto y onFailure
La configuración recomendada usa args, un timeout bajo y onFailure: "block". Añádela a .claude/settings.json en la raíz del proyecto:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "python3",
"args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/guard_bash.py"],
"timeout": 10,
"onFailure": "block"
}
]
}
]
}
}Cada campo tiene su motivo, según la referencia de hooks:
args: si está presente, el hook se ejecuta en exec form, sin shell. Claude Code buscacommanden elPATHy pasa cada elemento deargscomo un argumento literal, así que una ruta con espacios en${CLAUDE_PROJECT_DIR}no rompe nada. La documentación recomiendaargssiempre que uses un placeholder de ruta.timeout: el valor por defecto para hookscommandenPreToolUsees de 600 segundos. Con"block", un guard colgado te tendría esperando diez minutos para acabar bloqueando igualmente. Como referencia práctica, no como dato, un guard que solo aplica regex debería responder en milisegundos, y 10 segundos dan un margen de sobra.onFailure:"continue"es el valor por defecto;"block"hace que un fallo se comporte como un exit 2 en ese evento.
Si editas el fichero con una sesión abierta y no notas el cambio, reinicia la sesión antes de dar la prueba por mala.
Paso 4: provoca los fallos y lee qué ha pasado
Un guard fail-closed solo está verificado cuando lo has visto bloquear por las dos vías: por política (exit 2) y por fallo. Empieza fuera de Claude Code, pasando al script el mismo tipo de JSON que recibiría:
echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf build"}}' | python3 .claude/hooks/guard_bash.py; echo "exit=$?"
echo '{"tool_name":"Bash","tool_input":{"command":"ls -la"}}' | python3 .claude/hooks/guard_bash.py; echo "exit=$?"
echo '{"tool_name":"Bash"}' | python3 .claude/hooks/guard_bash.py; echo "exit=$?"La salida esperada es: el mensaje de bloqueo y exit=2 en el primero, exit=0 en el segundo, y un traceback con KeyError seguido de exit=1 en el tercero. Ese tercer caso es el que antes se colaba.
Después, prueba el fallo dentro de Claude Code. Cambia temporalmente "command": "python3" por "command": "python3-no-existe", abre la sesión con claude --debug y pide a Claude que ejecute ls. Según la documentación, la llamada se bloquea y el error incluye failed; blocking because onFailure is "block". Tras un timeout, el mensaje dice timed out.
Para confirmarlo en el log, la documentación indica que --debug no imprime en la terminal, sino en ~/.claude/debug/<session-id>.txt:
grep "Hooks: PreToolUse:Bash" ~/.claude/debug/*.txt | tail -n 5
# Desde la v2.1.296, cada ejecución deja una línea con el estado y la duración,
# por ejemplo "finished with status 2 (40ms)", "failed to start" o "timed out after ...ms"Hay una trampa con los intérpretes. Por convención Unix, que recoge la documentación de sys.exit, el código 2 se usa para errores de línea de comandos, y algunas herramientas devuelven 2 cuando no encuentran su fichero. Si tu intérprete sale con 2 cuando el script no existe, Claude Code lo tomará como un bloqueo deliberado. El resultado es el mismo, pero el motivo que ve Claude será el error del intérprete. Compruébalo con python3 /ruta/que/no/existe.py; echo $? para saber qué mensaje esperar. Cuando acabes, devuelve "command" a "python3".
Qué hooks cerrar y cuáles dejar en abierto
Cierra los hooks que deciden si algo puede ocurrir. Deja abiertos los que solo añaden comodidad. Fallar cerrado tiene un coste: si el hook se rompe, el agente se queda sin la herramienta entera hasta que lo arregles. Este árbol de decisión sirve de punto de partida:
| Tipo de hook | onFailure recomendado | Condición |
|---|---|---|
Guard PreToolUse de comandos o rutas sensibles | "block" | Siempre que el hook imponga una política |
PermissionRequest que aprueba automáticamente | "block" | Un fallo deniega la petición en vez de bloquear, según la documentación |
| Hook HTTP contra un servicio de auditoría | "block" solo si el servicio es fiable | Si el servicio se cae, te quedas sin la herramienta |
UserPromptSubmit que filtra secretos en el prompt | "block" con timeout corto | Un fallo bloquea el prompt: si se rompe, no puedes ni escribirle al agente |
| Formateador o linter tras editar | "continue" | Un formateador roto no debería frenar el trabajo |
| Notificaciones y logs | "continue" | No deciden nada |
Stop, SubagentStop, TaskCompleted, TeammateIdle | Sin efecto | El campo se ignora en estos eventos |
Hooks con async o asyncRewake | Sin efecto | Los hooks en segundo plano no pueden bloquear |
La excepción de Stop tiene lógica: en esos eventos, un exit 2 manda a Claude a seguir trabajando, y Claude no puede reparar un hook que no llega a ejecutarse. Si lo que quieres es una verificación al final de la tarea, necesitas otro mecanismo.
Cuándo no seguir estos pasos
onFailure te protege de tus propios hooks rotos, no de hooks maliciosos ni de un agente decidido a esquivarte. Hay varios casos en los que no basta o no compensa:
- Si ejecutas
claude -psobre repos ajenos: la documentación avisa de que en sesiones-po del SDK la carpeta se trata como de confianza y se ejecutan los hooks del.claude/settings.jsondel repositorio. Revisa esos ficheros o desactiva los hooks para esa ejecución con--settings '{"disableAllHooks": true}'. - Si tu equipo tiene versiones mezcladas: por debajo de la 2.1.295 no hay garantía de comportamiento fail-closed. Hasta que todos actualicen, no presentes el guard como una barrera.
- Si el hook depende de algo frágil del entorno: un
python3que no está en elPATHde un compañero (en Windows el ejecutable puede tener otro nombre) le bloqueará todos los comandos Bash. Es el comportamiento correcto, pero documéntalo para que nadie lo "arregle" borrando el hook. - Si el hook es lento por diseño (llama a un LLM o a una API lenta): con
"block", cada timeout es una herramienta denegada. Mueve la lógica pesada fuera del camino crítico.
Antes de dar por cerrado un guard, repasa esta checklist:
claude --versionda 2.1.295 o superior en todas las máquinas que usan el repo.- El script sale con 2 para bloquear, nunca con 1.
- No hay
try/exceptque termine enexit 0. - El hook usa
argspara la ruta y untimeoutexplícito. - Has visto un bloqueo por política y otro por fallo, y has encontrado las dos líneas
Hooks:en el debug log. - El guard está respaldado por reglas de permisos, no las sustituye.