Skip to content
Personal blog. Opinions are my own. Always refer to official documentation.
Back to posts
Security

Locking Down Claude Code: Permissions, Sandbox, Hooks, and Enterprise Controls

EL
Eric Lam
March 10, 2026 · 14 min read

Version: March 2026 | Applies to: Claude Code v2.1+

Claude Code’s security features evolve rapidly. Verify settings against official docs if this guide is more than 3 months old.


1. Overview

By default, Claude Code:

Without sandbox enabled, this write restriction is Claude’s own policy, NOT OS-enforced. Nothing physically prevents a Bash command from writing outside the project unless the sandbox is turned on. With sandbox enabled ("sandbox": {"enabled": true}), the OS enforces the restriction — Bash commands are physically blocked from writing outside CWD.

WARNING: CLAUDE.md Is NOT a Security Control

Instructions in CLAUDE.md are loaded as context for the model. They are suggestions Claude tries to follow, not enforced restrictions. Writing “never access files outside this directory” in CLAUDE.md provides zero security guarantee. For enforceable restrictions, use permission rules, sandbox, and hooks as described below.


2. Threat Model

Before configuring controls, understand what you’re protecting against:

ThreatControlResidual Risk
Reads secret files (.env, credentials)Sandbox denyRead + permission deny rules + .claudeignoreBash cat ~/.env bypasses Read-tool rules without sandbox
Writes outside project directorySandbox restricts Bash writes to CWD only (OS-enforced) + hooksNone if sandbox enabled
Exfiltrates data via networkSandbox allowedDomainsHTTPS content not inspected; domain fronting possible
Modifies system configsSandbox denyWrite to system pathsSymlink-based bypass if paths not canonicalized
Env variable leakage via BashPermission deny rules for Bash commandsPattern matching is fragile; echo $AWS_KEY has many variants
Deletes critical filesSandbox write restrictions + hooks on rm commandsAllowed paths are still deletable

Get secure first, understand why later. Place this in .claude/settings.json in your project:

{
  "permissions": {
    "defaultMode": "default",
    "deny": [
      "Read(./.env)",
      "Read(~/.aws/credentials)",
      "Read(~/.ssh/*)",
      "Bash(rm -rf *)",
      "Bash(rm -r *)"
    ]
  },
  "sandbox": {
    "enabled": true,
    "autoAllowBashIfSandboxed": true,
    "allowUnsandboxedCommands": false,
    "filesystem": {
      "denyRead": ["~/.aws/credentials", "~/.ssh"],
      "denyWrite": ["//etc", "//usr", "~/.bashrc", "~/.zshrc"]
    },
    "network": {
      "allowedDomains": ["github.com", "*.npmjs.org", "registry.yarnpkg.com"]
    }
  },
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "bash .claude/hooks/validate-file-path.sh"
          }
        ]
      }
    ]
  }
}

Create the hook script at .claude/hooks/validate-file-path.sh:

#!/bin/bash
# Block writes outside the project directory
# Hook receives JSON on stdin with tool name and arguments

INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.command // ""')

# Get the project root (where .claude/ lives)
PROJECT_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"

# Resolve the target path (handles both absolute and relative paths)
if [ -n "$FILE_PATH" ]; then
    # Convert relative paths to absolute
    if [[ "$FILE_PATH" != /* ]]; then
        FILE_PATH="$PROJECT_ROOT/$FILE_PATH"
    fi
    # Resolve symlinks and .. components
    RESOLVED=$(cd "$(dirname "$FILE_PATH")" 2>/dev/null && pwd)/$(basename "$FILE_PATH") || RESOLVED="$FILE_PATH"
    if [[ "$RESOLVED" != "$PROJECT_ROOT"* ]]; then
        echo "BLOCKED: Write to $FILE_PATH is outside project directory $PROJECT_ROOT" >&2
        exit 2  # Exit code 2 = block the action
    fi
fi

exit 0  # Allow

Make it executable:

chmod +x .claude/hooks/validate-file-path.sh

Also create a .claudeignore file in the project root:

# Secrets
.env
.env.*
*.pem
*.key

# Credentials
.aws/
.ssh/

# Large/irrelevant directories
node_modules/
.git/objects/

4. .claudeignore — The Simplest Restriction

.claudeignore uses gitignore syntax and prevents Claude Code from reading or indexing matched files. It’s the easiest first line of defense.

Location: Project root (next to .claude/ directory)

Example .claudeignore:

# Secrets and credentials
.env
.env.local
.env.production
*.pem
*.key
*.p12

# Cloud credentials
.aws/
.gcp/
.azure/

# SSH keys
.ssh/

# Build artifacts
dist/
build/
node_modules/

What it does:

What it does NOT do:

Best used with: Sandbox denyRead rules for defense-in-depth.


5. Permission Modes

Set the overall permission behavior for the session:

ModeRead filesEdit filesRun BashPrompt behavior
defaultAuto-allowedPromptsPromptsStandard interactive
acceptEditsAuto-allowedAuto-allowedPromptsGood for trusted editing
planAuto-allowedBlockedBlockedRead-only analysis
dontAskAuto-allowedPer rules onlyPer rules onlyAuto-denies unless pre-approved via /permissions or allow rules
bypassPermissionsAuto-allowedAuto-allowedAuto-allowedDangerous: no prompts at all

How to set:

# CLI flag
claude --permission-mode plan

# In .claude/settings.json
{
  "permissions": {
    "defaultMode": "dontAsk"
  }
}

# During session
/permissions

Precedence: deny rules always win, regardless of permission mode. If you deny Read(./.env) and use bypassPermissions, the .env read is still blocked.

Enterprise recommendation: Use default or dontAsk mode. Never allow bypassPermissions — disable it with:

{
  "permissions": {
    "disableBypassPermissionsMode": "disable"
  }
}

Note: disableBypassPermissionsMode is a managed-settings-only field (requires Teams/Enterprise).


6. Permission Rules (allow / deny / ask)

Fine-grained control over which tools can access which paths.

Syntax

{
  "permissions": {
    "allow": ["tool(pattern)"],
    "ask": ["tool(pattern)"],
    "deny": ["tool(pattern)"]
  }
}

Path Prefixes

PrefixMeaningExample
//Absolute filesystem pathRead(//etc/passwd)
~/Home directoryRead(~/.aws/credentials)
/Relative to project rootEdit(/src/**/*.ts)
./ or noneRelative to CWDRead(./.env)

Examples

{
  "permissions": {
    "allow": [
      "Bash(npm run *)",
      "Bash(git status)",
      "Bash(git diff *)",
      "Read(/src/**)",
      "Edit(/src/**/*.ts)"
    ],
    "deny": [
      "Read(./.env)",
      "Read(~/.aws/credentials)",
      "Read(~/.ssh/*)",
      "Bash(curl *)",
      "Bash(wget *)",
      "Bash(rm -rf *)",
      "Edit(//etc/*)",
      "Write(//usr/*)"
    ]
  }
}

Settings File Locations & Precedence

From highest to lowest priority:

  1. Managed settings (Teams/Enterprise admin) — cannot be overridden
  2. CLI arguments (--permission-mode, --allowedTools)
  3. .claude/settings.local.json — local project, not committed
  4. .claude/settings.json — shared project settings
  5. ~/.claude/settings.json — user-level defaults

Important Notes


7. Sandbox (OS-Level Enforcement)

The sandbox provides OS-enforced restrictions on Bash commands, unlike permission rules which are policy-based.

When sandbox is enabled, Bash can only write to CWD and subdirectories by default. This is enforced by the operating system (macOS Seatbelt / Linux bubblewrap), not by Claude’s policy. A sandboxed echo "test" > /tmp/file.txt will fail at the OS level.

How It Works

OSTechnologyStatus
macOSSeatbelt (sandbox-exec)Supported
Linux / WSL2bubblewrap (bwrap)Supported
WSL1Not supported
Windows (native)Not yet available

Configuration

{
  "sandbox": {
    "enabled": true,
    "autoAllowBashIfSandboxed": true,
    "allowUnsandboxedCommands": false,
    "filesystem": {
      "allowWrite": [],
      "denyWrite": ["//etc", "//usr", "~/.bashrc", "~/.zshrc"],
      "denyRead": ["~/.aws/credentials", "~/.ssh"]
    },
    "network": {
      "allowedDomains": ["github.com", "*.npmjs.org"],
      "allowUnixSockets": [],
      "allowManagedDomainsOnly": false
    }
  }
}

Key Settings

SettingPurpose
sandbox.enabledEnable OS-level sandboxing for Bash
allowUnsandboxedCommandsSet false to block the dangerouslyDisableSandbox escape hatch
filesystem.allowWriteAdditional directories Bash can write to (beyond CWD)
filesystem.denyWriteDirectories Bash cannot write to, even within allowed scope
filesystem.denyReadDirectories Bash cannot read from
network.allowedDomainsWhitelist of domains Bash commands can reach
autoAllowBashIfSandboxedSkip Bash approval prompts when sandbox is active

Note on path syntax: Sandbox filesystem paths (e.g., in denyRead, denyWrite) use ~/ for home directory and // for absolute paths, matching the permission rule syntax. Verify the exact syntax against your Claude Code version, as this may evolve.

dangerouslyDisableSandbox

The Bash tool has a dangerouslyDisableSandbox parameter that runs commands outside the sandbox. To completely prevent this:

{
  "sandbox": {
    "allowUnsandboxedCommands": false
  }
}

With this set, any Bash call requesting dangerouslyDisableSandbox: true will be denied outright.

Sandbox Unavailability

If the sandbox runtime is not available (e.g., bubblewrap not installed on Linux), behavior depends on configuration:


8. Hooks (Runtime Validation)

Hooks intercept tool calls and can block actions before they execute. This is the most flexible control layer.

How Hooks Work

  1. Claude decides to use a tool (e.g., Write to a file)
  2. PreToolUse hook fires with the tool name and arguments as JSON on stdin
  3. Hook script evaluates and returns one of:

Exit code method:

JSON output method (recommended): Exit 0 and print JSON to stdout with a decision:

{"decision": "block", "reason": "Write outside project not allowed"}

For PreToolUse, you can also use hookSpecificOutput.permissionDecision to return "allow" or "deny".

Configuration

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "bash .claude/hooks/validate-file-path.sh"
          }
        ]
      },
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "bash .claude/hooks/validate-bash-command.sh"
          }
        ]
      }
    ]
  }
}

Example: Block Bash Commands That Write Outside CWD

.claude/hooks/validate-bash-command.sh:

#!/bin/bash
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // ""')

# Block dangerous patterns
BLOCKED_PATTERNS=(
    "rm -rf /"
    "rm -rf ~"
    "> /etc/"
    ">> /etc/"
    "chmod 777"
    "mkfs"
)

for pattern in "${BLOCKED_PATTERNS[@]}"; do
    if echo "$COMMAND" | grep -qF "$pattern"; then
        echo "BLOCKED: Dangerous command pattern detected: $pattern" >&2
        exit 2
    fi
done

exit 0

Hook Security Considerations

Available Hook Events

EventWhenCan Block?
PreToolUseBefore any tool executesYes
PostToolUseAfter tool succeedsNo
PostToolUseFailureAfter tool failsNo
UserPromptSubmitBefore processing user inputYes
SessionStartSession beginsNo
StopBefore Claude stops respondingYes
ConfigChangeSettings modified during sessionYes
SubagentStartSubagent launchedNo
SubagentStopSubagent completedNo
PermissionRequestPermission prompt shownNo
NotificationNotification displayedNo
PreCompactBefore context compactionNo
SessionEndSession endsNo

Note: This is not exhaustive — check the official hooks docs for the latest event list. Timeout defaults vary: command hooks 10 minutes, prompt hooks 30 seconds, agent hooks 60 seconds.


9. MCP Server File Access

Model Context Protocol (MCP) servers extend Claude Code with external tools. MCP tools can bypass some built-in restrictions.

The Risk

If you configure a filesystem MCP server (e.g., @modelcontextprotocol/server-filesystem), it can:

However, MCP tools ARE subject to Claude Code’s permission rules. You can deny specific MCP tools with rules like deny: ["mcp__filesystem__write_file"]. MCP tools also appear in hook events, so PreToolUse hooks can intercept them.

Mitigations

  1. Restrict MCP servers to managed-only (Enterprise):

    {
      "permissions": {
        "allowManagedMcpServersOnly": true
      }
    }
    
  2. Use permission rules for MCP tools:

    {
      "permissions": {
        "deny": ["mcp__filesystem__write_file"]
      }
    }
    
  3. Avoid filesystem MCP servers in security-sensitive environments. Use Claude Code’s built-in Read/Write/Edit tools instead — they respect permission rules and sandbox.


10. Enterprise & Admin Controls

Managed Settings (Teams/Enterprise)

Server-managed settings override all user and project settings. They are fetched at authentication time and refreshed periodically.

Key managed-only settings:

SettingPurpose
disableBypassPermissionsModePrevent users from enabling bypass mode
allowManagedPermissionRulesOnlyOnly admin-defined permission rules apply
allowManagedHooksOnlyOnly admin-defined hooks execute
allowManagedMcpServersOnlyRestrict MCP servers to admin-approved list
sandbox.network.allowManagedDomainsOnlyOnly admin-approved domains accessible

Device Management

PlatformToolMethod
macOSJamf, Kandjicom.anthropic.claudecode preference domain
WindowsGroup PolicyRegistry keys

Headless / CI Mode

Claude Code can run non-interactively. Security posture changes in CI:

# Non-interactive with specific tools allowed
claude --print --allowedTools "Read,Grep,Glob" "analyze this code"

# DANGEROUS: skips all permissions (only use in isolated containers)
claude --print --dangerously-skip-permissions "run tests"

Enterprise CI recommendation:

Audit & Observability

What exists:

What does NOT exist:

DIY audit logging via hooks:

#!/bin/bash
# .claude/hooks/audit-log.sh — log all tool invocations
INPUT=$(cat)
TOOL=$(echo "$INPUT" | jq -r '.tool_name // "unknown"')
ARGS=$(echo "$INPUT" | jq -c '.tool_input // {}')
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) TOOL=$TOOL ARGS=$ARGS" >> .claude/audit.log
exit 0  # Always allow — this is just logging

11. Limitations & Mitigations

Every limitation is paired with an actionable mitigation:

LimitationMitigation
Bash can write outside CWD without sandbox (Claude’s policy only, not OS-enforced)Enable sandbox: "sandbox": {"enabled": true} — OS blocks writes outside CWD
CLAUDE.md is advisory only — not enforcedUse permission rules + sandbox + hooks
Permission patterns are fragile for Bash commands (variants bypass rules)Deny the tool entirely or use sandbox network/filesystem restrictions
Network sandbox doesn’t inspect HTTPS contentRestrict allowedDomains narrowly; avoid wildcard domains
.claudeignore doesn’t block Bash cat/head commandsCombine with sandbox denyRead rules
Docker/WSL1 not sandboxedUse WSL2 or run Claude Code in devcontainers
Env vars exposed via Bash (echo $SECRET)Don’t store secrets in env vars on dev machines; use vault/secret managers
Unmanaged devices: admin users can modify settingsUse managed settings (refreshed hourly); run in managed containers
Hook timeout (10 min default) allows action on hangKeep hook scripts fast; set explicit timeout; fail-closed on errors
MCP servers bypass .claudeignore and sandbox (but NOT permission rules)Use allowManagedMcpServersOnly; deny specific MCP tools via permission rules
Symlinks can bypass path-based rulesDeny symlink creation in hooks; use sandbox denyWrite on sensitive targets
Failing hooks don’t block (non-0, non-2 exit codes allow action)Design hooks to exit 2 on any unexpected error (fail-closed)

12. Real-World Scenarios

Scenario 1: Protecting Secrets

A developer has .env, ~/.aws/credentials, and ~/.ssh/ on their machine.

Configuration (.claude/settings.json):

{
  "permissions": {
    "deny": [
      "Read(./.env)",
      "Read(./.env.*)",
      "Read(~/.aws/*)",
      "Read(~/.ssh/*)",
      "Bash(cat .env*)",
      "Bash(cat ~/.aws/*)",
      "Bash(cat ~/.ssh/*)"
    ]
  },
  "sandbox": {
    "enabled": true,
    "filesystem": {
      "denyRead": ["~/.aws", "~/.ssh"]
    }
  }
}

.claudeignore:

.env
.env.*

The gotcha: Bash(cat .env) bypasses the Read(./.env) deny rule because they are different tools. The Bash(cat .env*) deny rule catches the obvious case, but Bash(head -1 .env) or Bash(python -c "open('.env').read()") still work. The sandbox denyRead is the only reliable protection.

Scenario 2: Monorepo Scoping

Team A’s code is in /repo/team-a/, Team B’s is in /repo/team-b/. Claude should only access Team A’s directory.

# Start Claude Code from Team A's directory
cd /repo/team-a
claude

.claude/settings.json in /repo/team-a/:

{
  "permissions": {
    "deny": [
      "Read(//repo/team-b/**)",
      "Edit(//repo/team-b/**)",
      "Write(//repo/team-b/**)"
    ]
  },
  "sandbox": {
    "enabled": true,
    "filesystem": {
      "denyRead": ["//repo/team-b"],
      "denyWrite": ["//repo/team-b"]
    }
  }
}

Scenario 3: Contractor with Temporary Access

A contractor should use Claude Code on /projects/client-x/ but must not access other projects on a shared dev machine.

Setup:

  1. Create a dedicated user or use containers

  2. Start Claude from the project directory:

    cd /projects/client-x
    claude --permission-mode dontAsk
    
  3. .claude/settings.json:

    {
      "permissions": {
        "defaultMode": "dontAsk",
        "allow": [
          "Read(/src/**)",
          "Edit(/src/**)",
          "Bash(npm *)",
          "Bash(git status)",
          "Bash(git diff *)"
        ],
        "deny": [
          "Read(//projects/client-y/**)",
          "Read(~/*)",
          "Bash(curl *)",
          "Bash(wget *)"
        ]
      },
      "sandbox": {
        "enabled": true,
        "allowUnsandboxedCommands": false
      }
    }
    

Scenario 4: Recovering from Misconfiguration

If someone set overly permissive rules, how to audit what Claude accessed:

Step 1: Check if you have audit hooks configured (see Section 10). If yes, review .claude/audit.log.

Step 2: If no audit log exists, there is no built-in way to retroactively see what Claude accessed. Check:

Prevention: Always deploy the audit hook (Section 10) from day one.


13. Verification & Testing Checklist

After configuring restrictions, verify they work:

Test 1: Verify Read Restrictions

You: Read the file ~/.aws/credentials

Expected: Claude should be blocked by the deny rule or sandbox. You should see a permission denied error.

Test 2: Verify Write Restrictions

You: Create a file at /tmp/test-claude-escape.txt with the content "hello"

Expected: With sandbox enabled, the OS blocks this — /tmp is outside CWD. Without sandbox, Claude’s policy says it won’t write there, but a Bash(echo "hello" > /tmp/test.txt) command would succeed because it’s not OS-enforced.

Test 3: Verify Bash Sandbox

You: Run this command: cat /etc/passwd

Expected: If denyRead includes //etc, the sandbox blocks it. Without sandbox, the command succeeds.

Test 4: Verify Hook Blocks

You: Edit the file /etc/hosts and add a line

Expected: The PreToolUse hook blocks the Edit tool for paths outside the project. You should see the “BLOCKED” message.

Test 5: Verify .claudeignore

You: Read the .env file in this project

Expected: Claude’s Read tool should refuse, stating the file is ignored.

Test 6: Test the Bash Bypass of .claudeignore

You: Run: cat .env

Expected: This will succeed unless sandbox denyRead is configured. This test confirms why .claudeignore alone is insufficient.


Summary: Defense-in-Depth Layers

LayerWhat It ProtectsEnforcementBypass Risk
.claudeignoreBlocks Read tool + indexingTool-levelHigh (Bash bypasses it)
Permission rulesControls which tools access which pathsPolicy-basedMedium (Bash variants)
SandboxOS-enforced: Bash writes restricted to CWD only + network isolationOS-levelLow (requires escape exploit)
HooksRuntime validation of any tool callScript-basedMedium (timeout, crash)
Managed settingsAdmin-enforced configurationServer-sideLow (needs device admin)

Minimum enterprise recommendation: Permission rules + Sandbox + Hooks (all three).

Maximum security: All of the above + managed settings + containerized execution + audit hooks.

Questions or feedback? Reach out on LinkedIn