DEV Community

klement Gunndu
klement Gunndu

Posted on

Lock Down Claude Code With 5 Permission Patterns

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 *)"
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Glob patterns use * for wildcards. Bash(npm run test *) matches npm run test unit, npm run test --verbose, and any other variation. The space before * enforces a word boundary -- Bash(npm *) matches npm run build but not npmx.

  2. 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.

  3. Deny rules block the built-in tools, not Bash subprocesses. A Read(./.env) deny rule blocks the Read tool but does not prevent cat .env in Bash. For full protection, deny both: add Bash(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)
Enter fullscreen mode Exit fullscreen mode

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 *)"
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode
// .claude/settings.local.json (personal, gitignored)
// Your own additions that don't affect the team
{
  "permissions": {
    "allow": [
      "Bash(python -m pytest *)",
      "Bash(docker compose *)"
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

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)"
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

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"]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Create .claude/settings.json with deny rules for .env, secrets, and destructive commands.
  2. Add .claude/settings.local.json to .gitignore so personal preferences stay personal.
  3. Deny force-push in both flag positions. git push --force * and git push * --force.
  4. Deny Read AND the Bash equivalent for sensitive files. Read(./.env) plus Bash(cat .env).
  5. Enable sandboxing if you are on macOS or Linux. It closes the Bash escape hatch.
  6. Set disableBypassPermissionsMode to "disable" in shared settings.
  7. Review auto-approved commands with /permissions after every session. Remove rules you did not intend to save.
  8. Use acceptEdits mode 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)