---
title: 'Claude Code Mod to Block .env Reads: Tested (2026)'
description: Build a Claude Code mod to block .env reads. It denies Read, Edit, Write and Bash calls on secret files, passes 5 tests, and leaks on 5 of 14 shell commands.
url: https://nerdleveltech.com/claude-code-mod-block-env-secrets-tested
language: en
date: '2026-10-05'
category: security
tags:
  - claude code
  - claude code mods
  - ai agent security
  - tool.call hook
  - env file protection
  - agentic coding
  - typescript
markdown_url: https://nerdleveltech.com/claude-code-mod-block-env-secrets-tested.md
attribution: 'Source: NerdLevelTech — https://nerdleveltech.com/claude-code-mod-block-env-secrets-tested'
terms: https://nerdleveltech.com/llms.txt
---

# 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.[^1][^2][^3]
- 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.[^6]

## 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.[^2][^3]

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](https://nerdleveltech.com/plugin4shell-ai-coding-agent-plugin-security).

For 10 more mods, from command guards to panes, commands and tools, see our [Claude Code mods guide with tested use cases](https://nerdleveltech.com/claude-code-mods-use-cases-examples).

## 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`:

```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`:

```json
{ "modules": ["./register.ts"] }
```

Save this as `secret-guard/hooks/register.ts`:

```typescript
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.[^7] Each test calls `$.tool.call(...)`, which sends the call through your hooks, and a stub registered with `on` answers in Claude Code's place.[^7]

Save this as `secret-guard/tests/secret-guard.test.ts`:

```typescript
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.[^7] `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."

```text
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:

```text
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](https://nerdleveltech.com/images/claude-code-mod-secret-guard-test-run.png)

*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.[^5]

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

```json
{"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.[^6] 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 `head`[^6] | 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-effort[^6] |
| 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 reach[^6] | Applies only to Bash, PowerShell and Monitor[^6] |

For a plain path block, a permission rule such as `Read(./.env)` needs no code.[^6] 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](https://nerdleveltech.com/human-in-the-loop-claude-agent-sdk-typescript-tutorial) 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. Settings-based command and HTTP hooks have their own fail-open default, with a one-line fix tested in [Claude Code hook failures: onFailure block tested](https://nerdleveltech.com/claude-code-hook-fail-closed-onfailure-tested).
- **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`.[^5]
- **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.[^5]

If you install third-party mods, run `claude plugin validate` and read the `calls:` line first.[^5] 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](https://nerdleveltech.com/ai-sdk-tool-drift-detection-mcp-rug-pull).

## FAQ

**How do I build a Claude Code mod to block .env access?**
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]

**How do I test a Claude Code mod?**
Add a `*.test.ts` file that imports from `claude-code/testing`, then run `claude plugin test` in the mod folder. It needs no session or network and exits 1 on failure.[^7]

**Does a mod need Node.js or a build step?**
No. Anthropic's tutorial says you don't need Node.js, a bundler or a build step, because Claude Code loads `.js` and `.ts` files directly.[^3]

**Is a regex guard enough to protect secrets from an AI agent?**
No. In my run it missed 5 of 14 commands, including `cat .en*` and an archive of the whole directory. Combine it with a `Read` deny rule and OS-level sandboxing.[^6]

**Which Claude Code version do I need?**
v2.1.287 or later.[^3] I tested on 2.1.289.

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

[^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]: 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)
[^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)
[^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)
[^5]: 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)
[^6]: 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)
[^7]: 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)
[^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)
