Hooks de Claude Code / Ruta 02
Ruta 02 · Guardrails
Convierte "por favor, no hagas eso" en un bloqueo real: detener comandos y archivos peligrosos antes de que ocurra, decidir entre allow/ask/deny y proteger secretos — sin depender de que el modelo lo recuerde.
Lecciones de esta ruta
PreToolUse y exit 2: el bloqueo más simple que existe y cómo el motivo vuelve a Claude.
El comando llega a tool_input.command: extraer con jq, detectar patrones peligrosos y no meterte en problemas con las comillas.
rm -rf, push --force en main, tocar .env y otros archivos protegidos.
La decisión en JSON, fail-open para nunca bloquear por un bug tuyo y las reglas de higiene de un hook.
Ruta 02 · Clase 1 · Intermedio
Un bloqueo es solo un hook de PreToolUse que decide, antes de que se ejecute la herramienta, si puede continuar. La señal es el código de salida, y eso es todo lo que necesitas para empezar.
↓ desplázate para estudiar
Un bloqueo es un hook de PreToolUse: lo ejecuta antes de la herramienta y decide si puede continuar. La señal es el código de salida:
stderr se convierte en el motivo.Observa la diferencia: el bloqueo no «avisa después», sino que impide antes. Es la diferencia entre un extintor y un informe de incendio. En las próximas clases vas a enseñar a esta barrera a leer el comando, cubrir los casos que realmente duelen y decir allow/ask/deny — pero la base es solo esto: exit 0 continúa, exit 2 bloquea.
Pruébate
¿Qué código de salida BLOQUEA la acción en PreToolUse?
Cuando el bloqueo detiene la acción, Claude no se queda a oscuras: todo lo que tu script imprime en el stderr antes del exit 2 se convierte en el motivo que él lee. Es tu única oportunidad de explicar por qué: escríbele a él, no a ti:
# mal: no dice nada útil exit 2 # bien: Claude sabe exactamente qué evitar la próxima vez echo "trava: rm -rf apaga tudo sem confirmação — use um caminho específico" >&2 exit 2
Piensa en el mensaje como una instrucción breve para que Claude vuelva a intentarlo de la manera correcta, no como un registro de error genérico. «bloqueado» dice poco; «bloqueado: usa rm arquivo.txt, no rm -rf pasta/" enseña.
Pruébate
¿Por dónde sale el texto que se convierte en el "motivo" que se muestra a Claude cuando el bloqueo se activa?
Une las dos piezas — leer el comando (más sobre esto en la próxima clase) y decidir con el código de salida — y ya tienes un bloqueo de verdad:
# .claude/settings.json → PreToolUse, matcher "Bash"
jq -r '.tool_input.command' | grep -qE 'rm -rf' && {
echo "trava: rm -rf bloqueado" >&2
exit 2
}
exit 0
Lee de adentro hacia afuera: toma el command de stdin, busca el patrón rm -rf; si lo encuentra, imprime el motivo y bloquea; si no lo encuentra, el exit 0 al final deja continuar. Es breve y ya resuelve el accidente más clásico.
Todo comando que no coincide con el patrón peligroso debe continuar normalmente — y quien lo garantiza es un exit 0 explícito al final del script. Sin él, el código de salida del último comando ejecutado (el grep, por ejemplo) puede filtrarse y confundir a Claude Code.
exit 0 después del bloque de verificación — aunque parezca obvio.Con esto, el ciclo se cierra: leer el comando → comparar con el patrón → exit 2 (bloquea, con motivo) o exit 0 (continúa). Es el bloqueo más simple que existe y ya es real: Claude no puede simplemente «decidir ignorarlo».
Además del exit code, un hook PreToolUse puede devolver una decisión más detallada en JSON por stdout (con allow/ask/deny y un motivo estructurado). Es el tema de la Lección 4 de esta ruta; empieza por el código de salida, que ya resuelve la mayoría de los casos.
Practica ahora 0/4 hecho
Haz que un hook bloquee de verdad un rm -rf — probando en una ruta desechable, sin acercarte a nada importante. ~5 min.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "jq -r '.tool_input.command' | grep -qE 'rm -rf' && { echo 'trava: rm -rf bloqueado' >&2; exit 2; }; exit 0" }
]
}
]
}
}✓ Tienes un bloqueo real: rm -rf bloqueado antes de ejecutarse, y todo lo demás sigue normalmente. En la próxima lección aprenderá a leer cualquier comando, no solo este.
Ruta 02 · Clase 2 · Intermedio
Antes de decidir, el hook necesita ver el comando real: llega listo en el JSON de stdin; te corresponde extraerlo y compararlo con lo que es peligroso.
↓ desplázate para estudiar
Antes de ejecutarse, Claude le entrega a tu hook un JSON por la entrada estándar (stdin) con el contexto del momento. Algunos campos están siempre presentes (session_id, cwd, hook_event_name); en el PreToolUse, también vienen tool_name e o tool_input:
{
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": { "command": "rm -rf build/" },
"cwd": "/home/voce/projeto"
}
Ten en cuenta: como la herramienta aún no se ejecutó, no existe tool_response en ese momento — solo el intención. Es exactamente esa intención la que tu barrera examina antes de dejar que se convierta (o no) en una acción.
Pruébate
¿Dónde llega el comando que Bash está a punto de ejecutar?
Para extraer el texto sin formato del JSON, usa jq. La diferencia entre usar -r o no es lo que separa un valor utilizable de uno lleno de comillas:
# sin -r: devuelve el valor codificado en JSON, con comillas $ jq '.tool_input.command' "rm -rf build/" # con -r (raw): devuelve el texto sin procesar, listo para comparar $ jq -r '.tool_input.command' rm -rf build/
Sin -r, las comillas siguen pegadas al valor y dificultan cualquier comparación de texto. Adopta este hábito: en un hook de shell, jq -r casi siempre es lo que quieres.
Pruébate
¿Cuál es la diferencia entre jq '.x' e jq -r '.x'?
Con el texto en mano, comparar con varios varios patrones de una sola vez es algo habitual. Dos formas equivalentes: elige la que se lea mejor en tu script:
# con grep -E (alternancia "|") cmd=$(jq -r '.tool_input.command') echo "$cmd" | grep -qE 'rm -rf|git push --force|:\(\)\{ :' && exit 2 # con case (más legible cuando la lista crece) case "$cmd" in *"rm -rf"*|*"push --force"*) echo "trava: padrão perigoso" >&2; exit 2 ;; esac exit 0
grep -E es más corto para pocos patrones; case se vuelve más legible cuando la lista de patrones crece y cada uno merece un comentario. Ambas hacen lo mismo: coincidir el comando con una lista de lo que es peligroso.
Leer bien el comando tiene dos trampas comunes:
grep demasiado amplio también captura cosas inocentes (p. ej.: rm -rf dentro de un comentario o de una cadena). Prefiere patrones específicos en lugar de una palabra suelta.$cmd y devolverlo en un eval o en otro bash -c "$cmd" abre la puerta a inyección de comandos. Tu bloqueo solo debe leer y comparar el texto, nunca volver a ejecutarlo.Siempre usa "$cmd" entre comillas dobles al manipular la variable (evita que los espacios rompan la comparación), y trata el valor como dato, no como código para ejecutar.
Pruébate
¿Por qué NO se debe volver a interpolar $cmd en un nuevo shell (p. ej., eval "$cmd") dentro del propio hook?
Practica ahora 0/4 hecho
Un hook que lee el comando real y bloquea un push forzado — el tipo de accidente que reescribe el historial de los demás. ~5 min.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "jq -r '.tool_input.command' | grep -qE 'push.*(--force|-f)' && { echo 'trava: push --force bloqueado' >&2; exit 2; }; exit 0" }
]
}
]
}
}✓ El bloqueo ahora lee el comando antes de decidir: ya no es un patrón fijo aislado, sino una regla que examina el texto de verdad. (Fíjate que el patrón -f por sí solo es amplio: es el «falso positivo» del paso 04; ajústalo si tu proyecto usa -f para otra cosa.)
Ruta 02 · Clase 3 · Intermedio
No necesitas bloquearlo todo, solo los errores costosos. Cuatro casos que justifican el bloqueo por sí solos, desde la primera vez que evitan un desastre.
↓ desplázate para estudiar
El peligro real no es la frase "rm -rf" por sí sola: es el objetivo. Una rm -rf dentro de la carpeta build/ del proyecto es difícil de recuperar; un rm -rf /, rm -rf ~ o rm -rf .. es catastrófico. Bloquea objetivos amplios, no la sintaxis:
jq -r '.tool_input.command' | grep -qE 'rm -rf (/|~|\.\.)($| )' && exit 2
Vale la pena hacer una verificación adicional: comparar la ruta con $CLAUDE_PROJECT_DIR (la raíz del proyecto) garantiza que el objetivo del comando no salga de la carpeta que realmente estás dispuesto a arriesgar.
Pruébate
¿Por qué bloquear rm -rf / o rm -rf ~ es más urgente que bloquear rm -rf build/?
git push --force reescribe el historial remoto, si es en la main o master, el daño afecta a todo el equipo. Bloquea la combinación específica (forzado + rama protegida), no solo el forzado por separado:
cmd=$(jq -r '.tool_input.command')
echo "$cmd" | grep -qE -- '--force' && echo "$cmd" | grep -qE '\b(main|master)\b' &&
{ echo "trava: push --force direto na main/master" >&2; exit 2; }
exit 0
Fíjate en las dos condiciones combinadas: necesita tener fuerza e apuntar a la rama principal. Eso deja pasar un --force inofensivo en una rama de feature tuya, y bloquea solo lo que amenaza al equipo.
Editar archivos es otro evento: aquí el hook revisa tool_input.file_path, no para command. Bloquea la edición de secretos antes de que ocurra:
# PreToolUse, matcher "Edit|Write"
file=$(jq -r '.tool_input.file_path')
case "$file" in
*.env|*.pem|*id_rsa*|*credentials*)
echo "trava: arquivo protegido ($file)" >&2; exit 2 ;;
esac
exit 0
Esto bloquea la creación y la edición, incluso la «primera vez» que alguien intentaría pegar una clave dentro de un .env versionado sin querer.
Pruébate
Para bloquear la edición de un archivo (y no un comando de terminal), ¿qué campo del tool_input ¿tú lees?
La misma técnica del paso anterior se generaliza: cualquier archivo que no quieras que cambie sin revisión se convierte en una entrada de la lista — package-lock.json, .github/workflows/*, el propio .claude/settings.json. La plantilla siempre es:
PreToolUse.Edit|Write (captura la edición o creación de archivos).tool_input.file_path con tu lista de elementos protegidos.No existe una lista perfecta desde el principio. Empieza por los secretos (paso 03) y luego agrega lo que te dé problemas: cada susto se convierte en una línea nueva del bloqueo.
Practica ahora 0/4 hecho
Un bloqueo que impide cualquier edición o creación de archivos .env en el proyecto. ~5 min y se puede probar sin ningún riesgo.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "jq -r '.tool_input.file_path' | grep -qE '\\.env$' && { echo 'trava: .env é protegido' >&2; exit 2; }; exit 0" }
]
}
]
}
}✓ Ahora bloqueas por archivo, no solo para comandos: el mismo patrón (matcher Edit|Write + file_path) protege cualquier archivo sensible que incluyas en la lista.
Ruta 02 · Clase 4 · Avanzado
Ve más allá de exit 2: devuelve una decisión estructurada en JSON, falla siempre del lado seguro y trata tu hook como el código de producción que es.
↓ desplázate para estudiar
Exit code es rápido, pero rudimentario: solo bloquea o no, y el motivo es un texto suelto en stderr. Existe una segunda opción: tu hook imprime un JSON de decisión en el stdout, y termina con exit 0 incluso cuando está bloqueando:
{
"decision": "approve" | "block",
"reason": "string",
"hookSpecificOutput": {
"permissionDecision": "allow" | "deny" | "ask"
}
}
Fíjate en el cambio: en la ruta del código de salida, exit 2 Es la decisión. En la ruta JSON, exit 0 casi siempre es la respuesta: lo que decide es el contenido impreso en stdout.
Pruébate
En la ruta del JSON de decisión, ¿con qué código suele terminar el hook, incluso cuando está bloqueando la acción?
Dentro de hookSpecificOutput, el campo permissionDecision tiene tres posturas y un permissionDecisionReason para explicar cuál de ellas y por qué:
{ "hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "push --force na main não é permitido por aqui" } }
exit 2, pero con un motivo estructurado.Pruébate
¿Qué valor de permissionDecision ¿PAUSA la acción y pide confirmación a una persona, en vez de decidir por su cuenta?
Un hook es solo un script, y los scripts fallan (el jq falla, una variable queda vacía, hay un error de sintaxis). La pregunta que importa: cuando tu propio hook tiene un bug, ¿qué debería pasar? La respuesta correcta es fail-open: permitir y avisar, nunca bloquear todo el proyecto por un error que no es del usuario.
# justo al inicio del script set +e trap 'exit 0' ERR # … resto del hook …
O trap garantiza que, si algo dentro del script falla, el hook igual termina con exit 0 — Claude sigue trabajando y el error queda para que lo investigues después, sin detener a nadie a mitad del proceso. Fail-open no es una excusa para escribir la barrera con poco cuidado: es una red de seguridad para cuando falle de una manera que no habías previsto.
Un hook se ejecuta con todos tus permisos de usuario — no es un entorno aislado. Trátalo como código de producción, con cuatro reglas de higiene:
"$var" entre comillas; nunca vuelvas a interpolar tool_input directamente en un shell nuevo.file_path no sale del proyecto (sin ../ escapando hacia afuera)..env, credenciales, .ssh, .git, las claves privadas nunca deben aparecer en ningún registro, ni en el motivo impreso.Estas cuatro reglas caben en una frase: un hook rápido, idempotente y que no filtre secretos, que cierra esta ruta y se convierte en hábito de aquí en adelante.
Los nombres de herramientas son case-sensitive (Bash, Edit, Read, Write), el timeout de un hook se expresa en segundos (el valor predeterminado para comandos es un generoso 600s, conviene acortarlo), y los hooks no pueden ser interactivos; todas estas particularidades apuntan en la misma dirección: escribe hooks cortos, predecibles y que fallen de forma segura.
Practica ahora 0/4 hecho
Cambia el exit 2 en bruto por una decisión estructurada: el mismo objetivo de la Lección 1 (rm -rf), ahora devuelve deny con el motivo vía JSON en stdout. ~7 min.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "cmd=$(jq -r '.tool_input.command'); echo \"$cmd\" | grep -qE 'rm -rf' && printf '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"trava: rm -rf bloqueado\"}}'; exit 0" }
]
}
]
}
}✓ Fin de la ruta: pasaste de exit 2 crudo hasta convertirse en un guard que habla el lenguaje de allow/ask/deny: el mismo freno, ahora con una decisión explícita y un motivo que Claude realmente lee.