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/denyrules with the correct syntax. - Configure
sandbox.filesystemandsandbox.networkfor safe Bash execution. - Avoid the three permission anti-patterns that quietly disable safety checks.
rm -rf typos.Two Independent Layers
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.
| Property | Permission Modes | Sandbox |
|---|---|---|
| Enforcement | Claude asks before acting (cooperative) | OS blocks the action (mandatory) |
| Failure mode | Could be bypassed if Claude misbehaves | Returns ENOENT / network error to Claude |
| Granularity | Per-tool, per-pattern (Bash(npm run:*)) | Filesystem path / network domain |
| Scope | Just Claude Code's tool calls | Everything bash spawns (incl. subprocesses) |
| Cost to set up | Minutes (settings.json) | Some setup (paths, domains, OS support) |
| Best for | Day-to-day workflow tuning | "Nothing escapes this directory" |
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 | Right for |
|---|---|---|
plan | Read-only. Claude can read/search/think but cannot edit or execute. | Risky migrations, auth refactors, "explain it first" |
default | Asks for permission on bash, edits, fetches. | Day-to-day work on anything you care about |
acceptEdits | Auto-accepts file edits and common filesystem commands. | Prototyping new files, throw-away projects |
bypassPermissions | Skip permission prompts entirely. | Sandboxed containers / disposable VMs only — never your laptop |
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:
Bashmatches every Bash call. - Bash prefix:
Bash(npm test:*)matches calls where the command starts withnpm 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 insrc/. - WebFetch domain:
WebFetch(domain:github.com)matches GitHub URLs. - MCP tool:
mcp__github__create_issuematches 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.
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
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, setenableWeakerNestedSandbox: true. - Windows native: limited sandbox support. Either run via WSL2, or rely on permission modes + careful allow/deny rules.
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":["*"]}}
}
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
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 .claudeThen create the settings file:
{
"$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.
{
"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{
"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:
claudeInside 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:cleanstill blocked even if it appeared in allow somewhere.
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?
acceptEdits — speed matters.plan — read-only, get a written plan first.bypassPermissions — you're a senior dev.default — just answer the prompts as they come.default to execute.2. Sandbox vs permission modes — which statement is true?
3. Your settings.json has "allow": ["Bash"] and "deny": ["Bash(rm:*)"]. Claude is asked to run rm -rf node_modules. What happens?
4. You set sandbox.enabled: true with filesystem.allowWrite: ["./"]. Claude tries to run a script that writes to /tmp/cache/foo. What happens?
/tmp is special-cased./tmp is outside the allowlist./tmp/cache to allowWrite if you need it.allowWrite returns a real OS error to bash.5. A teammate sets "defaultMode": "bypassPermissions" in their user-level settings.json. What's the risk?
rm, git push --force, MCP tools with database connection strings. One bug or hallucination, big damage.claude --bypass sessions.bypassPermissions applies to all sessions in all projects, with no per-tool guardrails. Use only in disposable environments.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.allowedDomainswithdeniedDomains: ["*"]as default-deny. - Three profiles: dev laptop · untrusted-build sandbox · CI runner. Copy-paste based on context.
- Never
bypassPermissionson a dev machine. Disposable environments only.