Claude Code の Bash sandbox、有効化しただけでは認証情報が読めてしまいます

Claude Code の Bash sandbox、有効化しただけでは認証情報が読めてしまいます

It's quite complex!
2026.09.18

This page has been translated by machine translation. View original

製造ビジネステクノロジー部 の Kazue here.

What is Bash sandbox

Claude Code's Bash sandbox is a feature that sets boundaries on commands executed by the Bash tool and all their descendant processes. Through these "boundaries," the goal is to let AI agents autonomously perform various tasks such as builds, tests, and Git operations, while minimizing impact on the host environment and sensitive information.

The main purpose of this feature is to reduce approval mistakes caused by "approval fatigue." Depending on the permission mode, Claude Code asks users for approval every time it tries to execute a command. There is a risk that users become exhausted from a large number of approval requests, approve without carefully reviewing the content, and end up approving erroneous operations or external transmission of confidential information. The idea behind this Bash sandbox is to manage the risk of command execution not through "sequential approval" but by "defining safe boundaries in advance."

※ Strictly speaking, the official documentation only states the purpose as "allowing autonomous execution without stopping prompts," not "preventing mistaken approvals due to approval fatigue." Please consider this my own independent interpretation going one step further.

However, the default settings are weak

The Bash sandbox feature can be easily enabled from /sandbox during a session. However, simply enabling it limits the power of those "boundaries."

The reason for this is probably (= my speculation) that basically, the greater the power of the "boundaries," the more convenience is compromised. In other words, the strength of Bash sandbox and convenience ≒ developer experience (DX) are fundamentally in a trade-off relationship. I think the default settings represent a balance between the two.

Thinking about maximally secure settings focused on information leakage

So in this entry, I'll think about Bash sandbox settings leaning as far as possible toward maximum security. Furthermore, since examining various risks together would blur the focus, I'll narrow it down to information leakage risk. I'll also write about what becomes inconvenient when we strengthen the settings.

I'll also narrow down the assumed attack vector to one: supply chain attacks. Malicious code is embedded in a dependency package, and lifecycle scripts such as preinstall / postinstall that automatically execute behind npm install serve as a foothold to read authentication credentials on the developer's machine and send them externally. The Shai-Hulud observed on npm in September 2025 is a typical example, self-propagating by contaminating other packages with stolen npm tokens. In Shai-Hulud 2.0 confirmed in November of the same year, execution moved to preinstall, widening the scope of impact, and it even had a fallback that destroyed the home directory if credential theft failed.

And I believe that the more work is delegated to AI agents, the easier it becomes to fall into this attack vector. Even in situations where a human might pause thinking "this package looks suspicious," an agent will proceed to npm install as instructed (this is not based on quantitative evidence, but my personal sense). That's precisely why the idea becomes: rather than stopping the execution itself, create a state where there's nothing to steal even if execution occurs.

Let me state the conclusion first. sandbox.enabled: true alone is almost completely ineffective as a measure against information leakage. The reason is simple: the defaults of Bash sandbox are as follows:

  • Reading is allowed for the entire machine. ~/.ssh/ and ~/.aws/credentials can be read
  • Environment variables are inherited wholesale from the parent process. NPM_TOKEN and AWS_SECRET_ACCESS_KEY are visible as-is
  • There is no pre-prepared deny list for credentials. Only the files and variables you enumerate yourself are restricted

Dividing the default state into three parts, the nature of the strength differs for each.

  • Writing: Solid from the start. Nothing outside the working directory can be written, and protected paths under .claude cannot be opened even with allowWrite or Edit allow rules
  • Reading: Has holes from the start. The entire machine is permitted, and blocking only works once you configure it yourself
  • Network: Solid at first, but loosens with use. Pre-approved domains are zero, but the allowlist grows each time you select "Yes, and don't ask again" in a prompt

If you want to stop information leakage (= reading and sending outside), the real work starts with adding settings to both reading, which has holes from the start, and network, which becomes holes if left alone.

This article builds up settings for each leakage path, based on official documentation.

https://code.claude.com/docs/en/sandboxing

Verification Environment

Open only if you want to know the details about the verification environment.

In the main text, there are several behaviors not written in the official documentation, or behaviors that did not work as described. All have been verified on actual machines, with the following environment:

  • Primary verification environment: macOS, Claude Code v2.1.273
  • Some items only: Linux (VM of Claude Code on the Web), also Claude Code v2.1.273

The latter involves nested sandboxing (running Bash sandbox inside an already isolated VM), so please read environment-dependent behaviors with some discount. Conversely, Bash sandbox also works inside Claude Code on the Web.

The following 7 points were confirmed on actual machines. Each will be described in detail in the corresponding section of the main text.

  • Writing wildcards like AWS_* causes no error but silently has no effect (Linux / "Hole ②: Environment variables are inherited wholesale")
  • When socat is not installed, only a warning is shown and the command passes through without a sandbox (Linux / "failIfUnavailable: true (fail-closed)")
  • Masked variables appear as fake_value_... inside the sandbox, while the real value is returned in ! shell mode (macOS / "Making credentials 'usable without being passed': mask")
  • Forgetting tlsTerminate doesn't show up in the startup screen and is only discovered through claude doctor (macOS / same as above)
  • strictAllowlist written in project settings is ignored and approval prompts appear (macOS / "strictAllowlist to eliminate prompts")
  • Incorrectly placing denyRead in user settings causes the agent to detour to the Read tool and continue working (macOS / "filesystem.denyRead to close off the entire home directory")
  • Read(//**/*.pem) written to protect private keys destroys all HTTPS inside the sandbox (macOS / "Collateral damage: HTTPS breaks due to rules protecting private keys")

Premise: What Bash sandbox protects

First, let's confirm the position of the boundary. Bash sandbox uses OS security mechanisms (Seatbelt on macOS, bubblewrap on Linux / WSL2) to enforce boundaries on commands executed by the Bash tool and all their descendant processes. This is important: preinstall / postinstall scripts run by npm install, which frequently appear in supply chain attacks, also fall within the boundary.

On Linux / WSL2, in addition to bubblewrap, installing socat (which relays communications to the sandbox proxy) is also required. If either is missing, the sandbox will not become effective. However, that doesn't mean command execution stops. The default is fail-open: a warning is issued and commands are executed without a sandbox (see "failIfUnavailable: true (fail-closed)" described later). macOS has Seatbelt built into the OS, so nothing needs to be installed.

On the other hand, the following are outside the boundary:

  • Read / Edit / Write tools: Controlled by the permission system, not the sandbox
  • MCP servers and hooks: Not subject to the sandbox as they don't go through the Bash tool
  • Commands you type in ! shell mode: Even if typed within a Claude Code session, they run outside the boundary because they don't go through the Bash tool
  • Commands you type in your own terminal, IDE extensions, app launches

The third is particularly easy to overlook. You might mistakenly think it's inside the boundary because it's typed within a Claude Code session, but the official documentation states clearly:

A developer can still type a command at the ! shell-mode prompt and run it outside the sandbox, with the same access they already have in any terminal outside Claude Code.

There are only two exceptions: background sessions and cases where CLAUDE_CODE_SUBPROCESS_ENV_SCRUB is set on Linux. If neither of these applies, shell mode runs outside the boundary even with allowUnsandboxedCommands: false (the Strict sandbox mode described later).

And this behavior changed in v2.1.260. Before that, Strict sandbox mode also placed shell mode commands inside the boundary. If you test based on old memories, results will differ, so check claude --version before trying.

In fact, when verifying the settings in this article, I accidentally ran cat with !, and once mistakenly judged "a file that should not be readable was readable." To verify whether settings are working, you need to ask Claude to execute it via the Bash tool.

The default filesystem behavior can be summarized as follows:

Operation Default Scope
Writing Working directory and its subdirectories + session temporary directory only
Reading The entire machine (except certain denied directories)
Network Pre-approved domains are zero. Confirmation requested each time

The "pre-approved domains are zero" for network looks most solid at first glance, but this table is only the initial values. Of the three, only the allowlist grows during operation (described later).

The write side is solid from the start. Shell configurations like ~/.bashrc and system binaries like /bin/ cannot be modified. Furthermore, as "protected paths," even within the working directory, writes to configuration files under .claude, .claude/hooks, .mcp.json, .git/hooks, .gitconfig, etc. are denied. This is to prevent code inside the sandbox from expanding its own privileges or planting hooks that run outside the boundary. This protection cannot be lifted by allowWrite or Edit allow rules.

Just listing specifications makes the value feel thin, so let me give a concrete example. The aforementioned Shai-Hulud 2.0 destroys the home directory when credential theft fails, but as long as it runs via the Bash tool, this destruction cannot reach outside the working directory. This article focuses on the reading side, but the write side is already working with the defaults.

The problem is the read side. Let's start plugging those holes.

Hole ①: Credential files can be read

Since the default read scope is the entire machine, ~/.ssh/ and ~/.aws/credentials can be read transparently from inside the sandbox. The documentation also explicitly states this:

Default read behavior: read access to the entire computer, except certain denied directories. Note that this default still allows reading credential files such as ~/.aws/credentials and ~/.ssh/.

There are two ways to plug this.

Individually deny with sandbox.credentials.files

This is a block dedicated to credentials. Specifying mode: "deny" causes reading of that path to be denied inside the sandbox.

{
  "sandbox": {
    "enabled": true,
    "credentials": {
      "files": [
        { "path": "~/.aws/credentials", "mode": "deny" },
        { "path": "~/.ssh", "mode": "deny" }
      ]
    }
  }
}

deny entries are merged from all configuration scopes. The design is one-directional: any scope can add entries, but no scope can revoke a deny added by another scope.

Note that the sandbox.credentials block itself requires Claude Code v2.1.187 or later.

Close off the entire home directory with filesystem.denyRead

A stronger approach is to close the entire home directory and then reopen only the project. When read rules overlap, the more specific path wins, so you can write it like this:

{
  "sandbox": {
    "enabled": true,
    "filesystem": {
      "denyRead": ["~/"],
      "allowRead": ["."]
    }
  }
}

This setting needs to be placed in the project's .claude/settings.json. This is a subtle trap: the resolution of . changes depending on where the configuration file is located.

Prefix Resolves to
/ Absolute path from filesystem root
~/ Relative to home directory
./ or no prefix Project root for project settings, ~/.claude for user settings

The official documentation also specifically instructs placing this setting in project settings:

Place it in your project's .claude/settings.json, because the relative path . resolves to the project root only when the configuration lives in project settings

If you place the same JSON in ~/.claude/settings.json, . resolves to ~/.claude, so project files remain unreadable, caught by denyRead: ["~/"]. Note that this is a "silently produces different results than intended" pattern rather than an error, so be careful.

When I actually tried this misplacement, I understood well what "silently" means. With the setting placed in user configuration, when I asked Claude to cat a file in the project, this was the response:

The Bash sandbox restriction prevented the cat command from executing (reading the project directory was blocked by the sandbox). I confirmed the contents using the Read tool instead.

As mentioned earlier, the Read tool is outside the boundary, so the agent finds a detour on its own and achieves the goal. Since the work still progresses, there's no opportunity to notice the configuration error. Without realizing it, you end up with "an environment where only Bash-executed tools fail for inexplicable reasons."

Detailed handling of prefixes (trailing slashes and wildcards) is described in the settings reference.

https://code.claude.com/docs/en/settings-reference#sandbox-path-prefixes

Also, denyRead: ["~/"] is a fairly strong setting. In environments where toolchains (~/.nvm, ~/.cargo, ~/.rustup, etc.) are placed under home, builds will stop working, so you'll need to reopen the necessary parts with allowRead. Protection works in the other direction too: writing denyRead: ["~/**/.env"] inside a broad permission like allowRead: ["~/"] means deny wins. Wide permissions won't accidentally re-expose secrets.

Permission Read deny rules also merge in

As a third path, something that is not a sandbox setting also affects read restrictions. If you have rules like Read(//**/id_rsa*) in permissions.deny, those paths merge directly into the sandbox read restrictions as well.

Paths and domains from both sandbox settings and permission rules are merged into the final sandbox configuration.

Permission rules and the sandbox are different layers (the former is judgment before command execution, the latter is OS enforcement), but paths and domains are merged into the final sandbox configuration. This is symmetric to how WebFetch(domain:...) allow rules flow into the allowlist on the network side described later — this is the read version.

So, in environments where you already have Read / Edit deny rules blocking private keys, some protection is already in place before touching the sandbox block. Conversely, you can't understand the actual boundary just by looking at sandbox settings. Checking the resolved values in the Config tab of /sandbox is the reliable approach. When you actually look, you can see that paths derived from permissions are listed alongside the denyRead you wrote yourself.

Collateral damage: HTTPS breaks due to rules protecting private keys

This merging has side effects. I had the following in my ~/.claude/settings.json, intending to prevent Claude from reading private keys:

{
  "permissions": {
    "deny": [
      "Read(//**/id_rsa*)",
      "Read(//**/id_ed25519*)",
      "Read(//**/*.pem)"
    ]
  }
}

This was completely destroying HTTPS communications from inside the sandbox.

* Establish HTTP proxy tunnel to api.github.com:443
< HTTP/1.1 200 Connection Established
* (304) (OUT), TLS handshake, Client hello (1):
* error setting certificate verify locations:  CAfile: /etc/ssl/cert.pem CApath: none
curl: (77) error setting certificate verify locations:  CAfile: /etc/ssl/cert.pem CApath: none

macOS's CA bundle is at /etc/ssl/cert.pem, and this matches *.pem. Running cat /etc/ssl/cert.pem from inside the sandbox results in Operation not permitted, making TLS certificate verification impossible. The cause is very hard to see because the proxy tunnel itself is established with 200 but then fails.

A clue for debugging is that HTTP works but only HTTPS fails. Even for the same host, curl http://example.com succeeds.

What makes it even more troublesome is that the two settings causing the issue are in separate files:

  • Read(//**/*.pem) is in user settings, and was originally intended to prevent the Read tool from reading private keys
  • sandbox.enabled: true is in project settings

Looking at either file alone won't lead you to the cause. The structure is: a rule written with the Read tool in mind suddenly affects Bash too the moment you enable the sandbox in a different file. Here too, the Config tab of /sandbox is the only way to verify.

The settings meant to protect private keys were destroying TLS itself — rules that collectively block by file extension cause this kind of collateral damage. It's safer to narrow it down to a directory like ~/.ssh/**, or to reopen the CA bundle path with sandbox.filesystem.allowRead.

The official documentation does mention TLS verification on macOS, but that's about Go-based CLIs like gh / gcloud / terraform, which is a separate matter from this issue.

Hole ②: Environment variables are inherited wholesale

Commands inside the sandbox inherit the parent process's environment variables as-is by default. If you have NPM_TOKEN or AWS_SECRET_ACCESS_KEY as environment variables, they are visible from postinstall scripts too.

With sandbox.credentials.envVars, you can unset variables before command execution inside the sandbox.

{
  "sandbox": {
    "enabled": true,
    "credentials": {
      "envVars": [
        { "name": "NPM_TOKEN", "mode": "deny" },
        { "name": "GITHUB_TOKEN", "mode": "deny" },
        { "name": "AWS_ACCESS_KEY_ID", "mode": "deny" },
        { "name": "AWS_SECRET_ACCESS_KEY", "mode": "deny" },
        { "name": "AWS_SESSION_TOKEN", "mode": "deny" }
      ]
    }
  }
}

You might want to specify them all at once with a wildcard like AWS_*, but you can't. This is explicitly stated in the settings reference:

The name must start with a letter or underscore and contain only letters, digits, and underscores.

Since only alphanumeric characters and underscores are allowed, variable names must be listed one by one.

The tricky part is that writing AWS_* causes no error. When I tested with { "name": "AWS_*", "mode": "deny" } in my local environment (v2.1.273), there were no warnings or errors at startup, and both AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY remained visible from inside the sandbox. Since it's easy to assume "I wrote it, so it must be blocked," there's no choice but to visually verify that nothing in the list is missing.

Use CLAUDE_CODE_SUBPROCESS_ENV_SCRUB to strip everything at once

For bulk removal, use the environment variable mechanism. Setting CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=1 strips Anthropic and cloud provider credentials from the subprocess environment.

The key point is that this has a wider scope — it covers not just the Bash tool but also hooks and MCP stdio servers. As mentioned earlier, hooks and MCP are outside the sandbox, so sandbox.credentials cannot protect that scope. The parent Claude process continues to hold credentials for API calls, but child processes can no longer read them.

On Linux, additionally, Bash subprocesses run in an isolated PID namespace, so reading the host process's environment via /proc also becomes impossible. One side effect to be aware of is that ps / pgrep / kill will no longer be able to see host processes.

Note that setting this variable turns off autoAllowBashIfSandboxed and causes filesystem.disabled to be ignored across all scopes (= filesystem isolation is always on), which are additional side effects.

Making credentials "usable without being passed": mask

The weakness of deny is that tools that need tokens stop working. gh and npm don't function without credentials. mode: "mask" addresses the requirement of "don't want it read, but can't afford for it to be unusable."

Here's how it works:

  1. Commands inside the sandbox see a per-session dummy value (sentinel) rather than the real value
  2. A proxy running outside the sandbox substitutes the real value when sending to hosts specified in injectHosts

As a result, neither the command itself nor the logs the command outputs hold the real credentials, yet request authentication passes.

When I verified this by masking GITHUB_TOKEN in my local environment (v2.1.273), inside the sandbox it looked like this:

$ printenv GITHUB_TOKEN
fake_value_f63c59ff-b003-47b6-9652-fb4186680cbc...

It's a string starting with fake_value_, bearing no resemblance to the real thing. On the other hand, viewing the same variable in ! shell mode (outside the boundary, as mentioned earlier) returns the actual value that was set. Comparing these two is the easiest way to verify that mask is working.

Here is a configuration example for mask:

{
  "sandbox": {
    "enabled": true,
    "network": {
      "tlsTerminate": {},
      "allowedDomains": ["*.github.com", "registry.npmjs.org"]
    },
    "credentials": {
      "envVars": [
        { "name": "GITHUB_TOKEN", "mode": "mask", "injectHosts": ["api.github.com"] },
        { "name": "NPM_TOKEN", "mode": "mask" }
      ]
    }
  }
}

There are three conditions for using it:

  • network.tlsTerminate is required. The proxy needs to terminate TLS and inspect the content to rewrite the request. Specifying {} generates a temporary CA for the session. Without this setting, the sentinel reaches the server as-is and authentication fails (no leakage, though)
  • The injectHosts destination must also be reachable through allowedDomains. The proxy only injects on connections passed by the allowlist. Omitting injectHosts makes all allowedDomains hosts the target
  • Does not work from repository configuration files. Described later

There is a detection mechanism for forgetting the first condition, but it doesn't appear on the startup screen. When I tried starting without tlsTerminate in my local environment (v2.1.273), everything looked normal on screen, and I only found out by running claude doctor:

% claude doctor
(omitted)
1 warning found
- sandbox.credentials mask entries (GITHUB_TOKEN) are configured but TLS termination is unavailable — sandboxed commands see only a sentinel value and the proxy cannot substitute the real credential on egress, so tools needing these will fail to authenticate.
  Fix: Enable sandbox.network.tlsTerminate (or remove the mask entries)

The documentation says it "reports this misconfiguration at startup," so if you expect to catch it at startup, you'll miss it. Run claude doctor once after configuring mask to be safe.

The targets for substitution are headers and request bodies. For cases with structured values, there are also options like extract (masks only group 1 of a regex — for hiding just the password inside DATABASE_URL) and decode: "jwt" (replaces with a structurally correct fake JWT) (v2.1.224 and later). mask for files is also possible, but there is a platform difference: on Linux / WSL2, a sentinel copy is read, while on macOS the file simply becomes unreadable (effectively the same as deny) (v2.1.221 and later).

Note that mask silently falls back to deny for entries it can't mask safely. The documentation lists four conditions:

Claude Code falls back to deny for a mask entry it can't mask safely: a directory path, a glob pattern, a file larger than 8 MiB, or a file that isn't UTF-8 text.

The four are: a directory, a glob pattern, a file larger than 8 MiB, and a file that isn't UTF-8 text. I think it's worth being aware that glob patterns, which you tend to write like ~/.aws/*, are included.

For AWS, which uses signing (SigV4), you need to mask AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY together. The proxy detects the request using the access key sentinel as a cue, substitutes the real value, and re-signs. If you mask only the secret, it can't detect the request signed with the placeholder, and it will fail on the AWS side.

What mask prevents and what it doesn't

I want to be precise here. What mask prevents is "theft of the credentials themselves," not "misuse of those credentials."

If you allow registry.npmjs.org for npm install, a npm publish with a stolen NPM_TOKEN (the self-propagation route of the aforementioned Shai-Hulud) will pass as legitimate traffic. This is the limitation: the allowed domain itself can become an exfiltration path.

Masking NPM_TOKEN does not close this hole. The proxy injects real values into connections passed by the allowlist, so if malware inside the sandbox executes npm publish, the proxy will authenticate that.

What mask reliably eliminates are the following paths:

  • Outputting the token value via echo to logs or files
  • POSTing the token value to an attacker's server
  • Smuggling the token value out by hiding it in a request to another allowed domain

It's a guarantee that "the value itself will never go outside," not that "legitimate-format operations against allowed domains" will be stopped. That's addressed by the network settings in the next section, and by designing to reduce the credentials brought inside the boundary in the first place.

Narrowing exit points: Network settings

Since leakage requires not just "reading" but also "sending outside," exit settings are just as effective as read restrictions.

By default, there are zero pre-approved domains, and a prompt appears each time a new domain is needed. There's a trap here: selecting "Yes, and don't ask again" in a prompt saves a WebFetch(domain:...) allow rule to local settings, which then flows into the sandbox's allowlist. In other words, the allowlist naturally grows as you use it.

Explicitly set allowedDomains and keep it minimal

List the necessary domains in advance to avoid prompts. Wildcards in the *. prefix format at the beginning can be used.

{
  "sandbox": {
    "network": {
      "allowedDomains": ["registry.npmjs.org", "*.github.com"]
    }
  }
}

The documentation itself warns that allowing broad domains like github.com can become an exfiltration path. Since GitHub has writable destinations (Gist, repositories, Issues), the granularity of permissions deserves attention.

Use deniedDomains to patch exceptions

When allowedDomains wildcards accidentally match too broadly, deniedDomains takes precedence.

{
  "sandbox": {
    "network": {
      "allowedDomains": ["*.example.com"],
      "deniedDomains": ["sensitive.cloud.example.com"]
    }
  }
}

Eliminate prompts entirely with strictAllowlist

This is the most effective measure for information leakage prevention. Setting it to true causes access to hosts outside the allowlist to be denied without showing a prompt (v2.1.219 and later).

{
  "sandbox": {
    "network": {
      "strictAllowlist": true,
      "allowedDomains": ["registry.npmjs.org", "*.github.com"]
    }
  }
}

This structurally blocks the path of "accidentally pressing Yes and having the allowlist grow."

There's one more thing it can block. In auto mode, there's a mechanism where Claude declares the hosts needed for that command (per-command allowed domains). The declared hosts are only open during that command's execution and don't remain in the session's allowlist or settings. While convenient, it's still a path outside the allowlist. strictAllowlist rejects these too.

A per-command list widens only what the sandbox denies by default. deniedDomains entries still block. When strictAllowlist or allowManagedDomainsOnly locks the allowlist, Claude Code refuses per-command lists.

However, this setting is only effective from user settings or managed settings. It cannot be turned on/off from repository-side settings.

Getting this wrong results in a state where "you think it's working but it isn't." I wrote strictAllowlist: true and allowedDomains: ["example.com"] in the project's .claude/settings.json and tried it. When I had Claude curl to www.iana.org, which I hadn't allowed, it was indeed blocked once. However, immediately after, an approval prompt appeared to allow www.iana.org for this command only.

If strictAllowlist were working, this prompt would not appear. The fact that it was blocked was simply because it wasn't on the allowlist; the setting itself was being ignored. The appearance of a prompt is how you can tell "it's not working." After configuring, always verify that no prompt appears for hosts outside the allowlist.

Closing escape routes

allowUnsandboxedCommands: false

Claude Code has an escape hatch (≒ bypass route). When a command fails due to sandbox restrictions, Claude may retry it outside the sandbox with the dangerouslyDisableSandbox parameter. The retry goes through the normal permission flow, so in manual mode a confirmation prompt appears, but in auto mode it's left to the classifier's judgment.

Setting it to false causes dangerouslyDisableSandbox to be completely ignored. In the Overrides tab of /sandbox, this is displayed as Strict sandbox mode.

{
  "sandbox": {
    "allowUnsandboxedCommands": false
  }
}

As a side effect, when git merge / git checkout encounters an unable to unlink old error due to overwriting protected paths, Claude can no longer offer to retry outside the sandbox. You'll need to run it yourself in another terminal, or add that command to excludedCommands.

As mentioned earlier, even with this setting in place, commands you type yourself in ! shell mode still run outside the boundary. Please understand that what this blocks is only "commands that Claude executes."

failIfUnavailable: true (fail-closed)

The default behavior is fail-open. If the sandbox cannot start due to reasons such as bubblewrap or socat not being installed, or the platform being unsupported, Claude Code issues a warning and executes commands without a sandbox.

When I actually started without installing socat on Linux (the VM of Claude Code on the Web), this was displayed and commands still passed. Note that socat is only needed on Linux / WSL2, so this warning doesn't appear on macOS.

⚠ Sandbox disabled: sandbox is enabled but dependencies are missing: socat not installed
  Commands will run WITHOUT sandboxing. Network and filesystem restrictions will NOT be enforced.

Even if sandbox.enabled: true is written in the configuration file, there is no boundary in this state.

If you're treating it as a security gate, this should be a hard stop.

{
  "sandbox": {
    "enabled": true,
    "failIfUnavailable": true
  }
}

With this, if the sandbox cannot start, Claude Code itself exits with an error at startup.

Keep excludedCommands narrow

Commands listed in excludedCommands always run outside the sandbox. There are practical needs such as adding docker * because Docker is incompatible with the sandbox, or adding gh / gcloud / terraform on macOS because they fail TLS verification.

However, if part of a compound command matches, the entire command runs outside the sandbox. Since excludedCommands is a hole, keep the list narrow. As mentioned later, this key has no lockdown via managed settings.

autoAllowBashIfSandboxed is not a leakage measure

Let me state this clearly to avoid misunderstanding. This key does not change security strength. The documentation explicitly states "filesystem and network restrictions are identical in both modes," and the only difference is whether sandboxed commands are automatically approved or whether a prompt is shown.

Setting it to false increases prompts, but doesn't change the boundary strength against leakage. While there's value in having a human review layer, avoid counting this as a "measure" to prevent misdesigning your security approach.

Enforcing Across an Organization

To apply settings to everyone on a team or client project, use managed settings. For boolean keys (enabled, failIfUnavailable, etc.), the managed value takes precedence and developers' local settings are ignored.

On the other hand, array keys (excludedCommands, allowRead, etc.) are merged from all scopes, so developers can add entries and expand the policy. There are keys provided to prevent this.

Key Effect Scope
sandbox.filesystem.allowManagedReadPathsOnly Only allowRead values from managed settings are honored. denyRead continues to be merged from all scopes Managed
sandbox.network.allowManagedDomainsOnly Locks allowed domains to the managed values; non-allowed domains are blocked without a prompt Managed

Here is an example of managed settings.

{
  "sandbox": {
    "enabled": true,
    "failIfUnavailable": true,
    "allowUnsandboxedCommands": false,
    "filesystem": {
      "denyRead": ["~/"],
      "allowRead": ["~/work"],
      "allowManagedReadPathsOnly": true
    },
    "network": {
      "allowedDomains": ["registry.npmjs.org", "*.github.com"],
      "allowManagedDomainsOnly": true
    },
    "credentials": {
      "files": [
        { "path": "~/.aws", "mode": "deny" },
        { "path": "~/.ssh", "mode": "deny" }
      ]
    }
  }
}

There is one more behavior worth knowing. If managed settings configure sandbox.filesystem, or if sandbox.credentials.files has even one entry with "mode": "deny", then filesystem.disabled can only be set from managed settings. Since filesystem.disabled is the key that turns off filesystem isolation entirely, this mechanism ensures that developers cannot remove the read restrictions an administrator has put in place.

Note that excludedCommands has no equivalent lockdown. Developers can always add entries to increase the number of commands that run outside the sandbox. The only option is to keep the managed list narrow.

Configuration Examples by Scope

This is the key point for practical use. Some keys are ignored from repository configuration files. If you try to place a single JSON in your project and call it done, half of it will have no effect.

The following keys are ignored from a repository's .claude/settings.json / .claude/settings.local.json.

  • mask entries under credentials (deny is valid)
  • network.tlsTerminate
  • credentials.allowPlaintextInject / awsPairs / sigv4
  • network.strictAllowlist
  • filesystem.disabled
  • allowAppleEvents

Why can't these be set from a repository? The list makes more sense when you see it as two groups with different natures.

The first group is keys that authorize sending real credentials (mask / tlsTerminate / allowPlaintextInject / awsPairs / sigv4). The documentation states the reason explicitly:

Unlike deny, masking authorizes the proxy to send your real credential to the listed hosts, so Claude Code honors it only from settings you or your administrator control: user settings, managed settings, and the --settings CLI flag.

While deny is a narrowing directive that says "don't allow reading this," mask is a permission that says "it's okay to send the real value to this host." If a repository could write injectHosts, checked-out code could specify where your own tokens get sent. That is why this group is only read from files managed by you or an administrator.

The second group is keys that can weaken the boundary itself (filesystem.disabled / allowAppleEvents). There is also an explicit statement about filesystem.disabled:

Project settings in .claude/settings.json and .claude/settings.local.json can't, so a checked-out project can't switch filesystem isolation off.

The purpose is stated directly: "so a checked-out project can't switch filesystem isolation off."

The remaining network.strictAllowlist is slightly different — it cannot be turned off from a repository, but neither can it be turned on. As mentioned earlier, this is a source of the failure mode where you write something and it has no effect.

The underlying principle is that repository configuration files are treated as "received from someone else." Your own ~/.claude/settings.json and the managed settings distributed by an administrator are trusted, but a repository you cloned is not granted the same authority. That is why keys that move the boundary and keys that handle real credentials are collectively ignored.

With that said, let's split things into two files.

~/.claude/settings.json (User Settings)

{
  "sandbox": {
    "enabled": true,
    "failIfUnavailable": true,
    "allowUnsandboxedCommands": false,
    "network": {
      "strictAllowlist": true,
      "tlsTerminate": {},
      "allowedDomains": ["registry.npmjs.org", "*.github.com"]
    },
    "credentials": {
      "files": [
        { "path": "~/.aws", "mode": "deny" },
        { "path": "~/.ssh", "mode": "deny" }
      ],
      "envVars": [
        { "name": "AWS_ACCESS_KEY_ID", "mode": "deny" },
        { "name": "AWS_SECRET_ACCESS_KEY", "mode": "deny" },
        { "name": "AWS_SESSION_TOKEN", "mode": "deny" },
        { "name": "NPM_TOKEN", "mode": "deny" },
        { "name": "GITHUB_TOKEN", "mode": "mask", "injectHosts": ["api.github.com"] }
      ]
    }
  }
}

Two supplementary notes on this block:

  • ~/.aws is specified as a directory. In the body of this article, following the official documentation example, we listed ~/.aws/credentials, but ~/.aws/config also contains information such as role_arn, sso_start_url, and profile names, so here we block the entire directory. As noted earlier, mask falls back to deny for directories, so if you're specifying a directory, it's cleaner to just write deny from the start.
  • strictAllowlist: true is a "close everything first" setting. Hosts outside the allowlist are rejected without a prompt, so with just the two domains above, pip / cargo / apt and others will not pass. Also, GitHub delivers release assets and raw files from a separate domain, githubusercontent.com, so allowing *.github.com will not reach objects.githubusercontent.com or raw.githubusercontent.com. If you use git / gh, you will need to add those separately. Please treat this as a starting point that assumes you will add domains based on your stack.

Project .claude/settings.json

{
  "sandbox": {
    "enabled": true,
    "filesystem": {
      "denyRead": ["~/"],
      "allowRead": ["."]
    }
  }
}

For . to resolve to the project root, placing this filesystem block in the project settings is mandatory.

Version Requirements

The required Claude Code version differs depending on the configuration key. With older builds, settings are silently ignored rather than causing an error, so if something seems to have no effect, check this first.

Feature Required Version
sandbox.credentials (deny) v2.1.187 or later
mask for environment variables, network.tlsTerminate v2.1.199 or later
filesystem.disabled v2.1.216 or later
network.strictAllowlist v2.1.219 or later
mask for files v2.1.221 or later
extract / decode / awsPairs / sigv4 v2.1.224 or later
IPv6 bracket notation v2.1.229 or later

Reflection: Staying Stuck on the Official Sample

To be honest, the .claude/settings.json in the repository where I write this blog looked like this:

{
  "sandbox": {
    "enabled": true,
    "autoAllowBashIfSandboxed": false,
    "allowUnsandboxedCommands": false,
    "network": {
      "allowedDomains": [],
      "allowUnixSockets": [],
      "allowAllUnixSockets": false,
      "allowLocalBinding": false
    },
    "enableWeakerNestedSandbox": false,
    "excludedCommands": []
  }
}

This was based on the official sample from anthropics/claude-code (I did not bring over allowManagedPermissionRulesOnly and httpProxyPort / socksProxyPort that appear in the sample). The escape hatches are closed, and allowedDomains is empty. It looks fine at first glance.

However, there is no credentials block or filesystem block. As we saw in the first half of this article, that means ~/.ssh and ~/.aws/credentials are readable. I had been reassured that "the sandbox is enabled," but in terms of preventing information leakage, the most important read restrictions were absent — that was the reality.

And the official sample itself also lacks a credentials block and filesystem block. The sample is not wrong; I think the reason is that the contents of these two blocks vary per environment, making them impossible to include in a sample. The official sample is a starting point, not a finished product — and that is also the motivation behind writing this article.

Remaining Gaps

Even with all these settings in place, the Bash sandbox is not a complete isolation boundary. Let's organize this in line with the Limitations section of the documentation.

Domain fronting: The built-in proxy makes allow decisions based on the hostname reported by the client, and by default it neither terminates nor inspects TLS. This means code inside the sandbox could potentially reach hosts outside the allowlist through techniques like domain fronting. tlsTerminate does terminate TLS for mask, but it does not add content filtering. If you need strong guarantees here, you will need a configuration that involves a custom proxy (httpProxyPort / socksProxyPort) that terminates and inspects TLS, with its CA injected into the sandbox.

What lies outside the boundary: As mentioned earlier, Read / Edit / Write, MCP servers, hooks, and ! shell mode are outside the scope of the sandbox. These need to be protected separately with CLAUDE_CODE_SUBPROCESS_ENV_SCRUB and permission rules.

Privilege escalation via Unix sockets: allowUnixSockets settings can grant access to powerful system services. Allowing /var/run/docker.sock is effectively granting access to the host system (the same structure as Dev Container's DooD).

Weakening switches: enableWeakerNestedSandbox (bind-mounts /proc to run bubblewrap inside an unprivileged container), enableWeakerNetworkIsolation, and macOS's allowAppleEvents (allows commands inside the sandbox to launch other applications outside the sandbox, breaking code execution isolation) all weaken the boundary. These should only be used when a separate isolation layer is guaranteed on the outside.

Persistence within the working directory: Everything within the working directory outside of protected paths can be overwritten. If package.json scripts are rewritten, they execute outside the boundary the moment a developer runs npm run dev in their own terminal.

The strength of the Bash sandbox is how easy it is to get started — just type /sandbox and you're off. At the same time, what it protects is limited to AI command execution. If you need a stronger boundary than that, you will need to consider alternative approaches such as enclosing the entire development environment in a Dev Container, or using Claude Code on the Web to move the terminal away from your local machine.

There is an article that lines up these three options as "three walls" and organizes their use cases by the scope of protection they offer and the effort required to adopt them.

https://dev.classmethod.jp/articles/claude-code-isolation-20260901/

Summary

  • sandbox.enabled: true alone is not sufficient for preventing information leakage. By default, reads are permitted across the entire machine, environment variables are inherited from the parent, and there is no pre-configured deny list for credentials. The network allowlist also tends to grow over time in practice, every time someone chooses "Yes, and don't ask again."
  • There are four things to lock down. Reads (credentials.files deny / filesystem.denyRead), environment variables (credentials.envVars / CLAUDE_CODE_SUBPROCESS_ENV_SCRUB), egress (keep allowedDomains minimal and set strictAllowlist: true), and escape hatches (allowUnsandboxedCommands: false and failIfUnavailable: true). If you want to keep a tool running while hiding its value, use mode: "mask".
  • Where you write things changes how they take effect. mask / tlsTerminate / strictAllowlist / filesystem.disabled are ignored from repository configuration files, while conversely denyRead: ["~/"] + allowRead: ["."] must be placed in the project settings or the resolution of . will change. Write these split between user settings and project settings.
  • "I wrote it but it has no effect" is the most dangerous situation. Confirm the resolved values in the Config tab of /sandbox, and use claude doctor to catch warnings that don't appear on the startup screen. When testing, do not use ! shell mode (it will always give false results since it is outside the boundary).
  • The settings accumulated here can become a tradeoff between security and convenience — i.e., developer experience (DX). denyRead: ["~/"] will sweep up toolchains under the home directory, and strictAllowlist: true will stop builds for any domain you forgot to allow. I think it is safer to start on the strict side and only open things up with allowRead / allowedDomains where you get stuck, rather than starting with permissive settings.

I think the place to start is checking whether your own ~/.claude/settings.json has a credentials block. Mine did not.

References


Claudeならクラスメソッドにお任せください

クラスメソッドは、Anthropic社とリセラー契約を締結しています。各種製品ガイドから、業種別の活用法、フェーズごとのお悩み解決などサービス支援ページにまとめております。まずはご覧いただき、お気軽にご相談ください。

サービス詳細を見る

Share this article

AI白書