Claude Code Mastery — Direct Track
CC4 — Safety Module 5 of 16
30 minIntermediate
← CC3: CLAUDE.md & Memory 🏠 Home CC5: Skills & Slash Commands →

CC4: Permissions & Sandbox

Two independent safety layers: permission modes (Claude asks before acting) and the sandbox (OS-enforced jail). Use both. Pick the right combination for dev, CI, and production-touching work.

Learning Objectives

  • Distinguish permission modes (advisory) from the sandbox (OS-enforced) and understand why you layer both.
  • Pick the right permission mode for: prototyping · reviewing risky changes · unattended CI · production-touching work.
  • Write permissions.allow / ask / deny rules with the correct syntax.
  • Configure sandbox.filesystem and sandbox.network for safe Bash execution.
  • Avoid the three permission anti-patterns that quietly disable safety checks.
Two layers, different jobs. Get both right and you stop worrying about rm -rf typos.

Two Independent Layers

Everyday Analogy

Think of how a research lab handles a new chemical. The first layer is the posted procedure: "wear gloves, work in the fume hood, no flames within 10 feet." The second layer is the physical engineering: the fume hood actually exists, the gas valve is locked, the lab door has a keycard. The procedure is advisory; the engineering is enforced.

If you only had the procedure, a careless researcher could ignore it. If you only had the engineering, every routine task would feel locked-down and tedious. You need both, layered, with the engineering catching the mistakes the procedure misses.

Same here: permission modes are the posted procedure ("ask me before bash"). The sandbox is the physical engineering (the OS won't let bash touch /etc). Permission modes are configurable per session, sandbox is more rigid — both have their place.

The Two Layers Compared
PropertyPermission ModesSandbox
EnforcementClaude asks before acting (cooperative)OS blocks the action (mandatory)
Failure modeCould be bypassed if Claude misbehavesReturns ENOENT / network error to Claude
GranularityPer-tool, per-pattern (Bash(npm run:*))Filesystem path / network domain
ScopeJust Claude Code's tool callsEverything bash spawns (incl. subprocesses)
Cost to set upMinutes (settings.json)Some setup (paths, domains, OS support)
Best forDay-to-day workflow tuning"Nothing escapes this directory"
Why both, not either-or

Permission modes are how you tell Claude "don't bother me about npm test; ask me before git push." That's UX. The sandbox is how you tell the OS "even if Claude misbehaves, it can't write outside this project." That's security. Different problems, different tools.

The Four Permission Modes

Modes are set per session via --permission-mode at launch, by cycling with Shift+Tab inside a session, or as the default in permissions.defaultMode:

Mode → Behavior → When to Use
ModeBehaviorRight for
planRead-only. Claude can read/search/think but cannot edit or execute.Risky migrations, auth refactors, "explain it first"
defaultAsks for permission on bash, edits, fetches.Day-to-day work on anything you care about
acceptEditsAuto-accepts file edits and common filesystem commands.Prototyping new files, throw-away projects
bypassPermissionsSkip permission prompts entirely.Sandboxed containers / disposable VMs only — never your laptop
The cardinal rule

Never use bypassPermissions on your dev machine. One rm -rf and your home folder is gone, no prompt, no stop. Use it in ephemeral environments only: Docker containers, GitHub Actions runners, throw-away VMs, sandboxed worktrees. In any of those, the blast radius is the container — bounded.

Cycling modes inside a session

Press Shift+Tab to cycle through modes without restarting. The current mode shows in the prompt. Common pattern: start in plan to design, switch to default to execute, switch back to plan to review. Press Ctrl+G to see what each mode means right now.

Allow / Ask / Deny Rules — The Pattern Language

Modes are a coarse setting. Permission rules are the fine knobs: pre-approve a specific command, deny a specific path, force a prompt for a specific MCP tool. They live under permissions in settings.json:

{
  "permissions": {
    "defaultMode": "default",
    "allow": [
      "Bash(npm test:*)",
      "Bash(git diff:*)",
      "Bash(git status:*)",
      "Read(./**)",
      "Edit(src/**)",
      "WebFetch(domain:github.com)",
      "WebFetch(domain:*.anthropic.com)"
    ],
    "ask": [
      "Bash(git push:*)",
      "Bash(git commit:*)",
      "Edit(.env*)",
      "Edit(prisma/schema.prisma)"
    ],
    "deny": [
      "Bash(rm:*)",
      "Bash(sudo:*)",
      "Edit(.git/**)",
      "Read(./.env)",
      "Read(~/.ssh/**)",
      "WebFetch(domain:*)"
    ]
  }
}

The pattern language

  • Tool name alone: Bash matches every Bash call.
  • Bash prefix: Bash(npm test:*) matches calls where the command starts with npm test. The :* suffix is the prefix-match wildcard for Bash rules.
  • Bash exact: Bash(npm install) matches only that exact command, no arguments.
  • Path glob: Edit(src/**) matches edits anywhere in src/.
  • WebFetch domain: WebFetch(domain:github.com) matches GitHub URLs.
  • MCP tool: mcp__github__create_issue matches that one MCP tool.
  • Subagent: Agent(code-reviewer) matches spawning that subagent.

Precedence: deny > ask > allow

If a tool call matches multiple rules, deny wins, then ask, then allow. So you can confidently allow Bash and add narrow deny Bash(rm -rf*) — the deny wins.

Three permission anti-patterns

1. Allow-list with no deny rules. "allow": ["Bash"] with no deny means rm -rf auto-runs. Always pair allow with explicit deny for destructive patterns like Bash(rm:*).

2. Allow-listing too broad. WebFetch(domain:*) = "fetch literally anything." Lock domains.

3. Storing rules only at user level. Project-shared rules belong in ./.claude/settings.json. Personal preferences live at user level. Don't conflate them — teammates don't get your user-level rules.

OS-Level Sandbox — The Engineering Layer

Technical Definition

The sandbox wraps every Bash call inside an OS-level jail: Seatbelt on macOS, namespaces on Linux/WSL2. When sandbox is on, Bash commands cannot read or write outside paths you allow, cannot reach domains you haven't allowlisted, and cannot escape to the parent shell. Even if Claude misbehaves, the OS enforces the wall.

Bonus: with autoAllowBashIfSandboxed: true (default), sandboxed commands skip the permission prompt — the OS is enforcing the boundary, so the cooperative prompt is redundant. This makes sandboxes the safest way to run permission-free workflows.

Minimum-viable sandbox config

{
  "sandbox": {
    "enabled": true,
    "failIfUnavailable": true,
    "filesystem": {
      "allowWrite": ["./", "~/.cache/claude"],
      "denyWrite": ["./.git", "./.env*"],
      "denyRead":  ["./.env", "~/.ssh", "~/.aws", "~/.config/gcloud"]
    },
    "network": {
      "allowedDomains": [
        "registry.npmjs.org",
        "api.github.com",
        "*.anthropic.com",
        "*.your-internal.dev"
      ],
      "deniedDomains": ["*"]
    },
    "excludedCommands": ["docker", "kubectl"]
  }
}

Path prefixes: / = absolute, ~/ = home, ./ = project-relative. Network: wildcards work; deniedDomains: ["*"] establishes default-deny so allowedDomains is the explicit allowlist. excludedCommands: a list of binaries that bypass the sandbox — needed for docker and kubectl which require their own networking.

Platform notes

  • macOS: native Seatbelt. Most reliable platform. Some macOS-specific keys: allowMachLookup, allowLocalBinding, Unix sockets.
  • Linux / WSL2: namespace-based. Requires unprivileged user namespaces (most distros enable by default; check /proc/sys/kernel/unprivileged_userns_clone). For unprivileged Docker, set enableWeakerNestedSandbox: true.
  • Windows native: limited sandbox support. Either run via WSL2, or rely on permission modes + careful allow/deny rules.
When sandbox earns its keep

The classic case: you ask Claude to run a build script you don't fully trust ("this gradle plugin came from a stranger's blog"). Without sandbox, that script could exfiltrate ~/.aws/credentials. With sandbox, it can't reach ~/.aws at all — the read returns ENOENT. The script either adapts or fails loudly. Either way, your credentials are safe.

Recommended Defaults — Three Profiles

Three profiles to copy-paste based on context:

Profile A: Dev laptop — "personal default"

User-level ~/.claude/settings.json. Balanced for daily work.

{
  "permissions": {
    "defaultMode": "default",
    "allow": ["Bash(git status:*)","Bash(git diff:*)","Bash(npm test:*)","Bash(pnpm test:*)","Read(./**)"],
    "deny": ["Bash(rm:*)","Bash(sudo:*)","Read(./.env)","Read(~/.ssh/**)","Read(~/.aws/**)","Edit(.git/**)"]
  }
}

Profile B: Untrusted-build sandbox — "show me the receipts"

Project-level for repos with external/untrusted scripts. Sandbox on, narrow domains.

{
  "permissions": {"defaultMode": "default"},
  "sandbox": {
    "enabled": true,
    "filesystem": {"allowWrite":["./"],"denyRead":["./.env","~/.ssh","~/.aws"]},
    "network": {"allowedDomains":["registry.npmjs.org","registry.yarnpkg.com"],"deniedDomains":["*"]}
  }
}

Profile C: CI runner — "fully unattended"

For GitHub Actions / GitLab CI inside a sandboxed container. Everything pre-approved, narrow tool surface, sandbox on. Only safe because the container itself is the blast-radius boundary.

{
  "permissions": {
    "defaultMode": "bypassPermissions",
    "allow": ["Bash(pnpm:*)","Bash(git diff:*)","Read(./**)","Edit(./**)","WebFetch(domain:api.github.com)"],
    "deny": ["Bash(git push:*)","Bash(rm:*)","Bash(curl:*)"]
  },
  "sandbox": {"enabled": true,"filesystem":{"allowWrite":["./"]},"network":{"allowedDomains":["api.github.com","registry.npmjs.org"],"deniedDomains":["*"]}}
}
What just happened?

Three profiles cover most cases. Profile A is the "always ask" workflow you'll use 80% of the time. Profile B adds sandbox when you don't trust what's about to run. Profile C is for headless CI where there's no human to prompt.

Hands-On Lab — Three Permission Tiers for the PublicRecords API

Working in the PublicRecords API from CC0–CC3. You'll configure three behaviors: Maven test/build runs silently, deploy and git push prompt, production database commands are denied outright. Then prove each tier responds correctly. About 10 minutes.

Step 1 — Snapshot what's loaded right now

$ cd /path/to/PublicRecordsAPI
$ claude
> /status

/status shows model, mode, settings sources, and active permission rules. Note which sources are populated — you may already have a user-level settings.json from another project.

Step 2 — Write project-level permissions for the API

How it works

Permissions live in .claude/settings.json at the project root and are committed to git so the whole team uses the same rules. Three tiers stack:

  • allow: pre-approved — runs silently, no prompt. The dev inner loop.
  • ask: prompts you (yes once / yes for session / no). Use for anything visible to the team or production.
  • deny: hard refuses — no prompt, no override. Use for the truly destructive stuff.

The matcher syntax Bash(mvn test:*) means "any Bash invocation that starts with mvn test." Trailing :* is the wildcard.

Make the directory:

mkdir -p .claude

Then create the settings file:

JSON Project Settings
.claude/settings.json
{
  "$schema": "https://json.schemastore.org/claude-code-settings.json",
  "permissions": {
    "defaultMode": "default",
    "allow": [
      "Bash(mvn test:*)",
      "Bash(mvn compile:*)",
      "Bash(mvn spring-boot:run:*)",
      "Bash(mvn spotless:apply:*)",
      "Bash(git status:*)",
      "Bash(git diff:*)",
      "Bash(git log:*)",
      "Bash(curl http://localhost:8080/*)",
      "Edit(src/**)",
      "Edit(pom.xml)"
    ],
    "ask": [
      "Bash(mvn deploy:*)",
      "Bash(mvn release:*)",
      "Bash(git push:*)",
      "Bash(git commit:*)",
      "Edit(.github/**)",
      "Edit(application-prod.yml)"
    ],
    "deny": [
      "Bash(mvn flyway:clean:*)",
      "Bash(psql:*)",
      "Bash(rm:*)",
      "Bash(sudo:*)",
      "Read(./application-prod.yml)",
      "Read(~/.ssh/**)",
      "Read(~/.aws/**)",
      "Edit(.git/**)"
    ]
  }
}

Commit it so the team gets it:

git add .claude/settings.json && git commit -m "chore(claude): project permission tiers"

Restart Claude Code from the project root and run /status — your rules should appear under "Project settings."

Step 3 — Validate the allow tier (no prompt)

> Run the tests.

> Now compile only — don't run tests this time.

> Show me git status.

Expected: all three execute immediately, no prompts. If you see a prompt, your matcher is too narrow — check the trailing :* wildcard.

Step 4 — Validate the ask tier (prompt)

> Run `mvn deploy` to push this to staging.

Expected: a permission prompt with three choices (yes once / yes for session / no). Choose no — nothing should actually deploy, and Claude should acknowledge the deny.

Step 5 — Validate the deny tier (refuse, hard stop)

> Run `mvn flyway:clean` to wipe the database for a fresh start.

> Now read application-prod.yml and tell me the production DB URL.

Expected: both refused outright, no prompt offered. Deny rules don't ask — they block. If either succeeds, your JSON didn't load (most often: trailing comma, wrong path, or settings.json is in the wrong directory). Run /status and look at "Effective permissions."

Step 6 — Add user-level dev defaults that apply everywhere

Project rules cover this repo. User-level rules cover everything you do, including throwaway terminals where you forget to set up a project settings.json.

JSON User Settings
~/.claude/settings.json
{
  "permissions": {
    "deny": [
      "Bash(rm:*)",
      "Bash(sudo:*)",
      "Bash(curl:*)",
      "Read(~/.ssh/**)",
      "Read(~/.aws/**)",
      "Read(~/.config/gcloud/**)",
      "Read(./.env)",
      "Read(./.env.*)"
    ]
  }
}

These deny rules are belt-and-braces protection — they apply even when you're working on a repo without a project-level settings.json.

Step 7 — Try the sandbox for a "run this random Maven plugin" scenario

Permissions ask you (or refuse). The sandbox goes further — the OS itself blocks file writes outside your project root and network access to non-allowlisted domains. Use it when you're about to evaluate an unknown Maven plugin or run code from an untrusted source.

Create a throwaway test directory and the sandbox config:

mkdir -p /tmp/sandbox-mvn-test && cd /tmp/sandbox-mvn-test && mkdir -p .claude
JSON Sandbox Profile
/tmp/sandbox-mvn-test/.claude/settings.json
{
  "permissions": { "defaultMode": "default" },
  "sandbox": {
    "enabled": true,
    "filesystem": {
      "allowWrite": ["./"],
      "denyRead":  ["~/.ssh", "~/.aws", "~/.m2/settings-security.xml"]
    },
    "network": {
      "allowedDomains": ["repo.maven.apache.org", "repo1.maven.org"],
      "deniedDomains":  ["*"]
    }
  }
}

Launch Claude in that directory:

claude

Inside Claude Code, ask it to write to /etc/test.txt — you'll get an OS-level EACCES. Ask it to curl example.com — the network is blocked. Maven Central still works because we allowlisted it.

Step 8 — Verify the layered picture with /status

Back in the PublicRecords API directory, one last /status should show:

  • User settings: your global deny defaults from Step 6
  • Project settings: the three-tier matrix from Step 2
  • Effective permissions: union, with denies on top — mvn flyway:clean still blocked even if it appeared in allow somewhere.
Lab complete — what you should have

Project-level permissions that make the inner dev loop silent (mvn test, mvn compile, git status), prompt for anything that escapes your laptop (deploy, push, commit), and refuse production DB commands outright. Plus user-level deny defaults as a safety net and a sandbox template for evaluating untrusted code. Never use bypassPermissions on your laptop — ever. CC5 builds slash commands that respect these tiers.

Knowledge Check

1. You're about to do a risky migration of authentication code across 12 files. Which permission mode?

A
acceptEdits — speed matters.
B
plan — read-only, get a written plan first.
C
bypassPermissions — you're a senior dev.
D
default — just answer the prompts as they come.
Correct. Plan mode for risky / multi-file / unfamiliar work. Get the plan, review it, then drop into default to execute.
Look again. Plan mode is the cheapest way to catch architectural bugs before any code is written.

2. Sandbox vs permission modes — which statement is true?

A
They're alternatives; pick one.
B
Permissions are advisory (cooperative); sandbox is OS-enforced. Layer both.
C
Sandbox replaces permission modes when it's on.
D
They protect different tools (sandbox = bash only, permissions = everything else).
Correct. Permissions are how you talk to Claude ("ask me first"); sandbox is how the OS enforces boundaries regardless. Together they cover both UX and security.
Look again. They serve different purposes (UX vs security) and you typically run both: permissions for daily flow, sandbox for hard limits.

3. Your settings.json has "allow": ["Bash"] and "deny": ["Bash(rm:*)"]. Claude is asked to run rm -rf node_modules. What happens?

A
Auto-runs — matches the allow rule first.
B
Blocked — deny beats allow on the same tool.
C
Prompts you — rules conflict.
D
Returns an error to Claude without telling you.
Correct. Precedence is deny > ask > allow. Pair broad allows with narrow denies for destructive patterns.
Look again. Deny always wins over allow when both match the same call.

4. You set sandbox.enabled: true with filesystem.allowWrite: ["./"]. Claude tries to run a script that writes to /tmp/cache/foo. What happens?

A
Writes succeed — /tmp is special-cased.
B
Write fails (EACCES) — /tmp is outside the allowlist.
C
Claude prompts you to allow.
D
It's redirected silently into the project dir.
Correct. Sandbox is OS-enforced — only allowlisted paths are writable. Add /tmp/cache to allowWrite if you need it.
Look again. The sandbox is OS-level. Anything outside allowWrite returns a real OS error to bash.

5. A teammate sets "defaultMode": "bypassPermissions" in their user-level settings.json. What's the risk?

A
Just a bit faster, no risk.
B
Every session in every project runs every tool without prompting — including rm, git push --force, MCP tools with database connection strings. One bug or hallucination, big damage.
C
It only affects claude --bypass sessions.
D
Project-level settings always override it.
Correct. User-level bypassPermissions applies to all sessions in all projects, with no per-tool guardrails. Use only in disposable environments.
Look again. User-level settings apply globally to every session. bypassPermissions there means every session, everywhere, no prompts.

Module Summary

  • Two layers: permission modes (advisory, configurable) + sandbox (OS-enforced, mandatory). Use both.
  • Four modes: plan, default, acceptEdits, bypassPermissions. Cycle with Shift+Tab.
  • Rules: allow / ask / deny. Precedence: deny > ask > allow.
  • Sandbox: filesystem.allowWrite, denyRead, network.allowedDomains with deniedDomains: ["*"] as default-deny.
  • Three profiles: dev laptop · untrusted-build sandbox · CI runner. Copy-paste based on context.
  • Never bypassPermissions on a dev machine. Disposable environments only.