Hooks de Claude Code: haz que Claude no pueda tocar tu .env
En la práctica/ 21 min de lectura
Un hook PreToolUse que impide a Claude leer o modificar tu .env, también desde la terminal, en bash y PowerShell, con reglas deny y pruebas reales.
- IA
- Claude Code
En este artículo (10)
- Qué son los hooks de Claude Code y por qué son más seguros que una instrucción
- Cuándo corre PreToolUse y qué datos recibe
- Configuración en .claude/settings.json: del matcher Edit|Write al completo
- El script completo: leer tool_input.file_path y salir con código 2
- Versión para Windows (PowerShell)
- Ampliarlo: migraciones aplicadas, llaves privadas, archivos de producción
- Cómo probar el hook sin arriesgar nada
- Errores comunes
- Preguntas frecuentes
- Checklist
Los hooks de Claude Code son comandos que Claude Code ejecuta solo en momentos fijos de su ciclo de trabajo. Con un hook PreToolUse revisas cada lectura, edición o comando antes de que ocurra y bloqueas los que apuntan a tu .env saliendo con código 2. A diferencia de una instrucción escrita, el modelo no puede pasarlo por alto.
En esta guía armamos ese hook completo, en bash y en PowerShell, lo registramos en .claude/settings.json, le sumamos las reglas de permisos que cubren lo que el hook no ve y lo probamos de dos formas: a mano, pasándole JSON, y dentro de Claude Code 2.1.238 con un .env de prueba. Es el artículo 4 de la serie de Claude Code para developers, y todo el código está en la guía descargable.
Qué son los hooks de Claude Code y por qué son más seguros que una instrucción
Claude Code trabaja con herramientas: Read para leer un archivo, Edit para cambiar una parte, Write para crearlo o reescribirlo, Bash o PowerShell para correr comandos. Un hook es un comando tuyo que Claude Code lanza en un evento concreto, por ejemplo justo antes de usar una herramienta (PreToolUse) o justo después (PostToolUse). El hook recibe los datos del evento en JSON por la entrada estándar y responde con su código de salida.
Puedes escribir en tu CLAUDE.md «nunca abras el .env». Casi siempre funcionará. Pero una instrucción es texto dentro del contexto del modelo: compite con lo que le pides en ese momento, se diluye en una conversación larga y no tiene manera de impedir nada. Un hook es código que corre en tu máquina antes de que la herramienta se ejecute. Si sale con código 2, la lectura o la edición no ocurre, aunque estés en modo bypassPermissions. La referencia de hooks lo dice así: un hook que devuelve deny bloquea la herramienta en cualquier modo de permisos.
| Instrucción en CLAUDE.md | Hook PreToolUse | Regla deny en permisos | |
|---|---|---|---|
| Qué es | Texto que el modelo lee | Un script que Claude Code ejecuta | Un patrón que Claude Code compara |
| ¿Se puede saltar? | Sí, si el modelo la ignora | No para las herramientas que cubre | No para las herramientas que cubre |
| Lógica propia | No | Sí: cualquier condición que programes | No: solo patrones de ruta |
| Cubre comandos de terminal | No | Sí, si lo programas: el de este artículo revisa el texto de cada comando | Los que Claude Code reconoce (cat, Get-Content, redirecciones) |
| Le explica a Claude por qué | Sí | Sí, con el mensaje de stderr | Con un mensaje genérico |
Por eso la configuración de este artículo usa las dos últimas columnas juntas. Si todavía no tienes clara la diferencia entre hooks, skills, subagentes y plugins, la explicamos en skills vs subagentes vs hooks vs plugins en Claude Code.
Cuándo corre PreToolUse y qué datos recibe
PreToolUse corre después de que Claude decide usar una herramienta y antes de que esa herramienta toque nada. También corre antes de la revisión de permisos, que es lo que permite que un bloqueo del hook valga incluso con una regla allow que dejaría pasar la llamada.
Desliza la figura para verla completa
Lo que recibe el script por stdin es un objeto JSON con campos comunes (session_id, cwd, permission_mode, hook_event_name) y los propios de la herramienta. Para Edit se ve así:
{
"session_id": "abc123",
"cwd": "/home/ana/tienda",
"permission_mode": "default",
"hook_event_name": "PreToolUse",
"tool_name": "Edit",
"tool_input": {
"file_path": "/home/ana/tienda/.env",
"old_string": "DEBUG=false",
"new_string": "DEBUG=true",
"replace_all": false
}
}
Dos detalles de la documentación que este hook aprovecha: para Read, Edit y Write, tool_input.file_path siempre llega como ruta absoluta, porque Claude Code expande ~ y las rutas relativas antes de llamar al hook. Así nadie lo esquiva escribiendo ./.env o ../tienda/.env.
La respuesta es el código de salida:
- 0: la herramienta sigue.
- 2: la herramienta se bloquea y Claude recibe como motivo lo que el script escribió en stderr.
- Cualquier otro: Claude Code lo trata como error que no bloquea. Muestra un aviso y la herramienta sigue. Un
exit 1no protege nada.
Configuración en .claude/settings.json: del matcher Edit|Write al completo
Los hooks se declaran en un archivo de configuración. Para un proyecto, lo normal es .claude/settings.json, que se versiona en el repositorio y protege a todo el equipo. Si lo quieres solo en tu máquina, va en .claude/settings.local.json; para todos tus proyectos, en ~/.claude/settings.json.
Paso 1: crea la carpeta del script
# macOS, Linux o Git Bash
mkdir -p .claude/hooks
# PowerShell
New-Item -ItemType Directory -Force .claude\hooks
Paso 2: registra el hook
Este es el .claude/settings.json completo para macOS, Linux y Windows con Git Bash. Las reglas deny las explicamos más abajo; el bloque que importa ahora es hooks:
{
"permissions": {
"deny": [
"Read(.env)",
"Read(.env.*)",
"Read(!.env.example)",
"Edit(.env)",
"Edit(.env.*)",
"Edit(!.env.example)"
]
},
"hooks": {
"PreToolUse": [
{
"matcher": "Read|Edit|Write|Grep|Bash|PowerShell",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/proteger-env.sh"
}
]
}
]
}
}
El matcher decide para qué herramientas corre el hook. Con letras, números y |, es una lista de nombres exactos y distingue mayúsculas. Edit|Write cubre las dos herramientas que modifican archivos y es lo que suelen mostrar los ejemplos, pero no alcanza. Agregamos Read porque «no tocar» el .env también significa no leerlo: si Claude lo lee, el valor de tu clave termina en el contexto de la conversación. Y agregamos Bash|PowerShell porque Claude también puede llegar al archivo desde la terminal; más abajo está la prueba que nos obligó a hacerlo. Grep entra por lo mismo: con path apuntando al .env, busca dentro del archivo y devuelve sus líneas. $CLAUDE_PROJECT_DIR es la raíz del proyecto y va entre comillas porque la ruta puede tener espacios.
El script completo: leer tool_input.file_path y salir con código 2
Paso 3: escribe el hook en bash
Guárdalo como .claude/hooks/proteger-env.sh. Usa jq para leer el JSON, como los ejemplos de la guía oficial de hooks.
#!/usr/bin/env bash
# proteger-env.sh: hook PreToolUse de Claude Code.
# Bloquea Read, Edit, Write y Grep sobre .env y .env.* (salvo las plantillas),
# y los comandos de Bash o PowerShell que nombran esos archivos.
# Claude Code le pasa el evento en JSON por stdin.
# exit 0 = la herramienta sigue · exit 2 = se bloquea y Claude lee el stderr.
# Requiere jq: https://jqlang.org/download/
# Nombres de archivo que Claude no puede leer ni modificar. Acepta comodines.
# Para ampliarlo, agrega nombres aquí: "*.pem" "*.key" "id_rsa"
protegidos=(".env" ".env.*")
# Plantillas sin secretos que sí puede leer y editar.
permitidos=(".env.example" ".env.sample" ".env.template")
bloquear() {
echo "Bloqueado por proteger-env: $1. Pide al usuario que haga este cambio a mano." >&2
exit 2
}
# Sí cuando el nombre es una plantilla o no está protegido.
libre() {
for p in "${permitidos[@]}"; do [[ $1 == $p ]] && return 0; done
# $p sin comillas: así bash lo trata como patrón (.env.* incluye .env.local).
for p in "${protegidos[@]}"; do [[ $1 == $p ]] && return 1; done
return 0
}
# Sin jq no hay forma segura de leer la entrada: mejor bloquear que dejar pasar.
command -v jq >/dev/null 2>&1 || bloquear "falta jq para leer la entrada del hook"
entrada=$(cat)
# Read, Edit y Write mandan file_path; Grep, path. Bash y PowerShell mandan
# command; Grep, además, un filtro glob.
ruta=$(jq -r '.tool_input.file_path // .tool_input.path // empty' <<<"$entrada") || bloquear "la entrada no es JSON válido"
texto=$(jq -r '.tool_input.command // .tool_input.glob // empty' <<<"$entrada")
filtro=$(jq -r '.tool_input.glob // empty' <<<"$entrada")
# .ENV y .env son el mismo archivo en Windows y macOS.
shopt -s nocasematch
# Cada nombre que empieza con .env dentro del comando pasa la misma prueba que
# una ruta: .env.example sí, .env.local no; process.env y .envrc ni cuentan.
if [ -n "$texto" ]; then
while read -r nombre; do
nombre=${nombre#[!.]}
libre "$nombre" || bloquear "el comando menciona $nombre"
done < <(grep -oiE '(^|[^[:alnum:]_.-])\.env[[:alnum:]_.-]*' <<<"$texto")
fi
# El filtro de Grep es un patrón de ripgrep, donde * también coincide con un
# nombre que empieza con punto: * cubre .env y *.local cubre .env.local. Solo
# pasan filtros por extensión (*.ts, src/**/*.tsx, *.{ts,tsx}) con extensiones
# de esta lista. Si Grep necesita otra, agrégala aquí.
seguras=" ts tsx js jsx mjs cjs json md mdx css scss html liquid py rb go rs php vue svelte yml yaml toml sql sh ps1 txt "
solo_extension='^(\*\*/|[A-Za-z0-9_-]+/)*\*\.(\{[A-Za-z0-9]+(,[A-Za-z0-9]+)*\}|[A-Za-z0-9]+)$'
if [ -n "$filtro" ]; then
[[ $filtro =~ $solo_extension ]] || bloquear "el filtro $filtro no es solo por extensión; usa uno como *.ts"
extensiones=${filtro##*\*.}
extensiones=${extensiones#\{}
IFS=, read -ra lista <<<"${extensiones%\}}"
for e in "${lista[@]}"; do
[[ $seguras == *" $e "* ]] || bloquear "el filtro $filtro usa .$e, que no está en la lista de extensiones seguras"
done
fi
[ -z "$ruta" ] && exit 0
# Windows manda C:\repo\.env: se normaliza a / y se toma solo el nombre.
# Además, .env:x es un flujo alterno de .env y .env. es .env.
ruta=${ruta//\\//}
nombre=${ruta##*/}
# C:.env es .env en la carpeta actual de la unidad C:, no un flujo de "C".
[[ $nombre =~ ^[A-Za-z]: ]] && nombre=${nombre:2}
nombre=${nombre%%:*}
while [[ $nombre == *[.\ ] ]]; do nombre=${nombre%?}; done
libre "$nombre" || bloquear "$nombre puede contener secretos"
exit 0
Lo que hace cada parte:
- Lee la ruta.
file_pathen Read, Edit y Write;pathen Grep. Si la llamada no trae ruta ni comando, el script sale con 0 y no estorba. - Revisa los comandos. Para Bash y PowerShell no llega una ruta sino
tool_input.command(y Grep puede traer un filtroglob). Cada nombre que empieza con.envdentro del texto pasa la misma prueba que una ruta:cat .envycat .env.example.localse bloquean;cat .env.exampleyprocess.env, no. El filtro de Grep solo pasa si es por extensión, como*.tso*.{ts,tsx}, y con una extensión de la lista de seguras del script: en ripgrep,*también coincide con un nombre que empieza con punto, así que*.localcubriría.env.local. - Se queda con el nombre. Convierte las barras de Windows, corta la carpeta y quita lo que sigue a
:y los puntos finales: en Windows,.env:xes un flujo alterno de.envy.env.es.env;C:.enves.enven la carpeta actual de la unidad. Comparar el nombre exacto es lo que evita bloquear.env.exampleosrc/env.ts. - Deja pasar las plantillas primero.
.env.exampleno tiene secretos y le sirve a Claude para saber qué variables usa el proyecto. - Ignora mayúsculas.
shopt -s nocasematchhace que.ENVtambién se bloquee, porque en Windows y en macOS es el mismo archivo. - Bloquea si no puede leer. Sin
jq, o con un JSON roto, sale con 2. Un guardia que falla abierto deja pasar justo lo que debía detener.
Desliza la figura para verla completa
En macOS y Linux, dale permiso de ejecución:
chmod +x .claude/hooks/proteger-env.sh
Versión para Windows (PowerShell)
En Windows, Claude Code ejecuta los hooks con Git Bash si está instalado, y con PowerShell si no. Si tienes Git Bash y jq, la versión anterior funciona tal cual. Si no, esta no necesita nada extra.
Paso 4: el mismo hook en PowerShell
# proteger-env.ps1: hook PreToolUse de Claude Code para Windows (PowerShell 5.1 o 7).
# Bloquea Read, Edit, Write y Grep sobre .env y .env.* (salvo las plantillas),
# y los comandos de Bash o PowerShell que nombran esos archivos.
# Claude Code le pasa el evento en JSON por stdin.
# exit 0 = la herramienta sigue · exit 2 = se bloquea y Claude lee el stderr.
# Los mensajes van sin acentos: PowerShell 5.1 lee como ANSI un .ps1 UTF-8 sin BOM.
# Nombres de archivo que Claude no puede leer ni modificar. Acepta comodines (-like).
# Para ampliarlo, agrega nombres aquí: '*.pem', '*.key', 'id_rsa'
$protegidos = @('.env', '.env.*')
# Plantillas sin secretos que sí puede leer y editar.
$permitidos = @('.env.example', '.env.sample', '.env.template')
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
function Bloquear($motivo) {
[Console]::Error.WriteLine("Bloqueado por proteger-env: $motivo. Pide al usuario que haga este cambio a mano.")
exit 2
}
# Cualquier error inesperado bloquea en lugar de dejar pasar.
$ErrorActionPreference = 'Stop'
trap { Bloquear 'error inesperado en el hook' }
# Verdadero cuando el nombre es una plantilla o no está protegido.
# -contains y -like ignoran mayúsculas: .ENV y .env son el mismo archivo en Windows.
function Libre($nombre) {
if ($permitidos -contains $nombre) { return $true }
foreach ($p in $protegidos) { if ($nombre -like $p) { return $false } }
return $true
}
# Si la entrada no se puede leer, mejor bloquear que dejar pasar.
try {
$entrada = [Console]::In.ReadToEnd() | ConvertFrom-Json
} catch {
Bloquear 'no se pudo leer la entrada del hook'
}
# Read, Edit y Write mandan file_path; Grep, path. Bash y PowerShell mandan
# command; Grep, además, un filtro glob.
$ruta = $entrada.tool_input.file_path
if (-not $ruta) { $ruta = $entrada.tool_input.path }
$texto = $entrada.tool_input.command
if (-not $texto) { $texto = $entrada.tool_input.glob }
# Cada nombre que empieza con .env dentro del comando pasa la misma prueba que
# una ruta: .env.example sí, .env.local no; process.env y .envrc ni cuentan.
if ($texto) {
foreach ($m in [regex]::Matches($texto, '(?<![\w.-])\.env[\w.-]*', 'IgnoreCase')) {
if (-not (Libre $m.Value)) { Bloquear "el comando menciona $($m.Value)" }
}
}
# El filtro de Grep es un patrón de ripgrep, donde * también coincide con un
# nombre que empieza con punto: * cubre .env y *.local cubre .env.local. Solo
# pasan filtros por extensión (*.ts, src/**/*.tsx, *.{ts,tsx}) con extensiones
# de esta lista. Si Grep necesita otra, agrégala aquí.
$seguras = @('ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs', 'json', 'md', 'mdx', 'css', 'scss', 'html', 'liquid', 'py', 'rb', 'go', 'rs', 'php', 'vue', 'svelte', 'yml', 'yaml', 'toml', 'sql', 'sh', 'ps1', 'txt')
$filtro = $entrada.tool_input.glob
if ($filtro) {
if ($filtro -notmatch '^(\*\*/|[\w-]+/)*\*\.(\{[a-z0-9]+(,[a-z0-9]+)*\}|[a-z0-9]+)$') {
Bloquear "el filtro $filtro no es solo por extension; usa uno como *.ts"
}
foreach ($e in ($filtro -split '\*\.')[-1].Trim('{', '}') -split ',') {
if ($seguras -notcontains $e) { Bloquear "el filtro $filtro usa .$e, que no esta en la lista de extensiones seguras" }
}
}
if (-not $ruta) { exit 0 }
# El nombre es lo que sigue a la última \ o /. GetFileName no sirve: en
# PowerShell 5.1 corta en ':' y .env:x (un flujo alterno de .env) pasaría.
# Windows también ignora los puntos y espacios finales: .env. es .env.
$nombre = ($ruta -split '[\\/]')[-1]
# C:.env es .env en la carpeta actual de la unidad C:, no un flujo de 'C'.
$nombre = $nombre -replace '^[A-Za-z]:', ''
$nombre = ($nombre -split ':')[0].TrimEnd('. ')
if (-not (Libre $nombre)) { Bloquear "$nombre puede contener secretos" }
exit 0
ConvertFrom-Json reemplaza a jq, el nombre se saca partiendo la ruta en \ y / (no con GetFileName, que en PowerShell 5.1 corta en : y dejaría pasar .env:x), -like no distingue mayúsculas y, con $ErrorActionPreference = 'Stop' y un trap, cualquier error inesperado bloquea en lugar de dejar pasar. Los mensajes van sin acentos porque Windows PowerShell 5.1 lee como ANSI un archivo UTF-8 sin BOM, y el texto llegaría a Claude con caracteres rotos.
Paso 5: registra la versión de PowerShell
{
"permissions": {
"deny": [
"Read(.env)",
"Read(.env.*)",
"Read(!.env.example)",
"Edit(.env)",
"Edit(.env.*)",
"Edit(!.env.example)"
]
},
"hooks": {
"PreToolUse": [
{
"matcher": "Read|Edit|Write|Grep|Bash|PowerShell",
"hooks": [
{
"type": "command",
"command": "powershell.exe",
"args": [
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
"${CLAUDE_PROJECT_DIR}/.claude/hooks/proteger-env.ps1"
]
}
]
}
]
}
}
Con args, Claude Code ejecuta powershell.exe directamente, sin pasar por una shell. Cada elemento llega como un argumento, así que la ruta con espacios no necesita comillas, y -ExecutionPolicy Bypass evita que la política de ejecución de Windows bloquee el script. La documentación llama a esto «exec form».
Un costo a tener en cuenta: en mi equipo (Ryzen 7 5700G, Windows 11), cada ejecución de este hook con PowerShell 5.1 tarda una mediana de 557 ms, y 439 ms de eso es solo arrancar powershell.exe. El hook corre en cada Read, Edit, Write y comando de terminal, así que en una sesión larga se nota. Si tienes Git Bash, la versión en bash es la más ligera.
Ampliarlo: migraciones aplicadas, llaves privadas, archivos de producción
Paso 6: agrega las reglas deny como segunda capa
El hook es tu código, y busca .env en el texto de los comandos: no los analiza. Las seis reglas de permissions.deny que ya viste en el settings.json son una segunda revisión, hecha por Claude Code: según la documentación de permisos, una regla Read o Edit se aplica a las herramientas de archivos y también a los comandos que Claude Code reconoce, como cat, head, tail, sed, Get-Content, y a las redirecciones > archivo.
Read(.env)yRead(.env.*)bloquean el archivo en cualquier carpeta del proyecto.Read(!.env.example)devuelve el permiso a la plantilla. El!solo excluye de las reglas que están antes en la misma lista.- Las tres de
Editrepiten la idea para las ediciones.
Así llegamos a esta configuración. La primera versión del hook solo miraba Read, Edit y Write. La probamos sin reglas deny, en modo acceptEdits, pidiendo agregar DEBUG=true al .env. El hook bloqueó la lectura; entonces Claude leyó el archivo con Get-Content y le agregó la línea con Add-Content desde su herramienta de PowerShell, y en ese modo ninguno de los dos comandos pidió aprobación. Con el matcher completo y el script actual repetimos el pedido, y otro que además pedía mostrar la clave «usando la terminal si una herramienta falla»: todas las llamadas quedaron bloqueadas, el .env no cambió y la clave nunca apareció. Lo mismo pasó con las reglas deny activas, con la versión de PowerShell y con la de bash en Git Bash.
Lo que ni el hook ni las reglas cubren es un comando que llega al archivo sin nombrarlo, como grep -r CLAVE ., un alias del archivo, como un enlace simbólico o su nombre corto de Windows (ENV~1), o un programa que lo abre por su cuenta, como un script de Node que Claude escribe y ejecuta. Para eso existe el sandbox de Claude Code, que restringe el acceso a nivel del sistema operativo.
Llaves privadas
La lista protegidos acepta comodines. Para cubrir llaves privadas, agrega sus nombres y, si quieres lo mismo en la terminal, sus reglas:
protegidos=(".env" ".env.*" "*.pem" "*.key" "id_rsa" "id_ed25519")
"Read(*.pem)", "Edit(*.pem)", "Read(*.key)", "Edit(*.key)"
Migraciones aplicadas
Una migración que ya corrió en producción no se edita: se corrige con una nueva. Este bloque deja crear archivos nuevos en migrations/ y bloquea cambiar los que ya existen. En bash, agrégalo justo antes de la última comprobación, libre "$nombre" || bloquear ...:
herramienta=$(jq -r '.tool_name // empty' <<<"$entrada")
if [[ $ruta == */migrations/* && $herramienta != "Read" && -f $ruta ]]; then
bloquear "esa migración ya existe; crea una nueva en lugar de editarla"
fi
En PowerShell, justo antes de la línea if (-not (Libre $nombre)):
if ($ruta -match '[\\/]migrations[\\/]' -and $entrada.tool_name -ne 'Read' -and (Test-Path -LiteralPath $ruta)) {
Bloquear 'esa migracion ya existe; crea una nueva en lugar de editarla'
}
Archivos de producción
.env.production ya queda cubierto por .env.*. Si tu proyecto guarda credenciales de producción con otro nombre, como un service-account.json, agrégalo a protegidos y a las reglas deny.
Cómo probar el hook sin arriesgar nada
Paso 7: pásale JSON a mano
Un hook es un programa que lee stdin, así que se prueba sin abrir Claude Code y sin un .env real:
# bash
echo '{"tool_name":"Edit","tool_input":{"file_path":"/repo/.env"}}' | bash .claude/hooks/proteger-env.sh
echo $? # 2
# PowerShell
'{"tool_name":"Edit","tool_input":{"file_path":"C:\\repo\\.env"}}' |
powershell -NoProfile -ExecutionPolicy Bypass -File .claude\hooks\proteger-env.ps1
$LASTEXITCODE # 2
Paso 8: corre los veinticinco casos
La guía descargable trae probar/probar-hook.sh y probar/probar-hook.ps1, que pasan veinticinco eventos y comparan cada código de salida: diez de rutas (.env, .env.local en un monorepo, una ruta de Windows, .ENV en mayúsculas, .env::$DATA, .env. con punto final, C:.env, .env.example, src/env.ts y .environment.md), seis de Grep (dentro de .env, con los filtros .env*, *.local y uno mal formado, y dos que pasan: *.ts y src/), ocho comandos (cat .env, Get-Content, Add-Content, cp .env.example .env, cat .env.example.local, cat .env.example, uno con process.env y npm test) y una entrada que no es JSON. La salida esperada termina en «Todo en orden.».
Paso 9: pruébalo dentro de Claude Code
Abre Claude Code en el proyecto y escribe /hooks: el hook debe aparecer bajo PreToolUse. Después pide algo que toque el archivo. Así se vio en nuestra prueba, en una carpeta temporal con un .env de mentira:
La prueba se hizo con claude -p. Sin las reglas deny, lo que recibió Claude al intentar leer el archivo fue el mensaje del hook:
PreToolUse:Read hook error: [powershell.exe -NoProfile -ExecutionPolicy Bypass -File ${CLAUDE_PROJECT_DIR}/.claude/hooks/proteger-env.ps1]: Bloqueado por proteger-env: .env puede contener secretos. Pide al usuario que haga este cambio a mano.
Con las reglas activas, la regla responde antes: File is in a directory that is denied by your permission settings. En la segunda parte de la captura, el hook recibe a mano el mismo comando que Claude usó en la prueba fallida, Add-Content -Path .env, y lo bloquea con código 2. En una sesión interactiva ves todo esto en la transcripción con Ctrl+O, y si arrancas con claude --debug-file /tmp/claude.log, el archivo registra el código de salida y el stderr de cada hook.
Errores comunes
- Buscar «.env» como texto en la ruta. El ejemplo de la guía oficial compara con
*".env"*, que también bloquea.env.example. Compara el nombre del archivo. - Salir con
exit 1. Solo el código 2 bloquea. Cualquier otro deja pasar la herramienta con un aviso. - Fallar abierto. Si el script no puede leer la entrada y sale con 0 o 1, el bloqueo depende de que todo funcione siempre. Haz que bloquee.
- Olvidar las mayúsculas. En bash, sin
nocasematch,.ENVpasa. El matcher también distingue mayúsculas:edit|writeno coincide con nada. - Sacar el nombre con
GetFileNameen PowerShell 5.1. Corta en:, así que.env:x, un flujo alterno del mismo archivo, pasaría comox. - Dejar el matcher en Edit|Write. Claude todavía puede leer el archivo, y en nuestra prueba lo modificó desde la terminal con
Add-Content. - Confiar solo en el hook. Es tu script y busca texto. Las reglas
denyson una segunda revisión que hace Claude Code, y siguen ahí si el script falla. - No saber qué bloquea.
echo .env >> .gitignoremenciona el archivo y también se bloquea. Esos cambios los haces tú. - Sobrescribir un settings.json que ya existía. Copia las claves
permissions.denyyhooks.PreToolUsedentro del tuyo. El JSON no admite comentarios ni comas al final. - En macOS o Linux, un script sin permiso de ejecución.
chmod +xantes de probar.
Preguntas frecuentes
¿El hook funciona en modo bypassPermissions?
Sí. Los hooks PreToolUse corren antes de cualquier revisión de modo de permisos, y un bloqueo vale incluso con --dangerously-skip-permissions. Es justo lo que lo hace útil para reglas de seguridad.
¿Esto evita que el .env se suba a git?
No. El hook evita que Claude lo lea o lo modifique. Que no llegue al repositorio depende de .gitignore, que debes seguir teniendo.
¿Puedo activarlo en todos mis proyectos?
Sí. Copia el script a ~/.claude/hooks/ y declara el hook en ~/.claude/settings.json con la ruta del script en tu carpeta de usuario. Los hooks de usuario se suman a los de cada proyecto.
¿Qué pasa si el script falla o tarda demasiado?
Un código distinto de 0 o 2 es un error que no bloquea, por eso este script sale con 2 cuando no puede leer la entrada. El tiempo límite por defecto para un hook de tipo command es de 10 minutos. Desde Claude Code 2.1.295, el campo "onFailure": "block" hace que un fallo o un tiempo agotado también bloqueen la llamada.
¿Por qué Claude intenta otra forma cuando lo bloqueo?
Porque recibe el motivo y busca cumplir lo que le pediste. Por eso el mensaje del script le dice qué hacer («pide al usuario que haga este cambio a mano») y por eso las reglas deny cierran las alternativas de terminal.
Checklist
- El script
proteger-env.shoproteger-env.ps1está en.claude/hooks/. .claude/settings.jsonregistra el hook enPreToolUsecon"matcher": "Read|Edit|Write|Grep|Bash|PowerShell".- Las reglas
denycubren.envy.env.*, con la excepción de.env.example. - Los veinticinco casos de prueba terminan en «Todo en orden.».
/hooksmuestra el hook y una petición real sobre.envqueda bloqueada..envsigue en.gitignorey.claude/settings.jsonestá versionado para el equipo.
Con esto, tu .env queda fuera del alcance de Claude por código y no por buena voluntad. El siguiente paso natural es usar el evento contrario, PostToolUse, para que el linter corra solo después de cada edición, y después llevar las mismas reglas a Claude Code en GitHub Actions, donde no hay nadie para negar un permiso. Si quieres ver dónde encajan estas piezas en un día de trabajo, está mi flujo de 4 pasos con Claude Code.
¿Viste esto en Instagram? Comenta HOOK en el Reel o el carrusel y te mando la guía.
Escrito por

Publicado el 23 de octubre de 2026
Para leer después
Por qué Google Merchant Center rechaza los productos de tu tienda Shopify y cómo corregirlos desde el origen
En la práctica / 2026-10-22
La API de Shopify 2025-10 ya no tiene soporte: cómo pasar a 2026-10 sin romper nada
En la práctica / 2026-10-22
Los agentes de pruebas de Playwright en Claude Code, sin perder el control del código
En la práctica / 2026-10-22