Hooks vs permissions en Claude Code: dónde va el guardrail
Abrí mi propio .claude/settings.local.json mientras preparaba este post. Buscaba un ejemplo bonito de hooks vs permissions en Claude Code para ilustrar la diferencia. Encontré otra cosa.
364 reglas allow. Cero reglas deny. Cero ask. Y "defaultMode": "bypassPermissions".
La primera regla de la lista es Bash(*).
Bash(*) equivale a Bash: matchea todos los comandos. Las otras 185 reglas de Bash de la lista ya no afinan nada, porque no queda nada que afinar. Y las 178 restantes —WebFetch, PowerShell, Skill— tampoco protegen: una regla allow nunca dice que no, solo evita que te pregunten. Con bypassPermissions puesto, ni eso: no iba a preguntar de todas formas. Una allowlist de 364 líneas que no le dice que no a nada.
Lo mejor viene ahora. En el .claude/settings.json versionado del mismo repositorio sí hay dos hooks PreToolUse, con matchers Bash y Read|Glob. Funcionan perfectamente. Son hooks de guía y observabilidad — le dicen a Claude por dónde buscar antes de lanzarse a hacer grep por todo el repo — no de seguridad.
Capa de hooks montada. Capa de permisos abierta de par en par.
Hace poco escribí, en el post donde probé la blocklist típica y pasaron 16 de 20 comandos destructivos, esta frase exacta: "Revisa el tuyo con una pregunta concreta: ¿hay alguna regla comodín tipo Bash(*) que anule a todas las demás? Si la hay, tu allowlist es decorativa."
Hooks vs permissions: las dos capas, y cuál manda
Claude Code tiene dos sitios donde puedes decir "esto no".
El sistema de permisos: configuración declarativa con reglas allow, deny y ask. Los hooks: scripts tuyos que se ejecutan antes de la llamada a la herramienta y pueden bloquearla. Las dos son piezas del agent harness, esa capa que rodea al modelo y decide qué puede tocar de verdad.
El sistema de permisos de Claude Code es configuración declarativa —reglas allow, deny y ask en settings.json— que se evalúa antes de ejecutar la herramienta. Un hook PreToolUse es un script tuyo que Claude Code ejecuta antes de la llamada y que puede bloquearla saliendo con código 2. La diferencia que decide dónde va tu guardrail: los permisos no pueden fallar, el hook sí.
Casi todo el que se preocupa por la seguridad monta un hook. Es lo divertido: escribes código, haces pattern matching, devuelves exit 2 y te sientes ingeniero de seguridad. Si nunca has montado uno, empieza por Claude Code hooks: guardrails, logging y automatización para tus agentes, el tutorial del cómo.
Este post va del dónde. Y el dónde importa porque las dos capas no tienen la misma autoridad.
Un hook solo puede restringir, nunca ampliar
Un hook PreToolUse es una válvula de un solo sentido. Cierra, no abre.
La documentación de permisos de Claude Code no deja margen a interpretación. Traduzco las dos frases que lo zanjan:
"Las decisiones de un hook no saltan las reglas de permisos. Claude Code evalúa las reglas
denyyasksea cual sea lo que devuelva un hookPreToolUse: una regladenyque coincida bloquea la llamada, y una reglaaskque coincida sigue preguntando incluso cuando el hook ha devueltoallowoask."
"Un hook que bloquea también tiene precedencia sobre las reglas
allow. Un hook que sale con código 2 detiene la llamada antes de que se evalúen las reglas de permisos, así que el bloqueo se aplica incluso cuando una reglaallowhabría dejado pasar la llamada."
Puede vetar lo que tus permisos habrían permitido. No puede rescatar lo que ya han denegado. Si diseñas la seguridad pensando que el hook es "el sitio donde yo decido", la estás montando sobre la capa con menos autoridad.
La propia documentación sugiere el patrón combinado: para ejecutar todos los comandos Bash sin prompts salvo unos pocos, pon "Bash" en allow y registra un hook PreToolUse que rechace esos concretos. Es un patrón legítimo. Fíjate en para qué lo propone: ergonomía. No suelo de seguridad.
El hook falla abierto. La regla deny no puede fallar.
Un hook es un proceso. Y a los procesos les pasan cosas. Esto es lo que ocurre según cómo termine:
Esto es lo que dice la documentación de hooks según cómo termine el proceso:
- exit 2 → bloquea. Imprima JSON o no; ni un
permissionDecisiondeallowpuede anularlo. El mensaje de bloqueo es la razón del JSON si la hay, y si no, el stderr. - exit 0 con JSON válido → manda la decisión del JSON y el exit code se ignora.
- Cualquier otro exit code, un script que no existe, JSON inválido o un timeout → error no bloqueante. En palabras de la doc: "the action proceeds". La llamada continúa por el flujo normal de permisos y en el transcript aparece un aviso
<hook name> hook error.
Lee otra vez la tercera.
El día que renombras el script, cambias de máquina, se te cuela una coma en el JSON o el proceso se pasa del timeout, tu "guardrail de seguridad" deja pasar el comando. No para el mundo: escribe un aviso y sigue.
Incluida la salida 1, la de fallo de toda la vida en Unix: si tu hook revienta con exit 1, el comando pasa.
En PreToolUse el timeout por defecto es de 600 s. Y todos los hooks que matchean corren en paralelo, así que tampoco hay un orden de ejecución en el que apoyarte.
Una regla deny no tiene ninguna de esas formas de fallar: no hay proceso, ni exit code, ni ruta a un binario que pueda cambiar. Es configuración que se evalúa antes de ejecutar nada. Si una herramienta está denegada en cualquier nivel, ningún otro nivel puede permitirla — ni --allowedTools en la línea de comandos.
Falla cerrado por construcción. Por eso el suelo va ahí.
El sistema de permisos entiende de shell. Tu grep no.
Cuando escribes un hook con grep haces pattern matching sobre un string. Claude Code no: parsea el comando.
Los separadores que reconoce son &&, ||, ;, |, |&, & y los saltos de línea. Cada subcomando debe coincidir por separado. Y las reglas deny y ask matchean más allá de una asignación de variable de entorno.
Dos comandos y las dos capas, lado a lado:
safe-cmd && rm -rf /
FOO=bar rm -rf tmp/
| Comando | Hook con grep |
Regla de permisos |
|---|---|---|
safe-cmd && rm -rf / |
[[ "$cmd" == safe-cmd* ]] → pasa: el string empieza por safe-cmd |
Bash(safe-cmd *) no da permiso: cada subcomando se matchea por separado |
FOO=bar rm -rf tmp/ |
grep -E '^rm ' → no matchea: el comando empieza por FOO |
Bash(rm *) en deny sí coincide: matchea pasada la asignación |
Dos comandos. Dos fallos del hook ingenuo. Cero de las reglas.
Y hay más parseo que no vas a reimplementar bien. Antes de matchear se despojan los wrappers conocidos: timeout, time, nice, nohup, stdbuf, los builtins command y builtin, y el noglob de zsh. Por eso Bash(npm test *) también cubre timeout 30 npm test.
Claude Code hasta rechaza tus patrones frágiles por ti: una regla como Bash(command:rm *) sería esquivable con un comando compuesto, así que la ignora y emite un warning al arrancar. Lo correcto es Bash(rm *).
Los agujeros que sí existen
Los permisos no son magia. Hay tres sitios donde te toca poner de tu parte.
Los runners de entorno no están en la lista de wrappers que se despojan: direnv exec, devbox run, mise exec, npx, docker exec. Ejecutan sus argumentos como comando, así que Bash(devbox run *) cubre también devbox run rm -rf .. La regla correcta lleva runner + comando interno, Bash(devbox run npm test), una por cada uno. Tedioso y correcto.
Los patrones sobre argumentos son frágiles: Bash(curl http://github.com/ *) no cubre las variaciones. Deniega curl y wget y usa WebFetch con WebFetch(domain:github.com).
Las redirecciones se comprueban como escritura de archivo: el destino de >, >> y 2> se valida contra tus reglas Edit. Bash(git commit *) permite el comando, no el destino. /dev/null no se comprueba.
Nada de esto lo arregla un hook. Enumerar strings peligrosos es el diseño equivocado, lo escribas en un middleware o en un PreToolUse.
La sintaxis que casi nadie escribe bien
| Regla | Qué matchea |
|---|---|
Bash(npm run build) |
exactamente npm run build |
Bash(npm run *) |
npm run build, npm run test --watch, y el escueto npm run |
Bash(ls *) |
ls -la y ls — pero no lsof |
Bash(ls*) |
ls -la y lsof |
Read(./.env) |
leer el .env del directorio actual |
Read(./secrets/**) |
glob estilo gitignore |
WebFetch(domain:example.com) |
peticiones a ese dominio |
El espacio antes del * es toda la diferencia entre ls y lsof. El sufijo :* equivale al wildcard final — Bash(ls:*) ≡ Bash(ls *) — pero solo se reconoce al final del patrón: en Bash(git:* push) los dos puntos son un carácter literal.
Y el * va después del subcomando. Bash(git log *) permite solo git log; Bash(git *) permite todo git. Claude Code te avisa al arrancar si lo escribes antes.
Queda la precedencia, que mucha gente asume al revés: se evalúa deny, luego ask, luego allow. La primera coincidencia en ese orden decide, y la especificidad de la regla no altera el orden. Una deny amplia como Bash(aws *) bloquea todo lo que coincida, incluida una allow más estrecha como Bash(aws s3 ls).
Dicho de otra forma: una regla deny no admite excepciones de allowlist. Si necesitas una excepción, no la pongas en deny.
Hooks vs permissions en Claude Code: qué va en cada capa
permissions |
Hooks PreToolUse |
|
|---|---|---|
| Naturaleza | Configuración declarativa | Proceso que ejecutas |
| Modo de fallo | No puede fallar: no hay nada que ejecutar | Falla abierto: error, timeout o JSON inválido → la acción continúa |
| Autoridad | deny es absoluto en todos los niveles |
Solo restringe; nunca amplía |
| Entiende shell | Sí: separadores, wrappers, asignaciones, redirecciones | Solo lo que tú programes |
| Superficie | Toda herramienta con regla | Solo lo que cubra tu matcher |
| Para qué sirve | Suelo de seguridad, lo irreversible, secretos | Contexto, logging, reescritura, reglas que dependen del estado del proyecto |
| Ejemplo | Bash(rm *), Read(./.env) |
Registrar cada comando, avisar si el working tree está sucio, guiar la búsqueda |
El JSON que deberías tener
{
"permissions": {
"defaultMode": "default",
"deny": [
"Bash(rm *)",
"Bash(curl *)",
"Bash(wget *)",
"Read(./.env)",
"Read(./.env.*)",
"Read(./secrets/**)"
],
"ask": [
"Bash(git push *)",
"Bash(docker *)",
"Bash(npm publish *)"
],
"allow": [
"Bash(npm run build)",
"Bash(npm test *)",
"Bash(git status)",
"Bash(git log *)",
"Bash(ls *)",
"WebFetch(domain:github.com)"
],
"disableBypassPermissionsMode": "disable"
}
}
Bash(rm *) en deny cubre FOO=bar rm -rf tmp/ sin que hagas nada. Bash(npm test *) en allow cubre timeout 30 npm test gracias al despojado de wrappers. Y deny bloquea aunque más abajo haya una allow que coincida: no hay forma de escribir la excepción, y esa es justo la propiedad que querías.
Este es el tipo de decisión que trabajo en el curso Construye con IA: de la idea al producto con Claude Code: antes de darle capacidades a un agente, dejar por escrito qué no puede hacer.
Entonces, ¿para qué el hook?
Para lo que los permisos no saben expresar. Contexto: la hora, la rama, si el working tree está sucio. Observabilidad: registrar cada llamada. Guía: decirle por dónde buscar antes de que haga grep por todo el repo, que es lo que hacen los dos hooks de mi repo. Reescritura y guía de comandos. Decisiones que dependen del estado del proyecto, no del string del comando.
Con un límite que conviene tener delante. En PreToolUse el matcher se compara contra el nombre de la herramienta. "*", "" u omitido matchean todo. Si solo contiene letras, dígitos, _, -, espacios, , y |, es un string exacto o una lista: Bash, Edit|Write. Cualquier otro carácter lo convierte en una expresión regular de JavaScript sin anclar: ^Bash.
Consecuencia práctica: un matcher "Bash" no cubre PowerShell, ni Write, ni Edit, ni las herramientas MCP (mcp__servidor__tool). Si tu guardrail vive solo ahí, toda esa superficie está descubierta y no te vas a enterar.
Qué hacer hoy
Abre tu .claude/settings.local.json y cuenta las reglas deny. Si el número es cero, ya tienes plan para esta tarde.
Escribe cinco. Solo cinco, y que sean lo irreversible: los secretos y el borrado.
Read(./.env)— que no lea tus claves.Read(./secrets/**)— ni el resto de secretos.Bash(rm *)— el borrado, que además cubreFOO=bar rm -rf tmp/.Bash(curl *)— la exfiltración por red.Bash(wget *)— la otra mitad de lo mismo.
Luego quita bypassPermissions y ponle "disableBypassPermissionsMode": "disable". Y si trabajas con equipo, todo eso va en managed settings: la precedencia más alta, no lo anula ningún otro nivel ni los argumentos de línea de comandos.
Un último detalle que resume el post. Poner disableAllHooks solo en tus settings de usuario no basta: los settings de proyecto del repositorio tienen precedencia sobre los tuyos y pueden volverlo a false. Ni siquiera desactivar los hooks se decide desde donde viven los hooks.
La autoridad está en la configuración. Tu script es la capa de encima.
Mi settings.local.json ya tiene reglas deny. Tardé cuatro minutos. Llevaba meses con Bash(*) en la primera línea y un hook precioso que no protegía absolutamente nada.
Si quieres ver esta capa montada en proyectos reales, con los settings completos y los hooks que sí aportan, lo trabajamos dentro de Dominicode Labs.
Comportamiento verificado contra la documentación oficial de Claude Code en septiembre de 2026.
Preguntas frecuentes
¿Puedo usar un hook para permitir algo que una regla deny bloquea?
No. Claude Code evalúa las reglas deny y ask sea cual sea lo que devuelva el hook. Una regla deny que coincida bloquea la llamada aunque el hook haya devuelto allow, y una regla ask sigue preguntando igual.
Al revés sí funciona: un hook que sale con exit 2 detiene la llamada antes de que se evalúen los permisos, así que bloquea aunque una regla allow la hubiera dejado pasar. El hook solo restringe.
¿Qué pasa si mi hook de seguridad falla o el script no existe?
El comando se ejecuta. Cualquier exit code que no sea 0 o 2, un script inexistente, un JSON inválido o un timeout se tratan como error no bloqueante: la acción continúa por el flujo normal de permisos y en el transcript aparece un aviso <hook name> hook error.
Por eso un hook no es un buen suelo de seguridad. Una regla deny es configuración, no hay proceso que pueda reventar.
¿Basta con poner Bash en deny para bloquear todos los comandos?
Sí, y hace algo más de lo que esperas. Bash(*) es equivalente a Bash y matchea todos los comandos. Como regla deny, ambas formas eliminan la herramienta del contexto de Claude: el modelo ni siquiera la ve.
Lo que no puedes es denegar Bash entero y luego abrir excepciones con reglas allow más estrechas. Una regla deny no admite excepciones de allowlist.
¿Por qué mi regla Bash(ls *) no permite lsof?
Porque el espacio antes del asterisco forma parte del patrón. Bash(ls *) matchea ls -la y el escueto ls, pero no lsof. Si quieres cubrir los dos, la regla es Bash(ls*), sin espacio.
Mismo cuidado con el subcomando: el * va después. Bash(git log *) permite solo git log; Bash(git *) te abre todo git.
¿Cómo evito que alguien active bypassPermissions en mi equipo?
Con permissions.disableBypassPermissionsMode a "disable", y su equivalente permissions.disableAutoMode para el modo auto. En tus settings de usuario ya sirven, pero puestos en managed settings son inanulables: ese nivel tiene la precedencia más alta y no lo tumba ningún otro, ni los argumentos de línea de comandos.
¿Dónde pongo las reglas: settings.json o settings.local.json?
.claude/settings.json se versiona y lo comparte todo el equipo. .claude/settings.local.json es tuyo y solo tuyo: Claude Code lo añade a tus git excludes la primera vez que lo escribe, así que no se sube. Si lo creaste tú a mano, añádelo al .gitignore.
La precedencia va de mayor a menor: managed settings, argumentos de línea de comandos, settings.local.json del proyecto, settings.json del proyecto y ~/.claude/settings.json de usuario. Con una salvedad que ya conoces: si algo está denegado en cualquiera de esos niveles, ningún otro puede permitirlo.
Así que el suelo compartido va en el settings.json versionado, donde lo hereda todo el equipo. Tus atajos personales, en el local.
Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.
¿Te resultó útil este artículo?
Compártelo con tu comunidad y ayuda a otros desarrolladores.
