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.

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 --versionshould 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:
{
"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
Readdeny rule also blocks Edit and Write on the same path, including creating a new file there. SoRead(./.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 .envis denied by the same rule that denies the Read tool. askis not a weakerdeny. An ask rule prompts you even when a more specific allow rule matches, which is exactly what you want forgit 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 openA 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*)blocksrm .env. It does not blocknode 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/ *)missescurl -X GET, misseshttps://, missesURL=… && 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:
- Reading secret files —
.env,.pem,.p12,id_rsa,id_ed25519,credentials. - Destroying a secret file from the shell —
rm -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.examplestill works — a sample file is meant to be disposable. - 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 genericapi_key = "…"/password: "…"form. Test fixtures and*.examplefiles are allowlisted, so it doesn't fight your own repo. - Anything you add — project-specific banned patterns from
.claude/guardrails.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:
{
"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@guidekitProve it in sixty seconds
steps
- Try to read. Ask Claude to
cat .env. You should get a refusal that came from the gate, not from the model being polite. - 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. - Try the escape hatch. Restart with
--dangerously-skip-permissionsand repeat step 1. The deny rule still holds. - Watch it work. Set
GUARDRAILS_DEBUG=1and every decision lands in.claude/.guardrails/hooks.log.