# Claude Code's Bash Sandbox

> For the complete site index, see [llms.txt](https://danny.is/llms.txt)

A few months ago I threw together [denv](https://github.com/dannysmith/denv) as an interim solution to sandboxing local Claude Code sessions. My main requirement was that I could have a local project directory open in a terminal and VS Code (or any other local app I want) while running the Claude Code TUI on the same project dir in a sandbox.

Although I spent **a lot of time** digging into the various options (dev containers, various VMs etc), I settled on a pretty simple setup in the end. Running `denv create ~/dev/my-project` creates a Docker container from a `denv-base` and bind-mounts the project directory to `/workspace` in the container. The [Dockerfile](https://github.com/dannysmith/denv/blob/main/Dockerfile) just installs a bunch of runtimes and developer tooling, writes some basic dotfiles which make the shell *a little bit* like my local environment and sets up some "global" Claude stuff, including some [hooks](https://github.com/dannysmith/denv/blob/main/claude/hooks/gate-writes.sh) to prevent certain write operations without my permission. I could then run `denv <project-name>` to SSH into the container and run `claude` in a generally similar environment to my local one. For new projects, `denv scaffold <name>` scaffolds an empty project in my `~/dev/` directory and creates the container, while a few convenience commands basically just wrap docker: `denv start`, `denv stop`, `denv ls` and the like.

The whole idea was that:

1. Claude can **work without restrictions** because the only thing it can mess with on my actual machine is the project files.
2. It can also have fairly permissive **read access** to the internet via its own tools and CLI tools like `gh`, which it needs to do research, but needs my permission for external **write** operations like pushing to git remotes or creating GitHub issues.
3. I still **feel like I'm working locally** in every other respect.

This worked pretty well, and is still a decent solution for any Claude sessions I wanna run with very open [permissions](https://code.claude.com/docs/en/permission-modes). But increasingly I've found myself just running Claude Code locally in `~/dev/my-project/` because it's just easier and I'm lazy. Using *denv* containers means:

- The project needs to have one, or I gotta create it (which also means remembering to run [OrbStack](https://orbstack.dev/)).
- Each container has its own isolated config for both its shell and Claude Code. Even if I remember to mirror changes to my local setup in the *denv* repo, I need to `denv rebuild` and maybe `denv update-plugins` before the containers get those changes.
- Because the containers are intentionally long-lived (so Claude can install project-specific stuff it wants), it's too easy for them to diverge from the base config-as-code in ways that are awkward to maintain.[^1]
- A lot of my projects include macOS or [Tauri](https://tauri.app/) apps and need to run **some** stuff on my actual mac, or access data in places like `~/Library/Application Support/MyApp/` or whatever, which can be awkward since agents running in the container can't access them without explicit setup from me.

## What do I actually want from a "sandbox"?

If I was running agents in YOLO mode I'd want a *very* tight sandbox, but since I mostly run Claude Code with *some* oversight what I really want is this:

1. **Claude runs locally**. Running `claude` in a project directory should Just Work™ – it can still access files, processes and tooling only available on my local machine *when necessary*.
2. **Without full access to everything**. The Claude process does not have free rein over my local machine by default because most of the time *that's unnecessary*.
3. **Obviously dangerous stuff is blocked**. Claude cannot read sensitive files (keys, secrets etc) or run dangerous commands on my machine.
4. **Few permission prompts for "normal" work**. Claude can work freely within the project directory without constantly asking my permission for the kinds of things I'm always gonna say "yes" to.[^2] This probably means restricting write operations more than read operations.
5. **When in doubt, ask me**. Anything not blocked by (3) or allowed by (4) should fall back to asking my permission.

## Claude's Bash Sandbox

Claude Code has a [number of different sandboxing options](https://code.claude.com/docs/en/sandboxing), and the most interesting for me is the **Bash sandbox**, because it's the only one that leaves me working locally. It ensures the `Bash` tool always runs inside an OS-enforced *Seatbelt* sandbox. It only covers `Bash` tool calls so other built-in tools like `Read`, `Edit`, and `WebFetch` aren't covered, nor are MCP tools.

When a sandboxed command is blocked – because it touched a denied path or host – Claude can retry it "unsandboxed", which triggers a permission prompt. That denial → retry → prompt flow is how it reaches out of the sandbox and asks. Worth knowing that approving one runs the **whole command** unsandboxed, not just the bit that was blocked.

## Configuration

You can find my current Claude [`settings.json`](https://github.com/dannysmith/dotfiles/blob/master/claude/settings.json) in my dotfiles repo:

<BookmarkCard url="https://github.com/dannysmith/dotfiles" />

### Permissions

Claude's [permission rules](https://code.claude.com/docs/en/permissions) govern everything the Bash sandbox doesn't: the other tools, MCP, and any command running outside the sandbox. `deny` and `ask` rules also survive the sandbox, so they apply to sandboxed commands too. They're set up like this:

- **Allow** - Read-only bash commands like `ls` `git status` and the like are always allowed, along with `MCP(context7:*)` and a few specific `Skill` tool calls. Explicitly allowing these means I'm never prompted for them.
- **Ask** - Commands which I *always* want to manually confirm. While this is the default outside the sandbox, explicitly listing certain bash commands here ensures I'm asked even inside the sandbox. These are mostly things like `Bash(gh pr create:*)`.
- **Deny** - Bash commands which I always want to be denied – mostly OS stuff like `Bash(mount:*)`, but also includes `Read` for sensitive files like `Read(~/.ssh/**)`.

There's one `ask` rule doing more work than all the others:

```json
"Bash(dangerouslyDisableSandbox:true)"
```

Without it a command like `cat ~/.ssh/id_ed25519` fails inside the sandbox, Claude offers to retry unsandboxed, and my own read-only `Bash(cat:*)` allow rule waves it through. This rule ensures every escape from the sandbox prompts me regardless. The mechanism is interesting too: ask rules with a pattern inside the parens are checked before anything else, but a bare `Bash` or `Bash(*)` rule isn't, and gets skipped entirely for sandboxed commands.

### Bash Sandbox

Inside the Bash sandbox, I allow network access to certain domains like *registry.npmjs.org* which are required for tools like `npm` to work, as well as *localhost* and `*.danny.is`. Filesystem access from the sandbox is set up like this:

- `denyRead` - Everywhere I don't want the sandbox to read, including stuff like `~/Desktop`, `~/Library`. For the occasions where I **do want** to allow access to these, Claude can always ask my permission to run the command unsandboxed.
- `denyWrite` - A similar list of directories I don't ever want sandboxed bash to write to.
- `allowRead` and `allowWrite` - Directories where read or write access is explicitly allowed from the sandbox, mostly overriding certain things denied by the above. Includes directories like `~/Library/Caches`, `~/Library/pnpm` and `~/Library/Developer`.

Certain credentials and environment variables are also [protected with a deny list](https://code.claude.com/docs/en/sandboxing#protect-credentials).

### Excluded Commands

I've intentionally [excluded certain commands](https://code.claude.com/docs/en/sandboxing#the-unsandboxed-retry-escape-hatch) via `excludedCommands` so they **always run outside the sandbox**, governed by the permission rules above instead. I do this for two categories of command:

1. Things which can never work sandboxed, so the sandboxed attempt is just a dead step before the inevitable prompt. `git push` over SSH is the obvious one – `~/.ssh` is deny-read by design – as are `ps` and `lsof`, since the sandbox is process-blind. Most of these have `allow` rules too, so they don't prompt at all.
2. Commands which I *always* want to approve with a prompt, like `kill` and `docker`. These are excluded *without* an allow rule, so they prompt me.

## Additions to CLAUDE.md

I've updated my [global CLAUDE.md](https://github.com/dannysmith/dotfiles/blob/master/claude/CLAUDE.md) with a brief mention that we are likely running in the Bash sandbox, and I've also temporarily added the following to help with debugging sandbox-related issues.

```md preview
- If you are running with the bash sandbox enabled and have issues you suspect are because of it, tell the user before trying any complex workarounds. Common tells: EPERM on paths under `~/Library`, a tool silently using a project-local cache/store it normally wouldn't, or a tool resolving to a different version than usual (check `command -v` — sandbox denies can make the shell fall through to a different binary on PATH).
```

I expect to remove this after ironing out any teething problems.

## How's it going?

After using this setup for a couple of weeks in [manual *acceptEdits* mode](https://code.claude.com/docs/en/permission-modes#auto-approve-file-edits-with-acceptedits-mode) I'm pretty happy with how things are working. It feels like I'm seeing a similar number of permission prompts as before, but with a much less risky setup. Crucially, the vast majority are about running unsandboxed commands which genuinely deserve my attention. There's been a notable decrease in permission prompts where I'd *always* say yes.

The only downsides I've noticed so far are related to the tighter permissions rather than the Bash sandbox, so I expect to do some tweaking over the next few weeks.

## Enabling Auto Mode

I always intended that this stuff would eventually provide a marginally safer environment in which to run [auto mode](https://code.claude.com/docs/en/permission-modes#eliminate-prompts-with-auto-mode) locally, so when Anthropic [recently made auto mode the default](https://claude.com/blog/auto-mode-default-in-claude-code) I decided to switch it on after a few weeks using the new sandbox and permissions without it.

What the classifier actually governs is pretty narrow because the sandbox gets there first – anything that can be sandboxed is auto-allowed without the classifier ever seeing it. So its actual surface is the `excludedCommands`, requests to non-allowlisted domains, and the non-Bash tool calls. Crucially it does **not** get to judge unsandboxed retries, because that `dangerouslyDisableSandbox` ask rule is evaluated before it.

## Wrapping up

I've only been using auto mode with this setup for a few days, so I fully expect to fiddle with a bunch of stuff before I find the right *safety vs autonomy* balance for local dev work.

I don't think we've solved the whole sandboxing issue yet – at least not in a way which adequately addresses both agent isolation and developer experience. Until we do, I expect I'll continue to use both [denv](https://github.com/dannysmith/denv) and Claude's [sandboxed runtime](https://code.claude.com/docs/en/sandbox-environments#sandbox-runtime) in certain situations.

[^1]: I could obviously solve this by having all my projects manage their own dev container config as code and rebuild on every new session from that and a base image.
[^2]: Asking the user to approve a bash command is basically pointless in August 2026 – modern agents generate such complex compound commands that any normal user will just smash *Yes* unless the first ~20 characters are obviously scary.