EN · Read in English Explorar gratis

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:

HerramientaSe bloqueaSe permite
Read, Edit, Write.env, .env.*, *.pem, *.key, id_rsa*, id_ed25519*, credentials.json, .npmrc, .pypirc.env.example, .env.sample, .env.template
Grepbuscar dentro de esos archivosel resto del proyecto
Bashcomandos que nombran esos archivos (cat .env, cp x .env…)process.env, .envrc
Bashrm -rf (o -fr, -Rf, con sudo) sobre /, ~, $HOME, ., .., * o cualquier ruta absolutarm -rf node_modules dist
Bashgit push --force, -f, --force-with-lease o +rama hacia main/master, o sin rama si estás en maingit 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

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.

Todas las recetas