Volver a las novedades

¿Cómo hacer que un hook de Claude Code falle cerrado?

Un hook PreToolUse de Claude Code falla cerrado cuando un envoltorio convierte todo código de salida que no sea 0 ni 2 en 2, y ese envoltorio necesita reloj propio para sobrevivir a un guardia que se cuelga. Medimos 9 brazos de 3 ejecuciones cada uno, 27 sesiones en total, en Claude Code 2.1.240 el 22 de agosto de 2026: el guardia sano bloqueó la escritura en 3 de 3, el guardia que se rompió antes de decidir la dejó pasar en 3 de 3, el envoltorio alrededor de ese mismo guardia roto bloqueó en 3 de 3, y un guardia que durmió más allá del timeout del hook dejó pasar la escritura en 3 de 3 tanto solo como bajo el envoltorio simple. Solo el envoltorio que cronometró al guardia bloqueó las 3. El veredicto en cada ejecución fue el archivo en el disco, no lo que dijo el agente.

¿Qué significa que un hook de Claude Code falle cerrado?

Un hook PreToolUse de Claude Code falla cerrado cuando cualquier cosa que salga mal dentro del guardia produce un rechazo en lugar de una aprobación. El hook es un comando que Claude Code ejecuta antes de una llamada de herramienta que coincida con el matcher, entregándole un payload JSON por la entrada estándar. El código de salida 2 significa denegar, y el motivo escrito en la salida de error vuelve al modelo. El código 0 significa que el hook no tiene objeción. Todo lo demás, una caída, un archivo ausente, un error de sintaxis, un cuelgue, es el caso interesante, porque el guardia no decidió nada y la llamada de herramienta igual tiene que ir hacia un lado o hacia el otro.

Por defecto pasa. Ese es el comportamiento que medimos el 16 de agosto de 2026 y volvimos a medir en esta batería: un guardia que lee su payload, imprime un error y sale con 1 no detiene la escritura. Fallar cerrado, entonces, no es algo que se configura, es algo que se construye. La receta de abajo tiene dos piezas, y solo encontramos la segunda porque fuimos a buscar el modo de falla que nuestra propia receta de agosto no cubría.

Las palabras importan aquí porque son prestadas. Fallar cerrado y fallar abierto vienen del control de acceso y de los admission webhooks de Kubernetes, donde la misma elección es un campo llamado failurePolicy. Un hook que falla cerrado rechaza trabajo cuando el guardia está roto. Un hook que falla abierto deja seguir el trabajo cuando el guardia está roto. Ninguno de los dos es correcto en abstracto, y de eso trata una sección más adelante.

¿Qué pasa cuando un hook de Claude Code se rompe antes de decidir?

Un hook de Claude Code que se rompe antes de decidir deja pasar al agente, en 3 de 3 ejecuciones, y el agente además informa éxito sin mencionar el hook. Nuestro brazo roto tenía cuatro líneas: leer el payload, agregarlo a un archivo de rastro, imprimir guard failed before deciding en la salida de error y salir con 1. La tarea dada a cada sesión fue idéntica, crear notes.txt con la palabra ready en el directorio actual y luego confirmar si el archivo existe. En el brazo roto el archivo existía en las tres ejecuciones, con 6 bytes, y la sesión respondió con una confirmación prolija de la ruta y del contenido.

Esta es la línea de base que estableció nuestra medición del 16 de agosto de 2026, y la repetimos aquí solo para que los brazos nuevos tengan un control al lado, en la misma máquina y en la misma versión. El archivo de rastro es lo que impide que un archivo de configuración mal escrito se haga pasar por resultado: el guardia roto registró 3 payloads en sus tres ejecuciones, todos de llamadas Bash, o sea que fue invocado, vio venir la escritura y no la detuvo. El guardia sano a su lado registró 6 payloads en sus tres ejecuciones, 3 de Bash y 3 de Write, porque una sesión bloqueada prueba la otra herramienta antes de rendirse.

Esos conteos de intercepción son los números más blandos de esta página. Cuentan llamadas de herramienta que el modelo eligió hacer, se movieron entre nuestras propias ejecuciones, y lo único que sostienen es que el hook fue invocado. El archivo en el disco es el número duro.

¿Cómo se envuelve un hook para que toda salida inesperada se vuelva rechazo?

Usted hace que un hook de Claude Code falle cerrado apuntando el hook a un envoltorio en lugar de apuntarlo a su guardia, y haciendo que el envoltorio traduzca cualquier código de salida que no sea 0 ni 2 a 2. El envoltorio lee el payload una vez, lo canaliza al guardia real, conserva la salida del propio guardia y deja pasar 0 y 2 tal cual, de modo que las aprobaciones y los rechazos normales se comportan exactamente como antes. Todo lo demás se vuelve un rechazo con un motivo que dice que el guardia no pudo decidir.

#!/usr/bin/env bash
# envoltorio: fallar cerrado alrededor de cualquier guardia
input="$(cat)"
tmp="$(mktemp)"
printf '%s' "$input" | "$GUARD" > "$tmp" 2>&1
rc=$?
out="$(cat "$tmp")"; rm -f "$tmp"
if [ "$rc" = 0 ] || [ "$rc" = 2 ]; then
  printf '%s\n' "$out" >&2
  exit "$rc"
fi
printf 'guard could not decide (exit %s), refusing by default\n' "$rc" >&2
exit 2

En nuestra batería el envoltorio alrededor del guardia roto bloqueó la escritura en 3 de 3 ejecuciones, contra 0 de 3 del mismo guardia solo. Las sesiones del brazo envuelto leyeron el rechazo, probaron también la herramienta Write, recibieron la misma respuesta, verificaron con Read e informaron que el archivo no existía. Las tres citaron el mensaje del envoltorio de vuelta hacia nosotros textualmente, guard could not decide (exit 1), refusing by default, que es el argumento práctico para poner el código de salida en el texto: quien esté depurando eso a las dos de la mañana se entera de cuál mitad se rompió.

¿El envoltorio sigue funcionando cuando el script del hook no existe?

El envoltorio bloquea en 3 de 3 ejecuciones incluso cuando el guardia al que llama no existe en el disco, que es el error de configuración común y no uno exótico. Armamos un brazo cuyo envoltorio invocaba una ruta que nunca fue creada. El shell devuelve 127 para un comando que no encuentra, 127 no es 0 ni 2, así que el envoltorio rechazó. En las tres ejecuciones notes.txt quedó ausente, y una de las sesiones diagnosticó la cadena entera por su cuenta, nombrando el script ausente, citando el código 127 y llamando fail-closed al envoltorio antes de decidir no buscar una manera de esquivarlo.

Este brazo importa más de lo que parece, porque muestra que el envoltorio cubre una clase y no un caso. Una ruta de hook que quedó mal después de mover el repositorio, un guardia que perdió el bit de ejecución, un intérprete que no está instalado en la máquina del colega, un script que muere en una variable sin definir: todos llegan al envoltorio como algún código de salida que no es 0 ni 2, y todos salen de ahí como rechazo. Usted no tiene que enumerar las maneras en que su guardia puede romperse.

Una limitación honesta de este brazo: como el guardia nunca se ejecutó, nada se agregó al archivo de rastro, así que su conteo de invocaciones es cero y no corrobora nada. La evidencia aquí es el archivo ausente sumado al mensaje de rechazo que la sesión citó, y ese mensaje solo pudo venir del envoltorio.

¿La forma JSON de decisión protege a un hook que se rompe?

La forma de salida JSON estructurada no protege a un hook de Claude Code que se rompe, y en nuestra batería falló exactamente igual que la forma por código de salida, 0 de 3 bloqueados. Claude Code permite que un hook salga con 0 e imprima un objeto JSON con un bloque hookSpecificOutput que lleva permissionDecision, lo que suena más deliberado que los códigos de salida y suele darse por más seguro. Corrimos las dos mitades. Un guardia que imprimió un objeto de denegación bien formado y salió con 0 bloqueó la escritura en 3 de 3 ejecuciones, o sea que la forma en sí funciona y tenemos derecho a hablar de ella. Un guardia que se rompió antes de imprimir cualquier cosa dejó pasar la escritura en 3 de 3.

Esa simetría es el punto. La decisión que detiene a un agente va cargada en algo que el guardia produce al final de su ejecución, sea ese algo un código de salida o una línea de JSON, así que cualquier falla antes del final no produce decisión alguna. Elegir la forma JSON le compra una cadena de motivo y una distinción entre denegar y preguntar, y en el único modo de falla que medimos, un guardia que muere antes de imprimir, no le compró nada. El envoltorio es lo que compra el caso de falla, y envuelve a un guardia JSON con la misma facilidad con que envuelve a uno por código de salida, porque un guardia JSON que se rompe también sale con algo que no es 0 ni 2.

¿Qué pasa cuando un hook de Claude Code se cuelga en vez de romperse?

Un hook de Claude Code que se cuelga deja pasar la escritura, 3 de 3, cuando vence el timeout del harness. Este es el modo de falla que no habíamos probado en agosto, y se comporta distinto que una caída en algo que importa: nada del guardia está roto. Nuestro guardia colgado era el guardia sano con un sleep 60 insertado antes de su rechazo, y la entrada del hook en el archivo de configuración llevaba "timeout": 5. Todas las ejecuciones crearon el archivo y lo confirmaron, 6 bytes, contenido ready, sin mención alguna a un hook.

Colgarse es la versión realista de esta falla, no la artificial. Un guardia que consulta un servidor de políticas por HTTP, que lee un archivo de bloqueo que otro proceso está sosteniendo, que llama a un escáner externo o que espera en un sistema de archivos de red puede tardar más que su timeout estando perfectamente correcto. El rastro muestra que el guardia fue invocado en las tres ejecuciones y simplemente nunca llegó a decir que no.

Lo que este brazo establece es estrecho: un guardia, durmiendo 60 segundos bajo un timeout de 5, falló abierto en 3 de 3 ejecuciones. No establece con qué frecuencia una dependencia real supera su propio timeout, lo que depende de su servidor de políticas y de su red, no de Claude Code. La consecuencia de diseño es la parte que viaja: un código de salida se puede traducir, mientras que una decisión que nunca llega no la puede traducir quien está esperándola, que es lo que midió la sección siguiente.

¿Por qué el envoltorio de una sola pieza falla contra un hook que se cuelga?

El envoltorio solo no salva a un guardia colgado, 0 de 3 bloqueados, porque el envoltorio está esperando al guardia cuando el harness mata a los dos. Lo predijimos antes de correrlo y la predicción se sostuvo: el envoltorio solo llega a traducir un código de salida después de que el guardia devuelve uno, así que un guardia que nunca devuelve deja al envoltorio sin nada que traducir. El timeout del harness dispara, el hook no produce decisión, y la escritura pasa exactamente como si el envoltorio no estuviera.

El arreglo es una segunda pieza: el envoltorio lleva su propio reloj, más corto que el timeout del hook, y rechaza cuando el guardia pierde la hora. En nuestra batería, un envoltorio que le dio 3 segundos al guardia y convirtió la muerte resultante en código 2 bloqueó la escritura en 3 de 3 ejecuciones contra el mismo guardia colgado. Dos de las tres sesiones citaron el mensaje del envoltorio textualmente, guard could not decide (exit 142), refusing by default, y una de ellas observó que Read seguía funcionando mientras Bash y Write no.

#!/usr/bin/env bash
# envoltorio con reloj propio, 3 segundos, bajo un timeout de hook de 5
input="$(cat)"
tmp="$(mktemp)"
printf '%s' "$input" | perl -e 'alarm shift; exec @ARGV' 3 "$GUARD" > "$tmp" 2>&1
rc=$?
out="$(cat "$tmp")"; rm -f "$tmp"
if [ "$rc" = 0 ] || [ "$rc" = 2 ]; then printf '%s\n' "$out" >&2; exit "$rc"; fi
printf 'guard could not decide (exit %s), refusing by default\n' "$rc" >&2
exit 2

Escribir la salida del guardia en un archivo temporal en lugar de capturarla en una sustitución de comandos no es una elección de estilo, es la diferencia entre un envoltorio que devuelve y uno que no devuelve. Nuestra primera versión capturaba al guardia con $(...), y matar al guardia no cerraba la tubería, porque el sleep huérfano seguía sosteniendo abierto el extremo de escritura, así que el envoltorio se quedaba ahí leyendo de un proceso muerto. Medidas lado a lado dos veces el 22 de agosto de 2026, la versión con sustitución de comandos nunca devolvió por su cuenta y hubo que matarla con una alarma externa fijada en 40 segundos, que la alcanzó a los 40,2 y a los 40,1 segundos, mientras que la versión con archivo temporal devolvió código 2 en 3,2 segundos las dos veces. Lo descubrimos probando el instrumento antes de la batería, no por los resultados, que es el argumento para probar su guardia a mano con un payload real antes de gastar sesiones en él.

¿Cuándo debería un guardia fallar abierto?

Un guardia debería fallar abierto cuando el costo de rechazar es mayor que el costo de dejar pasar una operación, y ese caso es bastante real como para que un proveedor lo publique como decisión de diseño. El 20 de agosto de 2026, PandoCore publicó un texto de Eliot Ferstl titulado Why We Ship Our Security Webhook Fail-Open, explicando que su admission webhook para Kubernetes se entrega con failurePolicy: Ignore, a propósito. Su razonamiento: un webhook en la ruta crítica de la creación de pods que falla cerrado no parece un incidente de seguridad, parece el clúster rompiéndose, con ReplicaSets lanzando FailedCreate, despliegues colgados y el autoscaler trabado.

El mismo texto es igual de claro sobre el precio, llamando a una carga de trabajo silenciosamente desprotegida la peor falla que existe para un producto de seguridad, y describiendo la ingeniería real como hacer ruidosa la falla silenciosa, mediante eventos de advertencia y un contador de Prometheus. Además declara una laguna propia, la de que la alerta activada por defecto para cuando el webhook está caído todavía no fue entregada. Es un proveedor argumentando contra nuestra receta con sus propios números, y vale leerlo antes de adoptar cualquiera de las dos posiciones como regla.

Lo que separa a las dos situaciones es el radio de daño, no el principio. Cuando un hook PreToolUse falla cerrado, una sesión de programación se detiene y una persona lee un mensaje de rechazo que nombra al guardia. Cuando un admission webhook falla cerrado, un clúster entero deja de agendar. En CanvasCode corremos varios agentes de programación lado a lado, que es justo donde una barrera que dejó de funcionar en silencio sale más cara, porque la escritura que usted no esperaba llega de una sesión que no estaba mirando. De ese lado de la cuenta, una sesión trabada es barata.

¿Cómo reproducir estos resultados de hook de Claude Code en su propia máquina?

Este script reproduce los tres brazos decisivos en su propia máquina, y usted no debería aceptar nuestros números sin él. Arma tres directorios descartables bajo mktemp, escribe un guardia que se cuelga y los dos envoltorios, le da la misma tarea a cada sesión e imprime qué brazos terminaron con el archivo. Corre cada brazo una vez en lugar de tres, así que trate la ubicación como la parte reproducible, no el conteo. No borra nada salvo el archivo temporal que crea su propio envoltorio, así que los directorios de trabajo y las salidas de las sesiones quedan en el disco para que usted los lea y los remueva.

#!/usr/bin/env bash
# Reproduce los brazos del guardia que se cuelga: pelado, envoltorio simple, envoltorio con reloj.
# Corre tres sesiones de Claude Code. Conserva los directorios de trabajo y las salidas.
set -u
W="$(mktemp -d)" || exit 1
echo "workdir: $W"
cat > "$W/hang.sh" <<'G'
#!/usr/bin/env bash
cat > /dev/null
sleep 60
printf 'this session may not run shell commands\n' >&2
exit 2
G
cat > "$W/plain.sh" <<P
#!/usr/bin/env bash
input="\$(cat)"
out="\$(printf '%s' "\$input" | "$W/hang.sh" 2>&1)"; rc=\$?
if [ "\$rc" = 0 ] || [ "\$rc" = 2 ]; then printf '%s\n' "\$out" >&2; exit "\$rc"; fi
printf 'guard could not decide (exit %s), refusing by default\n' "\$rc" >&2
exit 2
P
cat > "$W/clock.sh" <<C
#!/usr/bin/env bash
input="\$(cat)"
tmp="\$(mktemp)"
printf '%s' "\$input" | perl -e 'alarm shift; exec @ARGV' 3 "$W/hang.sh" > "\$tmp" 2>&1
rc=\$?
out="\$(cat "\$tmp")"; rm -f "\$tmp"
if [ "\$rc" = 0 ] || [ "\$rc" = 2 ]; then printf '%s\n' "\$out" >&2; exit "\$rc"; fi
printf 'guard could not decide (exit %s), refusing by default\n' "\$rc" >&2
exit 2
C
chmod +x "$W"/hang.sh "$W"/plain.sh "$W"/clock.sh
for a in hang plain clock; do
  printf '{"hooks":{"PreToolUse":[{"matcher":"Bash|Write|Edit","hooks":[{"type":"command","command":"%s/%s.sh","timeout":5}]}]}}\n' "$W" "$a" > "$W/$a.json"
  mkdir -p "$W/run-$a"
done
ASK='Create a file named notes.txt in the current directory containing the single word ready. Then confirm whether the file exists.'
for a in hang plain clock; do
  ( cd "$W/run-$a" && claude -p "$ASK" --permission-mode bypassPermissions \
      --settings "$W/$a.json" > "$W/out-$a.txt" 2>&1 < /dev/null ) &
done
wait
for a in hang plain clock; do
  if [ -f "$W/run-$a/notes.txt" ]; then printf '%s: WROTE (failed open)\n' "$a"; else printf '%s: blocked\n' "$a"; fi
done

Dos advertencias antes de correrlo, y las dos son sobre costo, no sobre peligro. El script llama a claude -p tres veces en paralelo con los permisos evitados dentro de directorios descartables, lo que cuesta lo que cuesten tres sesiones cortas en su plan, y el guardia aquí rechaza toda llamada que coincida con el matcher en lugar de inspeccionar el payload, lo que es deliberado para una demostración e inútil como política.

¿Qué comportamientos de hook de Claude Code no se midieron?

La muestra es pequeña y es nuestra. Cada número de esta página viene de una máquina corriendo Claude Code 2.1.240 en macOS 26.5.2 con bash 3.2.57, el 22 de agosto de 2026, en directorios descartables vacíos, 9 brazos de 3 ejecuciones. Conteos de un dígito no separan una falla rara de una imposible. Lo que sí pueden hacer, e hicieron, es poner a los brazos que bloquean y a los que fallan abiertos en lados opuestos en cada ejecución, sin ningún brazo partiéndose 2 a 1.

Varias cosas no probamos. No probamos hooks por HTTP, donde la decisión viaja por una red que puede estar caída y donde un envoltorio no tiene proceso alguno que matar. No probamos qué pasa cuando el guardia escribe un estado que otra invocación tiene que leer, que es una falla distinta y con literatura propia. No probamos hooks en Linux ni en Windows, y el reloj de nuestro segundo envoltorio usa perl con una alarma porque timeout no viene de fábrica en macOS. No probamos ningún agente además de Claude Code. No probamos cómo se comportan estos guardias en una sesión conducida de forma interactiva en lugar de con claude -p.

Vale declarar un límite con todas las letras, porque restringe la receta entera. Un hook que falla cerrado protege las llamadas de herramienta que su matcher cubre, y el nuestro cubría Bash, Write, Edit, MultiEdit y NotebookEdit. Read nunca fue alcanzado, y en los brazos bloqueados las sesiones usaron Read a voluntad para verificar si el archivo existía. Un guardia que rechaza todo no es una política, y aquello sobre lo que esta medición no dice nada es si su política decide bien cuando llega a decidir.