I denied .env file reads in my settings.json. Claude Code read them anyway. Here is how to build permissions that actually hold.
Claude Code ships with a tiered permission system that most developers never configure beyond clicking "Yes, don't ask again." That default workflow creates invisible gaps. Every auto-approved command persists permanently in your project settings. Every unconfigured tool runs with maximum access. The result is an AI assistant with more filesystem and network access than any human on your team.
This article covers 5 permission patterns that lock down Claude Code properly -- from basic deny rules to OS-level sandboxing.
Pattern 1: Deny-First Rules in settings.json
Claude Code evaluates permission rules in a strict order: deny, then ask, then allow. Rules are evaluated by category: all deny rules are checked first, then ask rules, then allow rules. A deny rule always beats an allow rule, regardless of order in the JSON array or which settings file it lives in.
Here is a starter configuration that blocks secrets, restricts network tools, and allows only your build commands:
{
"permissions": {
"deny": [
"Read(./.env)",
"Read(./.env.*)",
"Read(./secrets/**)",
"Bash(curl *)",
"Bash(wget *)",
"Bash(git push --force *)",
"Bash(rm -rf *)"
],
"allow": [
"Bash(npm run lint)",
"Bash(npm run test *)",
"Bash(git commit *)",
"Bash(python -m pytest *)",
"Bash(ruff check *)"
]
}
}
Save this to .claude/settings.json in your project root. It gets checked into version control, so every developer on your team inherits the same restrictions.
Three things to notice:
Glob patterns use
*for wildcards.Bash(npm run test *)matchesnpm run test unit,npm run test --verbose, and any other variation. The space before*enforces a word boundary --Bash(npm *)matchesnpm run buildbut notnpmx.Read and Edit rules follow gitignore syntax.
Read(./.env.*)blocks.env.local,.env.production, and every dotenv variant.Read(./secrets/**)blocks everything recursively under the secrets directory.Deny rules block the built-in tools, not Bash subprocesses. A
Read(./.env)deny rule blocks the Read tool but does not preventcat .envin Bash. For full protection, deny both: addBash(cat .env)or enable sandboxing (Pattern 4).
Pattern 2: The 4-Layer Settings Hierarchy
Claude Code loads settings from 4 sources, evaluated in this precedence order:
1. Managed settings (admin-deployed, cannot be overridden)
2. Command line arguments (--allowedTools, --disallowedTools)
3. Local project settings (.claude/settings.local.json, gitignored)
4. Shared project settings (.claude/settings.json, committed)
5. User settings (~/.claude/settings.json, global)
If a tool is denied at any level, no lower level can allow it. A managed settings deny cannot be overridden by --allowedTools. A project-level deny overrides a user-level allow.
This hierarchy enables a practical team workflow:
// .claude/settings.json (shared, committed)
// Team-wide rules everyone follows
{
"permissions": {
"deny": [
"Bash(git push --force *)",
"Read(./.env)",
"Read(./.env.*)",
"Bash(rm -rf *)"
],
"allow": [
"Bash(npm run *)",
"Bash(git commit *)"
]
}
}
// .claude/settings.local.json (personal, gitignored)
// Your own additions that don't affect the team
{
"permissions": {
"allow": [
"Bash(python -m pytest *)",
"Bash(docker compose *)"
]
}
}
The local file adds your personal tool approvals without weakening team-wide deny rules. You cannot override the shared deny on git push --force from your local file -- the deny always wins.
Pattern 3: MCP Server and Subagent Controls
Claude Code connects to MCP servers and spawns subagents. Both need permission rules. Without them, any MCP server tool runs with full auto-approval once you click "allow" once, and every subagent has unrestricted access.
MCP permission rules follow the format mcp__<server>__<tool>:
{
"permissions": {
"allow": [
"mcp__filesystem__read_file",
"mcp__github__list_pull_requests"
],
"deny": [
"mcp__filesystem__write_file",
"mcp__github__merge_pull_request"
]
}
}
This allows reading through MCP but blocks writing. The server name matches the key you configured in your MCP settings.
For subagents, use Agent(name) rules:
{
"permissions": {
"deny": [
"Agent(Explore)"
],
"allow": [
"Agent(Plan)",
"Agent(my-reviewer)"
]
}
}
Denying the Explore agent prevents Claude from spawning a read-only exploration subprocess. This is useful in CI environments where you want deterministic behavior -- no side explorations, no extra tool calls, just the task you assigned.
Pattern 4: Sandbox for OS-Level Enforcement
Permission rules control what Claude Code chooses to do. Sandboxing controls what the operating system allows. These are complementary layers.
The core problem: a Read(./.env) deny rule blocks the Read tool, but Claude can still run cat .env through Bash. Permission rules are application-level. A determined or confused model can work around them.
Sandboxing fixes this by restricting the Bash tool at the OS level:
{
"permissions": {
"deny": [
"Read(./.env)",
"Read(./.env.*)",
"Bash(cat .env)",
"Bash(cat .env.*)",
"Bash(curl *)",
"Bash(wget *)"
]
},
"sandbox": {
"enabled": true,
"filesystem": {
"denyRead": [".env", ".env.*", "secrets/**"],
"allowRead": ["src/**", "tests/**", "docs/**"]
},
"network": {
"allowedDomains": ["registry.npmjs.org", "pypi.org"]
}
}
}
With sandboxing enabled, cat .env fails at the OS level, regardless of whether your permission rules catch it. The allowedDomains list restricts which domains Bash commands can reach, closing the curl escape hatch.
When sandboxing is enabled with the default autoAllowBashIfSandboxed: true, sandboxed Bash commands run without prompting. The sandbox boundary replaces the per-command permission prompt. This gives you fewer interruptions with stronger enforcement.
Use both layers together for defense in depth: permission deny rules prevent Claude from attempting restricted actions, and sandbox restrictions block the underlying process even if a prompt injection bypasses Claude's decision-making.
Pattern 5: Permission Modes for Different Workflows
Claude Code supports 6 permission modes. Most developers use default and never change it. Matching the mode to your workflow eliminates unnecessary prompts without weakening security.
{
"permissions": {
"defaultMode": "acceptEdits"
}
}
Here is when to use each mode:
default -- Standard behavior. Prompts on first use of each tool. Use this when you are learning the tool or working on an unfamiliar codebase.
acceptEdits -- Auto-approves file edits for the session but still prompts for Bash commands. Use this when you trust Claude's code changes but want to review every shell command.
plan -- Read-only mode. Claude can analyze files but cannot modify anything or run commands. Use this for code review, architecture planning, or when you want analysis without side effects.
dontAsk -- Auto-denies every tool unless it is pre-approved via your permissions.allow list. This is the most restrictive interactive mode. Use this in CI or automated pipelines where you want zero prompts and complete control.
auto -- Auto-approves tool calls with background safety checks that verify actions align with your request. The classifier blocks actions it deems risky, like force-pushing or accessing domains outside your configured trust boundary. Currently a research preview.
bypassPermissions -- Skips all permission prompts except writes to protected directories (.git, .claude, .vscode). Only use this inside containers or VMs where Claude Code cannot cause lasting damage.
To prevent bypass mode from being used at all, set this in your managed settings or any settings file:
{
"permissions": {
"disableBypassPermissionsMode": "disable"
}
}
This is the single most important setting for teams. One developer in bypass mode can undo every permission rule you have configured.
A Real-World Settings File
Here is the complete settings.json we use in production. It combines all 5 patterns:
{
"permissions": {
"defaultMode": "acceptEdits",
"deny": [
"Read(./.env)",
"Read(./.env.*)",
"Read(./secrets/**)",
"Bash(curl *)",
"Bash(wget *)",
"Bash(git push --force *)",
"Bash(git push * --force)",
"Bash(rm -rf *)",
"Bash(git checkout .)",
"Bash(git reset --hard *)",
"mcp__filesystem__write_file"
],
"allow": [
"Bash(npm run *)",
"Bash(python -m pytest *)",
"Bash(ruff check *)",
"Bash(black *)",
"Bash(git status)",
"Bash(git diff *)",
"Bash(git add *)",
"Bash(git commit *)",
"Bash(git log *)",
"Bash(* --version)",
"Bash(* --help *)",
"mcp__github__list_pull_requests",
"mcp__github__get_pull_request",
"Agent(Plan)"
],
"disableBypassPermissionsMode": "disable"
}
}
Notice the duplicate force-push deny rules: git push --force * and git push * --force. The flag can appear before or after the remote name. Both patterns must be denied.
The Permission Audit Checklist
Run this checklist on every new project before the first Claude Code session:
-
Create
.claude/settings.jsonwith deny rules for.env, secrets, and destructive commands. -
Add
.claude/settings.local.jsonto.gitignoreso personal preferences stay personal. -
Deny force-push in both flag positions.
git push --force *andgit push * --force. -
Deny Read AND the Bash equivalent for sensitive files.
Read(./.env)plusBash(cat .env). - Enable sandboxing if you are on macOS or Linux. It closes the Bash escape hatch.
-
Set
disableBypassPermissionsModeto"disable"in shared settings. -
Review auto-approved commands with
/permissionsafter every session. Remove rules you did not intend to save. -
Use
acceptEditsmode as your default. It eliminates edit prompts while keeping Bash prompts active.
Every "Yes, don't ask again" click saves a permanent allow rule to your local settings. After a month of development, you may have dozens of invisible allow rules you never explicitly configured. The /permissions command lists all of them. Audit it.
What Happens When You Skip This
A Claude Code session with default permissions and no settings.json has:
- Full read access to every file in your project directory
- Full write access after one approval per session
- Full network access through Bash (curl, wget, any HTTP client)
- No restrictions on destructive git commands
- Permanent allow rules accumulating from every "don't ask again" click
One prompt injection in a pasted error message or a dependency's README can exploit all of these. The permission system exists to shrink this attack surface. Use it.
Follow @klement_gunndu for more Claude Code content. We are building in public.
Top comments (0)