Keep Claude Code out of sensitive files: a PreToolUse hook for .env, keys and secrets
I have a project with a `.env` full of production keys. If Claude Code opens it "to check the config" and quotes it back to me in a response, the secret is already out — the file has entered the model's context and from there it can end up in the reply, in a session log, in a shared artifact. The good news: I have a simple way to stop it — a PreToolUse hook that intercepts every `Read`/`Edit`/`Write` call, inspects the path, and blocks the tool before it runs when I don't like what it sees. In this article I show you the hook I use, how to wire it in `settings.json`, and why exit code 2 is the detail that makes the whole thing work.
🛡️ Why a hook, and why .gitignore is not enough
`.gitignore` protects your git history, not your agent. Claude Code, when it works on a repo, has access to the local filesystem: if you ask it "read me the project config", the `Read` tool has no implicit deny-list — it opens whatever is there, `.env` included. From that point the content is inside the model's context, and from there it can surface in a reply, a session log, or a shared artifact.
The clean fix isn't "remember not to ask for that file": it's to put a deterministic gate in front of the tool. PreToolUse hooks in Claude Code exist for exactly this — callbacks the agent runs before every tool call, that can let it through, add context, or block it entirely. It's not AI moderating AI: it's your code, fast and predictable, sitting between the model and the filesystem.
.gitignore
- Prevents the file from being committed
- Doesn't stop Claude from reading it
- Doesn't block Edit or Write
- Zero signal when someone tries to open it
PreToolUse hook
- Intercepts every Read/Edit/Write call
- Blocks before the tool runs (exit 2)
- Returns a message the model reads
- Loggable: you know who tried what
.gitignore protects your remote repository; the hook protects the agent's session. Different layers, both needed.
⚙️ How a PreToolUse hook works
A hook is a script (bash, python, node — your call) that Claude Code invokes on a tool lifecycle event. PreToolUse fires before the tool runs. The agent hands the script a JSON payload on stdin describing the call: tool name, input, session context. The script decides via exit code: `0` = go ahead, `2` = blocked (Claude reads stderr as the reason), any other code = transparent error.
The nice thing about that contract is it's easy to test by hand: you can pipe a fake JSON to the script and see what it returns, without spinning up the agent. And since it's your code, you have full freedom: path regex, folder whitelists, integration with a secret scanner, structured logs to a file. The only discipline is to return fast — the hook runs in-line with the tool call, so it's not the place for slow work.
- 01Claude proposes the tool callThe model decides to call Read on a path.
- 02PreToolUse receives the JSONTool name, path, full input on stdin.
- 03The script decidesPath regex, whitelist, secret scan — whatever you want.
- 04Exit code drives the outcome0 proceeds, 2 blocks with a message, other = error.
- 05Claude sees the blockThe stderr from the block becomes context for the next reply.
🧪 The script I use: block-secrets.sh
Here's the minimal script I keep in my user profile. It lives at `~/.claude/hooks/block-secrets.sh`, is executable, and does one thing: reads the JSON on stdin, extracts the file path from a `Read`/`Edit`/`Write` call and, if the path matches a list of sensitive patterns, exits with code 2 and a clear message on stderr. Nothing else: small, fast, readable in thirty seconds.
The pattern list is the one I've found most useful in practice: `.env` and variants (`.env.local`, `.env.production`), private keys (`.pem`, `.key`), token files (`.npmrc` with `_authToken`, `.pypirc`), private folders (`secrets/`, `.aws/credentials`, `.ssh/`). It's not exhaustive by design — it's my baseline, you extend it to your context: add what's actually confidential in your repo, and don't pretend the hook is a firewall.
#!/usr/bin/env bash
# PreToolUse: block Read/Edit/Write on sensitive paths.
set -euo pipefail
payload=$(cat)
tool=$(echo "$payload" | jq -r '.tool_name // empty')
path=$(echo "$payload" | jq -r '.tool_input.file_path // empty')
case "$tool" in Read|Edit|Write) : ;; *) exit 0 ;; esac
[ -z "$path" ] && exit 0
deny='(^|/)\.env($|\.|/)|\.pem$|\.key$|(^|/)secrets/|(^|/)\.aws/credentials$|(^|/)\.ssh/'
if [[ "$path" =~ $deny ]]; then
echo "blocked by PreToolUse hook: sensitive path '$path'" 1>&2
echo "ask the user before touching this file." 1>&2
exit 2
fi
exit 0Claude Code hooks · official docs ↗
🔌 Wiring it in settings.json
You register the hook in `~/.claude/settings.json` (global) or `.claude/settings.json` in the project (local, committable). Same shape either way: the `hooks.PreToolUse` key holds an array of rules, each with a `matcher` (which tool you intercept) and a `hooks[]` (what runs). I put the rule globally so it covers every project by default; if a repo needs a special allow-list, I add a local rule that extends it.
One operational detail: the `matcher` is a regex on tool names. `"Read|Edit|Write"` covers the three calls that can open a file by content; if you're paranoid you also add `Bash` (to catch `cat .env`, though the list of commands to block is much longer and deserves its own hook). The `command` is the absolute path to the script — Claude Code hands it the JSON on stdin, the environment is your shell's.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Read|Edit|Write",
"hooks": [
{
"type": "command",
"command": "$HOME/.claude/hooks/block-secrets.sh"
}
]
}
]
}
}🚦 Why exit code 2 is the detail that makes it work
The hook contract is precise: `exit 0` lets the tool call through, `exit 2` blocks it deterministically and feeds your stderr to the model as the reason, any other exit code is treated as script error and shown to the user. 2 is the sweet spot because it does two things at once: it prevents the call and it tells Claude why, so the next reply doesn't blindly retry — it explains the file is protected.
In practice the UX holds up: when Claude tries to open `.env`, it gets back `blocked by PreToolUse hook: sensitive path '.env'`, and the reply becomes something like "I can't open `.env` — it's protected by your hooks, tell me what you'd like me to show you instead". No crash, no loop, no "mysterious error". With `exit 1` you'd only get noise; `exit 2` is what turns the hook into a policy the agent can read.
# Simulate the JSON Claude Code would pass
echo '{"tool_name":"Read","tool_input":{"file_path":".env"}}' \
| ~/.claude/hooks/block-secrets.sh
echo "exit=$?"
# → stderr: blocked by PreToolUse hook: sensitive path '.env'
# → exit=2
# Harmless path: passes
echo '{"tool_name":"Read","tool_input":{"file_path":"README.md"}}' \
| ~/.claude/hooks/block-secrets.sh
echo "exit=$?"
# → exit=0⚠️ Mistakes to avoid
First mistake: treating the hook as the only line of defense. A PreToolUse guards the `Read` tool, but if Claude runs `Bash: cat .env` it walks around the file filter: to cover that you need a second hook, matcher `Bash`, that inspects the full `command` string and looks for risky patterns. Treat the hook as one of many layers — alongside `.gitignore`, filesystem permissions (`chmod 600`) on sensitive files, and an external secret manager — and you close the loopholes.
Second mistake: slow logic in the hook. It runs in-line: if it calls an external service, scans 50 MB of files, waits on an API, every tool call Claude makes pays that latency. Keep the hook under a few milliseconds: if you need a heavier secret scan, push it to a PostToolUse async hook or to a git pre-commit, not to the hot path of the agent.
- Don't rely on the hook alone: `.gitignore`, filesystem permissions and a secret manager are still needed.
- Cover Bash too if you want to block `cat`, `grep`, `less` on sensitive files — separate matcher, different logic.
- Keep it fast: no network, no heavy scans; you're on the critical path.
- Log it: append a line when you block, so you learn what the agent tries to open.
- Retest after every change: a broken script with `exit 1` just produces noise; with `exit 0` it opens everything.
✅ Final checklist
Before I let Claude Code loose on a real repo, I always run the same short checklist — five items, and I skip none of them, because each has bitten me on a past project. It's boring, but it's what makes an agent session safety-equivalent to your own terminal session.
If any item isn't green, the agent doesn't start. It holds locally, it holds in a skill I share with the team, and it doubles in repos touching production data or customer files. The time you spend writing it the first time you get back the first time the hook actually catches something.
[ ] block-secrets.sh exists and is executable
[ ] settings.json registers the hook with matcher Read|Edit|Write
[ ] Manual test: .env → exit 2, README.md → exit 0
[ ] .gitignore covers the same paths (defense in depth)
[ ] Optional but recommended: an append-log so you know who tries whatFrequently asked questions about Claude Code PreToolUse hook
What is a PreToolUse hook in Claude Code?
It's a callback Claude Code runs before each tool call (Read, Edit, Write, Bash, etc.). It receives a JSON payload on stdin with the tool name and input, and decides via exit code whether the call proceeds (0), is blocked with a message (2), or errors out (any other code). It's the deterministic way to put your own policy between the model and the filesystem.
Why isn't .gitignore enough to protect .env from the agent?
.gitignore prevents commits, not reads. Claude Code accesses the local filesystem: if the Read tool opens .env, the content enters the model's context and from there it can surface in a reply or a log. The hook steps in one layer earlier: it blocks the tool call, so the file is never read.
Why exit code 2 instead of 1?
In the hook contract, exit 2 is specific: it blocks the tool call and feeds the hook's stderr to the model as the reason. Claude reads that message and the next reply explains to the user that the file is protected, instead of blindly retrying. Exit 1 (and every other code) is treated as a script error and does not produce the same clean block behavior.
Should I put the hook globally or per project?
I put it globally in ~/.claude/settings.json so it covers me on every repo by default. If a project needs a special allow-list (say, reading a dedicated .env.example), I add a local rule in the project's .claude/settings.json, which gets committed and applies to the whole team. The local rule doesn't replace the global one — they add up.
Does the hook also block cat .env inside Bash?
Not with the Read|Edit|Write matcher shown in this article — that only inspects file tools. To intercept Bash you need a rule with matcher Bash and a script that inspects the full command (tool_input.command) looking for patterns like cat .env, less .env, grep inside private folders. Treat them as two distinct hooks: the logic is different.
Does the hook slow the session down?
Only as slow as you make it. The script in this post does jq + regex and returns in milliseconds, below the perceivable threshold. If you want to integrate an external secret scanner or a network call, don't do it here: you're on the critical path of every tool call. Move it to an async PostToolUse or a git pre-commit, where latency doesn't hurt.
Let's talk
If this topic is relevant to you, write to me: comparing notes on code and AI is always time well spent.