Claude Sandbox

Released February 2026

View on GitHub
NixPythonBashLinuxbubblewrapmacOS Seatbelt

An external, OS-level sandbox that isolates Claude Code from the rest of the system — independent of Claude Code's own built-in protections, so a vulnerability in one doesn't compromise the other.

Claude Sandbox architecture diagram showing namespace boundary, filesystem isolation, socat bridge, and egress proxy

What I Built

~3,700 lines of shell and Python that wrap Claude Code in three independent security layers: kernel-level isolation, a filtering egress proxy, and post-session anomaly analysis. The entire stack has zero external runtime dependencies — the proxy is pure Python stdlib, the config parser is pure awk, and Nix handles reproducible builds across four platforms.

Filesystem Isolation

On Linux, bubblewrap bind mounts control exactly what the agent can see. Sensitive paths — ~/.ssh, ~/.aws, ~/.gnupg, browser session data, git credential stores, 13+ categories total — are mounted to /dev/null or empty tmpfs. System directories are read-only. Claude Code’s own settings.json is bind-mounted read-only inside the sandbox to prevent prompt injection from disabling deny rules. On macOS, dynamically generated Seatbelt profiles enforce equivalent restrictions at the kernel level.

Writable paths go through a TOCTOU mitigation: each path is canonicalized before mounting, then re-resolved immediately before exec. If a symlink target changed between those two checks — indicating a possible race attack swapping a writable path to point at a blocked one — the sandbox aborts. A separate verification suite (308 lines) tests this against direct symlinks, path traversal, multi-hop chains, and circular symlinks.

Network Isolation

On Linux, bubblewrap’s --unshare-net creates an empty network namespace — no interfaces, no DNS, no routes. The sandboxed process literally cannot make network connections. The only way out is a two-stage socat bridge: TCP inside the namespace → Unix domain socket through a bind mount → TCP on the host to the egress proxy. This indirection exists because Node.js (which Claude Code runs on) doesn’t support Unix socket proxy URLs, so the bridge translates between what Node expects and what the namespace boundary requires.

On macOS, Seatbelt kernel profiles deny all outbound traffic except localhost:$PROXY_PORT, and HTTP_PROXY environment variables route Claude Code through the proxy.

Egress Proxy

A Python HTTP/CONNECT proxy (~300 lines, stdlib only) checks every outbound connection against a per-profile domain allowlist. Allowed domains automatically include subdomains. Every request — allowed and blocked — is logged with timestamps in JSON-lines format. After the session ends, a separate report generator analyzes the audit log for anomalies: repeated attempts at blocked domains, unusually high block ratios, direct IP access bypassing domain filtering, and port-scanning patterns. Each session gets a 0–100 risk score.

An honest limitation: HTTPS CONNECT tunneling means the proxy sees CONNECT github.com:443 and then encrypted bytes. It can filter by domain but cannot inspect HTTP methods or payloads inside TLS.

Profiles

Four built-in profiles in config.toml define isolation levels for different trust contexts:

  • dev: Writable workspace, ~45 allowed domains (npm, PyPI, crates.io, GitHub, docs)
  • strict: Workspace-only writes, API-only networking (3 domains)
  • nixos-admin / macos-admin: Read-only system config access for diagnosis tasks

Platform Compatibility

One config.toml drives both Linux (bubblewrap) and macOS (Seatbelt) sandboxes. Nix packages the tool for x86_64-linux, aarch64-linux, aarch64-darwin, and x86_64-darwin with locked dependencies.

On Raspberry Pi and Debian 12+ (usr-merged systems where /bin/usr/bin), bubblewrap’s --ro-bind follows the source symlink but creates a directory at the destination, breaking dynamic linking. The sandbox detects usr-merge at runtime and reconstructs the correct symlink layout inside the namespace. It also handles atomically-replaced config files (~/.claude.json) by copying rather than bind-mounting — bind mounts go stale when the host replaces the inode. And it cleans up bubblewrap’s zero-byte mount-point stubs after each session to prevent git status pollution.

Key Decisions

Why bubblewrap over containers. Docker/Podman add a full container runtime, image management, and a larger attack surface. Bubblewrap is a single static binary that creates unprivileged namespaces — exactly the isolation primitive needed, nothing more. It also works on systems (like a Raspberry Pi) where running a container daemon is impractical.

Why a separate egress proxy instead of firewall rules. iptables/nftables operate at the IP level and can’t filter by domain name without DNS interception. A userspace proxy sees the actual hostname in HTTP CONNECT requests and can allowlist at the domain level. It also produces a complete audit trail without packet capture.

Why TOCTOU verification instead of just blocking symlinks. Rejecting all symlinks would break too many legitimate tools. The resolve-compare-resolve approach allows normal symlinks while catching the specific attack vector: a symlink that changes targets between validation and use.

Outcome

The sandbox runs daily on NixOS (x86_64) and Raspberry Pi (aarch64). It wraps Claude Code but the isolation architecture is agent-agnostic — the same profile-driven namespace, proxy, and audit pipeline works for any CLI tool that makes network requests.