AI Workflows

Your AI Agent Can Delete Your .env. It Just Hasn't Yet.

Two config files stop Claude Code from reading your secrets, hardcoding a key, or wiping .env — and one of them holds even in bypass mode.

7 min readVerified 8/25/2026

You gave an agent a shell, your repository, and a .env file with production credentials in it. Then you clicked "Yes, and don't ask again" because the fourteenth permission prompt in an hour was breaking your focus.

That is the whole story. Nothing has gone wrong yet — and the reason is luck, not architecture.

A cat holding a handgun, captioned .env — the agent has the file and the shell, and nothing between them

The fix takes about ten minutes and lives in two files. One of them keeps working even when you launch with --dangerously-skip-permissions.

What actually goes wrong

Not the Hollywood version. The boring version:

  • The cleanup. You ask for "remove the leftover config files." The agent's idea of leftover includes .env. It is not in git, because you did the right thing and gitignored it. It is now gone.
  • The convenience key. A test fails because an env var isn't loading. The agent "fixes" it by pasting the literal key into config.ts, commits, and pushes. GitHub's secret scanning tells you before your teammate does — if you're lucky.
  • The injected instruction. The agent fetches a page, reads a GitHub issue, or opens a dependency's README that contains text addressed to it, not to you. Now something other than you is steering a process that has your credentials — the mechanism is prompt injection, and it does not need you to make a mistake.

Layer 1 — the deny list

prerequisites

  • Claude Code installed and logged in — claude --version should answer.
  • A project you actually work in. Both layers live in .claude/, next to your code.
  • Node.js 20+ if you want the hook in the second half. The deny list needs nothing at all.
  • Ten minutes. There is no step here that needs a teammate's approval.

Claude Code evaluates permission rules in a fixed order: deny, then ask, then allow. First match wins, and specificity doesn't change the order. Which gives you one very useful property:

Deny rules block in every permission mode, including bypassPermissions. Allow rules have no effect in that mode at all.

So the deny list is the one thing that survives your own impatience. Paste this into .claude/settings.json:

json
{
  "permissions": {
    "deny": [
      "Read(./.env)",
      "Read(./**/.env)",
      "Read(./**/.env.*)",
      "Read(./secrets/**)",
      "Read(~/.ssh/**)",
      "Read(./**/*.pem)",
      "Edit(./.env)",
      "Edit(./**/.env.*)",
      "Bash(rm .env*)",
      "Bash(git push --force *)",
      "PowerShell(Remove-Item *.env*)"
    ],
    "ask": [
      "Bash(git push *)"
    ]
  }
}

Three things worth knowing about that block:

  • A Read deny rule also blocks Edit and Write on the same path, including creating a new file there. So Read(./.env) covers reading, rewriting, and clobbering in one line.
  • The rules reach into Bash, for the file commands Claude Code recognizes — cat, head, tail, sed. cat .env is denied by the same rule that denies the Read tool.
  • ask is not a weaker deny. An ask rule prompts you even when a more specific allow rule matches, which is exactly what you want for git push.

Where the file goes decides who gets protected:

file tree

your-project/
├── .claude/
│   ├── settings.json        ← commit this — every teammate gets the rules
│   └── settings.local.json  ← gitignored — your machine only
└── ~/.claude/settings.json  ← you, in every project you ever open

A deny in any scope beats an allow in any other scope, so a rule in your user settings can't be undone by a project that allows the same thing.

Where the deny list runs out

It matches text, not intent. That gap is real and worth being honest about:

  • Bash(rm .env*) blocks rm .env. It does not block node scripts/cleanup.js, which unlinks the same file — Read and Edit rules don't apply to arbitrary subprocesses that open files themselves.
  • Argument-level Bash patterns are fragile by design. The docs say so outright: Bash(curl http://github.com/ *) misses curl -X GET, misses https://, misses URL=… && curl $URL.
  • No path rule can catch "there is an AWS key sitting in the middle of this diff." That isn't a location, it's content.

For OS-level enforcement there's sandboxing. For content, you need something that reads the actual payload before the tool runs.

Layer 2 — a hook that reads the payload

A PreToolUse hook is a script Claude Code runs before a tool executes. If you have never wired one, start with the basics — the shape below assumes you know where hooks goes in a settings file. It gets the whole call as JSON on stdin — tool_name, tool_input, the command text, the file content about to be written — and it gets a veto:

  • Exit code 2 blocks the call, and whatever the script wrote to stderr is fed back to the model as the reason. That happens before permission rules are evaluated, so it beats an allow rule.
  • Exit 0 with JSON on stdout (permissionDecision: "allow" | "deny" | "ask") gives you the same power with a nicer message.

The asymmetry is deliberate and worth memorizing: a hook can block what your rules allowed, but a hook can never unblock what your rules denied. Deny-first holds.

Here's one that does the job. It's open source, MIT, one file, zero dependencies:

github.com/guidekitdev/claude-code-guardrails

enforce.mjs blocks four classes of action, deterministically, regardless of what the model decided:

  1. Reading secret files.env, .pem, .p12, id_rsa, id_ed25519, credentials.
  2. Destroying a secret file from the shellrm -f ./apps/api/.env.production, Remove-Item .\keys\id_ed25519, echo "" > .env. This is the case the permission rule structurally cannot cover: it reads the whole command and finds the target wherever it sits. Deleting .env.example still works — a sample file is meant to be disposable.
  3. Introducing hardcoded secrets — in a Write, an Edit, or a shell command. It matches on shape: -----BEGIN … PRIVATE KEY-----, AKIA…, ghp_…, xoxb-…, sk-…, and the generic api_key = "…" / password: "…" form. Test fixtures and *.example files are allowlisted, so it doesn't fight your own repo.
  4. Anything you add — project-specific banned patterns from .claude/guardrails.json:
json
{
  "enforce": {
    "enabled": true,
    "blockSecretDeletion": true,
    "allowSecretsIn": ["**/*.example", "**/fixtures/**", "**/*.spec.*"],
    "bannedPatterns": [
      { "pattern": "terraform\\s+destroy", "message": "destroy runs from CI, never from an agent session." },
      { "pattern": "git\\s+push\\s+.*--force", "message": "force-push to a shared branch needs a human." },
      { "pattern": "drop\\s+(table|database)", "message": "schema drops go through a migration, not a shell." }
    ]
  }
}

Which layer catches what

The thing that goes wrong Deny rule The hook
cat .env, or the Read tool on it ✅ blocked ✅ blocked
rm .env ✅ blocked ✅ blocked
rm -f ./apps/api/.env.production ❌ prefix doesn't match ✅ reads the whole command
echo "" > .env ⚠️ only if an Edit rule covers the target ✅ blocked
An AKIA… key pasted into config.ts ❌ not a path ✅ blocked
node scripts/cleanup.js deleting .env itself ❌ — this is what sandboxing is for

Neither layer is optional, which is the point: the deny rule survives bypass mode and the hook sees content. Run both.

Where to drop it

Option A — copy one file. Take enforce.mjs into .claude/hooks/, then wire it in .claude/settings.json:

json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Read|Edit|Write|MultiEdit|Bash|PowerShell",
        "hooks": [
          {
            "type": "command",
            "command": "node \"${CLAUDE_PROJECT_DIR}/.claude/hooks/enforce.mjs\"",
            "timeout": 10
          }
        ]
      }
    ]
  }
}

${CLAUDE_PROJECT_DIR} always resolves to your project root, so the same line works for everyone who clones the repo. Commit both files and the whole team is covered on their next session.

Option B — install it as a plugin, and the hook arrives already wired through the plugin's own hooks.json. Nothing to copy, nothing to path-fix:

Inside Claude Code, not your shell

/plugin marketplace add guidekitdev/claude-code-guardrails
/plugin install guardrails@guidekit

Prove it in sixty seconds

steps

  1. Try to read. Ask Claude to cat .env. You should get a refusal that came from the gate, not from the model being polite.
  2. Try to write. Ask it to add const key = "AKIAIOSFODNN7EXAMPLE" to any file. Blocked, with the reason handed back to the model so it self-corrects instead of retrying.
  3. Try the escape hatch. Restart with --dangerously-skip-permissions and repeat step 1. The deny rule still holds.
  4. Watch it work. Set GUARDRAILS_DEBUG=1 and every decision lands in .claude/.guardrails/hooks.log.

next steps

Official reference: Configure permissions · Hooks · Sandboxing