
Claude Code の Bash sandbox、有効化しただけでは認証情報が読めてしまいます
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. The "boundary" aims to let AI agents perform various tasks—such as builds, tests, and Git operations—with relative autonomy, 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,) every time Claude Code tries to execute a command, it asks the user for execution approval. Users may become fatigued by a large volume of approval requests, approve without scrutinizing the content, and as a result accidentally approve erroneous operations or external transmission of confidential information. This Bash sandbox is designed 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 "enabling autonomous execution without stopping prompts," not "preventing erroneous approvals due to approval fatigue." Please treat this as my own independent interpretation going one step further.
However, the default settings are weak
The Bash sandbox feature can be easily enabled during a session from /sandbox. However, simply enabling it leaves the power of its "boundary" quite limited.
The reason for this is probably (= my speculation) that basically, the stronger the "boundary," the more convenience is sacrificed. 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 strike a balance between the two.
Thinking about maximally secure settings, focused on information leakage
With that in mind, this entry explores Bash sandbox settings tuned as far toward maximum security as possible. Furthermore, since examining various risks all at once blurs the focus, I'll narrow it down to information leakage risk. I'll also write about what becomes inconvenient when strengthening the settings.
I'll 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, which self-propagated by contaminating other packages with stolen npm tokens. In Shai-Hulud 2.0 confirmed in November of the same year, execution moved to preinstall, expanding the scope of impact, and it even had a fallback that destroys the home directory if credential theft fails.
And I believe that the more you entrust work to AI agents, the easier it becomes to fall into this trap. Even in situations where a human might pause and think "this package looks suspicious," an agent will proceed to npm install as instructed (this is my personal sense rather than something with quantitative backing). That's why the idea becomes not stopping the execution itself, but creating a state where even if something executes, there's nothing it can take.
Let me state the conclusion first. sandbox.enabled: true alone is almost completely ineffective as an information leakage countermeasure. The reason is simple: the defaults for Bash sandbox are as follows.
- Reads are permitted across the entire machine.
~/.ssh/and~/.aws/credentialsare readable - Environment variables are inherited wholesale from the parent process.
NPM_TOKENandAWS_SECRET_ACCESS_KEYare visible as-is - There is no pre-prepared deny list for credentials. Only files and variables you enumerate yourself are restricted
Breaking the default state into three parts, the quality of strength differs for each.
- Writes: Solid from the start. Nothing outside the working directory can be written, and protected paths under
.claudecannot be opened withallowWriteorEditallow rules - Reads: A hole 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 every 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 begins by adding settings to both reads, which are already open, and network, which becomes a hole if left alone.
This article builds up settings for each leakage path, based on the official documentation.
Verification environment
Open only if you want to know the details of the verification environment.
In the main text, there are some behaviors not written in the official documentation, or behaviors that didn't work as documented. All were confirmed on actual hardware, with the following environment.
- Primary verification environment: macOS, Claude Code v2.1.273
- Some items only: Linux (VM on Claude Code on the Web), also Claude Code v2.1.273
The latter constitutes nested sandboxing (running Bash sandbox inside an already isolated VM), so please read environment-dependent behavior with that caveat. Conversely, Bash sandbox works even inside Claude Code on the Web.
The following 7 points were confirmed on actual hardware. I'll write about each in detail in the corresponding section of the main text.
- Writing a wildcard 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 without 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") - Missing
tlsTerminatedoesn't appear on the startup screen and is only discovered withclaude doctor(macOS / same) strictAllowlistwritten in project settings is ignored and an approval prompt appears (macOS / "strictAllowlistto eliminate prompts entirely")- Misplacing
denyReadin user settings causes the agent to detour through the Read tool and continue working (macOS / "filesystem.denyReadto close off the entire home directory") Read(//**/*.pem)written to protect private keys kills all HTTPS inside the sandbox (macOS / "Collateral damage: HTTPS breaks from a rule meant to protect private keys")
Prerequisite: 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—frequently appearing in supply chain attacks—also fall inside the boundary.
Note that on Linux / WSL2, socat (which relays communication to the sandbox proxy) also needs to be installed in addition to bubblewrap. If either is missing, the sandbox will not become effective. However, that does not stop command execution. The default is fail-open: commands are executed without sandboxing after a warning is displayed (see the later section "failIfUnavailable: true (fail-closed)"). On macOS, since Seatbelt is built into the OS, 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 · hooks: not executed via the Bash tool, so outside the scope of the sandbox
- Commands you type in
!shell mode: even if typed within a Claude Code session, they don't pass through the Bash tool and are outside the boundary - Commands humans type in their own terminal, IDE extensions, app launches
The third point is particularly easy to overlook. Since you're typing within a Claude Code session, it's tempting to assume you're inside the boundary, but the official documentation is explicit:
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're testing based on old memory, your results will differ, so check claude --version before testing.
In practice, when verifying the settings in this article, I accidentally ran cat with ! and once incorrectly judged that "a file that shouldn't be readable was readable." When you want to verify that settings are working, you need to ask Claude to execute via the Bash tool.
The default filesystem behavior breaks down as follows.
| Operation | Default scope |
|---|---|
| Write | Only the working directory and its subdirectories + session temporary directory |
| Read | Entire machine (except certain denied directories) |
| Network | Pre-approved domains: zero. Confirmation per prompt |
The network's "pre-approved domains are zero" looks most solid at first glance, but this table shows only initial values. Of the three, only the allowlist grows during operation (described later).
The write side is solid from the start. Shell configuration files like ~/.bashrc and system binaries in /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 prevents code inside the sandbox from expanding its own permissions or planting hooks that run outside the boundary. This protection cannot be disabled with allowWrite or Edit allow rules.
Just listing specifications makes the value hard to appreciate, 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 executes via the Bash tool, this destruction cannot reach outside the working directory. This article focuses on the read side, but the write side is already effective with default settings.
The problem is the read side. Let's start plugging holes from here.
Hole ①: Credential files are readable
The default read scope is the entire machine, so ~/.ssh/ and ~/.aws/credentials are readable straight through from inside the sandbox. The documentation makes this explicit.
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/credentialsand~/.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 reads 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: entries can be added from any scope, but a deny added by one scope cannot be revoked 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 must 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 the filesystem root |
~/ |
Relative to the home directory |
./ or no prefix |
Project root if in project settings, ~/.claude if in user settings |
The official documentation also explicitly 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 because they're caught by denyRead: ["~/"]. Be careful: this is the pattern where things "silently produce unintended results" rather than an error.
When I actually tried this misplacement, I understood what "silently" meant. With it placed in user settings, when I had Claude cat a file in the project, it returned this:
The cat command could not be executed due to Bash sandbox restrictions (reading the project directory was blocked by the sandbox). I checked the content using the Read tool instead.
As mentioned earlier, the Read tool is outside the boundary, so the agent detours on its own and accomplishes the goal. Since the work itself proceeds, there's no opportunity to notice the configuration error. Without noticing, you end up with an environment where "only tools running via Bash fail for unknown reasons."
Detailed handling of path prefixes (trailing slashes and wildcards) is described in the settings reference.
Also, denyRead: ["~/"] is quite a strong setting. In environments where toolchains (~/.nvm, ~/.cargo, ~/.rustup, etc.) are placed under the home directory, builds will stop working, so you'll need to reopen only what's necessary with allowRead. The protection also works in the opposite direction: writing denyRead: ["~/**/.env"] inside a broad permission like allowRead: ["~/"] means deny wins. You won't accidentally re-expose secrets with a broad permission.
Permission Read deny rules also merge in
As a third path, something that isn't a sandbox setting also affects read restrictions. If you have rules like Read(//**/id_rsa*) in permissions.deny, those paths are merged 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 evaluated before command execution, the latter is enforced by the OS), but for paths and domains, they 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-side version.
So, in an environment where you're already blocking private keys with Read / Edit deny rules, some things are already protected before you touch the sandbox block. Conversely, looking only at sandbox settings doesn't give you the full picture of actual boundaries. Checking the resolved values in the Config tab of /sandbox is the reliable approach. Looking at it in practice, you can see that paths originating from permissions are listed alongside the denyRead you wrote yourself.
Collateral damage: HTTPS breaks from a rule meant to protect private keys
This merging has side effects. I had the following in ~/.claude/settings.json, intending to prevent Claude from reading private keys.
{
"permissions": {
"deny": [
"Read(//**/id_rsa*)",
"Read(//**/id_ed25519*)",
"Read(//**/*.pem)"
]
}
}
This was killing all HTTPS communication 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 returns Operation not permitted, making TLS certificate verification impossible. Since the proxy tunnel itself establishes with 200 but then drops, the cause is very hard to see.
The clue for diagnosis is that HTTP works but only HTTPS fails. Even with the same host, curl http://example.com succeeds.
What makes it even trickier is that the two settings causing the issue are in separate files.
Read(//**/*.pem)is in user settings, and was originally a setting to prevent the Read tool from reading private keyssandbox.enabled: trueis in project settings
You can't identify the cause by looking at either file alone. A rule written with the Read tool in mind spreads to Bash the moment you enable the sandbox in another file. Here too, the Config tab in /sandbox is the only place to verify.
The setting meant to protect private keys was destroying TLS itself—rules that block things by extension like this cause this kind of collateral damage. It's safer to narrow by 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 section is about Go-based CLIs like gh / gcloud / terraform, which is a separate matter from this phenomenon.
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're 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 stated clearly in the settings reference.
The
namemust start with a letter or underscore and contain only letters, digits, and underscores.
Only alphanumerics and underscores are allowed, so variable names must be enumerated one by one.
The tricky part is that writing AWS_* causes no error. When I tested with { "name": "AWS_*", "mode": "deny" } on my machine (v2.1.273), there was no warning or error 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's blocked," there's no choice but to visually verify that the enumeration is complete.
To strip everything at once: CLAUDE_CODE_SUBPROCESS_ENV_SCRUB
If you want to remove them all at once, use the environment variable mechanism. Setting CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=1 strips Anthropic and cloud provider credentials from subprocess environments.
The key point is that this has wider coverage: 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 can't protect that range. The parent Claude process continues to hold credentials for API calls, but they become unreadable from child processes.
On Linux, additionally, Bash subprocesses run in an isolated PID namespace, preventing reading the host process's environment via /proc. Be aware of the side effect that ps / pgrep / kill will no longer 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) as side effects.
Making credentials "usable without being passed": mask
The weakness of deny is that tools requiring 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 to lose functionality."
Here's how it works:
- Commands inside the sandbox see a session-specific dummy value (sentinel) rather than the real value
- A proxy running outside the sandbox substitutes the real value when sending to hosts specified in
injectHosts
As a result, the command itself and the logs it outputs never hold the real credential, yet request authentication succeeds.
When I tested with GITHUB_TOKEN masked on my machine (v2.1.273), this is what was visible inside the sandbox:
$ printenv GITHUB_TOKEN
fake_value_f63c59ff-b003-47b6-9652-fb4186680cbc...
A string starting with fake_value_, nothing like the real value. On the other hand, checking the same variable in ! shell mode (outside the boundary, as mentioned earlier) returns the real configured value. Comparing these two is the easiest way to verify that mask is working.
Here is an example mask configuration.
{
"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 use.
network.tlsTerminateis required. For the proxy to rewrite request content, it needs to terminate TLS and inspect the content. Specifying{}generates a temporary CA for the session. If not set, the sentinel reaches the server as-is and authentication fails (no leakage, though)injectHostsdestinations must also be reachable viaallowedDomains. The proxy only injects into connections passed by the allowlist. OmittinginjectHoststargets all hosts inallowedDomains- Does not work from repository configuration files. More on this later
There is a detection mechanism for forgetting the first condition, but it doesn't appear on the startup screen. On my machine (v2.1.273), starting without tlsTerminate 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 substitution targets are headers and request bodies. For cases with structured values, there are also options like extract (masks only regex group 1, for hiding just the password in a DATABASE_URL) and decode: "jwt" (substitutes a structurally valid fake JWT) (v2.1.224 and later). mask is also possible for files, but there's a platform difference: on Linux / WSL2 the 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
denyfor amaskentry 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. The fact that glob patterns are included is worth being aware of, since people tend to write things like ~/.aws/*.
For AWS, which uses signing (SigV4), you need to mask AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY as a pair. The proxy detects requests using the access key sentinel as a marker, substitutes the real value, and re-signs. If only the secret is masked, requests signed with the placeholder cannot be detected, and they'll fail on the AWS side.
What mask prevents · what it doesn't
This is something I want to pin down accurately. What mask prevents is "theft of the credential itself," not "misuse of that credential."
If you permit registry.npmjs.org for npm install, npm publish using a stolen NPM_TOKEN (the self-propagation path of the aforementioned Shai-Hulud) passes through as legitimate traffic. This is the limitation where the permitted domain itself becomes the leakage path.
Masking NPM_TOKEN doesn't close this hole. The proxy injects the real value into connections passed by the allowlist, so if malware inside the sandbox runs npm publish, the proxy authenticates it.
What mask reliably eliminates are the following paths:
echo-ing the token value to write it to logs or files- POST-ing the token value to an attacker's server
- Smuggling the token value out mixed into another permitted domain
It's a guarantee that "the value itself will never go outside," not that "legitimate-format operations against permitted domains" are stopped. That becomes a matter addressed by the network settings in the next section, and by a design that minimizes the credentials brought inside the boundary in the first place.
Narrowing the exit: Network settings
Leakage requires not just "reading" but also "sending outside," so exit settings are just as effective as read restrictions.
By default, pre-approved domains are zero, and a prompt appears whenever a new domain is needed. There's a pitfall 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 allowlist too. In other words, the allowlist naturally grows with use.
Explicitly state allowedDomains and keep them minimal
List necessary domains in advance to avoid prompts. Wildcards in leading *. format are supported.
{
"sandbox": {
"network": {
"allowedDomains": ["registry.npmjs.org", "*.github.com"]
}
}
}
The documentation itself warns that permitting a broad domain like github.com can become a leakage path. Since GitHub is a destination where you can write (Gist, repositories, Issues), it's worth being conscious of permission granularity.
Fill exceptions with deniedDomains
When allowedDomains wildcards match more broadly than intended, deniedDomains takes priority.
{
"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 fattening the allowlist."
There's one more thing it blocks. In auto mode, there's a mechanism where Claude declares the hosts needed for that command (per-command allowed domains). Declared hosts open only during execution of that command and don't remain in the session allowlist or settings. While convenient, it's still a path out of the allowlist. strictAllowlist denies these too.
A per-command list widens only what the sandbox denies by default.
deniedDomainsentries still block. WhenstrictAllowlistorallowManagedDomainsOnlylocks 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 or off from the repository side.
Getting this wrong leads to a state where it "feels like it's working but it isn't." I tried writing strictAllowlist: true and allowedDomains: ["example.com"] in the project's .claude/settings.json. When I had it curl to www.iana.org which wasn't permitted, it was indeed blocked once. But immediately after, an approval prompt appeared asking to permit www.iana.org just for that command.
If strictAllowlist were working, this prompt wouldn't appear. It was blocked simply because it wasn't in the allowlist, while the setting itself was being ignored. If a prompt appears, you can judge that it's not working. After configuring it, always verify that no prompt appears for hosts outside the allowlist.
Closing escape routes
allowUnsandboxedCommands: false
Claude Code has an escape hatch (≒ bypass path). When a command fails due to sandbox restrictions, Claude may retry 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, it's displayed as Strict sandbox mode.
{
"sandbox": {
"allowUnsandboxedCommands": false
}
}
As a side effect, when git merge / git checkout gets an unable to unlink old error from rewriting protected paths, Claude can no longer offer to retry outside. You'd either run it yourself in a separate terminal, or add the command to excludedCommands.
As mentioned earlier, even with this setting in place, commands you type yourself in ! shell mode remain outside the boundary. Please understand that this only blocks "commands Claude executes."
failIfUnavailable: true (fail-closed)
The default behavior is fail-open. If the sandbox can't start—because bubblewrap or socat isn't installed, or the platform isn't supported—Claude Code issues a warning and executes commands without sandboxing.
When I actually started up on Linux (Claude Code on the Web VM) without installing socat, the following was displayed and commands passed through. 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's no boundary in this state.
If you treat this as a security gate, it should fail closed.
{
"sandbox": {
"enabled": true,
"failIfUnavailable": true
}
}
This causes Claude Code itself to exit with an error at startup when the sandbox can't start.
Keep excludedCommands narrow
Commands listed in excludedCommands always execute outside the sandbox. There are practical needs for adding entries: docker * because docker is incompatible with the sandbox, or gh / gcloud / terraform because they fail TLS verification on macOS.
However, if part of a compound command matches, the entire command runs outside the sandbox. excludedCommands is a hole, so keep the list narrow. As discussed later, this key has no lockdown via managed settings.
autoAllowBashIfSandboxed is not a leakage countermeasure
I want to state this clearly to avoid misunderstanding. This key doesn't 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 auto-approved or show a prompt.
Setting it to false increases prompts, but doesn't change the strength of the boundary against leakage. There's value in having a human review layer, but counting this as a "countermeasure" will lead to design mistakes.
Enforcing Across an Organization
To apply settings to everyone on a team or client engagement, 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 append entries and expand policies. Specific keys are 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 contains even a single 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 developers cannot remove read restrictions put in place by an administrator.
Note that excludedCommands has no equivalent lockdown. Developers can always append entries to add more 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 when placed in a repository's settings files. If you try to put everything in a single JSON file in your project, half of it will silently have no effect.
The following keys are ignored in a repository's .claude/settings.json / .claude/settings.local.json:
maskentries undercredentials(denyis valid)network.tlsTerminatecredentials.allowPlaintextInject/awsPairs/sigv4network.strictAllowlistfilesystem.disabledallowAppleEvents
Why can't these be set from a repository? The list makes more sense when you think of 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--settingsCLI flag.
While deny is a restrictive directive that says "don't allow reading," mask is a permission that says "it's okay to send the real value to this host." If repository-side code could write injectHosts, a cloned repository could specify where your own tokens get sent. That's why this group is only read from files controlled by you or an administrator.
The second group is keys that can weaken the boundary itself (filesystem.disabled / allowAppleEvents). The documentation is also explicit about filesystem.disabled:
Project settings in
.claude/settings.jsonand.claude/settings.local.jsoncan't, so a checked-out project can't switch filesystem isolation off.
The purpose is stated directly: "prevent a checked-out project from switching filesystem isolation off."
The remaining network.strictAllowlist is slightly different in character — it cannot be turned on from a repository, nor turned off. As mentioned earlier, this is a common cause of the "I wrote it but it's not working" failure.
The underlying principle is that repository settings files are treated as something received from a third party. Your own ~/.claude/settings.json and managed settings distributed by an administrator are trusted, but a git clone'd repository is not granted the same level of trust. That's why keys that move boundaries and keys that handle real credentials are all ignored together.
With that in mind, we split the configuration into two parts.
~/.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:
~/.awsis specified as a directory. The main text used~/.aws/credentialsto match the official documentation's example, but since~/.aws/configalso contains information such asrole_arn,sso_start_url, and profile names, this configuration blocks the entire directory. As noted earlier, sincemaskfalls back todenyfor directories, writingdenyfrom the start is the straightforward approach when specifying a directory.strictAllowlist: trueis a "close everything first" setting. Hosts not on the allowlist are rejected without a prompt, so the two domains listed above are not enough forpip/cargo/apt, etc. Also, GitHub serves release assets and raw files from a separate domain,githubusercontent.com, so allowing*.github.comdoes not coverobjects.githubusercontent.comorraw.githubusercontent.com. If you usegit/gh, you will need to add those separately. Treat this as a starting point where you add domains based on your stack.
Project .claude/settings.json
{
"sandbox": {
"enabled": true,
"filesystem": {
"denyRead": ["~/"],
"allowRead": ["."]
}
}
}
To have . resolve to the project root, placing this filesystem block in the project settings is required.
Version Requirements
The required Claude Code version differs by configuration key. Older builds silently ignore unknown keys rather than throwing an error, so if something isn't working, check here 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: Stuck With the Official Sample As-Is
To be honest, the .claude/settings.json in the repository for 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 (the allowManagedPermissionRulesOnly and httpProxyPort / socksProxyPort from the sample were not included). The escape hatches are blocked, and allowedDomains is empty. It seems reasonable enough.
However, there is no credentials block and no filesystem block. As we saw in the first half of this article, that means ~/.ssh and ~/.aws/credentials are readable. I felt secure because "the sandbox is enabled," but in terms of preventing information leakage, the most important read restrictions were simply absent.
And the official sample itself also has no credentials block or filesystem block. It's not that the sample is wrong — these two blocks have contents that vary by environment, so they can't really be written into a sample. The official sample is a starting point, not a finished configuration, and that is the motivation behind writing this article.
Holes That Remain
Even with all these settings in place, the Bash sandbox is not a complete isolation boundary. Here is a summary based on the Limitations section of the documentation.
Domain fronting: The built-in proxy makes allowlist decisions based on the hostname declared by the client, and by default does not terminate or inspect TLS. This means code inside the sandbox may be able to reach hosts outside the allowlist using techniques like domain fronting. tlsTerminate terminates TLS for mask, but does not add content filtering. If strong guarantees are needed here, you will need to set up a custom proxy (httpProxyPort / socksProxyPort) that terminates and inspects TLS, and inject its CA into the sandbox.
What lies outside the boundary: As noted earlier, Read / Edit / Write, MCP servers, hooks, and ! shell mode are outside the sandbox's scope. These need to be protected separately with CLAUDE_CODE_SUBPROCESS_ENV_SCRUB and permission rules.
Privilege escalation via Unix sockets: The allowUnixSockets setting can grant access to powerful system services. Allowing /var/run/docker.sock is effectively granting access to the host system (the same structure as DooD in Dev Containers).
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 separate isolation is guaranteed at an outer layer.
Persistence within the working directory: Files in the working directory outside protected paths can be modified. If package.json scripts are rewritten, they will 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. At the same time, the scope of what it can protect is limited to AI command execution. If you need a stronger boundary than that, you would need to consider other 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 entirely.
There is an article that presents these three approaches as "three walls" and organizes how to choose between them based on protection scope and adoption cost.
Summary
sandbox.enabled: truealone is not a measure against information leakage. The default allows read access to the entire machine, environment variables are inherited from the parent process, and there is no pre-configured deny list for credentials. The network allowlist also tends to grow over time as you select "Yes, and don't ask again" during normal use.- There are four things to lock down. Reads (
credentials.fileswithdeny/filesystem.denyRead), environment variables (credentials.envVars/CLAUDE_CODE_SUBPROCESS_ENV_SCRUB), outbound connections (minimizeallowedDomainsand setstrictAllowlist: true), and escape hatches (allowUnsandboxedCommands: falseandfailIfUnavailable: true). If you want to keep a tool running while hiding its value, usemode: "mask". - Where you write it changes whether it takes effect.
mask/tlsTerminate/strictAllowlist/filesystem.disabledare ignored in repository settings files, while converselydenyRead: ["~/"]+allowRead: ["."]must be placed in project settings for.to resolve correctly. Write them split across user settings and project settings. - "I wrote it but it's not working" is the most dangerous state. Verify resolved values in the Config tab of
/sandbox, and useclaude doctorto catch warnings that don't appear at startup. Don't use!shell mode for verification (it's outside the boundary, so it will always produce false results). - The settings accumulated here can become a tradeoff between security and convenience — the developer experience (DX).
denyRead: ["~/"]will catch toolchains under your home directory, andstrictAllowlist: truewill halt builds on any domain you forgot to allow. I think it's safer to start on the restrictive side and only open up specific entries inallowRead/allowedDomainsas you hit issues, rather than starting from a permissive configuration.
I think the first step is simply checking whether your ~/.claude/settings.json has a credentials block. Mine didn't.
References
- Configure the sandboxed Bash tool - Claude Docs
- Settings reference(sandbox settings)- Claude Docs
- Environment variables - Claude Docs
- Managed settings - Claude Docs
- Sandbox environments - Claude Docs
- anthropics/claude-code - examples/settings
- "Shai-Hulud" Worm Compromises npm Ecosystem in Supply Chain Attack - Unit 42
