Saltar al contenido

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

Serie Claude Code · 04 / 15

En este artículo (10)
  1. Qué son los hooks de Claude Code y por qué son más seguros que una instrucción
  2. Cuándo corre PreToolUse y qué datos recibe
  3. Configuración en .claude/settings.json: del matcher Edit|Write al completo
  4. El script completo: leer tool_input.file_path y salir con código 2
  5. Versión para Windows (PowerShell)
  6. Ampliarlo: migraciones aplicadas, llaves privadas, archivos de producción
  7. Cómo probar el hook sin arriesgar nada
  8. Errores comunes
  9. Preguntas frecuentes
  10. 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.mdHook PreToolUseRegla deny en permisos
Qué esTexto que el modelo leeUn script que Claude Code ejecutaUn patrón que Claude Code compara
¿Se puede saltar?Sí, si el modelo la ignoraNo para las herramientas que cubreNo para las herramientas que cubre
Lógica propiaNoSí: cualquier condición que programesNo: solo patrones de ruta
Cubre comandos de terminalNoSí, si lo programas: el de este artículo revisa el texto de cada comandoLos que Claude Code reconoce (cat, Get-Content, redirecciones)
Le explica a Claude por quéSíSí, con el mensaje de stderrCon 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.

una edición de Claude, paso a pasoEditClaude pidePreToolUsepermisosdeny · ask · allowla edicióntoca el discoPostToolUseya ocurriólo que recibe tu hook (stdin)"hook_event_name": "PreToolUse","tool_name": "Edit","tool_input": { "file_path": "/repo/.env" }tu hook respondeexit 0 sigueexit 2 se bloqueastderr Claude lo leecorre antes que los permisosun exit 2 bloquea incluso con --dangerously-skip-permissions

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 1 no 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:

  1. Lee la ruta. file_path en Read, Edit y Write; path en Grep. Si la llamada no trae ruta ni comando, el script sale con 0 y no estorba.
  2. Revisa los comandos. Para Bash y PowerShell no llega una ruta sino tool_input.command (y Grep puede traer un filtro glob). Cada nombre que empieza con .env dentro del texto pasa la misma prueba que una ruta: cat .env y cat .env.example.local se bloquean; cat .env.example y process.env, no. El filtro de Grep solo pasa si es por extensión, como *.ts o *.{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 *.local cubriría .env.local.
  3. 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:x es un flujo alterno de .env y .env. es .env; C:.env es .env en la carpeta actual de la unidad. Comparar el nombre exacto es lo que evita bloquear .env.example o src/env.ts.
  4. Deja pasar las plantillas primero. .env.example no tiene secretos y le sirve a Claude para saber qué variables usa el proyecto.
  5. Ignora mayúsculas. shopt -s nocasematch hace que .ENV también se bloquee, porque en Windows y en macOS es el mismo archivo.
  6. 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.
qué decide el script para cada rutafile_pathruta absolutanombresin la carpeta¿.env o .env.*?comparación exactanosíexit 0se permite¿es plantilla?.example .samplesínoexit 2stderr a Clauderutas de ejemplo/repo/.envbloquea/repo/.env.localbloquea/repo/.env.examplepermite/repo/src/env.tspermitecomparar el nombre, no buscar textobuscar ".env" dentro de la ruta también bloquea .env.example

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) y Read(.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 Edit repiten 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:

Terminal de PowerShell donde los hooks de Claude Code protegen el .env: Claude responde que un hook de protección bloquea el acceso al archivo y pide hacer el cambio a mano; después, el hook ejecutado a mano con un comando Add-Content imprime el mensaje de bloqueo y sale con código 2.

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, .ENV pasa. El matcher también distingue mayúsculas: edit|write no coincide con nada.
  • Sacar el nombre con GetFileName en PowerShell 5.1. Corta en :, así que .env:x, un flujo alterno del mismo archivo, pasaría como x.
  • 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 deny son una segunda revisión que hace Claude Code, y siguen ahí si el script falla.
  • No saber qué bloquea. echo .env >> .gitignore menciona el archivo y también se bloquea. Esos cambios los haces tú.
  • Sobrescribir un settings.json que ya existía. Copia las claves permissions.deny y hooks.PreToolUse dentro del tuyo. El JSON no admite comentarios ni comas al final.
  • En macOS o Linux, un script sin permiso de ejecución. chmod +x antes 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.sh o proteger-env.ps1 está en .claude/hooks/.
  • .claude/settings.json registra el hook en PreToolUse con "matcher": "Read|Edit|Write|Grep|Bash|PowerShell".
  • Las reglas deny cubren .env y .env.*, con la excepción de .env.example.
  • Los veinticinco casos de prueba terminan en «Todo en orden.».
  • /hooks muestra el hook y una petición real sobre .env queda bloqueada.
  • .env sigue en .gitignore y .claude/settings.json está 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

Luis Morales

Luis Morales

Desarrollador frontend y Shopify, EcomLabs

@luismoralesdev en Instagram

Publicado el 23 de octubre de 2026

← Blog