Ilustración abstracta de un flujo de bloques geométricos que atraviesa una compuerta; una de las piezas está rota y la compuerta permanece cerrada en tonos ámbar y azul

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 hookSin onFailure (valor "continue")Con onFailure: "block"
Exit 0 sin salidaSigue el flujo normal de permisosIgual
Exit 0 con permissionDecision: "deny"BloqueaBloquea
Exit 2BloqueaBloquea
Exit 1 u otro código (excepción no capturada)La herramienta se ejecutaBloquea
Exit 1 con JSON que dice "allow"La herramienta se ejecutaBloquea: cuenta como fallo
El ejecutable o el script no existeLa herramienta se ejecutaBloquea
Supera su timeoutLa herramienta se ejecutaBloquea (mensaje timed out)
JSON que no se puede parsear o no pasa el esquemaLa herramienta se ejecutaBloquea
Hook HTTP: fallo de conexión o respuesta no 2xxLa acción continúaBloquea

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 log

La 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 busca command en el PATH y pasa cada elemento de args como un argumento literal, así que una ruta con espacios en ${CLAUDE_PROJECT_DIR} no rompe nada. La documentación recomienda args siempre que uses un placeholder de ruta.
  • timeout: el valor por defecto para hooks command en PreToolUse es 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 hookonFailure recomendadoCondició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 fiableSi el servicio se cae, te quedas sin la herramienta
UserPromptSubmit que filtra secretos en el prompt"block" con timeout cortoUn 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, TeammateIdleSin efectoEl campo se ignora en estos eventos
Hooks con async o asyncRewakeSin efectoLos 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 -p sobre repos ajenos: la documentación avisa de que en sesiones -p o del SDK la carpeta se trata como de confianza y se ejecutan los hooks del .claude/settings.json del 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 python3 que no está en el PATH de 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:

  1. claude --version da 2.1.295 o superior en todas las máquinas que usan el repo.
  2. El script sale con 2 para bloquear, nunca con 1.
  3. No hay try/except que termine en exit 0.
  4. El hook usa args para la ruta y un timeout explícito.
  5. Has visto un bloqueo por política y otro por fallo, y has encontrado las dos líneas Hooks: en el debug log.
  6. El guard está respaldado por reglas de permisos, no las sustituye.
Compartir X LinkedIn