Lo esencial
En la lección anterior (Hooks: reglas automáticas) vimos la teoría: unos 30 eventos, 5 tipos de manejadores, el protocolo JSON. Aquí van tres hooks reales que se usan todos los días. El primero protege contra la filtración accidental de claves de API. El segundo evita que te pases del presupuesto. El tercero escribe un registro completo de qué tocó Claude y cuándo. De regalo, un webhook HTTP para avisos externos. Los construimos desde cero y revisamos cada línea.
Conceptos clave
- pre-tool-use-no-secrets.sh: revisa los archivos antes de escribirlos en busca de patrones de secretos
- pre-tool-use-budget-check.sh: revisa un contador de operaciones y detiene todo si se pasó el límite
- post-tool-use-audit-log.sh: anota en un registro cada cambio de archivo con su timestamp
- exit code 0 / 2: cómo el hook le dice a Claude Code "permitir" (0) o "bloquear" (2)
- matcher: un filtro: a qué herramientas reacciona (
"Write|Edit","Bash","*") - tool_input: un objeto JSON con los datos de entrada de la herramienta (ruta, contenido, comando)
- grep: búsqueda de patrones (claves de API, tokens) en el contenido de los archivos
- jq: lee el JSON que Claude Code le pasa al hook por stdin
- Probar el hook: cómo comprobar que el hook se dispara bien
Teoría
Cómo funciona un hook por dentro
Claude Code llama al hook como un script de bash normal. Le pasa los datos por stdin en formato JSON. El hook analiza los datos, aplica su lógica y devuelve el resultado mediante el exit code.
Claude Code quiere escribir un archivo
↓
Llama al hook PreToolUse (matcher: "Write|Edit")
↓
Le pasa por stdin el JSON:
{
"hook_event_name": "PreToolUse",
"tool_name": "Write",
"tool_input": {
"file_path": "/project/config.py",
"content": "API_KEY = 'sk-proj-abc123...'"
},
"session_id": "abc123",
"cwd": "/Users/me/project"
}
↓
El hook analiza tool_input.content
↓
exit 0 → Claude Code escribe el archivo
exit 2 → Claude Code se detiene y el stderr del hook se le pasa a ClaudeImportante: lo que bloquea es el exit code 2, no el 1. El exit code 1 es un error común del script: Claude sigue trabajando.
Al bloquear, el hook escribe un mensaje en stderr (>&2): eso es lo que verá Claude y lo que le dirá al usuario.
Hook 1: pre-tool-use-no-secrets.sh
Tarea: evitar que se escriban por accidente claves de API, tokens y contraseñas dentro del código.
El problema que resuelve: muchas veces se pega una clave directo en el código "por mientras", se olvida quitarla y se sube a git. El hook lo detiene antes de que se escriba el archivo.
Crear el archivo
mkdir -p ~/.claude/hooks
touch ~/.claude/hooks/pre-tool-use-no-secrets.sh
chmod +x ~/.claude/hooks/pre-tool-use-no-secrets.shEl contenido del script
#!/bin/bash
# pre-tool-use-no-secrets.sh
# Bloquea la escritura de archivos con secretos escritos directo en el código
# Leemos los datos de Claude Code por stdin
INPUT=$(cat)
# Extraemos los datos del formato JSON oficial
# tool_name está en el nivel superior
# file_path y content están dentro de tool_input
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
CONTENT=$(echo "$INPUT" | jq -r '.tool_input.content // empty')
# Para la herramienta Edit, el contenido está en el campo new_string
if [[ "$TOOL_NAME" == "Edit" ]]; then
CONTENT=$(echo "$INPUT" | jq -r '.tool_input.new_string // empty')
fi
# Revisamos solo las herramientas que escriben archivos
# (el matcher "Write|Edit" en settings.json ya filtra,
# pero una doble revisión no estorba)
if [[ "$TOOL_NAME" != "Write" && "$TOOL_NAME" != "Edit" ]]; then
exit 0 # No es una escritura: la dejamos pasar
fi
# Patrones que buscamos (posibles claves de API y tokens)
PATTERNS=(
'sk-[a-zA-Z0-9]{20,}' # OpenAI / Anthropic API keys
'ghp_[a-zA-Z0-9]{36}' # GitHub Personal Access Token
'xoxb-[0-9]+-[a-zA-Z0-9]+' # Slack Bot Token
'AKIA[0-9A-Z]{16}' # AWS Access Key
'AIza[0-9A-Za-z_-]{35}' # Google API Key
'password\s*=\s*["\'][^"\']+["\']' # Contraseña explícita en el código
'secret\s*=\s*["\'][^"\']+["\']' # Secreto explícito en el código
)
# Revisamos el contenido contra cada patrón
for PATTERN in "${PATTERNS[@]}"; do
if echo "$CONTENT" | grep -qE "$PATTERN"; then
# Mensaje a stderr: Claude lo verá y se lo pasará al usuario
echo "BLOQUEADO: se detectó un posible secreto o clave de API en el archivo $FILE_PATH" >&2
echo "Patrón: $PATTERN" >&2
echo "Usa variables de entorno (.env) o un gestor de secretos en lugar de escribirlo en el código." >&2
exit 2 # Exit code 2 = bloquear la acción
fi
done
exit 0 # No se encontraron secretos: lo permitimosAgregarlo a settings.json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "~/.claude/hooks/pre-tool-use-no-secrets.sh",
"timeout": 30
}
]
}
]
}
}Fíjate en la estructura:
"PreToolUse"en CamelCase (nopre_tool_use)"matcher": "Write|Edit": el hook se dispara solo al escribir o editar archivos (no al leer ni con comandos de bash)"type": "command"(no"type": "bash")"timeout": 30: si el script no responde en 30 segundos, Claude sigue
Probar el hook
Dale a Claude Code esta instrucción:
Crea el archivo config.py con el contenido: API_KEY = 'sk-proj-test123456789012345678901234'
Resultado esperado:
BLOQUEADO: se detectó un posible secreto o clave de API en el archivo config.py Usa variables de entorno (.env) o un gestor de secretos en lugar de escribirlo en el código.
Claude Code no escribirá el archivo. Te propondrá usar .env.
Hook 2: pre-tool-use-budget-check.sh
Tarea: controlar el gasto: detener a Claude Code si hay demasiadas operaciones en un día.
El problema que resuelve: las tareas autónomas largas pueden hacer miles de operaciones. El hook pone un límite estricto.
#!/bin/bash
# pre-tool-use-budget-check.sh
# Control de presupuesto por número de operaciones al día
COUNTER_FILE="/tmp/claude_ops_$(date +%Y%m%d).count"
DAILY_LIMIT=500 # Máximo de operaciones al día
# Leemos el contador actual
if [ -f "$COUNTER_FILE" ]; then
CURRENT=$(cat "$COUNTER_FILE")
else
CURRENT=0
fi
# Revisamos el límite
if [ "$CURRENT" -ge "$DAILY_LIMIT" ]; then
echo "ALTO: se alcanzó el límite diario de operaciones ($CURRENT/$DAILY_LIMIT)" >&2
echo "Se reinicia a medianoche. Para reiniciarlo a mano: rm $COUNTER_FILE" >&2
exit 2 # Exit code 2 = bloquear
fi
# Aumentamos el contador
echo $((CURRENT + 1)) > "$COUNTER_FILE"
# Aviso al llegar al 80% (por stdout: no bloquea)
THRESHOLD=$((DAILY_LIMIT * 80 / 100))
if [ "$CURRENT" -ge "$THRESHOLD" ]; then
echo "AVISO: llevas $CURRENT/$DAILY_LIMIT operaciones (80% del límite)"
fi
exit 0Agregarlo a settings.json (junto al primer hook)
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "~/.claude/hooks/pre-tool-use-no-secrets.sh",
"timeout": 30
}
]
},
{
"hooks": [
{
"type": "command",
"command": "~/.claude/hooks/pre-tool-use-budget-check.sh"
}
]
}
]
}
}Fíjate: el primer hook tiene "matcher": "Write|Edit": solo revisa la escritura de archivos. El segundo no tiene matcher, así que se dispara con todas las herramientas.
Varios hooks PreToolUse se ejecutan en paralelo. Si cualquiera devuelve exit 2, la acción se bloquea.
Hook 3: post-tool-use-audit-log.sh
Tarea: llevar un registro completo de lo que cambió Claude: qué archivos, a qué hora, con qué herramienta.
El problema que resuelve: después de una sesión no queda claro qué cambió Claude exactamente. El registro te deja rastrear cada cambio y revertirlo si hace falta.
#!/bin/bash
# post-tool-use-audit-log.sh
# Registro de auditoría de todos los cambios de archivos
LOG_FILE="$HOME/.claude/audit-log.txt"
mkdir -p "$(dirname "$LOG_FILE")"
# Leemos los datos de Claude Code por stdin
INPUT=$(cat)
# Extraemos la información de la acción
# tool_name está en el nivel superior; lo demás, dentro de tool_input
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // "unknown"')
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
PROJECT=$(basename "$(pwd)")
# Registramos solo las acciones con archivos
if [[ -n "$FILE_PATH" ]]; then
echo "[$TIMESTAMP] PROJECT=$PROJECT TOOL=$TOOL_NAME FILE=$FILE_PATH" >> "$LOG_FILE"
fi
# También registramos los comandos de bash (el campo command dentro de tool_input)
BASH_CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
if [[ -n "$BASH_CMD" ]]; then
# Mostramos los primeros 100 caracteres del comando
SHORT_CMD="${BASH_CMD:0:100}"
echo "[$TIMESTAMP] PROJECT=$PROJECT BASH: $SHORT_CMD" >> "$LOG_FILE"
fi
exit 0 # Los hooks PostToolUse no bloquean: siempre exit 0settings.json completo con los tres hooks + aviso
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "~/.claude/hooks/pre-tool-use-no-secrets.sh",
"timeout": 30
}
]
},
{
"hooks": [
{
"type": "command",
"command": "~/.claude/hooks/pre-tool-use-budget-check.sh"
}
]
}
],
"PostToolUse": [
{
"matcher": "Write|Edit|Bash",
"hooks": [
{
"type": "command",
"command": "~/.claude/hooks/post-tool-use-audit-log.sh"
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "osascript -e 'display notification \"Claude terminó la tarea\" with title \"Claude Code\"'"
}
]
}
]
}
}Lista de verificación del formato (revísala en tu archivo):
- Nombres de eventos en CamelCase:
PreToolUse,PostToolUse,Stop(no snake_case) - Tipo de manejador:
"type": "command"(no"type": "bash") - Cada evento → un arreglo → un objeto con
matcher+hooks→ un arreglo de manejadores matcherfiltra las herramientas:"Write|Edit","Bash", o vacío para todas
Cómo leer el registro de auditoría
[2026-10-04 14:23:01] PROJECT=acme-realty TOOL=Write FILE=/project/index.md
[2026-10-04 14:23:04] PROJECT=acme-realty TOOL=Edit FILE=/project/CLAUDE.md
[2026-10-04 14:23:09] PROJECT=acme-realty BASH: mkdir -p .claude/skills
[2026-10-04 14:25:33] PROJECT=my-platform TOOL=Write FILE=/strategy/plan.mdVes: la hora, el proyecto, la herramienta, el archivo. Si algo se rompió, sabes exactamente qué tocó Claude y cuándo.
# Ver el registro de hoy
tail -50 ~/.claude/audit-log.txt
# Encontrar todos los cambios de un archivo concreto
grep "CLAUDE.md" ~/.claude/audit-log.txt
# Encontrar todas las acciones en un proyecto concreto
grep "PROJECT=acme-realty" ~/.claude/audit-log.txtDe la práctica: un caso real con el archivo .env
De la transcripción de una clase: "Perfecto, no queremos que Claude toque el documento .env, porque si lo cambia, se rompen todas las automatizaciones: todas dependen de esas contraseñas."
Una variante del hook para proteger un archivo concreto:
#!/bin/bash
# Protege el archivo .env de cualquier cambio de Claude
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
# Bloqueamos cualquier cambio a .env
if [[ "$FILE_PATH" == *".env"* ]] && [[ "$TOOL_NAME" == "Write" || "$TOOL_NAME" == "Edit" ]]; then
echo "BLOQUEADO: el archivo .env está protegido contra cambios" >&2
echo "El archivo contiene secretos. Edítalo a mano." >&2
exit 2 # Exit code 2 = bloquear
fi
exit 0Con este hook, Claude Code te responderá literalmente: "No puedo hacerlo: un hook me bloquea el acceso a este archivo."
Más sencillo todavía: puedes usar el filtro "if" en settings.json en lugar de revisarlo en el script:
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"if": "Write(*.env)",
"command": "echo 'BLOQUEADO: .env está protegido' >&2 && exit 2"
},
{
"type": "command",
"if": "Edit(*.env)",
"command": "echo 'BLOQUEADO: .env está protegido' >&2 && exit 2"
}
]
}Aquí "if" funciona como un filtro extra por argumentos: la forma Herramienta(patrón) revisa una sola herramienta, por eso hay dos manejadores para Write y Edit. El hook se dispara solo para los archivos .env.
Extra: webhook HTTP, un hook sin script de bash
No tienes que hacerlo todo con bash. Si tienes un servidor (o un servicio con API de bots, como Slack o Telegram), puedes mandar los datos por HTTP.
Ejemplo: aviso en un chat cuando cambian archivos
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "http",
"url": "http://localhost:3000/hooks/file-changed",
"timeout": 10
}
]
}
]
}
}Claude Code enviará una solicitud POST con los datos JSON del archivo. Tu servidor recibirá:
{
"hook_event_name": "PostToolUse",
"tool_name": "Write",
"tool_input": { "file_path": "/project/index.md", "content": "..." },
"tool_response": "File written successfully",
"cwd": "/Users/me/project"
}El servidor puede reenviarlo a Slack, Discord o Telegram, guardarlo en una base de datos, lo que quieras.
Cuándo usar HTTP en lugar de command:
- Avisos a un servicio externo (Slack, Discord, Telegram)
- Auditoría centralizada de varias máquinas
- Cuando la lógica de revisión vive en un servidor (un microservicio de validación)
Probar los hooks: lista de verificación
Después de crear cada hook, compruébalo:
Para el hook no-secrets:
Crea el archivo test.py con el contenido: token = 'sk-proj-realkey123456789012345'
Lo esperado: Claude queda bloqueado y ves el mensaje del hook.
Para el hook audit-log:
Crea el archivo test-audit.md con el texto "Prueba de auditoría"
Luego: tail -5 ~/.claude/audit-log.txt; debe aparecer un registro nuevo.
Para el hook de aviso al terminar (Stop):
¿Qué es Claude Code? (una pregunta corta)
Lo esperado: después de la respuesta aparece una notificación de macOS.
Práctica
Tarea: poner a funcionar los tres hooks
- Crea la carpeta
~/.claude/hooks/ - Crea los tres scripts de bash con el contenido de la lección
- Dales permiso de ejecución:
chmod +x ~/.claude/hooks/*.sh - Crea o actualiza
.claude/settings.json: agrega los tres hooks según la plantilla de la lección - Prueba cada hook (la lista de verificación de arriba)
- Mira cómo se ve el registro de auditoría después de algunas operaciones
Meta: tres hooks funcionando, entender la lógica del exit code, un primer registro de auditoría con entradas reales
Herramientas y recursos
jq: leer JSON en bash (brew install jqen Mac)chmod +x: permisos de ejecución para el scriptosascript: notificaciones nativas de macOS (viene incluido en macOS)tail -f ~/.claude/audit-log.txt: ver el registro en tiempo real/hooks: el comando para ver los hooks activos desde la terminal de Claude Code
Conclusiones clave
exit 0 = permitir, exit 2 = bloquear. ¡No 1, sino justo 2! Exit 1 es solo un error del script; Claude sigue trabajando.
Al bloquear, el mensaje se escribe en stderr (
>&2), no en stdout. El stderr se le pasa a Claude como el motivo del bloqueo.
matcherfiltra por herramienta:"Write|Edit", solo operaciones con archivos. Sin matcher, el hook se dispara con todo.
Los hooks PostToolUse siempre hacen exit 0: registran, no bloquean. No hace falta detener a Claude después de la acción.
Los datos de Claude Code llegan en formato JSON por stdin. La ruta del archivo está en
tool_input.file_path, no solo enfile_path.
Tres hooks cubren las tres tareas principales: seguridad (secretos), economía (presupuesto), auditoría (quién tocó qué). Más un webhook HTTP para avisos externos.
El formato de settings.json: nombres de eventos en CamelCase (
PreToolUse), tipo de manejador"command"(no"bash"), tres niveles de anidación.
Qué sigue
La marca se guarda solo en este navegador y no se envía a ningún sitio. Mi progreso