Claude Code a .env soubory – kvízová odpověď, která nestačí

Claude Code a .env soubory – kvízová odpověď, která nestačí

Certifikační otázka má jedinou správnou odpověď ze čtyř – PreToolUse hook na Read a Grep. V produkci ti `.env` pořád protéká přes Bash. Funkční hook, který to zavře.

Jakub Kontra
Jakub Kontra
Developer

Certifikační otázka má jedinou správnou odpověď ze čtyř. I když ji trefíš, tvůj .env je pořád čitelný.

Otázka z certifikace a rychlá odpověď

Zadání kvízu zní zhruba takhle: „Jak zabráníš Claude Code, aby přečetl obsah .env souborů?" Nabídnuté odpovědi:

  • A) PostToolUse hook, matching Write and Edit.
  • B) PreToolUse hook, matching Write and Create.
  • C) PreToolUse hook, matching Read and Grep.
  • D) PostToolUse hook, matching Read and Delete.

Správně je C – jediný PreToolUse hook, který cílí na čtecí tooly. V produkci ti ale samotný matcher Read|Grep .env neuzavře.

Proč A, B, D padají

A je PostToolUse. Tool už doběhl, obsah .env je v tool result a model ho viděl. Blokovat Write a Edit navíc chrání proti zápisu, ne proti čtení. Dvojitá chyba.

B zní dobře, dokud si neuvědomíš, že tool Create v Claude Code neexistuje. Nové soubory vznikají přes Write. A Write stejně neřeší čtení.

D má obě chyby. Delete neexistuje (mažeš přes Bash rm) a PostToolUse přichází pozdě. Kvíz tu mimoděk testuje, jestli znáš reálný inventář toolů: Read, Write, Edit, NotebookEdit, Glob, Grep, Bash, Task, WebFetch, WebSearch, TodoWrite, BashOutput, KillShell, SlashCommand, ExitPlanMode. To je základní inventář. MCP servery seznam dynamicky rozšiřují, takže matcher kontroluj proti tomu, co v projektu reálně běží.

PreToolUse hook v jedné minutě

PreToolUse běží před vykonáním toolu. Hook skript dostane na stdin JSON s session_id, transcript_path, cwd, hook_event_name, tool_name a tool_input. Exit 0 pustí tool dál, 2 ho zablokuje a stderr jde modelu jako feedback. Cokoli jiného je chyba hooku a tool poběží dál.

Matcher v settings.json je regex nad tool_name. "Read|Grep|Glob" trefí všechny tři jednou konfigurací. Glob přidávám záměrně: samotný Glob .env* sice obsah nepřečte, ale prozradí modelu, že soubor existuje. To modelu stačí, aby se o čtení pokusil jinou cestou. Detaily ke všem devíti eventům (PreToolUse, PostToolUse, UserPromptSubmit, Notification, Stop, SubagentStop, PreCompact, SessionStart, SessionEnd) najdeš v kompletním průvodci hooks.

Funkční hook pro Read, Grep a Glob

Hook dáš do .claude/settings.json v projektu (committable) nebo do ~/.claude/settings.json (user-level):

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Read|Grep|Glob",
        "hooks": [
          { "type": "command", "command": ".claude/hooks/block-env.sh" }
        ]
      }
    ]
  }
}

A skript .claude/hooks/block-env.sh:

#!/usr/bin/env bash
set -euo pipefail

input=$(cat)
path=$(echo "$input" | jq -r '.tool_input.file_path // .tool_input.path // empty')

[ -z "$path" ] && exit 0

resolved=$(realpath -m "$path" 2>/dev/null || echo "$path")
base=$(basename "$resolved")

case "$base" in
  .env|.env.*)
    echo "Blocked: $resolved matches .env policy. Use a vault or ask the user." >&2
    exit 2
    ;;
esac

exit 0

Na čem ti hook spadne: Read používá file_path, Grep a Glob path (i pattern), takže parsuj oba. Bez realpath tě obejde ln -s .env public.txt – symlink je klasický bypass. A matchuj na basename, ne na celou cestu, jinak ti proteče apps/api/.env nebo packages/db/.env.production v monorepu.

Díra, kterou kvíz nezmíní: Bash

Matcher Read|Grep|Glob nechytne nic z tohohle:

cat .env
source .env
grep SECRET .env
export $(cat .env | xargs)
tail -f .env.production
awk '{print}' .env

Každý z těch příkazů jede přes tool Bash, ne Read. Model je použije, pokud uživatel řekne „načti proměnné" a nikdo nevysvětlil, že to nesmí. Druhá větev matcheru je povinná:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Read|Grep|Glob",
        "hooks": [
          { "type": "command", "command": ".claude/hooks/block-env.sh" }
        ]
      },
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": ".claude/hooks/block-env-bash.sh" }
        ]
      }
    ]
  }
}

Bash varianta čte tool_input.command a jede regexem:

#!/usr/bin/env bash
set -euo pipefail

cmd=$(jq -r '.tool_input.command // empty')

if echo "$cmd" | grep -Eq '(\.env($|\.[a-zA-Z0-9_.-]+)|/\.env($|\.))'; then
  if echo "$cmd" | grep -Eq '(cat|less|more|head|tail|grep|awk|sed|source|\.\s|xxd|od|base64|export)\b'; then
    echo "Blocked: Bash command reads .env. Reject or refactor." >&2
    exit 2
  fi
fi

exit 0

Počítej s tím, že regex občas trefí i nevinný příkaz – komentář, log řádek nebo import cestu, která jen obsahuje řetězec .env. Pro ten případ si nech otevřený bypass přes .claude/settings.local.json nebo prostě příkaz reformulovat. Laťka se zvedne z „jeden řádek" na „musíš to chtít".

Co i se dvěma větvemi neuchytíš

Regex nad command prohraje s interpretry:

node -e "console.log(require('fs').readFileSync('.env','utf8'))"
python -c "print(open('.env').read())"
ruby -e "puts File.read('.env')"
perl -e 'print <>' < .env

Můžeš přidat regexy na node -e, python -c, ruby -e, perl -e, ale to je závod, který nevyhraješ. Pojistky, které dávají smysl vedle hooku:

  • permissions.deny v settings.json – deklarativní vrstva, aktuální syntax Read(./.env), Read(./.env.*), Bash(cat:*). Méně flexibilní než hook, ale čitelné v review.
  • Filesystem permissions – .env vlastněný jiným uživatelem, Claude proces nemá read bit.
  • Rotace secretů a vault (1Password CLI, doppler, sops) – .env v repu prostě není.
  • .gitignore nechrání přístup – Claude Code si ho nečte. Pro blokaci čtení používej permissions.deny nebo hooks.

Certifikát ti dá bod za C. .env ochrání až hook, který ví o Bashi, symlinkách i vnořeném .env.production. I tak ti zbyde jeden interpretr, kterému musíš věřit.

Celou mapu hooks – devět eventů, exit codes, JSON I/O, security pitfalls – mám v průvodci Claude Code hooks.

Dělám workshopy a code review hooks/permissions setupů pro engineering týmy. Napiš na me@jakubkontra.com.