Skip to content
Skip to main content
A heavy steel security door standing ajar in a bare concrete room, representing the Claude Code sandbox boundary that stops writes but leaves reads open
8 min readBy Carlos Aragon

Claude Code Sandbox: What It Actually Blocks

The Claude Code sandbox runs Bash and every process it spawns inside a boundary the operating system enforces — Seatbelt on macOS, bubblewrap on Linux and WSL2. Writes are locked to your working directory and the session temp directory. Network egress is denied until you allow a domain. Reads are not restricted, which means a sandboxed command can still open ~/.ssh and ~/.aws/credentials on a default setup. That one asymmetry is the whole story, and it decides how you should configure it.

Why You End Up With an Allow List Full of Garbage

Before I turned any of this on, I opened my own ~/.claude/settings.local.jsonand counted: 31 allow rules, 13 of them Bash, every one added by clicking “yes, and don't ask again” on a prompt at some point over the last few months. This is my favorite one:

"Bash(ps -o pid,etime,command -p 782)"

A permanent, standing approval for inspecting process ID 782. That process died months ago. The rule will never match anything again, and it will sit in my settings file forever. Next to it: two rules that are the same lsof invocation with the flags in a different order, and a bare "Bash" that I clearly approved on a tired night and that quietly outranks all of them.

That's what approval fatigue produces. It isn't a policy, it's sediment. The reason people reach for --dangerously-skip-permissionsis not that they stopped caring about security — it's that clicking approve forty times an hour produces a worse security posture than not clicking at all, and everyone can feel it.

The sandbox is the actual answer to that, and it's worth understanding precisely, because “sandboxed” makes people assume a lot more containment than they're getting.

Permissions Decide If It Runs. The Sandbox Decides What It Can Touch.

These are two separate layers and they fail differently, which is the part worth internalizing.

A permission rule is evaluated beforethe command runs, from the command string. It's a judgment about text. If the string looks fine and the command does something else — a wrapper script, a Makefile target, a postinstall hook — the rule already said yes.

The sandbox is enforced by the kernel on the running process. It doesn't care what the command claimed to be. Every child process inherits the same boundary, so a build script that shells out to a package manager that runs a downloaded binary is still inside it. One layer guesses from a string; the other holds regardless. Run both.

Worth knowing about the seams: the sandbox covers Bash and its children only. The Read, Edit and Write tools go through the permission system instead. Subagents share the parent session's sandbox config, so a delegated task doesn't get a wider boundary than the session that spawned it. And /sandbox is not a permission mode — it sits alongside your mode rather than replacing it.

The Defaults, Precisely

I'm running Claude Code v2.1.220. Out of the box, with /sandbox enabled and nothing else configured:

LayerDefaultWhat that means in practice
WritesWorking directory + session temp dirCan't touch ~/.zshrc, /bin, or another repo
ReadsAlmost the entire filesystemSSH keys and cloud credentials are readable
NetworkNothing pre-allowedFirst hit on a new domain prompts, then sticks for the session
Env varsInherited from the parent processYour GITHUB_TOKEN is in there
Settings filesWrite-denied automaticallyA sandboxed command can't widen its own policy

Read that second row again. The write boundary is tight and the read boundary is basically your whole laptop. That is a deliberate trade — restricting reads breaks half the tools people run — but it means the sandbox on its own protects your machine from being modified, not your secrets from being seen. Pair it with an exfiltration path and you have a problem, which brings us to the network.

A Wide Allowlist Is an Exfiltration Route

Network egress goes through a proxy that runs outside the sandbox, and by default that proxy makes its decision from the hostname the client hands it. It does not terminate TLS. It does not look inside the connection.

So "allowedDomains": ["github.com"] reads like a tidy restriction and behaves like a door. Anthropic's own sandboxing documentation says it plainly: broad domains create paths for data exfiltration, and code inside the sandbox can use domain fronting to reach hosts outside the allowlist. Combine that with unrestricted reads and the shape of the attack is obvious — read the key, ship it to a host that resolves behind an allowed CDN.

The mental model that keeps this straight:

Filesystem isolation and network isolation only work as a pair. Without the network layer, a compromised agent walks your SSH keys out the door. Without the filesystem layer, it backdoors something on your $PATH to get the network back. Every time you widen one, check what it just undid on the other side.

This is the same lesson from the MCP tool poisoning problem: you don't make the model trustworthy, you make the blast radius small. The sandbox is the best blast-radius tool in the box, and it does nothing about the secret sitting in an environment variable unless you tell it to.

The Config I Run

This lives in ~/.claude/settings.json (user scope, not the project — several of these keys are deliberately ignored when they come from a repo you cloned):

{
  "sandbox": {
    "enabled": true,
    "autoAllowBashIfSandboxed": true,
    "allowUnsandboxedCommands": false,
    "filesystem": {
      "allowWrite": ["~/.npm", "~/.cache"],
      "denyRead": ["~/Documents", "~/Downloads"]
    },
    "network": {
      "strictAllowlist": true,
      "allowedDomains": [
        "registry.npmjs.org",
        "*.github.com",
        "api.anthropic.com",
        "pypi.org"
      ]
    },
    "credentials": {
      "files": [
        { "path": "~/.ssh", "mode": "deny" },
        { "path": "~/.aws/credentials", "mode": "deny" }
      ],
      "envVars": [
        { "name": "GITHUB_TOKEN", "mode": "deny" },
        { "name": "NPM_TOKEN", "mode": "deny" }
      ]
    }
  }
}

Four decisions in there are the ones that matter. credentials is the one nobody sets and everybody needs — file entries block the read, env entries unset the variable before each sandboxed command. There is no built-in deny list, so only what you name is protected.

strictAllowlist(v2.1.219+) changes an unlisted host from “prompt the human” to “denied.” That's the setting that makes unattended runs meaningful, because a prompt nobody is there to answer is just a hang.

allowUnsandboxedCommands: false removes the escape hatch. Normally, when a command fails because of sandbox restrictions, Claude can retry it with dangerouslyDisableSandboxand route it through the regular permission flow. Handy when you're sitting there. Pointless when you're not — set it false and everything must run sandboxed or be named in excludedCommands.

And allowWrite only gets paths a real tool needs. Never a directory on your $PATH, never a shell config. Write access to a place that later gets executed is how a filesystem restriction turns into code execution.

If a credential genuinely has to work inside the sandbox — you want gh to authenticate — use "mode": "mask" instead of deny. The command sees a sentinel and the proxy substitutes the real value on requests to the hosts in injectHosts. It needs network.tlsTerminate, and on macOS a masked file is blocked outright rather than masked, so don't plan around it there.

Sandbox vs Auto Mode vs Skipping Permissions

Three different things get described as “stop asking me,” and they are not interchangeable:

ApproachWhat replaces the promptUse it when
Sandbox auto-allowAn OS-enforced boundaryDefault choice for day-to-day work on a real machine
Auto modeA classifier that reviews each actionCovering non-Bash tools the sandbox never touches
--dangerously-skip-permissionsNothingOnly inside a container you'd be fine deleting

The first two compose — auto-allow handles Bash because the boundary contains it, and the classifier handles everything else. That combination is what actually ended the prompt-clicking for me without the sediment problem, because approvals stop being permanent rules I have to live with.

Even in auto-allow, some things still stop: explicit deny rules, rm aimed at / or your home directory, and content-scoped ask rules. Keep "Bash(git push *)" on your ask list — those fire for sandboxed commands too, and pushing is exactly the operation you want a human on.

Five Settings That Quietly Undo It

Every one of these exists for a legitimate reason and every one of these punches a hole. Know which you've enabled:

  • allowUnixSockets with /var/run/docker.sock — handing over the Docker socket is handing over the host. Full stop.
  • allowAppleEvents on macOS — makes open and osascript work, and lets sandboxed commands launch other apps unsandboxed with no prompt.
  • filesystem.disabled: true — keeps network isolation, drops the file boundary and the automatic write-protection on your settings files. Now a sandboxed command can edit the policy that governs the next one.
  • enableWeakerNestedSandbox on Linux — the compatibility mode for Docker without privileged namespaces. It is named honestly. Only use it when something else provides the isolation.
  • A wildcard in allowedDomains *.github.com is defensible; *.amazonaws.comis every S3 bucket on earth, including the attacker's.

One structural note for anyone running agents unattended, which is most of what I build: the sandbox is a containment boundary, not a budget. It won't stop a loop from burning $300 on tokens over a weekend — that's a different set of controls I wrote up in cost controls for autopilot agents. And if you want a rule the agent can't argue with at a specific lifecycle moment rather than a boundary around the whole process, hooks are the sharper instrument. The three layers stack; none of them replaces the others.

Start Here, Today

Twenty minutes, in this order. On macOS there's nothing to install; on Linux or WSL2, apt-get install bubblewrap socat first, and note that native Windows isn't supported at all — WSL2 or nothing.

  1. Run /sandbox and pick auto-allow. The panel tells you what dependencies are missing.
  2. Add the credentialsblock above. It's the highest-value five minutes here and it's off by default.
  3. Work normally for a day. Every domain prompt you hit is a real dependency — write it into allowedDomains, then turn on strictAllowlist.
  4. Delete your accumulated Bash(...) allow rules from settings.local.json. Mine included a permanent approval for a process that no longer exists. Yours will be similar.
  5. For unattended runs, set allowUnsandboxedCommands: false.

Anthropic ships starter settings files for common deployment scenarios if you'd rather adapt one than start blank. If you're building the agent workflows that run inside this boundary, the dynamic workflow patterns assume you've already drawn it.

Running Agents Nobody Is Watching?

I build production AI agent systems — Claude Code pipelines, MCP servers, and the n8n workflows underneath them — with the boundaries drawn before they go unattended, not after something leaks. If your agents already touch customer data and nobody has mapped what they can reach, let's fix that.

Related Posts

AI Agents

MCP Server Security: How to Stop Tool Poisoning

Tool poisoning is when an MCP server hides instructions inside its own tool descriptions — text your agent reads as commands and you almost never see. The model obeys it because, inside the context window, a description and a system prompt are the same kind of thing, which is why no system prompt fixes this. The four controls that hold are structural: approve individual tools instead of whole servers, pin exact versions and diff the tool descriptions in CI so a rug pull is a failed build, keep secrets out of the model's context entirely, and run local servers in a container with no network access.

AI Agents

How to Build a Private Claude Code Plugin Marketplace for Your Team

A private Claude Code plugin marketplace is a git repo with one file in it: .claude-plugin/marketplace.json. Your team runs /plugin marketplace add your-org/claude-plugins and installs what they need, and the git host handles who is allowed to see it — there is no server to run and no permissions layer to build. The setup is a manifest and a catalog. What actually costs you an afternoon is two things nobody warns you about: the background refresh disables git credential helpers, so private HTTPS auto-updates fail while manual ones work, and omitting the optional version field means every commit ships as a new release to everyone who installed the plugin.

AI Agents

Claude Skills vs Subagents: When to Use Each

Claude skills and subagents solve two different problems, and mixing them up is the fastest way to waste context. A skill is reusable instructions loaded into your current conversation — same model, same context, no isolation; it changes how the agent you're already talking to behaves. A subagent is a separate assistant with its own fresh context window, system prompt, tools, and optionally its own model, doing work you never see and returning only a summary. Use a skill for a repeatable procedure that needs the current context — a report format, a QA checklist, a deploy runbook. Use a subagent when you need isolation, parallelism, or context protection: fan out three to five at once, keep a huge read in a throwaway window, run a locked-down reviewer. The strongest setups use both — a skill defines the how, a subagent provides the isolated, parallel where.