Every coding-agent harness has a deny list. Almost nobody sets it, because each tool wants a different file, a different syntax and a different idea of what "deny" means.
I went through all of them while building penv guard. This is the audit, as of September 2026. Use it even if you never install penv.
The threat is boring
Your agent runs as you. If you can cat .env, so can it. It doesn't need to be malicious: a grep for context, an env while debugging, a stack trace with the environment attached. The value ends up in a transcript on someone else's server.
A deny rule stops the read. It does not stop the value leaking once it's in the process. Keep both problems in your head; this post is only about the first one.
What each harness wants
Two patterns, everywhere: .env and .env.*. Not *.env, not .env.local spelled out. The second one is the whole point: a rule on .env alone does nothing for .env.local, .env.production, .env.bak.
Claude Code
File: .claude/settings.json (project). Two mechanisms, and you want both.
{
"permissions": { "deny": ["Read(./.env)", "Read(./.env.*)"] },
"sandbox": { "filesystem": { "denyRead": ["./.env", "./.env.*"] } },
"hooks": {
"PreToolUse": [{ "matcher": ".*", "hooks": [{ "type": "command", "command": "penv hook claude-code" }] }]
}
}
permissions.deny is checked by the tool layer. sandbox.filesystem.denyRead is enforced by the OS sandbox on macOS and Linux. Native Windows has no Claude Code sandbox, so only the deny rules and the hook apply there.
There's also a user-scope file, ~/.claude/settings.json, where you can list env vars to mask with {"name": KEY, "mode": "mask"}. penv prints that block and tells you to paste it; it does not write outside your repo.
The hook answers on stdout with permissionDecision: "deny" and exit 0. Get that wrong and the hook is ignored.
Codex
File: .codex/config.toml.
[sandbox_workspace_write]
deny_read = ["**/.env", "**/.env.*"]
[shell_environment_policy]
inherit = "core"
ignore_default_excludes = false
Glob, not path. **/ because Codex resolves from the workspace root. The shell_environment_policy block matters as much as the deny: inherit = "core" keeps Codex from handing your whole environment to every subprocess.
Cursor
Two files. .cursor/cli.json:
{ "permissions": { "deny": ["Read(.env)", "Read(.env.*)"] } }
and .cursor/hooks.json:
{
"hooks": {
"beforeReadFile": [{ "command": "penv hook cursor", "failClosed": true }],
"beforeShellExecution": [{ "command": "penv hook cursor", "failClosed": true }]
}
}
failClosed: true is the line that matters. Without it, a hook that crashes is a hook that allows.
GitHub Copilot CLI
File: .github/copilot/permissions-config.json.
{ "permissions": { "deny": ["Read(**/.env)", "Read(**/.env.*)"] } }
Honest note, and it's in penv's own template comment: Copilot hasn't published a schema for this file. The shape above is best effort until they do.
Gemini CLI
File: .gemini/settings.json. Hook on run_shell_command, PreToolUse. Same idea as Claude Code, different matcher name.
Amp
File: .amp/settings.json.
{ "amp.guardedFiles.allowlist": [] }
Amp inverts the model: it guards files by default and you allow exceptions. An empty allowlist means nothing is exempt. penv only writes this file if it's absent, so an allowlist you already curated is left alone.
Cline
File: .clinerules/hooks/PreToolUse. A two-line /bin/sh script:
#!/bin/sh
exec penv hook cline "$@"
Must be executable. Denies on stderr, exit 2.
Windsurf
File: .windsurf/hooks.json. Two hooks, pre_run_command and pre_read_code, both pointing at penv hook windsurf.
Three things I got wrong the first time
The hook must be a binary, not a script. A node hook.js or python hook.py fails open when the interpreter is missing or the wrong version. penv hook <harness> is the penv binary itself; the only script in the whole set is Cline's two-line shim, because that's the only shape Cline accepts.
Deny replies are not uniform. Claude Code and Cursor want the decision on stdout with exit 0. Everyone else wants stderr and exit 2. Answer on both streams, or neither, and you're guessing what the harness does with it.
Empty stdin is the only allow. If the hook can't parse the payload but there was something to match on, it refuses. Fail closed, always.
What penv guard does with all this
It doesn't write eight files onto your machine. It probes for what's installed: a .claude or ~/.claude folder, a codex on PATH, and so on for each. Then it writes the guard for those and only those.
$ penv guard
HARNESS INSTALLED FILE STATUS
claude-code yes .claude/settings.json written
codex yes .codex/config.toml written
penv guard --check shows the same table without writing, and exits non-zero if anything is stale, so it works as a CI step. penv guard cursor targets one. penv guard --all writes every harness penv knows, for a template repo.
Merges are per-file, not per-harness. JSON deny lists are unioned with what you already have. TOML and the Cline script are append-unique. Amp is replace-if-absent. Your existing config survives.
Two invariants are tested on every render: the patterns are exactly .env and .env.*, never a list of filenames; and no rendered guard may contain .env.local (enumerating filenames is how you miss one) or .env.schema (the schema is the one file the agent should read).
The part the deny list can't do
Once a value is in your process, no deny rule helps. That's the other half: penv run -- your-app injects values at exec time and, when it detects an agent driving, masks every secret in the child's stdout and stderr in 14 encodings (raw, hex, base64 at three phases, URL-encoded, JSON-escaped). Different post.
The security claim penv prints, verbatim, from penv guard --check:
penv validates your .env, keeps values out of your agent's output, and blocks it from reading the file where its harness allows.
"Where its harness allows" is doing real work in that sentence. That's this post.
penv is MIT, one static Rust binary, no account needed for local mode.
OSS: https://github.com/penvhq/penvhq
Install: curl -fsSL penv.cloud/install | sh or npm i -g @penvhq/cli
If your harness isn't in the list, or one of these shapes is wrong, say so in the comments. The guard folder is data, so a fix is a PR to one guard.toml.
Top comments (0)