security

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

October 5, 2026

Claude Code Mod to Block .env Reads: Tested (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.json that points at a module, and the module itself.3
  • The tool.call event lets a mod refuse a call before it runs, and Claude reads your deny text as the tool's result.4
  • I ran the mod on Claude Code 2.1.289: claude plugin validate passed, and claude plugin test ran 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 Read deny 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.call guard that denies secret-file access
  • How to test the guard with claude plugin test and 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]

Terminal output of claude --version (2.1.289), claude plugin validate and claude plugin test for the secret-guard mod: validation passed, 5 pass, 0 fail

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:

CommandResultCorrect?
cat .env, cat ./.env, cat .env.local | head, cp .env /tmp/x, source .envrc, cat "$PWD/.env"DeniedYes
python3 -c "print(open('.env').read())"DeniedYes
cat .e""nvAllowedLeak
cat $(printf ".en%s" v)AllowedLeak
cat .en*AllowedLeak
cat *.pemAllowedLeak
tar czf a.tgz .AllowedLeak (archives .env)
echo environment.txt, ls .envoyAllowedYes

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.

LayerWhat it checksWeak spot
Read(./.env) deny ruleThe path in Claude's file tools and in Bash file commands such as cat and head5Doesn'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 guardWhatever your code inspects, including Bash textCommand-string regex (5 leaks above) and every tool it doesn't hook
SandboxOS-level limits on what shell commands and their child processes can reach5Applies 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 .catch to return { deny } and fail closed.4 Hooks also have a 10-second limit of their own running time.8 I did not test the .catch path in this run.
  • Deny rules outrank a mod's approval, with one gap. On machines where the built-in sec-default guard loads, a user's mod can't approve a call that a deny rule refuses. That covers Claude's tool calls, not the mod's own file access: the docs state that with Read(.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

  1. 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

  2. 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

  3. Claude Code Docs, "Create a mod" (version requirement, the three files, the modules key, no build step). https://code.claude.com/docs/en/plugins/mods/create (fetched 2026-10-05) ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7

  4. Claude Code Docs, "React to events with a mod" (tool.call, matchers, the deny text, fail-open behavior and .catch, the tool.check caveat, $.ui.log). https://code.claude.com/docs/en/plugins/mods/events (fetched 2026-10-05) ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9

  5. 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

  6. Claude Code Docs, "Test a mod" (claude plugin test, the test kit, on stubs, exit status). https://code.claude.com/docs/en/plugins/mods/test (fetched 2026-10-05) ↩ ↩2 ↩3 ↩4

  7. Claude Code Docs, "Manage mods for your organization" (sec-default@builtin, deny-rule precedence, the $.fs.read caveat, reviewing calls:). https://code.claude.com/docs/en/plugins/mods/admin (fetched 2026-10-05) ↩ ↩2 ↩3 ↩4

  8. 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) ↩

Frequently Asked Questions

Hook tool.call for Read, Edit, Write and Bash, test the path or command against a pattern, and return { deny: 'reason' } without calling next . The tool never runs and Claude reads the reason as the result. 4