claude-secret-guardrails
Health Warn
- License — License: MIT
- Description — Repository has a description
- Active repo — Last push 0 days ago
- Low visibility — Only 6 GitHub stars
Code Warn
- Code scan incomplete — No supported source files were scanned during light audit
Permissions Pass
- Permissions — No dangerous permissions requested
No AI report is available for this listing yet.
Permission deny rules that stop Claude Code's built-in tools from reading your .env, secrets, and credential files.
Claude Secret Guardrails
Stop Claude Code's built-in tools from reading .env, secrets, and credential files, using permission deny rules in ~/.claude/settings.json. A copy-paste starting point you can drop into any setup.
Scope check. This repo is the tool-side layer: it gates Claude Code's built-in
Read/Edit/Writetools. It is not the OS-level boundary. The actual boundary for what shell commands can touch is Claude Code's Bash sandbox (Seatbelt/bubblewrap) - that is where you stoppython,xxd,dd, or any other reader. The two are complementary: the sandbox does not cover the built-in file tools, and the permission rules here do not cover arbitrary subprocesses. Use both.
TL;DR
Add a permissions.deny block to your global Claude Code settings to make the built-in file tools refuse secret files. This is a hard gate for Read/Edit/Write - no prompt, no override.
It is not a shell boundary. A Bash(cat ...) deny only matches a command string, and there are unbounded ways to read a file (xxd, dd, python -c, < .env, ...). For the shell side, enable the sandbox. And remember the real backstop: keep secrets out of the repo and out of the shell environment - permission rules reduce risk, they are not a security boundary.
Why
By default, granting Claude Code access to a project directory grants access to everything inside it, including .env. There's no built-in secret-file exclusion. One debugging session where Claude decides to "check the config" can land your secrets in a transcript that's synced to the cloud.
Failure modes, and which layer actually covers each:
| Failure mode | Example | Covered by |
|---|---|---|
| Direct read | Claude opens .env with the Read tool |
Read() deny (this repo) - hard gate |
| Shell read, any reader | cat .env, xxd .env, python -c "open('.env')", < .env |
the sandbox denyRead - the boundary |
| Secret in the environment | env, printenv, os.environ reads a loaded .env value |
env hygiene - no file rule helps |
| Exfiltration | a read secret is sent out via curl/WebFetch/git push |
sandbox network allowlist (defense-in-depth) |
The point of the layered setup is to shrink each of these. No single rule closes all of them.
How the layers stack
| Layer | What it does | Boundary? |
|---|---|---|
preferences.md |
Nudges Claude not to try | No - guidance only |
Read/Edit/Write deny (this repo) |
Built-in file tools refuse the file | Hard gate, for those tools only |
Sandbox denyRead |
OS-level read-block for all Bash subprocesses | Yes - the real shell-side boundary |
| Sandbox network allowlist | Limits where commands can connect | Partial (no TLS inspection) |
| Env scrub / env hygiene | Keeps secrets out of inherited subprocess env | Mitigation |
| Managed settings | Any of the above, enforced org-wide, user cannot edit | Highest precedence |
The rest of this README documents the tool-side row (the deny rules) plus pointers to the others. The sandbox is the load-bearing boundary and is covered below.
What's blocked (this repo's deny rules)
| File pattern | Examples blocked | Why |
|---|---|---|
**/.env?(.!(example)) |
.env, .env.local, .env.production |
App secrets - explicitly allows .env.example |
**/*.tfvars |
terraform.tfvars, prod.tfvars |
Cloud credentials in Terraform vars |
**/.tokens.json |
persisted JWT caches | Custom convention used in some projects |
**/credentials.json |
GCP / service-account JSON | Common credential filename |
**/*.pem |
TLS keys, SSH PEM keys | Private keys |
**/id_rsa*, **/id_ed25519*, **/id_ecdsa*, **/id_dsa* |
all default SSH private keys | Covers every default key type, not just RSA (id_ed25519 is the modern default) |
Each pattern gets three rules: Read(...), Edit(...), Write(...). (Write also stops Claude creating a malicious one in your tree.) These apply to Claude Code's built-in file tools, which is the only path they can fully close. Shell reads are the sandbox's job - see The real boundary and the footnote on Bash denies.
The settings.json snippet
{
"permissions": {
"deny": [
"Read(**/.env?(.!(example)))",
"Edit(**/.env?(.!(example)))",
"Write(**/.env?(.!(example)))",
"Read(**/*.tfvars)",
"Edit(**/*.tfvars)",
"Write(**/*.tfvars)",
"Read(**/.tokens.json)",
"Edit(**/.tokens.json)",
"Write(**/.tokens.json)",
"Read(**/credentials.json)",
"Edit(**/credentials.json)",
"Write(**/credentials.json)",
"Read(**/*.pem)",
"Edit(**/*.pem)",
"Write(**/*.pem)",
"Read(**/id_rsa*)",
"Edit(**/id_rsa*)",
"Write(**/id_rsa*)",
"Read(**/id_ed25519*)",
"Edit(**/id_ed25519*)",
"Write(**/id_ed25519*)",
"Read(**/id_ecdsa*)",
"Edit(**/id_ecdsa*)",
"Write(**/id_ecdsa*)",
"Read(**/id_dsa*)",
"Edit(**/id_dsa*)",
"Write(**/id_dsa*)"
]
}
}
Install
Make sure
~/.claude/settings.jsonexists:mkdir -p ~/.claude && [ -f ~/.claude/settings.json ] || echo '{}' > ~/.claude/settings.jsonMerge the
permissions.denyblock above. Don't replace the file - preserve any existing keys (enabledPlugins,attribution, etc.). If you already have apermissionsblock, append to itsdenyarray.Verify:
jq '.permissions.deny | length' ~/.claude/settings.jsonSmoke-test: in a Claude Code session, ask "show me the contents of my .env file." It should refuse. Then ask it to read
.env"using python" - if that succeeds (it will, without the sandbox), that is exactly why you also want the sandbox.
A note on Bash(cat ...) denies
Earlier versions of this snippet enumerated reader commands - Bash(cat ...), Bash(head ...), Bash(grep ...), Bash(rg ...), and so on. They were removed on purpose.
Enumerating readers is unwinnable. A deny like Bash(cat **/.env) matches a command string, but a file can be read by xxd, od, dd, awk, sed, perl, python -c "open('.env')", $(<.env), source, base64, busybox, a copy-then-read, or any compiled binary - and Claude Code's own docs call argument-constraining Bash patterns "fragile." A list that blocks ten readers and misses the rest doesn't add a boundary; it adds the impression of one, which is worse, because it's parked in the config people copy and trust.
If you genuinely cannot run the sandbox (e.g. native Windows) you have no shell-level read protection - do not paste a Bash deny list and assume otherwise. The honest posture there is: don't put real secrets on that machine, or run Claude Code inside a container/VM.
The right shell-side control is the sandbox.
The real boundary: sandboxing
Claude Code's Bash sandbox is the OS-level boundary the deny rules can't be. It uses Seatbelt (macOS) or bubblewrap (Linux/WSL2) and binds the running process, so it holds regardless of which command the model picked and covers every child subprocess - one boundary instead of an endless reader list.
The shape you want is default-deny:
{
"sandbox": {
"enabled": true,
"filesystem": {
"denyRead": ["~/"],
"allowRead": ["."]
}
}
}
That denies reading the whole home directory and re-allows only the project (place it in the project's .claude/settings.json so . resolves to the project root). Two things to know:
- The sandbox's default read policy still allows
~/.aws/credentialsand~/.ssh/. It is not secure-by-default for reads - you have todenyReadthem (or deny~/as above). - The sandbox covers only Bash subprocesses. The built-in
Read/Edit/Writetools go through the permission system, not the sandbox - so the deny rules in this repo are still required with the sandbox on. The docs call them "complementary layers." - In-project secrets (
.envcommitted inside the tree) are insideallowRead: ["."], so a subprocess can still read them. Keep the per-fileRead()denies here for those, and gitignore them.
Setup has real friction (install bubblewrap + socat on Linux/WSL2, an AppArmor profile on Ubuntu 24.04+, not available on native Windows; docker/gh/terraform/jest may need excludedCommands). That friction is why this repo exists as the zero-dependency on-ramp - but the sandbox is the boundary. Full team setup (default-deny spine, network allowlist, excludedCommands, managed enforcement, env scrub) is involved enough to deserve its own guide; a companion sandbox-setup repo is the right home for it.
Don't forget environment variables
A file-only deny list closes the door and leaves the window open. In most projects, secrets live in .env files that get loaded into environment variables - and sandboxed Bash subprocesses inherit the parent process environment by default, including those credentials. env, printenv, or any subprocess reading os.environ gets them with zero file access, so no Read() or sandbox denyRead rule applies.
Mitigations, roughly in order:
- Don't export real secrets into the shell Claude Code runs in. Load them only in the subprocess that needs them (e.g.
dotenv run -- ...) rather thansource .envin your interactive shell. - Set
CLAUDE_CODE_SUBPROCESS_ENV_SCRUBto strip Anthropic and cloud-provider credentials from subprocess environments (see Claude Code's env-var reference for the exact value). Note it targets known cloud/Anthropic creds, not arbitrary app secrets you set yourself. - Use a secret manager so secrets are fetched at point-of-use, not left resident in the environment.
What this does NOT stop
Permission rules are defense-in-depth, not a sandbox. The gaps below are the cases the deny rules alone won't cover.
- Arbitrary subprocesses. Read/Edit/Bash rules "do not apply to arbitrary subprocesses that read or write files indirectly, like a Python or Node script that opens files itself." Covered only by the sandbox.
- Alternate shell readers.
xxd,od,dd,sed,awk,source, redirection, copy-then-read - see the Bash-deny note. Covered by the sandbox, not by deny rules. - Environment-resident secrets. See above.
- Recursive search.
rg SECRET .reads files through its own syscalls and traverses into.envregardless of arg-matching. Mitigations: gitignore secrets (most search tools skip gitignored paths), and the sandboxdenyRead. - MCP file tools. A filesystem MCP server is governed by
mcp__<server>__<tool>rules, not byRead()denies. If you run one, deny its read tools explicitly. - Exfiltration of already-read content. Once a secret is in context,
WebFetch/curl/git pushcould send it out. The sandbox network allowlist helps, but its proxy does not inspect TLS, so broad allowlists (e.g.github.com) are domain-frontable.
The durable mitigations underneath all of this: keep secrets out of the repo (gitignore + secret managers), keep them out of the shell environment, and use managed settings where you need a control the user can't disable.
Optional: a PreToolUse hook
If you want some shell-side coverage without the sandbox's setup cost, a PreToolUse hook can scan the whole Bash command string for secret filenames and block it. It is not a boundary - it's pattern-based and evadable for the same reader-space reason the Bash denies were (a base64'd path, an env-indirected filename, etc.). Treat it as a tripwire, not a wall, and prefer the sandbox where you can run it.
Add to ~/.claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "~/.claude/hooks/block-secret-reads.sh" }
]
}
]
}
}
Create ~/.claude/hooks/block-secret-reads.sh (and chmod +x it):
#!/usr/bin/env bash
# PreToolUse tripwire: flag Bash commands that name a likely secret file.
# Receives the tool-call JSON on stdin. Exit 2 = block the call.
# Pattern-based, not a boundary - prefer the sandbox.
cmd="$(jq -r '.tool_input.command // ""')"
# Allow the .env.example family through.
if printf '%s' "$cmd" | grep -Eq '\.env\.example([^[:alnum:]]|$)'; then
exit 0
fi
# Filename fragments that should not appear in a Bash command.
secrets='\.env([^[:alnum:]]|$)|\.tfvars|\.tokens\.json|credentials\.json|\.pem([^[:alnum:]]|$)|id_(rsa|ed25519|ecdsa|dsa)|\.ssh/|\.aws/credentials|\.npmrc|\.netrc|\.git-credentials'
if printf '%s' "$cmd" | grep -Eq "$secrets"; then
echo "Blocked by block-secret-reads.sh: command references a likely secret file." >&2
exit 2
fi
exit 0
Notes: exit 2 blocks, exit 0 allows (or print {"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"..."}} and exit 0). Hooks can only tighten, never loosen - a matching deny/ask rule still applies regardless of what the hook returns. Requires jq; it runs on every Bash call, so keep it fast.
The .env.example exception
Pattern: **/.env?(.!(example))
Reading it left-to-right:
**/- at any directory depth.env- must start with literal.env?(...)- optionally followed by ONE more chunk (picomatch extglob: zero-or-one).!(example)- that chunk is.plus anything except exactlyexample
Test cases (verified against picomatch with {dot:true}):
| File | Result |
|---|---|
.env |
blocked |
.env.local |
blocked |
.env.production |
blocked |
.env.production.local |
blocked |
foo/bar/.env |
blocked (any depth) |
.env.example |
allowed |
foo/.env.example |
allowed |
.env.example.bak |
blocked (backups of secret files stay protected) |
.env.sample |
blocked |
.env.template |
blocked |
.env.dist |
blocked |
Note the last three: only .env.example is whitelisted. Other common template names (.env.sample, .env.template, .env.dist) are treated as secrets and blocked. If your project commits one of those as the placeholder file, either rename it to .env.example or widen the exception (e.g. **/.env?(.@(local|prod)).!(example|sample|template|dist) - test any change against picomatch first, these get fiddly fast).
Why allow .env.example? It's intended to be committed and contains placeholder values - letting Claude read it gives useful context (which env vars a service expects, default values, format hints) without exposing real secrets.
If you'd rather lock it down completely with no exception, replace the pattern with **/.env* (drop the ?(.!(example)) suffix from the three .env rules).
Team tier: managed settings
Everything above lives in user settings, which the user can edit or delete. For a control that cannot be overridden, an admin can deploy the same permissions.deny (and sandbox) block as managed settings - the highest-precedence tier in Claude Code.
| OS | Managed settings path |
|---|---|
| macOS | /Library/Application Support/ClaudeCode/managed-settings.json |
| Linux | /etc/claude-code/managed-settings.json |
| Windows | C:\Program Files\ClaudeCode\managed-settings.json (or registry HKLM\SOFTWARE\Policies\ClaudeCode) |
All three also support a managed-settings.d/ drop-in directory whose fragments merge alphabetically. Managed deny rules merge with (and cannot be loosened by) user/project/local rules. For sandbox enforcement, managed settings can also pin failIfUnavailable: true and allowUnsandboxedCommands: false so the sandbox can't be silently skipped or escaped.
Soft layer: preferences.md
A preferences file nudges Claude toward the same outcome before it tries, often with a more graceful "I'll skip this" rather than a hard permission error. It's guidance, not a control - it can be ignored - so it complements the hard tiers, it doesn't replace them.
Create ~/.claude/rules/preferences.md:
# NEVER
- Read `.env` files (`.env.example` OK)
- Read `.tfvars` files
- Read files that may contain secrets - ask first
Customize
Add patterns for your project's secret-file conventions. For each, add Read(...), Edit(...), and Write(...) (and, if you run the hook, add the filename fragment to its regex). Common additions:
| Pattern | Catches |
|---|---|
**/*.key |
generic private keys (watch for false positives on non-secret .key files) |
**/*.p12, **/*.pfx |
PKCS#12 keystores |
**/*.jks, **/*.keystore |
Java keystores |
**/secrets.yaml, **/secrets.yml |
Helm / k8s secrets |
**/*.kdbx |
KeePass database |
**/.netrc, **/_netrc |
HTTP auth |
**/.npmrc |
npm auth tokens |
**/.pypirc |
PyPI tokens |
**/.git-credentials |
stored git HTTP creds |
**/.htpasswd |
basic-auth hashes |
**/.aws/credentials |
AWS SDK creds |
**/.kube/config, **/kubeconfig |
Kubernetes cluster creds |
**/.docker/config.json |
Docker registry auth |
**/*service-account*.json |
GCP service-account keys |
**/.ssh/** |
everything under an SSH dir |
Scope: where rules can live
| File | Scope | Override? | Git |
|---|---|---|---|
| Managed settings (paths above) | Org-wide, all users | Highest - cannot be overridden | admin-deployed |
~/.claude/settings.json |
All your projects | User can edit | n/a |
<project>/.claude/settings.json |
That repo, for everyone | User can edit | committed |
<project>/.claude/settings.local.json |
That repo, just you | User can edit | gitignored |
Deny rules merge across all sources (they don't replace each other), and deny always wins over allow. Put org policy in managed settings, personal defaults in ~/.claude/, and repo-specific conventions in the project files.
How permission rules work
| Field | Behavior |
|---|---|
allow |
Pre-approves the operation; no prompt |
ask |
Always prompts (overrides default approval) |
deny |
Refuses unconditionally - no prompt, no override |
Deny wins over allow, and deny/ask are evaluated regardless of what a PreToolUse hook returns. Patterns inside (...) use picomatch glob syntax with extglob enabled (?(...), !(...), +(...), @(...), *(...)).
Why this repo no longer enumerates Bash(...) readers (from the Claude Code docs):
- Matching is on the command string, by glob.
Bash(ls *)matchesls -labut notlsof. - Wrappers like
timeout,time,nice,nohupare stripped before matching. - Compound commands (
;,&&,|, ...) are matched per-subcommand. - The docs explicitly warn that argument-constraining Bash patterns are fragile (options before args, env-var substitution, redirection, extra whitespace all bypass them). That fragility is unfixable by adding more rules - which is what the sandbox is for.
Contributing
Found a missed file pattern, a better approach, or want to add support for another tool? Open an issue.
The ~/.claude/rules/preferences.md idea is from Tips for safer Claude coding by Anatoli Nicolae.
Reviews (0)
Sign in to leave a review.
Leave a reviewNo results found