Receta · Intermedio · 10 min
Hooks de Claude Code: bloquea comandos peligrosos y secretos
Un hook PreToolUse en Python que impide a Claude Code leer tu .env, lanzar rm -rf peligrosos o hacer git push --force a main. Probado.
Probado con Claude Code 2.1.316 · revisado
El problema
Claude Code ejecuta comandos y abre archivos por ti. Basta un mal paso para que lea tu .env y la clave acabe en la conversación, para que lance un rm -rf sobre la ruta equivocada o para que un git push --force reescriba main. Pedirlo en CLAUDE.md ayuda, pero es una instrucción para el modelo, no una garantía. Si trabajas con modos que aprueban solos, como acceptEdits o bypassPermissions, necesitas una regla que se cumpla siempre.
La solución
Un hook PreToolUse se ejecuta antes de cada herramienta. Si el script termina con código 2, Claude Code cancela la acción y le pasa a Claude el mensaje de error. Lo escribimos en Python sin dependencias para no necesitar jq.
1. Crea el script del hook
Guárdalo en .claude/hooks/proteger.py, dentro de tu proyecto:
#!/usr/bin/env python3
"""Hook PreToolUse: bloquea rm -rf peligrosos, acceso a secretos y git push --force a main."""
import json
import re
import subprocess
import sys
# Nombres de archivo con secretos (se comparan con el nombre, no con la ruta completa)
SECRETOS = re.compile(r"^(\.env(\..+)?|.*\.pem|.*\.key|id_rsa.*|id_ed25519.*|credentials\.json|\.npmrc|\.pypirc)$")
PLANTILLAS = re.compile(r"^\.env\.(example|sample|template)$") # estas sí se pueden leer
# Lo mismo, pero buscado dentro de un comando de Bash
SECRETO_EN_COMANDO = re.compile(
r"(?<![\w.-])(\.env(\.[\w-]+)?|[\w.-]+\.pem|[\w.-]+\.key|id_rsa\w*|id_ed25519\w*|credentials\.json)(?![\w.-])"
)
def bloquear(motivo):
print(f"Bloqueado por el hook de seguridad: {motivo}", file=sys.stderr)
sys.exit(2) # exit 2 = Claude Code cancela la herramienta y le pasa este mensaje a Claude
def es_secreto(ruta):
nombre = ruta.replace("\\", "/").rstrip("/").split("/")[-1]
return bool(SECRETOS.match(nombre)) and not PLANTILLAS.match(nombre)
def revisar_rm(comando):
for parte in re.split(r"&&|\|\||;|\||\n", comando):
palabras = parte.split()
if not palabras or palabras[0] not in ("rm", "sudo"):
continue
if palabras[0] == "sudo":
palabras = palabras[1:]
if not palabras or palabras[0] != "rm":
continue
opciones = "".join(p.lstrip("-") for p in palabras[1:] if p.startswith("-") and not p.startswith("--"))
largas = [p for p in palabras[1:] if p.startswith("--")]
recursivo = "r" in opciones.lower() or "--recursive" in largas
forzado = "f" in opciones or "--force" in largas
if not (recursivo and forzado):
continue
for destino in (p.strip("'\"") for p in palabras[1:] if not p.startswith("-")):
# Raíz, hogar, todo el directorio actual o cualquier ruta absoluta
if destino in ("/", "/*", "~", "~/", "~/*", ".", "./", "..", "*", "./*") \
or destino.startswith(("/", "~", "$HOME", "${HOME}", "..")):
bloquear(f"'rm -rf {destino}' es demasiado peligroso. Borra rutas concretas dentro del proyecto.")
def revisar_push(comando, cwd):
for parte in re.split(r"&&|\|\||;|\n", comando):
if not re.search(r"\bgit\s+(-C\s+\S+\s+)?push\b", parte):
continue
if not re.search(r"(\s--force(-with-lease)?\b|\s-[a-zA-Z]*f\b|\s\+\S+)", parte):
continue
if re.search(r"\b(main|master)\b", parte):
bloquear("'git push --force' a main/master está prohibido. Usa una rama y un pull request.")
# Sin rama explícita: mira en qué rama está el repositorio
try:
rama = subprocess.run(["git", "-C", cwd, "rev-parse", "--abbrev-ref", "HEAD"],
capture_output=True, text=True, timeout=5).stdout.strip()
except (OSError, subprocess.SubprocessError):
rama = ""
if rama in ("main", "master"):
bloquear(f"estás en '{rama}' y 'git push --force' la reescribiría. Usa una rama.")
def main():
datos = json.load(sys.stdin)
herramienta = datos.get("tool_name", "")
entrada = datos.get("tool_input", {})
if herramienta in ("Read", "Edit", "Write"):
ruta = entrada.get("file_path", "")
if es_secreto(ruta):
bloquear(f"'{ruta}' contiene secretos. Pídele al usuario el valor que necesites.")
elif herramienta == "Grep":
ruta = entrada.get("path", "") or ""
if es_secreto(ruta) or es_secreto(entrada.get("glob", "") or ""):
bloquear(f"no se busca dentro de archivos de secretos ('{ruta or entrada.get('glob')}').")
elif herramienta == "Bash":
comando = entrada.get("command", "")
for m in SECRETO_EN_COMANDO.finditer(comando):
if not PLANTILLAS.match(m.group(1)):
bloquear(f"el comando toca '{m.group(1)}', que contiene secretos.")
revisar_rm(comando)
revisar_push(comando, datos.get("cwd", "."))
sys.exit(0) # sin objeciones: sigue el flujo normal de permisos
if __name__ == "__main__":
main()
Qué bloquea:
| Herramienta | Se bloquea | Se permite |
|---|---|---|
Read, Edit, Write | .env, .env.*, *.pem, *.key, id_rsa*, id_ed25519*, credentials.json, .npmrc, .pypirc | .env.example, .env.sample, .env.template |
Grep | buscar dentro de esos archivos | el resto del proyecto |
Bash | comandos que nombran esos archivos (cat .env, cp x .env…) | process.env, .envrc |
Bash | rm -rf (o -fr, -Rf, con sudo) sobre /, ~, $HOME, ., .., * o cualquier ruta absoluta | rm -rf node_modules dist |
Bash | git push --force, -f, --force-with-lease o +rama hacia main/master, o sin rama si estás en main | git push --force origin feature/x |
2. Regístralo en .claude/settings.json
Crea o completa .claude/settings.json en la raíz del proyecto:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|Read|Edit|Write|Grep",
"hooks": [
{
"type": "command",
"command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/proteger.py"
}
]
}
]
}
}
El matcher es una lista de nombres exactos de herramienta separados por |, y distingue mayúsculas de minúsculas. $CLAUDE_PROJECT_DIR es la raíz del proyecto, así que el hook funciona aunque Claude cambie de carpeta. Llamar al script con python3 evita tener que darle permisos de ejecución.
3. Comparte la protección con tu equipo
Sube .claude/settings.json y .claude/hooks/proteger.py al repositorio, y añade .env a .gitignore si aún no está. Así, quien clone el proyecto tiene las mismas reglas.
Compruébalo
Primero prueba el script solo, simulando la entrada JSON que le envía Claude Code:
echo '{"tool_name":"Read","tool_input":{"file_path":"/tmp/demo/.env"}}' | python3 .claude/hooks/proteger.py; echo "exit $?"
echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf node_modules"}}' | python3 .claude/hooks/proteger.py; echo "exit $?"
echo '{"tool_name":"Bash","tool_input":{"command":"git push --force origin main"}}' | python3 .claude/hooks/proteger.py; echo "exit $?"
Salida esperada:
Bloqueado por el hook de seguridad: '/tmp/demo/.env' contiene secretos. Pídele al usuario el valor que necesites.
exit 2
exit 0
Bloqueado por el hook de seguridad: 'git push --force' a main/master está prohibido. Usa una rama y un pull request.
exit 2
Después pruébalo con Claude Code de verdad. Nosotros lo hicimos en modo bypassPermissions, el más permisivo:
claude -p "Prueba del hook: intenta leer .env con Read y luego con 'cat .env'. No muestres valores; copia el error exacto." --permission-mode bypassPermissions < /dev/null
Esto es lo que recibió Claude, copiado de la ejecución real con la versión 2.1.316:
PreToolUse:Read hook error: [python3 "$CLAUDE_PROJECT_DIR"/.claude/hooks/proteger.py]: Bloqueado por el hook de seguridad: '/tmp/claude-1000/receta-hooks/.env' contiene secretos. Pídele al usuario el valor que necesites.
PreToolUse:Bash hook error: [python3 "$CLAUDE_PROJECT_DIR"/.claude/hooks/proteger.py]: Bloqueado por el hook de seguridad: el comando toca '.env', que contiene secretos.
Con rm -rf /tmp/claude-1000/receta-hooks-basura y git push --force origin main también recibió el error de bloqueo, y la carpeta siguió en su sitio. En una sesión interactiva, escribe /hooks para confirmar que el hook aparece en PreToolUse.
Por qué funciona
Claude Code ejecuta los hooks PreToolUse antes de mirar los permisos y lo hace en todos los modos de permisos, incluidos bypassPermissions y dontAsk. El hook recibe por stdin un JSON con tool_name y tool_input, donde está command en Bash, file_path en Read, Edit y Write, y path en Grep. Claude Code convierte las rutas de las herramientas de archivos en absolutas antes de llamar al hook. Con exit 2, la acción se bloquea y el texto de stderr llega a Claude como motivo del rechazo. Con exit 0 no se aprueba nada: la acción sigue el flujo normal de permisos. Cualquier otro código de salida es un error que no bloquea, y la acción se ejecuta igual. Más detalles en la referencia de hooks y en la guía de hooks.
Errores típicos
- Todas las herramientas quedan bloqueadas con
can't open file→ la ruta del script está mal, ypython3termina con código 2 cuando no encuentra el archivo, así que bloquea todo → corrige la ruta ensettings.json. Debe ser"$CLAUDE_PROJECT_DIR"/.claude/hooks/proteger.py, con las comillas. - El hook no se ejecuta → el JSON de
settings.jsonno es válido (comas finales, comentarios), el archivo no está en.claude/settings.jsono elmatcherestá en minúsculas (bash) → valida el JSON conpython3 -m json.tool .claude/settings.jsony escribe/hooks. Si cambiaste el archivo con la sesión abierta y no aparece, reinicia la sesión. python3: command not found(en Windows suele llamarsepython) → cambiapython3por el nombre del intérprete que tengas encommand.- Tu script detecta el problema pero la acción se ejecuta igual → sale con
exit 1o lanza una excepción, y para Claude Code eso es un error que no bloquea → para bloquear hay que usarsys.exit(2). Ojo: si llega un JSON roto, este script falla con código 1 y deja pasar la acción. - Bloquea
cp .env.example .env→ es intencionado, porque el comando escribe en.env→ hazlo tú en tu terminal.
Preguntas frecuentes
¿Funciona con --dangerously-skip-permissions o en modo bypassPermissions?
Sí. La documentación dice que los PreToolUse se ejecutan antes de comprobar el modo de permisos y que un bloqueo se mantiene en todos los modos. Lo probamos con --permission-mode bypassPermissions.
¿Es una protección infalible?
No. Un comando que lee archivos sin nombrarlos, como python -c "open('.e'+'nv')" o grep -r clave ., se le escapa. Combínalo con reglas de permisos ("permissions": {"deny": ["Read(./.env)", "Read(./.env.*)"]}) y, si necesitas un bloqueo a nivel del sistema operativo, con el sandbox.
¿Por qué Python y no jq?
Los ejemplos oficiales usan jq, pero muchas máquinas no lo tienen instalado y entonces el hook falla con jq: command not found. python3 suele venir instalado en Linux y macOS, y el script solo usa la biblioteca estándar.
¿Puedo usarlo en todos mis proyectos?
Sí. Copia el script a ~/.claude/hooks/proteger.py y pon el mismo bloque hooks en ~/.claude/settings.json, con "command": "python3 \"$HOME\"/.claude/hooks/proteger.py".
¿Y si de verdad necesito que Claude use un valor del .env?
Dale tú el dato que haga falta, o deja que el código lo lea en tiempo de ejecución sin que Claude lo vea. Si lo que necesita es saber qué variables hay, mantén un .env.example sin valores: el hook lo deja leer.