# Sandboxing in Claude Code

Keep Claude Code's built-in Bash sandbox switched on inside the ticket-service devcontainer, with write, network and token rules in .claude/settings.json and the documented Dev Container Feature and firewall reference.

Source: https://ai-sw-factory.mellicci.dev/fundamentals/sandboxing/claude-code

## At a glance

| | Claude Code |
|---|---|
| Term | Sandbox (`/sandbox`), the sandboxed Bash tool |
| Configured in | `sandbox` block in `settings.json`; the `/sandbox` panel saves to `.claude/settings.local.json` |
| Loads / runs | The harness starts each Bash, PowerShell and Monitor command, and its children, under OS limits. The Read, Edit and Write tools are not sandboxed |
| Scope & precedence | Managed, command line, local, project, user settings. Path arrays merge across scopes; managed boolean keys win |

## Build the scenario

The container is the outer boundary; the built-in sandbox stays on inside it as a second, per-command layer.

| Step | What you write | Where |
|---|---|---|
| 1 | Claude Code Dev Container Feature, firewall capabilities, config volume | `.devcontainer/devcontainer.json`, block 1 |
| 2 | Firewall script allowing the registry, GitHub and the model API | Your own script, modeled on the reference `init-firewall.sh` |
| 3 | Sandbox rules for the repo | `.claude/settings.json`, block 2 |
| 4 | Task-scoped token as `GH_TOKEN` | Container environment; masking in block 3 |

```json
{
  "name": "ticket-service",
  "dockerComposeFile": "docker-compose.yml",
  "service": "app",
  "workspaceFolder": "/workspace",
  "features": { "ghcr.io/anthropics/devcontainer-features/claude-code:1.0": {} },
  "runArgs": ["--cap-add=NET_ADMIN", "--cap-add=NET_RAW"],
  "mounts": ["source=claude-code-config-${devcontainerId},target=/home/node/.claude,type=volume"],
  "containerEnv": { "CLAUDE_CONFIG_DIR": "/home/node/.claude" },
  "remoteUser": "node"
}
```

The Postgres service lives in the compose file. The documentation doesn't show compose-based setups or how the reference starts its firewall script, so wire that step yourself.

```json
{
  "sandbox": {
    "enabled": true,
    "failIfUnavailable": true,
    "allowUnsandboxedCommands": false,
    "filesystem": { "allowWrite": ["~/.local/share/pnpm"] },
    "network": { "allowedDomains": ["registry.npmjs.org", "github.com"] },
    "credentials": { "envVars": [{ "name": "GH_TOKEN", "mode": "deny" }] }
  }
}
```

The repo itself is writable by default. `deny` removes `GH_TOKEN` from every sandboxed command, so `gh` and authenticated `git` fail inside it; let the workflow push. To keep the tools working, mask the token instead, from user settings or `--settings` (repo files are ignored for `mask`):

```json
{
  "sandbox": {
    "network": { "tlsTerminate": {}, "allowedDomains": ["github.com"] },
    "credentials": { "envVars": [
      { "name": "GH_TOKEN", "mode": "mask", "injectHosts": ["github.com"] }
    ] }
  }
}
```

## Specifics

### Built-in sandbox

| Item | Behavior |
|---|---|
| Platforms | macOS (Seatbelt); Linux and WSL2 (`bubblewrap` and `socat`); not WSL1, not native Windows |
| Covers | Bash, PowerShell and Monitor commands and all child processes |
| Does not cover | Read, Edit, Write tools (permissions govern them), MCP servers, hooks, computer use |
| Auto-allow mode | Sandboxed commands run without prompts; deny rules, `rm` on critical paths and content-scoped ask rules still apply |
| Regular permissions mode | Every command goes through the normal permission flow, even sandboxed |
| Subagents | Same process, same sandbox configuration |

### Filesystem and network rules

| Key | Effect |
|---|---|
| Default writes | Working directory, per-user temp directory, `--add-dir` directories |
| `filesystem.allowWrite` / `denyWrite` | Extra writable paths / blocked paths. `/` is absolute, `~/` is home, `./` is project root in project settings |
| `filesystem.denyRead` / `allowRead` | Block reads / re-open a narrower path; the narrower rule wins |
| Default reads | The whole machine, so `~/.ssh` is readable unless denied |
| Protected paths | `.claude` settings, `.mcp.json`, shell startup files and `.git/hooks` stay write-denied; `allowWrite` can't lift this |
| `network.allowedDomains` / `deniedDomains` | Hostname allowlist at a proxy outside the sandbox; denied wins over a wildcard allow |
| New domain | Nothing is pre-allowed: Claude Code prompts on the first new host. `strictAllowlist` (user, managed, `--settings` only) denies instead |

The proxy decides from the requested hostname and doesn't inspect TLS by default, so an allowed `github.com` can carry exfiltration or domain fronting.

### Running inside a container

| Topic | What the documentation says |
|---|---|
| Install | Dev Container Feature `ghcr.io/anthropics/devcontainer-features/claude-code:1.0`; the tag pins the install script, not the CLI. To pin the CLI, `npm install -g @anthropic-ai/claude-code@X.Y.Z` in the Dockerfile plus `DISABLE_AUTOUPDATER=1` |
| Sign-in persistence | Named volume at `~/.claude` plus `CLAUDE_CONFIG_DIR` set to the same path |
| Reference container | `anthropics/claude-code/.devcontainer`: `devcontainer.json`, `Dockerfile`, `init-firewall.sh`. An example, not a maintained base image |
| Firewall | `init-firewall.sh` needs `NET_ADMIN` and `NET_RAW` via `runArgs`; neither is required by Claude Code itself |
| Bubblewrap failure | In an unprivileged container, `Can't mount proc ... Operation not permitted`. `sandbox.enableWeakerNestedSandbox: true` fixes it but exposes process information; use it only when the container is the boundary |
| Managed policy | `/etc/claude-code/managed-settings.json`, copied in from the Dockerfile |

The documentation doesn't say whether the reference firewall resists DNS rebinding.

### Credentials and secrets

| Mechanism | Effect |
|---|---|
| `sandbox.credentials.files` / `envVars` with `deny` | Files unreadable, variables unset for sandboxed commands; any scope may add, none may remove |
| `mode: mask` | Command sees a sentinel; the proxy swaps in the real value on `injectHosts`. Needs `network.tlsTerminate`; ignored in repo settings |
| Built-in deny list | None; only what you list is protected |
| `CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=1` | Strips credentials from all subprocesses, including hooks and MCP stdio servers |
| Environment | Sandboxed commands inherit it by default |

### Escape hatches and limits

| Item | Behavior |
|---|---|
| `dangerouslyDisableSandbox` | Claude may retry a blocked command unsandboxed, through the normal permission flow. `allowUnsandboxedCommands: false` turns it off |
| `excludedCommands` | Commands that always run outside the sandbox; `docker *` is the documented case |
| `failIfUnavailable` | Default is a warning and unsandboxed runs; `true` refuses to start |
| `bypassPermissions` | `--dangerously-skip-permissions`; isolated containers and VMs only, rejected as root. Deny rules still apply |
| `filesystem.disabled` | Drops filesystem isolation, keeps network; user, managed or `--settings` only |

## Gotchas

- Inside an unprivileged container the sandbox may not start. With `failIfUnavailable` it stops; without it, commands run unsandboxed after a warning.
- `--dangerously-skip-permissions` doesn't stop anything inside the container from leaving over allowed hosts, `~/.claude` credentials included. Keep the token task-scoped and the allowlist short.
- `allowUnixSockets` for `/var/run/docker.sock` hands over the host; the scenario mounts no socket, so don't add one. `docker` itself is incompatible with the sandbox.
- Hooks and MCP servers run outside the built-in sandbox; only the container and firewall bound them.
- The documentation doesn't say how the sandbox treats a compose service hostname such as the Postgres service; test that `pnpm test` reaches the database.
