An AI coding agent can open any file in your project. That includes your .env, where your API keys, database URLs and service-role tokens live. Here's a small Claude Code hook that says no before the file is ever opened.
Why one read is enough
The agent doesn’t have to be doing anything wrong for this to happen. Ask it “why can’t the app connect to the database?” and opening .env to check DATABASE_URL is a perfectly reasonable next step. It's trying to help.
The problem is what happens after. Once the agent reads that file, those values are in its context window: the running conversation the model sees on every turn. From there they can end up in places you never intended.
Once a secret is in context, it travels with the conversation. You can’t un-read it.
- Every later request. The context is sent to the model again on each turn, so the key rides along for the rest of the session.
- Transcripts and logs. Session history is saved to disk in plain text, outside the protections you put on .env itself.
- Code, tools and commits. The agent might “helpfully” inline a key into a config file, a test fixture, or an argument to another tool.
So the only reliable fix is to stop the read itself. You can do that with a Claude Code hook: a small script that runs before the agent uses a tool, and can say no. The whole setup takes about five minutes.
What a hook is
Claude Code lets you register shell commands that run at specific points in its lifecycle: when you submit a prompt, before a tool runs, after a tool runs, when Claude finishes, and so on. The one we want is PreToolUse. It fires after the agent decides to call a tool (read a file, edit a file, run a shell command) and before that tool actually runs.
The hook sits between “Claude decided” and “the tool ran”. Its exit code is the verdict.
Your script receives a JSON description of the pending tool call on standard input. It then answers with its exit code:
- Exit 0: the tool runs normally. Stderr goes nowhere that matters.
- Exit 2: blocked. The tool never runs, and stderr goes back to Claude as the reason.
- Anything else: the tool still runs. It’s treated as a hook error, not a “no”, and stderr is shown to you, not Claude.
Two details matter here. First, the agent isn’t just stopped. It’s told why, so it can pick another approach and carry on with the rest of the task. Second, only exit 2 blocks. If your script crashes or exits 1, the call goes through. Keep that in mind; it comes back in Step 4.
Step 1: Register the hook
Add this to .claude/settings.json in your project:
.claude/settings.json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Read|Edit|Write|Grep|Bash",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-secrets.sh"
}
]
}
]
}
}
Here’s what each part does:
- matcher is a pattern over tool names. Read|Edit|Write|Grep|Bash covers every built-in way to touch a file: reading it, changing it, searching inside it, or going around the file tools with cat .env in a shell.
- command is what runs. $CLAUDE_PROJECT_DIR is set by Claude Code to your project root, so the hook still resolves when Claude has cd'd into a subfolder. The quotes keep it working if the path has spaces.
Where you put this decides who it protects:
- .claude/settings.json: this project, shared with the team (commit it)
- .claude/settings.local.json: this project, just you (kept out of git)
- ~/.claude/settings.json: every project on your machine, just you
For the user-wide version, keep the script in ~/.claude/hooks/ and point command at ~/.claude/hooks/block-secrets.sh instead, since there's no single project directory to resolve against.
Step 2: Write the script
Before writing it, look at what it will receive. For a Read call, standard input looks like this:
Only two fields matter for this hook: tool_name and tool_input. The field inside tool_input depends on the tool.
Now create .claude/hooks/block-secrets.sh. Start with the simplest version that works:
.claude/hooks/block-secrets.sh — simple version
#!/bin/bash
input=$(cat)
target=$(echo "$input" | jq -r '.tool_input.file_path // .tool_input.command // ""')
if echo "$target" | grep -E '(^|[ /])\.env' | grep -qv '\.example'; then
echo "Blocked: .env files are off limits. Use .env.example for variable names." >&2
exit 2
fi
exit 0
Make it executable, and install jq if you don't have it (it's the tool that parses the JSON):
Terminal
chmod +x .claude/hooks/block-secrets.sh
brew install jq # macOS
sudo apt install jq # Debian / Ubuntu
What it does, line by line:
- Reads the whole tool-call JSON from stdin into input.
- Uses jq to pull out the file path (for Read, Edit, Write) or, if there isn't one, the command (for Bash). The // means "or else", so it falls back to an empty string for anything else.
- Checks whether that text contains .env at the start, after a slash, or after a space. That catches .env, config/.env and cat .env, but not src/environments/, because there the dot is missing.
- Lets .env.example through, because templates hold variable names, not values, and they're genuinely useful for the agent to read.
- On a match, prints a reason to stderr and exits with code 2. That message is exactly what Claude will see, so write it as an instruction: say what to do instead.
Step 3: Test it without Claude
You don’t need Claude running to test a hook. It’s just a script that reads stdin, so you can pipe in fake tool calls:
Terminal
echo '{"tool_name":"Read","tool_input":{"file_path":".env"}}' | .claude/hooks/block-secrets.sh
echo "exit code: $?"
Expected: the “Blocked” message and exit code: 2.
I ran the simple version against the obvious cases, and then against a few less obvious ones:
Tool Input Simple version
---- ---------------------------- ----------------
Read .env Blocked (exit 2)
Read /app/.env.local Blocked (exit 2)
Bash cat .env Blocked (exit 2)
Read .env.example Allowed (exit 0)
Read src/app.ts Allowed (exit 0)
Bash cat ".env" Allowed: gap
Bash cat .env.example && cat .env Allowed: gap
Grep search inside .env Allowed: gap
Any .env, with jq not installed Allowed: gap
The first five rows are what most tutorials stop at. The last four are why it’s worth running more than one test:
- Quotes. In cat ".env" the character before .env is a quote, not a space or slash, so the pattern doesn't match.
- A template in the same command. The .example check looks at the whole line. If .env.example appears anywhere, the entire command is waved through, including the cat .env after it.
- Grep. Its target lives in tool_input.path, which the script never reads.
- Missing jq. The script errors, target ends up empty, nothing matches, and it exits 0. It fails open, silently.
Step 4: Close the gaps
Here’s the version I actually use. It’s still about 20 lines:
.claude/hooks/block-secrets.sh — hardened
#!/bin/bash
# Fail closed: without jq we can't inspect the call, so block it.
if ! command -v jq >/dev/null 2>&1; then
echo "Blocked: jq is not installed, so the secrets hook can't inspect this call." >&2
exit 2
fi
input=$(cat)
# Collect every field that can name a file: Read/Edit/Write, Grep, Bash.
target=$(printf '%s' "$input" | jq -r '[.tool_input.file_path, .tool_input.path, .tool_input.glob, .tool_input.command] | map(select(. != null)) | join(" ")')
# Remove the safe templates first, then look for any .env that is left.
rest=$(printf '%s' "$target" | sed -E 's/\.env\.(example|sample|template)//g')
if printf '%s' "$rest" | grep -qE '(^|[^A-Za-z0-9_])\.env'; then
echo "Blocked: .env files are off limits. Use .env.example for variable names." >&2
exit 2
fi
exit 0
What changed, and why:
- It fails closed. If jq is missing, it blocks with a clear reason instead of silently allowing everything. Annoying for a minute, but far better than thinking you're protected when you're not.
- It reads every field that can name a file. file_path, path and glob cover Read, Edit, Write and Grep; command covers Bash. Whatever is present gets joined into one string to check.
- It removes templates before checking, instead of after. .env.example, .env.sample and .env.template are deleted from the string first. A template can no longer vouch for the rest of the command.
- It matches.env after any non-word character. Quotes, =, (, spaces and slashes all count. A letter before the dot doesn't, so process.env and import.meta.env in code or commands are left alone.
Run both versions through the same cases and the gaps close:
Tool Input Simple Hardened
---- --------------------------------------- ------- --------------------
Bash cat ".env" Allowed Blocked
Bash cat .env.example && cat .env Allowed Blocked
Grep path .env or glob .env* Allowed Blocked
Any jq not installed Allowed Blocked, with reason
Edit /proj/.env.production Blocked Blocked
Read .env.example Allowed Allowed
Bash node -e "console.log(process.env.HOME)" Allowed Allowed
Bash npm run dev Allowed Allowed
To make re-testing a habit, save the cases as a small script and run it whenever you change the hook:
test-hook.sh
for json in \
'{"tool_name":"Read","tool_input":{"file_path":".env"}}' \
'{"tool_name":"Bash","tool_input":{"command":"cat \".env\""}}' \
'{"tool_name":"Bash","tool_input":{"command":"cat .env.example && cat .env"}}' \
'{"tool_name":"Grep","tool_input":{"pattern":"KEY","path":".env"}}' \
'{"tool_name":"Read","tool_input":{"file_path":".env.example"}}' \
'{"tool_name":"Bash","tool_input":{"command":"npm run dev"}}'
do
printf '%s' "$json" | .claude/hooks/block-secrets.sh 2>/dev/null
echo "exit $? $json"
done
You should see four exit 2 lines followed by two exit 0 lines.
What it looks like in practice
If Claude Code is already open, start a new session so it picks up the hook (you can also review registered hooks with the /hooks command). Then ask it to "read the .env file".
Illustration of the exchange. The exact wording in your terminal will vary by Claude Code version.
The call to Read(.env) never runs. Claude sees the hook's message, then moves on. In my case it offered to work from the example config instead, which is exactly what the message told it to do.
Two things that are easy to worry about, but don’t need to:
- Your app still works. The hook only inspects the agent’s tool calls. When Claude runs npm run dev, your app loads .env itself through dotenv or your framework, and the command text never mentions the file.
- Auto-approve doesn’t skip it. The hook runs before the permission check, so it applies even to tools you’ve told Claude Code to run without asking.
Your secrets stay in the file, and out of the context window.
Limits you should know about
This is a guardrail, not a sandbox. It catches the ordinary, well-meaning ways an agent opens a secret file. It can’t stop something that’s trying to get around it.
- It’s a text pattern. The hook only sees the command string. A path built indirectly, like cat ./.e* (a glob the shell expands later) or a filename assembled from variables, doesn't contain .env, so it passes. I confirmed the glob case gets through the hardened script too.
- Programs can still read the file. If Claude runs a script that loads .env and prints its values, the hook sees node print-config.js, not the file. Be wary of debug scripts that dump the environment.
- It only covers the tools in your matcher. If you add other tools that can read files, such as an MCP filesystem server, add their tool names to the matcher (MCP tools are named like mcp__server__tool, and the matcher accepts patterns such as mcp__filesystem__.*). The hardened script already checks path, which many of them use.
- Other secret files exist. .env is the common one. To extend the check to *.pem keys, credentials.json and .npmrc, swap the grep line for the one below (and make the message say "secret files").
block-secrets.sh — extended pattern
if printf '%s' "$rest" | grep -qE '(^|[^A-Za-z0-9_])\.env|\.pem($|[^A-Za-z0-9_])|credentials\.json|\.npmrc'; then
Add a second and third layer
No single control covers everything, so stack a few that fail in different ways:
Each layer covers a gap the others leave open.
Layer 1: permission deny rules. Claude Code’s own settings can refuse to read specific paths, with no script involved. Add them next to the hooks block:
.claude/settings.json
{
"permissions": {
"deny": [
"Read(./.env)",
"Read(./.env.local)",
"Read(./.env.production)"
]
}
}
List your real files by name rather than using a wildcard like .env.*, which would also deny .env.example. Deny rules match on paths, so they don't see cat .env inside a shell command. That's the gap the hook fills.
Layer 2: the hook from this post, which also covers Bash and tells Claude what to do instead.
Layer 3: nothing valuable to leak. Keep real production secrets out of local files whenever you can. Use a secrets manager, and short-lived or tightly scoped keys for development. If something does slip through, rotating a scoped dev key is a five-minute job, not an incident.
Troubleshooting
- The hook never fires. Check the script is executable (chmod +x), the path in command is right, and settings.json is valid JSON (jq . .claude/settings.json will complain if it isn't). Then start a new session. Running claude --debug shows hook activity as it happens.
- Every call is blocked with “jq is not installed”. That’s the fail-closed check working. Install jq and it clears up.
- A file you need is blocked. The pattern also matches names that start with .env, like .envrc. Add the file to the sed line alongside example|sample|template if it's safe to read, or leave it blocked if it holds secrets too (an .envrc often does).
Quick checklist
- Add the PreToolUse block to .claude/settings.json
- Create .claude/hooks/block-secrets.sh (hardened version) and chmod +x it
- Make sure jq is installed
- Pipe fake tool calls through it: four exit 2, two exit 0
- Add permissions.deny rules for your real .env files
- Commit .claude/settings.json and .claude/hooks/ so the whole team gets the same guardrail
Wrapping up
AI coding agents are most useful when you let them move fast, and hooks let you set the boundaries once instead of watching every step. A 20-line script is a small price for keeping your keys out of the context window. Just remember to test it like an attacker would, not only like a user would. That’s where the interesting bugs were.
I share practical dev tips like this regularly. You can follow along on Instagram at @mubashi_mohd.builds or find more at ordinarydev.in.
Originally published on Medium in Artificial Intelligence in Plain English.





Top comments (0)