Claude Code Mod to Block .env Reads: Tested (2026)
October 5, 2026

A Claude Code mod to block .env reads hooks tool.call and returns { deny } for Read, Edit, Write and Bash calls that touch secret files. The mod below passed 5 of 5 tests but leaked on 5 of 14 test shell commands, so treat it as a guardrail.
TL;DR
- Mods shipped on October 1, 2026 and need Claude Code v2.1.287 or later.123
- A mod needs three files: a manifest, a
hooks.jsonthat points at a module, and the module itself.3 - The
tool.callevent lets a mod refuse a call before it runs, and Claude reads yourdenytext as the tool's result.4 - I ran the mod on Claude Code 2.1.289:
claude plugin validatepassed, andclaude plugin testran 5 tests with 0 failures in under a second. - A regex on the command text denied 7 of 14 test commands. Of the 7 it allowed, 5 were leaks and 2 were correct allows. Pair the mod with a
Readdeny rule and the sandbox.5
What You'll Learn
- What a Claude Code mod is and which files it needs
- How to write a
tool.callguard that denies secret-file access - How to test the guard with
claude plugin testand what a denied call returns - Where the guard fails, measured on 14 shell commands
- How it differs from permission deny rules, and why mods themselves are not sandboxed
What is a Claude Code mod?
A Claude Code mod is a plugin with a hooks module: a JavaScript or TypeScript file whose functions Claude Code calls when events happen. Anthropic describes mods as "small TypeScript functions that change how Claude Code works."1 They can observe an event, rewrite it, or answer it in Claude Code's place.4 Mods require Claude Code v2.1.287 or later, and v2.1.287 is the changelog entry that added them.23
This post uses a narrow slice of the feature: a guard on tool calls. If you want the wider plugin-supply-chain picture first, read our analysis of the Plugin4Shell coding-agent plugin risk.
How do you build a Claude Code mod to block .env reads?
You write three files. The manifest names the mod, hooks/hooks.json lists one module path under modules (that key is what makes a plugin a mod), and the module exports register.3
Save this as secret-guard/.claude-plugin/plugin.json:
{
"name": "secret-guard",
"version": "0.1.0",
"description": "Denies Read/Edit/Write/Bash tool calls that touch .env files or private keys.",
"author": { "name": "NerdLevelTech" }
}
Save this as secret-guard/hooks/hooks.json:
{ "modules": ["./register.ts"] }
Save this as secret-guard/hooks/register.ts:
import type { Register } from 'claude-code'
// Files a coding agent should not read or write: .env, .env.local, .envrc, id_rsa, id_ed25519, *.pem
const SECRET_PATH = /(^|\/)(\.env(rc|\..+)?|id_(rsa|ed25519)|[^/]+\.pem)$/
// The same names, found inside a shell command string
const SECRET_IN_COMMAND = /(^|[\s"'=\/])(\.env(rc|\.[\w.-]+)?|id_(rsa|ed25519)|[\w.-]+\.pem)(?=$|[\s"';|&)])/
export const register: Register = on => {
// An array matcher covers all three file tools with one hook
on('tool.call', { tool: ['Read', 'Edit', 'Write'] }, ($, e, next) =>
SECRET_PATH.test(e.file_path)
? { deny: `${$.plugin.name}: ${e.file_path} is a protected secret file. Ask the user for the variable names or non-secret values you need.` }
: next(e),
)
on('tool.call', { tool: 'Bash' }, ($, e, next) =>
SECRET_IN_COMMAND.test(e.command)
? { deny: `${$.plugin.name}: this command touches a protected secret file. Ask the user for the variable names or non-secret values you need.` }
: next(e),
)
}
Three details come straight from Anthropic's documentation. A matcher field can be a value, an array of values, or a regular expression, so one hook covers three tools.4 tool.call fires for each tool call, including calls a subagent makes and calls to MCP tools.4 And because Claude reads the deny text as the tool's result, the docs say to write it as an instruction Claude can act on, which is why ours tells the agent to ask the user for variable names or non-secret values.4
How do you test a Claude Code mod?
Run claude plugin test from the mod folder. It runs your *.test.ts files with no session, sign-in or network, and it exits with status 1 when a test fails, so it works in CI.6 Each test calls $.tool.call(...), which sends the call through your hooks, and a stub registered with on answers in Claude Code's place.6
Save this as secret-guard/tests/secret-guard.test.ts:
import { test, expect } from 'claude-code/testing'
test('denies Read of .env', async $ => {
const r = await $.tool.call({ tool: 'Read', file_path: '/repo/.env' })
expect(r.deny).toContain('protected secret file')
})
test('denies Bash cat .env.local', async $ => {
const r = await $.tool.call({ tool: 'Bash', command: 'cat .env.local | head' })
expect(r.deny).toContain('protected secret file')
})
test('lets Read of README.md through the plugin', async ($, on) => {
// Answer in Claude Code's place, so a call the guard passes on has somewhere to land
on('tool.call', () => ({ result: 'ok' }))
const r = await $.tool.call({ tool: 'Read', file_path: '/repo/README.md' })
expect(r).toEqual({ result: 'ok' })
})
test('does not match env-like words in ordinary commands', async ($, on) => {
on('tool.call', () => ({ result: 'ok' }))
const r = await $.tool.call({ tool: 'Bash', command: 'echo environment.txt' })
expect(r).toEqual({ result: 'ok' })
})
test('denies Write of a private key and Bash that reads it', async $ => {
const w = await $.tool.call({ tool: 'Write', file_path: '/home/u/.ssh/id_ed25519', content: 'x' })
expect(w.deny).toBeDefined()
const b = await $.tool.call({ tool: 'Bash', command: 'openssl x509 -in server.pem' })
expect(b.deny).toBeDefined()
})
The two allow tests register the stub the docs use, on('tool.call', () => ({ result: 'ok' })), so a call the guard passes on has somewhere to land.6 toEqual({ result: 'ok' }) then shows the call reached the stub. The deny tests need no stub, because the guard answers first.
What I ran
I ran these on Claude Code 2.1.289 (October 5, 2026, in a cloud sandbox). claude plugin validate ./secret-guard printed the hooks and $ calls it found in the module and passed. The manifest carries an author field because without one the command still passes but warns "No author information provided."
Validating plugin manifest: /tmp/mods/secret-guard/.claude-plugin/plugin.json
Validating hooks: /tmp/mods/secret-guard/hooks/hooks.json
❯ ./register.ts hooks: tool.call{tool=Read|Edit|Write}, tool.call{tool=Bash}
❯ ./register.ts calls: nothing on $
✔ Validation passed
claude plugin test, run inside the secret-guard folder, then printed:
tests/secret-guard.test.ts:
(pass) denies Read of .env [38.80ms]
(pass) denies Bash cat .env.local [12.92ms]
(pass) lets Read of README.md through the plugin [15.21ms]
(pass) does not match env-like words in ordinary commands [13.25ms]
(pass) denies Write of a private key and Bash that reads it [13.50ms]
5 pass
0 fail
Ran 5 tests across 1 file. [0.23s]

Method: claude --version, claude plugin validate ./secret-guard and claude plugin test, captured on October 5, 2026 with TERM=xterm-256color, then re-rendered as an image from the captured text. Test timings vary from run to run.
The calls: nothing on $ line is the useful part for reviewers. Anthropic's admin guide tells reviewers to read that line to see what a mod can reach, including files, processes and the network, and this guard calls none of them.7
Every result in this post comes from that test harness and from a scratch test that sends calls through the mod's hooks. I did not load the mod into a live Claude Code session. To try it in one, start Claude Code with claude --plugin-dir ./secret-guard, which loads a plugin directory for one session without installing it.3
A test failure worth knowing about
My first draft asserted r.isError was true on a denied call, and both denial tests failed with Received: undefined. Printing the result showed why: a denied $.tool.call resolves to an object whose only key is deny.
{"deny":"secret-guard: /repo/.env is a protected secret file. Ask the user for the variable names or non-secret values you need."}
Assert on deny. This is what I observed on 2.1.289, so recheck it if your version differs.
Where does the regex guard fail?
It fails wherever the shell can build a filename the regex never sees. I sent 14 Bash commands through the mod with $.tool.call in a scratch test, separate from the five tests above, and recorded whether deny came back:
| Command | Result | Correct? |
|---|---|---|
cat .env, cat ./.env, cat .env.local | head, cp .env /tmp/x, source .envrc, cat "$PWD/.env" | Denied | Yes |
python3 -c "print(open('.env').read())" | Denied | Yes |
cat .e""nv | Allowed | Leak |
cat $(printf ".en%s" v) | Allowed | Leak |
cat .en* | Allowed | Leak |
cat *.pem | Allowed | Leak |
tar czf a.tgz . | Allowed | Leak (archives .env) |
echo environment.txt, ls .envoy | Allowed | Yes |
Seven commands were denied, five leaked, and two were correctly allowed. Anthropic's own docs make the same point about a command-text hook. After an example tool.check hook that refuses git push while the branch is main, they say: "The hook matches the text of the command, so treat it as a reminder for Claude."4 A mod that reads command strings is a speed bump for an agent that wanders into .env by accident, not protection against an agent that has been prompt-injected to evade it.
The guard is also narrow in what it watches, and the same scratch run showed it. Both patterns deny .env.example and .env.sample, which usually hold placeholders, not secrets. They let .npmrc, ~/.aws/credentials and server.key through, and .ENV too, which names the same file on a case-insensitive filesystem. grep -r API_KEY . passes because it never names the file, a gap the permissions docs also describe for deny rules.5 And because the hooks match only Read, Edit, Write and Bash, a Grep call with path: '/repo/.env', a Glob call, a NotebookEdit call and a call to a tool named mcp__filesystem__read_file all passed straight through.
Mod, deny rule or sandbox: which should you use?
Use all three for secrets. They fail in different places.
| Layer | What it checks | Weak spot |
|---|---|---|
Read(./.env) deny rule | The path in Claude's file tools and in Bash file commands such as cat and head5 | Doesn't apply to a command that reads files without naming them, such as grep -r pattern ., or to a script that opens files itself. Grep and Glob coverage is best-effort5 |
Mod tool.call guard | Whatever your code inspects, including Bash text | Command-string regex (5 leaks above) and every tool it doesn't hook |
| Sandbox | OS-level limits on what shell commands and their child processes can reach5 | Applies only to Bash, PowerShell and Monitor5 |
For a plain path block, a permission rule such as Read(./.env) needs no code.5 The mod earns its place when you want a denial message that tells Claude what to do next, a log line in the transcript, or logic a static rule can't express.4 Our human-in-the-loop approval tutorial for the Claude Agent SDK shows the SDK-side counterpart: a PreToolUse hook that hard-blocks writes to secrets paths, with a canUseTool approval prompt behind it.
Are Claude Code mods sandboxed?
No. Anthropic's announcement says: "Mods run with the same access to your machine as Claude Code itself. They aren't sandboxed, and you should only install mods from sources you trust, the same way you'd install any code on your computer."1 Three consequences follow for a guard like this one:
- A failing hook fails open by default. If a hook throws or times out before calling
next, Claude Code skips it and the call carries on as if your guard weren't there. Attach.catchto return{ deny }and fail closed.4 Hooks also have a 10-second limit of their own running time.8 I did not test the.catchpath in this run. - Deny rules outrank a mod's approval, with one gap. On machines where the built-in
sec-defaultguard loads, a user's mod can't approve a call that adenyrule refuses. That covers Claude's tool calls, not the mod's own file access: the docs state that withRead(.env)denied, a mod can still read that file with$.fs.read.7 - The built-in guard is conditional. It loads when the machine has managed settings or the user is signed in on a Team or Enterprise plan.7
If you install third-party mods, run claude plugin validate and read the calls: line first.7 For a related risk, an MCP server that changes its tool definitions after you approved them, see our write-up on detecting MCP tool drift.
Bottom Line
A tool.call mod gives you a cheap, testable guard: five tests passed in under a second, and the denial message steers the agent toward asking you instead. Its limit is real, because 5 of 14 test commands got through. Treat the mod as the friendly layer, keep the deny rule and the sandbox underneath it, and read any third-party mod's calls: line before you install it.
Footnotes
-
Anthropic, "Customize Claude Code with mods in TypeScript," October 1, 2026 (the "small TypeScript functions" description, the "available today" note and the "aren't sandboxed" warning). https://claude.com/blog/claude-code-mods (fetched 2026-10-05) ↩ ↩2 ↩3
-
Claude Code Docs, "Claude Code changelog," version 2.1.287 (October 1, 2026): "Added Claude Mods: plugins may now modify deeper behavior". https://code.claude.com/docs/en/changelog (fetched 2026-10-05) ↩ ↩2
-
Claude Code Docs, "Create a mod" (version requirement, the three files, the
moduleskey, no build step). https://code.claude.com/docs/en/plugins/mods/create (fetched 2026-10-05) ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 -
Claude Code Docs, "React to events with a mod" (
tool.call, matchers, thedenytext, fail-open behavior and.catch, thetool.checkcaveat,$.ui.log). https://code.claude.com/docs/en/plugins/mods/events (fetched 2026-10-05) ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 -
Claude Code Docs, "Configure permissions" (
Read(./.env), what Read deny rules cover, best-effort Read coverage, sandboxing scope). https://code.claude.com/docs/en/permissions (fetched 2026-10-05) ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 -
Claude Code Docs, "Test a mod" (
claude plugin test, the test kit,onstubs, exit status). https://code.claude.com/docs/en/plugins/mods/test (fetched 2026-10-05) ↩ ↩2 ↩3 ↩4 -
Claude Code Docs, "Manage mods for your organization" (
sec-default@builtin, deny-rule precedence, the$.fs.readcaveat, reviewingcalls:). https://code.claude.com/docs/en/plugins/mods/admin (fetched 2026-10-05) ↩ ↩2 ↩3 ↩4 -
Claude Code Docs, "Use the mods API" (10-second limit on a hook's own running time). https://code.claude.com/docs/en/plugins/mods/api (fetched 2026-10-05) ↩



