Last active
July 29, 2026 18:21
-
-
Save hgn/665d7355b430b9701ae3355e611e3028 to your computer and use it in GitHub Desktop.
claude-isolated: bubblewrap sandbox for Claude Code with a pasta+nftables network firewall (internet allowed, home LAN restricted to an allowlist, dual-stack, ICMP)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| import os | |
| import platform | |
| import shutil | |
| import struct | |
| import subprocess | |
| import sys | |
| import tempfile | |
| from pathlib import Path | |
| # =========================================================================== | |
| # USER CONFIGURATION -- adjust these for your host; the rest is generic. | |
| # =========================================================================== | |
| # | |
| # Network firewall: the sandbox reaches the internet freely but is restricted | |
| # on the local network(s) this host is directly attached to. Only the hosts | |
| # listed below are reachable, everything else on the home LAN is dropped. ICMP | |
| # to the allowed hosts and to the internet stays open (it is only dropped for | |
| # the blocked home hosts). The home networks themselves are auto-detected at | |
| # launch (on-link IPv4 subnets and IPv6 RA/kernel prefixes), so a rotating | |
| # ISP-delegated IPv6 prefix is handled without editing this file. | |
| FW_ALLOW_V4 = ["10.10.10.1", "10.10.10.10"] # gateway, nas | |
| FW_ALLOW_V6 = [ | |
| "fd43:8dc9:a524:0:6f52:456c:c709:12f3", # nas ULA (stable-privacy) | |
| "fd43:8dc9:a524:0:2e91:abff:fe57:b3a", # gateway ULA (stable) | |
| ] | |
| # Extra networks to always treat as home and block (in addition to the | |
| # auto-detected on-link nets), e.g. a guest VLAN reachable via a route. | |
| FW_EXTRA_HOME_V4: list[str] = [] | |
| FW_EXTRA_HOME_V6: list[str] = [] | |
| # Optional network share to expose inside the sandbox (e.g. an autofs NAS | |
| # mount). Bound read-write, but only when it currently carries a real | |
| # filesystem. Set to None to disable entirely. | |
| NAS_MOUNT: str | None = "/mnt/nas" | |
| # Apply a seccomp syscall filter to the sandboxed program (Docker-default-style | |
| # denylist: allow by default, EPERM for dangerous syscalls). Defence-in-depth on | |
| # top of the dropped capabilities; x86_64 only. Set to False to disable. | |
| SECCOMP_FILTER = True | |
| # =========================================================================== | |
| _INNER_FLAG = "--__apply-firewall" | |
| def check_requirements(full_net: bool) -> dict[str, str]: | |
| """Verify all required tools up front and report every missing one at once. | |
| Returns a mapping of tool name to its resolved path.""" | |
| # (name, apt package, resolver) | |
| required = [ | |
| ("bwrap", "bubblewrap", lambda: shutil.which("bwrap")), | |
| ("claude", "npm install -g @anthropic-ai/claude-code", lambda: shutil.which("claude")), | |
| ] | |
| if not full_net: | |
| # Needed only for the firewalled default path (skipped with -F). | |
| required += [ | |
| ("pasta", "passt", lambda: shutil.which("pasta")), | |
| ("nft", "nftables", find_nft), | |
| ("ip", "iproute2", lambda: shutil.which("ip")), | |
| ] | |
| found: dict[str, str] = {} | |
| missing: list[tuple[str, str]] = [] | |
| for name, hint, resolve in required: | |
| path = resolve() | |
| if path: | |
| found[name] = path | |
| else: | |
| missing.append((name, hint)) | |
| if missing: | |
| lines = ["Error: required tool(s) not found:"] | |
| lines += [f" - {name} (install: {hint})" for name, hint in missing] | |
| if any(name in ("pasta", "nft", "ip") for name, _ in missing): | |
| lines += ["", "Or run with -F/--full-net to skip the network firewall."] | |
| sys.exit("\n".join(lines)) | |
| return found | |
| def find_nft() -> str | None: | |
| return shutil.which("nft") or next( | |
| (p for p in ("/sbin/nft", "/usr/sbin/nft") if Path(p).is_file()), None | |
| ) | |
| # Dangerous x86_64 syscalls to reject with EPERM (namespace/mount/module/kernel | |
| # tampering, ptrace, keyring, cross-process memory, ...). This mirrors the intent | |
| # of Docker's default seccomp profile as a small default-allow denylist, so it | |
| # hardens without breaking the huge syscall surface of node/claude. | |
| _SECCOMP_DENY_X86_64 = ( | |
| 101, 165, 166, 155, 308, 272, 175, 313, 176, 246, 320, 321, 298, 248, 249, | |
| 250, 169, 167, 168, 164, 227, 305, 310, 311, 304, 303, 323, 173, 172, 163, | |
| 179, 134, | |
| ) # ptrace, mount, umount2, pivot_root, setns, unshare, init_module, | |
| # finit_module, delete_module, kexec_load, kexec_file_load, bpf, | |
| # perf_event_open, add_key, request_key, keyctl, reboot, swapon, swapoff, | |
| # settimeofday, clock_settime, clock_adjtime, process_vm_readv/writev, | |
| # open_by_handle_at, name_to_handle_at, userfaultfd, ioperm, iopl, acct, | |
| # quotactl, uselib | |
| def seccomp_memfd() -> int | None: | |
| """Compile a tiny cBPF seccomp program (allow by default, EPERM the denied | |
| syscalls, kill on a non-native arch) and hand it back as an inheritable | |
| memfd for bwrap's --seccomp. Returns None when disabled or not x86_64.""" | |
| if not SECCOMP_FILTER: | |
| return None | |
| if platform.machine() != "x86_64": | |
| print("claude-isolated: seccomp filter skipped (not x86_64)", file=sys.stderr) | |
| return None | |
| LD_W_ABS, JEQ_K, RET_K = 0x20, 0x15, 0x06 | |
| ARCH_X86_64, ALLOW, ERRNO_EPERM, KILL = 0xC000003E, 0x7FFF0000, 0x00050001, 0x80000000 | |
| nums = sorted(set(_SECCOMP_DENY_X86_64)) | |
| n = len(nums) | |
| prog = [ | |
| (LD_W_ABS, 0, 0, 4), # 0: load arch | |
| (JEQ_K, 0, n + 3, ARCH_X86_64), # 1: arch != x86_64 -> KILL | |
| (LD_W_ABS, 0, 0, 0), # 2: load syscall nr | |
| ] | |
| for i, nr in enumerate(nums): # 3..: nr == denied -> ERRNO | |
| prog.append((JEQ_K, n - i, 0, nr)) | |
| prog += [ | |
| (RET_K, 0, 0, ALLOW), # n+3 | |
| (RET_K, 0, 0, ERRNO_EPERM), # n+4 | |
| (RET_K, 0, 0, KILL), # n+5 | |
| ] | |
| blob = b"".join(struct.pack("<HBBI", *insn) for insn in prog) | |
| fd = os.memfd_create("claudejail-seccomp") | |
| os.write(fd, blob) | |
| os.lseek(fd, 0, os.SEEK_SET) | |
| os.set_inheritable(fd, True) | |
| return fd | |
| def with_seccomp(bwrap_cmd: list[str]) -> list[str]: | |
| """Insert --seccomp <fd> into a bwrap command line, if a filter is built.""" | |
| fd = seccomp_memfd() | |
| if fd is None: | |
| return bwrap_cmd | |
| return [bwrap_cmd[0], "--seccomp", str(fd), *bwrap_cmd[1:]] | |
| def _live_mount_fstype(path: str) -> str | None: | |
| """Filesystem type currently mounted exactly at path, or None if it is not | |
| a mount point. Returns the topmost mount (last matching line wins).""" | |
| try: | |
| with open("/proc/self/mountinfo") as f: | |
| result = None | |
| for line in f: | |
| left, _, right = line.partition(" - ") | |
| fields = left.split() | |
| if len(fields) > 4 and fields[4] == path and right.split(): | |
| result = right.split()[0] | |
| return result | |
| except OSError: | |
| return None | |
| def nas_bind_args() -> list[str] | None: | |
| """Bind args for the optional NAS_MOUNT share, or None when it is not | |
| configured or not currently usable. A systemd autofs direct mount that has | |
| not been triggered cannot be bind-mounted (bwrap aborts with ENODEV), so it | |
| is only bound when a real backing filesystem is present. Touching it via | |
| is_dir() triggers the automount first.""" | |
| if not NAS_MOUNT: | |
| return None | |
| p = Path(NAS_MOUNT) | |
| if p.is_dir() and _live_mount_fstype(NAS_MOUNT) not in (None, "autofs"): | |
| return ["--bind", NAS_MOUNT, NAS_MOUNT] | |
| return None | |
| def get_workspace() -> Path: | |
| try: | |
| out = subprocess.check_output( | |
| ["git", "rev-parse", "--show-toplevel"], | |
| stderr=subprocess.DEVNULL, | |
| text=True, | |
| ).strip() | |
| return Path(out) | |
| except (subprocess.CalledProcessError, FileNotFoundError): | |
| return Path.cwd() | |
| def get_base_args(workspace: Path) -> list[str]: | |
| home = Path.home() | |
| args = [] | |
| mounted = set() | |
| def add_bind(bind_type: str, src: str, dest: str): | |
| if dest not in mounted: | |
| args.extend([bind_type, src, dest]) | |
| mounted.add(dest) | |
| for sys_path in [ | |
| "/usr", "/bin", "/lib", "/lib64", "/etc", "/run", "/var", | |
| "/usr/share/ca-certificates", "/usr/local/share/ca-certificates" | |
| ]: | |
| p = Path(sys_path) | |
| if p.exists(): | |
| add_bind("--ro-bind", str(p), str(p)) | |
| sys_path = Path("/sys") | |
| if sys_path.exists(): | |
| add_bind("--bind", str(sys_path), str(sys_path)) | |
| args.extend([ | |
| "--dev", "/dev", | |
| "--proc", "/proc", | |
| "--tmpfs", "/tmp", | |
| "--tmpfs", "/var/tmp", | |
| ]) | |
| usb_dir = Path("/dev/bus/usb") | |
| if usb_dir.is_dir(): | |
| add_bind("--dev-bind", str(usb_dir), str(usb_dir)) | |
| resolv_path = Path("/etc/resolv.conf") | |
| if resolv_path.exists(): | |
| real_resolv = resolv_path.resolve() | |
| if not any(str(real_resolv).startswith(d) for d in ["/usr", "/bin", "/lib", "/lib64", "/etc", "/run", "/var"]): | |
| if real_resolv.is_file(): | |
| add_bind("--ro-bind", str(real_resolv), str(real_resolv)) | |
| # Bind the workspace read-write, unless it is $HOME (or an ancestor of it): | |
| # binding all of home would expose everything read-write and defeat the | |
| # curated, mostly read-only binds below (and collide with the ~/bin symlink). | |
| if workspace == home or workspace in home.parents: | |
| print( | |
| f"claude-isolated: not binding all of {workspace} (would expose your " | |
| "whole home); only the curated paths are available. Run from a " | |
| "subdirectory, or use -w PATH to expose a specific location.", | |
| file=sys.stderr, | |
| ) | |
| else: | |
| add_bind("--bind", str(workspace), str(workspace)) | |
| bin_path = home / "bin" | |
| if bin_path.is_dir(): | |
| add_bind("--bind", str(bin_path), str(bin_path)) | |
| for cmd in ["node", "claude"]: | |
| cmd_path = shutil.which(cmd) | |
| if cmd_path: | |
| real_path = Path(cmd_path).resolve() | |
| cmd_dir = real_path.parent | |
| if cmd_dir.is_dir() and str(cmd_dir).startswith(str(home)): | |
| add_bind("--ro-bind", str(cmd_dir), str(cmd_dir)) | |
| parent_dir = cmd_dir.parent | |
| if parent_dir.is_dir() and str(parent_dir).startswith(str(home)): | |
| add_bind("--ro-bind", str(parent_dir), str(parent_dir)) | |
| local_bin_path = home / ".local" / "bin" | |
| if local_bin_path.is_dir(): | |
| add_bind("--ro-bind", str(local_bin_path), str(local_bin_path)) | |
| claude_dir = home / ".claude" | |
| if claude_dir.is_dir(): | |
| add_bind("--bind", str(claude_dir), str(claude_dir)) | |
| claude_json = home / ".claude.json" | |
| if claude_json.is_file(): | |
| add_bind("--bind", str(claude_json), str(claude_json)) | |
| nas = nas_bind_args() | |
| if nas: | |
| add_bind(*nas) | |
| gitconfig = home / ".gitconfig" | |
| if gitconfig.is_file(): | |
| add_bind("--ro-bind", str(gitconfig), str(gitconfig)) | |
| ssh_dir = home / ".ssh" | |
| if ssh_dir.is_dir(): | |
| add_bind("--ro-bind", str(ssh_dir), str(ssh_dir)) | |
| # GitHub CLI config (account/host info). The token itself lives in the | |
| # keyring and is fetched over the D-Bus session bus (reachable via /run), | |
| # so this directory alone is enough for `gh` to authenticate. | |
| gh_config = home / ".config" / "gh" | |
| if gh_config.is_dir(): | |
| add_bind("--ro-bind", str(gh_config), str(gh_config)) | |
| # ssh-agent socket (gnome-keyring): the path is inherited via $SSH_AUTH_SOCK | |
| # and usually lives under /run (already bound). Bind its directory read-only | |
| # as well in case it sits elsewhere, so connect() finds it inside the jail. | |
| auth_sock = os.environ.get("SSH_AUTH_SOCK") | |
| if auth_sock: | |
| sock = Path(auth_sock) | |
| if sock.exists(): | |
| add_bind("--ro-bind", str(sock.parent), str(sock.parent)) | |
| for cred in [ | |
| home / ".pypirc", | |
| home / ".config" / "pip" / "pip.conf", | |
| ]: | |
| if cred.is_file(): | |
| # cred may be a symlink (e.g. ~/.pypirc -> dotfiles-private); | |
| # bind the resolved target at the canonical location so the | |
| # link's destination need not be mounted separately. | |
| add_bind("--ro-bind", str(cred.resolve()), str(cred)) | |
| return args | |
| def detect_home_nets() -> tuple[list[str], list[str]]: | |
| """Networks this host is directly attached to (the home LAN(s)).""" | |
| env = {**os.environ, "LC_ALL": "C"} | |
| def ip_lines(*args: str) -> list[str]: | |
| try: | |
| out = subprocess.check_output( | |
| ["ip", *args], text=True, env=env, stderr=subprocess.DEVNULL | |
| ) | |
| return out.splitlines() | |
| except (subprocess.CalledProcessError, FileNotFoundError): | |
| return [] | |
| v4 = [] | |
| for line in ip_lines("-4", "route", "show", "scope", "link"): | |
| parts = line.split() | |
| if parts and "/" in parts[0]: | |
| v4.append(parts[0]) | |
| v6 = [] | |
| for line in ip_lines("-6", "route", "show"): | |
| parts = line.split() | |
| if not parts or "/" not in parts[0]: | |
| continue | |
| net = parts[0] | |
| if net.startswith("fe80:") or net.startswith("ff") or "via" in parts: | |
| continue # skip link-local, multicast and gateway (non on-link) routes | |
| v6.append(net) | |
| v4 = list(dict.fromkeys(v4 + FW_EXTRA_HOME_V4)) | |
| v6 = list(dict.fromkeys(v6 + FW_EXTRA_HOME_V6)) | |
| return v4, v6 | |
| def build_nft_ruleset(home_v4: list[str], home_v6: list[str]) -> str: | |
| lines = [ | |
| "table inet claudejail {", | |
| " chain output {", | |
| " type filter hook output priority 0; policy accept;", | |
| ' oifname "lo" accept', | |
| ] | |
| if FW_ALLOW_V4: | |
| lines.append(f" ip daddr {{ {', '.join(FW_ALLOW_V4)} }} accept") | |
| if FW_ALLOW_V6: | |
| lines.append(f" ip6 daddr {{ {', '.join(FW_ALLOW_V6)} }} accept") | |
| if home_v4: | |
| lines.append(f" ip daddr {{ {', '.join(home_v4)} }} drop") | |
| if home_v6: | |
| lines.append(f" ip6 daddr {{ {', '.join(home_v6)} }} drop") | |
| lines += [" }", "}", ""] | |
| return "\n".join(lines) | |
| def parse_args(argv: list[str]) -> tuple[list[str], list[str], bool, bool]: | |
| custom_mounts = [] | |
| claude_args = [] | |
| debug_shell = False | |
| full_net = False | |
| i = 0 | |
| while i < len(argv): | |
| arg = argv[i] | |
| if arg in ("-w", "--write") and i + 1 < len(argv): | |
| path = str(Path(argv[i + 1]).resolve()) | |
| custom_mounts.extend(["--bind", path, path]) | |
| i += 2 | |
| elif arg in ("-r", "--read") and i + 1 < len(argv): | |
| path = str(Path(argv[i + 1]).resolve()) | |
| custom_mounts.extend(["--ro-bind", path, path]) | |
| i += 2 | |
| elif arg in ("-F", "--full-net"): | |
| full_net = True | |
| i += 1 | |
| elif arg in ("-s", "--shell", "--debug"): | |
| debug_shell = True | |
| i += 1 | |
| elif arg == "--": | |
| claude_args.extend(argv[i + 1 :]) | |
| break | |
| else: | |
| claude_args.append(arg) | |
| i += 1 | |
| return custom_mounts, claude_args, debug_shell, full_net | |
| def run_inner(argv: list[str]) -> None: | |
| """Executed inside the pasta network namespace (as root in the userns): | |
| load the firewall into the namespace, then hand off to bwrap.""" | |
| rules_path = argv[0] | |
| sep = argv.index("--") | |
| sandbox_cmd = argv[sep + 1 :] | |
| nft = find_nft() | |
| if not nft: | |
| sys.exit("claude-isolated: nft not found inside firewall setup") | |
| subprocess.run([nft, "-f", rules_path], check=True) | |
| try: | |
| os.unlink(rules_path) | |
| except OSError: | |
| pass | |
| os.execvp(sandbox_cmd[0], with_seccomp(sandbox_cmd)) | |
| def main() -> None: | |
| if sys.argv[1:2] == [_INNER_FLAG]: | |
| run_inner(sys.argv[2:]) | |
| return | |
| custom_mounts, claude_args, debug_shell, full_net = parse_args(sys.argv[1:]) | |
| tools = check_requirements(full_net) | |
| workspace = get_workspace() | |
| base_args = get_base_args(workspace) | |
| if debug_shell: | |
| print("[DEBUG] Starting isolated bash shell...", file=sys.stderr) | |
| target_cmd = [ | |
| "/bin/bash", | |
| "-c", | |
| r'export PS1="[claude-jail] \w \$ "; exec /bin/bash' | |
| ] | |
| else: | |
| target_cmd = [tools["claude"], "--dangerously-skip-permissions", *claude_args] | |
| sandbox_cmd = [ | |
| tools["bwrap"], | |
| *base_args, | |
| *custom_mounts, | |
| "--share-net", | |
| "--chdir", | |
| str(Path.cwd()), | |
| ] | |
| if full_net: | |
| sandbox_cmd += ["--", *target_cmd] | |
| os.execvp(tools["bwrap"], with_seccomp(sandbox_cmd)) | |
| return | |
| # Firewalled path: wrap the sandbox in a pasta network namespace and load | |
| # the nftables ruleset into that namespace before claude starts. Dropping | |
| # all capabilities keeps claude (which runs as root inside the userns) from | |
| # touching the firewall. | |
| home_v4, home_v6 = detect_home_nets() | |
| ruleset = build_nft_ruleset(home_v4, home_v6) | |
| fd, rules_path = tempfile.mkstemp(prefix="claudejail-", suffix=".nft") | |
| with os.fdopen(fd, "w") as f: | |
| f.write(ruleset) | |
| # pasta maps our uid to 0 inside its user namespace (needed so nft keeps | |
| # its capabilities across execve). Map claude back to the real uid/gid in a | |
| # nested user namespace so it does not run as root (claude refuses | |
| # --dangerously-skip-permissions as root), and drop all capabilities so it | |
| # cannot touch the firewall. | |
| sandbox_cmd += [ | |
| "--unshare-user", | |
| "--uid", str(os.getuid()), | |
| "--gid", str(os.getgid()), | |
| "--cap-drop", "ALL", | |
| "--", *target_cmd, | |
| ] | |
| pasta_cmd = [ | |
| tools["pasta"], | |
| "--quiet", | |
| "--config-net", | |
| "--no-map-gw", | |
| "--", | |
| sys.executable, | |
| os.path.abspath(__file__), | |
| _INNER_FLAG, | |
| rules_path, | |
| "--", | |
| *sandbox_cmd, | |
| ] | |
| os.execvp(tools["pasta"], pasta_cmd) | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment