denysvitali/boxy

0

stars

7

commits

Go

primary language

Aug 25, 2026

updated

README

boxy

Run an untrusted program on Linux with no access to anything on the machine — while it still gets its project directory and the public internet, so it can actually do its job.

boxy run --profile node -- npm install

The threat model is a package pulled from npm that is actively hostile and knows it is sandboxed. It will look for SSH and GPG keys, cloud credentials and tokens in the environment, try to reach the SSH agent or the Docker socket, scan the LAN and the tailnet, exhaust memory and PIDs, and try to escalate through nested user namespaces or io_uring. Defence is layered, so no single failed layer is enough to get out.

No root, no setuid binary. A run belongs to a per-user daemon that boxy run starts on demand, holding nothing the shell that started it did not already have.


What it does

FilesystemA fresh root built as an allowlist. /root, /var and every other user's home simply do not exist inside; $HOME and /run do, as empty tmpfs mounts, so a tool has somewhere to put its cache without anything of the real home being reachable. /etc is generated from scratch rather than borrowed, and sealed read-only at the mount layer once it is built.
procfsThe kernel-describing corner of /proc is masked: no config.gz, modules, kallsyms, iomem, no hardware inventory under /proc/bus, /proc/acpi or /proc/irq, and no cmdline — which on an encrypted host carries the LUKS partition UUID and the root device. boot_id is regenerated per run so it cannot tie two sandboxes together.
Copy-inA single file can be copied in rather than mounted, so a credential arrives without the directory around it. The sandbox gets its own copy at mode 0600 (0400 read-only), so a token refreshed inside cannot rewrite the host's.
EnvironmentAllowlisted, so SSH_AUTH_SOCK, GITHUB_TOKEN, NPM_TOKEN and AWS_* never arrive. USER, LOGNAME and SHELL are the sandbox's own, so the invoking user's name does not leak in and $SHELL names a shell that exists inside.
SyscallsA seccomp-bpf allowlist with argument inspection: no mount, no ptrace, no io_uring, no new namespaces, no terminal injection.
LandlockThe filesystem policy restated for the kernel to enforce against the process itself, plus ABI 9 refusal of connect() to unix sockets — so the SSH agent is unreachable even if its path is exposed.
NetworkA userspace TCP/IP stack terminates every flow in the supervisor. Public internet only; every private, loopback, link-local, CGNAT and IPv6-embedded-IPv4 range refused. Optionally only named hosts.
Resourcescgroup v2 memory, PID and CPU limits, applied before the payload starts.
VisibilityEvery policy decision and every connection, hostname and SNI is recorded, streamed live to anyone watching the session, and — with --pcap — captured packet for packet.

Requirements

Linux with Landlock (5.13+, ABI 4+ for network rules, ABI 9 for the unix-socket restriction), seccomp-bpf, unprivileged user namespaces, and /dev/net/tun. Check with:

boxy doctor
 + kernel    ok  7.1.3-arch2-2
 + landlock  ok  ABI 9
 + seccomp   ok  kill_process kill_thread trap errno user_notif trace log allow
 + userns    ok  unprivileged user+mount+pid+net namespaces
 + tun       ok  /dev/net/tun writable
 + cgroup2   ok  /sys/fs/cgroup/user.slice/... [cpu memory pids]

Usage

boxy run [flags] -- COMMAND [ARGS...]

  -p, --profile NAME     preset (node, python, generic, offline, claude)
                         or path to YAML
      --ro PATH          expose a path read-only            (repeatable)
      --rw PATH          expose a path read-write           (repeatable)
      --protect PATH     force read-only inside a --rw mount  (repeatable)
      --allow-git-write  let the sandbox write .git/hooks and .git/config
      --copy SRC[:DST]   copy one file in, writable         (repeatable)
      --copy-ro SRC[:DST]  copy one file in, read-only      (repeatable)
  -w, --workdir PATH     working directory, read-write      (default: cwd)
      --net MODE         public | allowlist | none
      --allow-host HOST  reachable hostname, optionally HOST:PORT[,PORT];
                         implies allowlist mode
      --allow-port N     permitted outbound TCP port
      --allow-cidr CIDR  address range to permit despite the private-address
                         denylist                             (repeatable)
      --tailscale        reach this machine's tailnet by MagicDNS name
  -e, --env NAME[=VAL]   pass or set an environment variable
      --memory 2GiB      --pids 512      --cpus 2      --timeout 10m
      --audit-log FILE   policy decisions as JSON lines
      --net-log FILE     every connection, hostname and SNI
      --pcap FILE        every packet on the sandbox's TAP, pcap format
      --pcap-max SIZE    stop the capture at this size    (default 1GiB)
      --seccomp MODE     strict (default) or compat
      --tty              expose /dev/tty to the sandbox
  -v, --verbose          report denials on stderr
  -d, --detach           print the session id and do not attach
boxy run --net none -- ./suspicious-binary          # nothing but its own loopback
boxy run --allow-host registry.npmjs.org -- npm ci  # default-deny by name
boxy run --profile python -- pip install -r requirements.txt
boxy run --profile claude -- claude                 # its token, not its directory

Running Claude Code in the sandbox

The claude profile is the copy-in mechanism's reason for existing. Claude Code keeps its OAuth token in ~/.claude, alongside session transcripts, months of prompt history and shell snapshots — on this machine, 509 bytes of credential inside 70 MB of everything else. Mounting the directory to obtain the token would hand all of it to the session.

So almost nothing under ~/.claude is mounted. The credentials file is copied in on its own, ~/.claude/CLAUDE.md follows read-only if it exists, and $HOME is a fresh tmpfs — Claude writes its config and history there, and they are discarded when the run ends. A token refreshed inside the sandbox cannot rewrite the host's, because the sandbox is holding a copy.

The exception is this project's session transcripts. Claude Code files them under ~/.claude/projects, one directory per project named after the project's path, and the profile exposes that one directory read-write:

filesystem:
  mkdir:
    - ~/.claude/projects/{workdir_slug}

{workdir_slug} is the working directory with its slashes turned into dashes, which is the name Claude Code uses. So a sandboxed session can read what earlier sessions in this repository did and /resume them, while every other project's transcripts, the prompt history and the shell snapshots stay absent as before. mkdir rather than rw because the first session in a new project is exactly the case where the directory does not exist yet, and a missing mount source is skipped — which would quietly send the transcript to the tmpfs to be discarded.

This is a real widening and worth being deliberate about: a session can read this project's earlier conversations, including anything pasted into one of them. Drop the mkdir entry from the profile to go back to per-run, disposable transcripts.

Which leaves the binary. The native installer keeps a version-stamped 270 MB executable under ~/.local/share/claude/versions and points ~/.local/bin/claude at the current one, so an empty $HOME takes Claude Code itself away along with everything else. The profile binds that symlink read-only: the kernel resolves the source path, so the mount lands on the real binary without the profile having to name a version that changes on every update, and ~/.local/bin inside the sandbox contains that one file and nothing else. Bound rather than copied — a copy would land on the sandbox's tmpfs, whose pages are charged to the payload's own cgroup, and spend 270 MB of the memory limit before Claude started.

The set of MCP servers available inside is whatever a file outside the sandbox says, rather than whatever the session inherits:

mkdir -p ~/.config/boxy
cat > ~/.config/boxy/claude-mcp.json <<'EOF'
{
  "mcpServers": {
    "gopls": {"command": "gopls", "args": ["mcp"]}
  },
  "hasCompletedOnboarding": true
}
EOF

boxy run --profile claude -- claude

That file becomes the sandbox's ~/.claude.json. A session cannot add a server in a way that outlives the run, because the file it would write to is a copy. Point somewhere else with --copy /path/to/mcp.json:$HOME/.claude.json.

Authenticate on the host first — the sandbox has no browser and no way to reach one, so the copied token is what carries the session in. If you use an API key instead, forward it explicitly and knowingly with -e ANTHROPIC_API_KEY; it is not passed by default.

Egress is default-deny by name: the allowlist is Anthropic's endpoints plus the Go module proxy and checksum database. Give the session what its task needs and no more:

boxy run --profile claude \
  --allow-host github.com --allow-host registry.npmjs.org \
  --net-log session.json -- claude

--net-log then tells you exactly where that session went.

Reaching your tailnet

A tailnet is the one private network a sandbox has a defensible reason to reach. It is not the LAN the payload happens to share a cable with; it is a set of machines you deliberately joined, addressed by name. boxy still refuses it by default — Tailscale hands out CGNAT and unique-local addresses, both on the denylist — so it takes a flag:

boxy run --tailscale --allow-host nas:22 -- ssh nas
boxy run --tailscale --allow-host db.tail0a1b2.ts.net:5432 -- psql -h db.tail0a1b2.ts.net

--tailscale does exactly two things, and it is worth being precise about which. It adds Tailscale's two ranges to the address exceptions, so the range test stops objecting; and it routes queries for your tailnet's MagicDNS suffix to the resolver at 100.100.100.100, which the supervisor can reach from the host and the sandbox never can. The suffix also becomes the sandbox's search domain, so a bare nas resolves the way it does everywhere else.

Nothing else is relaxed. In allowlist mode a tailnet machine has to be named with --allow-host like any other host, and reaching it still requires a DNS answer to have bound its address to that name — so a payload that hardcodes a tailnet address it was never told about is refused exactly as before. What the flag removes is the range objection, and only that.

The port syntax above is the other half of making this usable. --allow-port is a single global list, so before this a run that needed the registry on 443 and a machine on 22 permitted 22 to the registry too. --allow-host nas:22 keeps port 22 to nas, and the port is added to the run's overall list automatically — the kernel-level Landlock backstop underneath filters by port alone and cannot know which host a connection is for.

For a private network that is not a tailnet, --allow-cidr is the general form:

boxy run --allow-cidr 192.168.4.0/24 --allow-host printer.local -- ./setup

It is deliberately a list of prefixes rather than a mode, so what was opened is stated exactly and appears verbatim in the audit log.

If tailscaled is not running, the ranges still apply and the flag still works; what is lost is MagicDNS, and boxy says so rather than leaving you to discover it as a DNS failure inside the sandbox.

Architecture

Two processes. The supervisor stays on the host and owns the network gateway; the payload runs in a full set of namespaces with no privileges at all.

  supervisor (host netns, unrestricted)          payload namespaces
 ┌──────────────────────────────────────┐      ┌─────────────────────────┐
 │ netstack (gVisor)                    │      │ userns+mnt+pid+net+ipc  │
 │   tcp.Forwarder ─┐                   │ TAP  │ +uts+cgroup             │
 │   udp.Forwarder ─┤                   │◀─fd──│                         │
 │   :53 intercept ─┘                   │      │  tap0 10.0.2.15/24      │
 │          ▼                           │      │  pivot_root minimal fs  │
 │   ┌─────────────────┐                │      │  Landlock V9            │
 │   │ POLICY          │                │      │  seccomp allowlist      │
 │   │ 1 IP ranges     │                │      │  no capabilities        │
 │   │ 2 DNS bindings  │◀── dnsproxy    │      │                         │
 │   │ 3 SNI / Host    │                │      │      npm install        │
 │   └────────┬────────┘                │      └─────────────────────────┘
 │   net.Dial ▼   + audit + netlog      │
 └────────────────────────────────────┬─┘
                                      ▼ public internet only

Startup order is load-bearing — each step needs a privilege the next one removes:

  1. Create the TAP device, while /dev/net/tun is still reachable, and hand the descriptor to the supervisor over a SCM_RIGHTS control socket.
  2. Configure the interface (needs CAP_NET_ADMIN).
  3. Build the root filesystem and pivot_root (needs CAP_SYS_ADMIN).
  4. Drop every capability, empty the bounding set, lock the securebits.
  5. Apply Landlock — before seccomp, which denies the landlock_* syscalls.
  6. Apply seccomp — last, so nothing after it can trip its own filter.
  7. execve the payload.

Capabilities are carried into stage 2 as ambient capabilities. On execve the kernel only reconstructs a full capability set when the real uid is 0, and this sandbox maps the user to itself rather than to root — so the payload does not believe it is root, which npm and pip behave differently about. Ambient capabilities are the kernel's exception for exactly this case, and stage 2 clears them before the payload runs.

Why a userspace network stack

Because the goal includes allowlisting hosts, and nftables can only see addresses. Names map to constantly-shifting sets of CDN addresses; pinning them at startup breaks within minutes and still lets a payload reach anything else on the same Cloudflare address. Terminating flows in userspace puts the decision somewhere that knows names:

1. The sandbox's only resolver is boxy's own. Everything on port 53, to any
   address, is intercepted — hardcoding 8.8.8.8 does not bypass it.
2. A permitted name is resolved upstream and each returned address is recorded
   as a binding: ip → name, with the answer's TTL.
3. At connect time an address with no binding is refused. A payload that dials a
   hardcoded IP has no binding, so it is refused.
4. The TLS ClientHello's SNI is read off the wire (no interception, no MITM) and
   cross-checked, which closes the shared-CDN-address gap.

One mechanism defeats DNS rebinding, hardcoded-IP exfiltration and name spoofing at once. Underneath it, the address-range denylist always applies, and it decodes NAT64 (64:ff9b::/96) and 6to4 (2002::/16) before checking — otherwise 64:ff9b::192.168.1.1 walks straight through an IPv6-only range test and out to the local network.

Seeing what a package did

A run is a session, owned by the daemon rather than by the terminal that asked for it, so it can be inspected from somewhere else while it is still going:

boxy ps [-a]     running sessions, or every session on record
boxy show ID     one session in full
boxy watch [ID]  follow one as it happens; with no id, the only running one
boxy attach ID   take over its terminal    (Ctrl-] leaves it running)
boxy stop ID     SIGTERM, then SIGKILL     (-k for SIGKILL now)

The daemon assembles every sandbox, so after upgrading boxy it is the old daemon's rules a run would get. Client and daemon therefore compare builds and refuse to work together when they differ; boxy run replaces an idle daemon by itself, and says so rather than guessing when sessions are still on it.

boxy daemon status    which build is running, and how busy it is
boxy daemon restart   replace it with this build
boxy daemon stop      stop it (refused while sessions are running)

watch is the read-only one. Nothing on its connection reaches the payload: the daemon sends it events and reads nothing back, so it cannot type into a session, resize its terminal, or slow it down by falling behind — a watcher that cannot keep up loses events and is told how many, rather than stalling the transfer it is watching. Point it at a run somebody else is attached to and they will not know. Ctrl-C ends the watching and leaves the session running.

Watching a run as it happens

boxy run -d --profile node --allow-host registry.npmjs.org -- npm ci
boxy watch
session 9f3c1ba2  running  9s  npm ci
2 destination(s)  15 connection(s)  2 denied   up 38.4KiB (1.2KiB/s)  down 6.1MiB (2.4MiB/s)

DESTINATION          CONNS  DENIED  ADDRS       SENT   RECEIVED
registry.npmjs.org      14       0      3    38.4KiB     6.1MiB
169.254.169.254          1       1      1         0B         0B

RECENT
00:00.104 dns      resolve registry.npmjs.org                 104.16.0.35 104.16.1.35
00:00.118 tcp      allow   registry.npmjs.org:443
00:00.166 tcp      sni     registry.npmjs.org
00:02.905 tcp      close   registry.npmjs.org:443             up 1.4KiB  down 2.1MiB
00:07.914 net.dns  deny    c2.5f3a1.example.net               hostname not on the allowlist
00:08.002 net.ip   deny    tcp://169.254.169.254:80           link-local / cloud metadata
00:08.002 tcp      deny    169.254.169.254:80                 link-local / cloud metadata

Ctrl-C stops watching; the session keeps running.

The stream is pushed, not polled, because what is worth seeing here is a decision and not a counter. A refusal that happens between two polls leaves nothing behind for the next one to find: the denial count moves by one, and the reason the layer gave — which is the entire thing the person watching is waiting to read — was never in the aggregate to begin with. Refusals are painted red for the same reason, since they are what the screen exists for. The arithmetic still arrives, as a snapshot once a second alongside the events, so a watch that started halfway through a run shows the same totals boxy show will print for it afterwards rather than a sum of the part it happened to see.

The names come from the DNS proxy and the ClientHello, so resolve says which addresses a name is currently answering with and sni says which name the client asked the address for — the two halves of the check described above, side by side, at the moment it is made. Watching a session that has already finished prints its summary instead of flashing up an empty screen, and watching with stdout on a pipe writes one line per event and never repaints, so boxy watch ID | grep deny behaves.

What this can and cannot show

boxy terminates every flow in userspace, and that is the ceiling on the resolution of everything above. The supervisor knows a connection was attempted, where it was aimed, which name it was aimed at, what policy decided and how many bytes have crossed in each direction; byte counters are sampled about three times a second per connection, which is where the rates come from. What it cannot show is a packet, because at this layer there is nothing to show one of: a flow is two TCP implementations, one talking to the sandbox and one talking to the internet, and a retransmission or a stalled window on either side is that side's business and never becomes an event. Nothing is decrypted either — the SNI is read off the wire as it goes past, so what is shown is the name a client asked for, never what it then sent.

When you want packets

boxy run --profile node --pcap npm.pcap -- npm ci
wireshark npm.pcap

--pcap writes every frame that crossed the sandbox's TAP device, in pcap format, which is the one place the packets exist. It is taken at the link endpoint below the stack, so it holds the ARP exchange, every DNS query the sandbox made to boxy's own resolver, and — the reason to reach for it — the SYNs policy refused, answered with a reset and never dialled. Those appear in no capture taken anywhere else, because nothing outside the sandbox ever saw them.

What it does not hold is the other half: the supervisor's own conversation with the internet is an ordinary socket on the host, and belongs to tcpdump if you need both sides. Read the TCP behaviour in the file as the userspace stack's and not the far end's, for the same reason the live view cannot show it. Frames are never truncated — the tools that read this file reassemble streams, and they cannot tell a snapped payload from a lost one. The file is created before the stack is attached, so a capture that was asked for and cannot be written fails the run rather than leaving an empty file to be misread later, and asking for one under --net none is refused outright rather than producing nothing.

Afterwards

--net-log writes every connection, resolved name and SNI. Strings are interned and each connection is a fixed-shape tuple, so a build that makes thousands of connections to a handful of hosts stays small; the file carries its own field list.

$ boxy run --profile node --net-log net.json -- npm install
added 2 packages in 545ms

boxy: network destinations (1)
  DESTINATION         CONNS  DENIED  ADDRS  SENT    RECEIVED
  registry.npmjs.org  2      0       1      5.0KiB  30.2KiB
{
 "version": 1,
 "conn_fields": ["t_ms","proto","verdict","addr","port","host","sni","reason","up","down"],
 "strings": ["registry.npmjs.org", "104.16.1.34", ...],
 "conns": [[289, "tcp", "allow", 1, 443, 0, 0, -1, 2941, 24212]],
 "resolutions": {"registry.npmjs.org": ["104.16.0.34", "...24 addresses..."]},
 "summary": [...]
}

--audit-log separately records each policy decision with the layer that made it (net.ip, net.dns, net.sni, landlock, seccomp, cgroup).

Testing

go test ./...                     # unit + adversarial suite
go test ./test/escape/ -v         # what actually gets blocked

internal/seccomp simulates the assembled BPF in a virtual machine and asserts a verdict for every syscall on the architecture. A seccomp filter is otherwise unobservable — a wrong rule shows up only as a process dying with no explanation — and this filter has several hundred rules in it.

test/escape is adversarial: each case attempts something a hostile payload would attempt and asserts it fails, printing ESCAPED only on success, so a failure always means something got through rather than that an error message changed wording. It covers reading SSH/GPG/cloud credentials, connecting to the agent socket, path traversal, /proc/kcore and the rest of the masked procfs recon surface, a git hook planted in the work directory's .git, mount, nested user namespaces, ptrace, io_uring, bpf, keyctl, PID-namespace isolation, fork bombs, memory limits, timeouts, every private address range including NAT64-encoded ones, and the host allowlist. Terminal injection is covered in internal/seccomp rather than here, against the assembled filter rather than a running sandbox. It also asserts the things that must keep working, since a sandbox that blocks the job is not useful.

Notes and limitations

  • The work directory's .git is not fully writable. A hook in .git/hooks, or a command named by .git/config (core.pager, core.sshCommand, alias.* = !sh, and above all core.hooksPath), runs on the host, as you, with your network and none of this sandbox, the next time you type a git command in that repository — a complete escape, displaced in time. boxy binds hooks, config and config.worktree read-only in every repository it exposes read-write, in submodule gitdirs too, and makes the gitdir a mount point so it cannot be renamed out of the way. Objects, refs and the index stay writable, so git commit works; git config --local, installing a hook and git submodule add do not, and belong on the host anyway. --allow-git-write turns it off; --protect PATH names anything else that must stay read-only inside a writable mount, including a repository more than one level below it.
  • Landlock unions rights across ancestors. A right granted on a directory reaches everything beneath it, and a deeper rule cannot revoke it. Writes are still caught by the read-only bind mount, but connect() on a unix socket is not a mount-level operation. boxy therefore withdraws the socket right from any writable directory that contains a --ro path, so an explicit read-only flag covers the SSH agent. The cost is that unix sockets stop working in that subtree.
  • Directory listing is permitted tree-wide (read_dir on /, nothing else). Programs walk directories constantly. It can reveal entry names that a mistake elsewhere left reachable, never their contents.
  • Stage 2 stays resident as the namespace's init. The payload runs as its child rather than in place of it, which is what makes boxy stop work and what reaps the processes a run orphans. Neither happens otherwise: the kernel discards a signal sent from outside to a PID namespace's init unless that process installed a handler for it, which most programs have not done for SIGTERM, and an init that never calls wait leaves zombies that still hold process IDs and so count against --pids. The payload gets a process group of its own and the terminal's foreground, so job control behaves as it did when it was exec'd in place.
  • Throughput is that of a userspace TCP/IP stack, roughly 1–3 Gbps — ample for package installs, not for bulk transfer.
  • UDP is filtered by address only, so in allowlist mode a UDP destination still has to come from a permitted name, but there is no name check on the wire. QUIC is the case where that gap matters — its hostname is inside the encrypted Initial packet, which would make HTTP/3 the way to reach an unlisted host sharing a CDN address with a permitted one — so allowlist mode refuses UDP/443 outright and clients fall back to TCP, where the name is checked.
  • --seccomp compat downgrades kills to EPERM so a workload that trips the filter can be diagnosed. It is weaker by construction; do not leave it on.
  • A limit with no controller behind it is reported, not faked. On a host where cgroup v2 delegates nothing to the user, --pids falls back to RLIMIT_NPROC, which in a user namespace of the sandbox's own is a genuine equivalent. --memory gets no fallback and says so in the audit log: RLIMIT_AS bounds address space rather than resident memory, and runtimes reserve address space they never touch, so imposing it would fail workloads nowhere near the limit while still not bounding what they use. The scratch tmpfs mounts are sized from --memory regardless, so /tmp cannot reach the host's RAM even when nothing is charging its pages to a cgroup.
  • /proc/cpuinfo and /proc/meminfo are the host's, not the cgroup's, so the sandbox sees every core and all of RAM whatever --memory and --cpus say. boxy does not fake them, on purpose. The same numbers come back from sysinfo(2) and the CPUID instruction, which no mount can touch, so a fabricated file would hide nothing from anyone trying and would instead make build tools size themselves against a different lie. Doing it properly means an LXCFS-style FUSE daemon per sandbox — a /dev/fuse node inside, a host-side server the payload can talk to — which is a lot of new attack surface for an information leak that is not a privilege boundary. There is no /sys either, so container-aware runtimes cannot read the cgroup limits and will size themselves to the host: pass -e GOMAXPROCS=4 or make -j4 when it matters.

Layout

cmd/boxy/            run · watch · ps · show · attach · stop · doctor · profile
internal/daemon/     session ownership, terminal and event fan-out
internal/child/      stage 2: namespaces → landlock → seccomp → exec
internal/mountns/    pivot_root allowlist rootfs
internal/landlockfs/ Landlock V9 policy
internal/seccomp/    filter stack, argument conditions, BPF simulator
internal/netgw/      netstack gateway and forwarders
internal/netpolicy/  address ranges, DNS bindings, SNI parsing
internal/dnsproxy/   resolver, answer filtering, binding
internal/netlog/     interned connection log, live event stream
internal/cgroup/     cgroup v2 limits
internal/env/        environment scrubbing, sandbox PATH
internal/profile/    YAML profiles and presets
test/escape/         adversarial suite

Contributors

denysvitali

7 commits

denysvitali/boxy

0

stars

7

commits

Go

primary language

Aug 25, 2026

updated

README

boxy

Run an untrusted program on Linux with no access to anything on the machine — while it still gets its project directory and the public internet, so it can actually do its job.

boxy run --profile node -- npm install

The threat model is a package pulled from npm that is actively hostile and knows it is sandboxed. It will look for SSH and GPG keys, cloud credentials and tokens in the environment, try to reach the SSH agent or the Docker socket, scan the LAN and the tailnet, exhaust memory and PIDs, and try to escalate through nested user namespaces or io_uring. Defence is layered, so no single failed layer is enough to get out.

No root, no setuid binary. A run belongs to a per-user daemon that boxy run starts on demand, holding nothing the shell that started it did not already have.


What it does

FilesystemA fresh root built as an allowlist. /root, /var and every other user's home simply do not exist inside; $HOME and /run do, as empty tmpfs mounts, so a tool has somewhere to put its cache without anything of the real home being reachable. /etc is generated from scratch rather than borrowed, and sealed read-only at the mount layer once it is built.
procfsThe kernel-describing corner of /proc is masked: no config.gz, modules, kallsyms, iomem, no hardware inventory under /proc/bus, /proc/acpi or /proc/irq, and no cmdline — which on an encrypted host carries the LUKS partition UUID and the root device. boot_id is regenerated per run so it cannot tie two sandboxes together.
Copy-inA single file can be copied in rather than mounted, so a credential arrives without the directory around it. The sandbox gets its own copy at mode 0600 (0400 read-only), so a token refreshed inside cannot rewrite the host's.
EnvironmentAllowlisted, so SSH_AUTH_SOCK, GITHUB_TOKEN, NPM_TOKEN and AWS_* never arrive. USER, LOGNAME and SHELL are the sandbox's own, so the invoking user's name does not leak in and $SHELL names a shell that exists inside.
SyscallsA seccomp-bpf allowlist with argument inspection: no mount, no ptrace, no io_uring, no new namespaces, no terminal injection.
LandlockThe filesystem policy restated for the kernel to enforce against the process itself, plus ABI 9 refusal of connect() to unix sockets — so the SSH agent is unreachable even if its path is exposed.
NetworkA userspace TCP/IP stack terminates every flow in the supervisor. Public internet only; every private, loopback, link-local, CGNAT and IPv6-embedded-IPv4 range refused. Optionally only named hosts.
Resourcescgroup v2 memory, PID and CPU limits, applied before the payload starts.
VisibilityEvery policy decision and every connection, hostname and SNI is recorded, streamed live to anyone watching the session, and — with --pcap — captured packet for packet.

Requirements

Linux with Landlock (5.13+, ABI 4+ for network rules, ABI 9 for the unix-socket restriction), seccomp-bpf, unprivileged user namespaces, and /dev/net/tun. Check with:

boxy doctor
 + kernel    ok  7.1.3-arch2-2
 + landlock  ok  ABI 9
 + seccomp   ok  kill_process kill_thread trap errno user_notif trace log allow
 + userns    ok  unprivileged user+mount+pid+net namespaces
 + tun       ok  /dev/net/tun writable
 + cgroup2   ok  /sys/fs/cgroup/user.slice/... [cpu memory pids]

Usage

boxy run [flags] -- COMMAND [ARGS...]

  -p, --profile NAME     preset (node, python, generic, offline, claude)
                         or path to YAML
      --ro PATH          expose a path read-only            (repeatable)
      --rw PATH          expose a path read-write           (repeatable)
      --protect PATH     force read-only inside a --rw mount  (repeatable)
      --allow-git-write  let the sandbox write .git/hooks and .git/config
      --copy SRC[:DST]   copy one file in, writable         (repeatable)
      --copy-ro SRC[:DST]  copy one file in, read-only      (repeatable)
  -w, --workdir PATH     working directory, read-write      (default: cwd)
      --net MODE         public | allowlist | none
      --allow-host HOST  reachable hostname, optionally HOST:PORT[,PORT];
                         implies allowlist mode
      --allow-port N     permitted outbound TCP port
      --allow-cidr CIDR  address range to permit despite the private-address
                         denylist                             (repeatable)
      --tailscale        reach this machine's tailnet by MagicDNS name
  -e, --env NAME[=VAL]   pass or set an environment variable
      --memory 2GiB      --pids 512      --cpus 2      --timeout 10m
      --audit-log FILE   policy decisions as JSON lines
      --net-log FILE     every connection, hostname and SNI
      --pcap FILE        every packet on the sandbox's TAP, pcap format
      --pcap-max SIZE    stop the capture at this size    (default 1GiB)
      --seccomp MODE     strict (default) or compat
      --tty              expose /dev/tty to the sandbox
  -v, --verbose          report denials on stderr
  -d, --detach           print the session id and do not attach
boxy run --net none -- ./suspicious-binary          # nothing but its own loopback
boxy run --allow-host registry.npmjs.org -- npm ci  # default-deny by name
boxy run --profile python -- pip install -r requirements.txt
boxy run --profile claude -- claude                 # its token, not its directory

Running Claude Code in the sandbox

The claude profile is the copy-in mechanism's reason for existing. Claude Code keeps its OAuth token in ~/.claude, alongside session transcripts, months of prompt history and shell snapshots — on this machine, 509 bytes of credential inside 70 MB of everything else. Mounting the directory to obtain the token would hand all of it to the session.

So almost nothing under ~/.claude is mounted. The credentials file is copied in on its own, ~/.claude/CLAUDE.md follows read-only if it exists, and $HOME is a fresh tmpfs — Claude writes its config and history there, and they are discarded when the run ends. A token refreshed inside the sandbox cannot rewrite the host's, because the sandbox is holding a copy.

The exception is this project's session transcripts. Claude Code files them under ~/.claude/projects, one directory per project named after the project's path, and the profile exposes that one directory read-write:

filesystem:
  mkdir:
    - ~/.claude/projects/{workdir_slug}

{workdir_slug} is the working directory with its slashes turned into dashes, which is the name Claude Code uses. So a sandboxed session can read what earlier sessions in this repository did and /resume them, while every other project's transcripts, the prompt history and the shell snapshots stay absent as before. mkdir rather than rw because the first session in a new project is exactly the case where the directory does not exist yet, and a missing mount source is skipped — which would quietly send the transcript to the tmpfs to be discarded.

This is a real widening and worth being deliberate about: a session can read this project's earlier conversations, including anything pasted into one of them. Drop the mkdir entry from the profile to go back to per-run, disposable transcripts.

Which leaves the binary. The native installer keeps a version-stamped 270 MB executable under ~/.local/share/claude/versions and points ~/.local/bin/claude at the current one, so an empty $HOME takes Claude Code itself away along with everything else. The profile binds that symlink read-only: the kernel resolves the source path, so the mount lands on the real binary without the profile having to name a version that changes on every update, and ~/.local/bin inside the sandbox contains that one file and nothing else. Bound rather than copied — a copy would land on the sandbox's tmpfs, whose pages are charged to the payload's own cgroup, and spend 270 MB of the memory limit before Claude started.

The set of MCP servers available inside is whatever a file outside the sandbox says, rather than whatever the session inherits:

mkdir -p ~/.config/boxy
cat > ~/.config/boxy/claude-mcp.json <<'EOF'
{
  "mcpServers": {
    "gopls": {"command": "gopls", "args": ["mcp"]}
  },
  "hasCompletedOnboarding": true
}
EOF

boxy run --profile claude -- claude

That file becomes the sandbox's ~/.claude.json. A session cannot add a server in a way that outlives the run, because the file it would write to is a copy. Point somewhere else with --copy /path/to/mcp.json:$HOME/.claude.json.

Authenticate on the host first — the sandbox has no browser and no way to reach one, so the copied token is what carries the session in. If you use an API key instead, forward it explicitly and knowingly with -e ANTHROPIC_API_KEY; it is not passed by default.

Egress is default-deny by name: the allowlist is Anthropic's endpoints plus the Go module proxy and checksum database. Give the session what its task needs and no more:

boxy run --profile claude \
  --allow-host github.com --allow-host registry.npmjs.org \
  --net-log session.json -- claude

--net-log then tells you exactly where that session went.

Reaching your tailnet

A tailnet is the one private network a sandbox has a defensible reason to reach. It is not the LAN the payload happens to share a cable with; it is a set of machines you deliberately joined, addressed by name. boxy still refuses it by default — Tailscale hands out CGNAT and unique-local addresses, both on the denylist — so it takes a flag:

boxy run --tailscale --allow-host nas:22 -- ssh nas
boxy run --tailscale --allow-host db.tail0a1b2.ts.net:5432 -- psql -h db.tail0a1b2.ts.net

--tailscale does exactly two things, and it is worth being precise about which. It adds Tailscale's two ranges to the address exceptions, so the range test stops objecting; and it routes queries for your tailnet's MagicDNS suffix to the resolver at 100.100.100.100, which the supervisor can reach from the host and the sandbox never can. The suffix also becomes the sandbox's search domain, so a bare nas resolves the way it does everywhere else.

Nothing else is relaxed. In allowlist mode a tailnet machine has to be named with --allow-host like any other host, and reaching it still requires a DNS answer to have bound its address to that name — so a payload that hardcodes a tailnet address it was never told about is refused exactly as before. What the flag removes is the range objection, and only that.

The port syntax above is the other half of making this usable. --allow-port is a single global list, so before this a run that needed the registry on 443 and a machine on 22 permitted 22 to the registry too. --allow-host nas:22 keeps port 22 to nas, and the port is added to the run's overall list automatically — the kernel-level Landlock backstop underneath filters by port alone and cannot know which host a connection is for.

For a private network that is not a tailnet, --allow-cidr is the general form:

boxy run --allow-cidr 192.168.4.0/24 --allow-host printer.local -- ./setup

It is deliberately a list of prefixes rather than a mode, so what was opened is stated exactly and appears verbatim in the audit log.

If tailscaled is not running, the ranges still apply and the flag still works; what is lost is MagicDNS, and boxy says so rather than leaving you to discover it as a DNS failure inside the sandbox.

Architecture

Two processes. The supervisor stays on the host and owns the network gateway; the payload runs in a full set of namespaces with no privileges at all.

  supervisor (host netns, unrestricted)          payload namespaces
 ┌──────────────────────────────────────┐      ┌─────────────────────────┐
 │ netstack (gVisor)                    │      │ userns+mnt+pid+net+ipc  │
 │   tcp.Forwarder ─┐                   │ TAP  │ +uts+cgroup             │
 │   udp.Forwarder ─┤                   │◀─fd──│                         │
 │   :53 intercept ─┘                   │      │  tap0 10.0.2.15/24      │
 │          ▼                           │      │  pivot_root minimal fs  │
 │   ┌─────────────────┐                │      │  Landlock V9            │
 │   │ POLICY          │                │      │  seccomp allowlist      │
 │   │ 1 IP ranges     │                │      │  no capabilities        │
 │   │ 2 DNS bindings  │◀── dnsproxy    │      │                         │
 │   │ 3 SNI / Host    │                │      │      npm install        │
 │   └────────┬────────┘                │      └─────────────────────────┘
 │   net.Dial ▼   + audit + netlog      │
 └────────────────────────────────────┬─┘
                                      ▼ public internet only

Startup order is load-bearing — each step needs a privilege the next one removes:

  1. Create the TAP device, while /dev/net/tun is still reachable, and hand the descriptor to the supervisor over a SCM_RIGHTS control socket.
  2. Configure the interface (needs CAP_NET_ADMIN).
  3. Build the root filesystem and pivot_root (needs CAP_SYS_ADMIN).
  4. Drop every capability, empty the bounding set, lock the securebits.
  5. Apply Landlock — before seccomp, which denies the landlock_* syscalls.
  6. Apply seccomp — last, so nothing after it can trip its own filter.
  7. execve the payload.

Capabilities are carried into stage 2 as ambient capabilities. On execve the kernel only reconstructs a full capability set when the real uid is 0, and this sandbox maps the user to itself rather than to root — so the payload does not believe it is root, which npm and pip behave differently about. Ambient capabilities are the kernel's exception for exactly this case, and stage 2 clears them before the payload runs.

Why a userspace network stack

Because the goal includes allowlisting hosts, and nftables can only see addresses. Names map to constantly-shifting sets of CDN addresses; pinning them at startup breaks within minutes and still lets a payload reach anything else on the same Cloudflare address. Terminating flows in userspace puts the decision somewhere that knows names:

1. The sandbox's only resolver is boxy's own. Everything on port 53, to any
   address, is intercepted — hardcoding 8.8.8.8 does not bypass it.
2. A permitted name is resolved upstream and each returned address is recorded
   as a binding: ip → name, with the answer's TTL.
3. At connect time an address with no binding is refused. A payload that dials a
   hardcoded IP has no binding, so it is refused.
4. The TLS ClientHello's SNI is read off the wire (no interception, no MITM) and
   cross-checked, which closes the shared-CDN-address gap.

One mechanism defeats DNS rebinding, hardcoded-IP exfiltration and name spoofing at once. Underneath it, the address-range denylist always applies, and it decodes NAT64 (64:ff9b::/96) and 6to4 (2002::/16) before checking — otherwise 64:ff9b::192.168.1.1 walks straight through an IPv6-only range test and out to the local network.

Seeing what a package did

A run is a session, owned by the daemon rather than by the terminal that asked for it, so it can be inspected from somewhere else while it is still going:

boxy ps [-a]     running sessions, or every session on record
boxy show ID     one session in full
boxy watch [ID]  follow one as it happens; with no id, the only running one
boxy attach ID   take over its terminal    (Ctrl-] leaves it running)
boxy stop ID     SIGTERM, then SIGKILL     (-k for SIGKILL now)

The daemon assembles every sandbox, so after upgrading boxy it is the old daemon's rules a run would get. Client and daemon therefore compare builds and refuse to work together when they differ; boxy run replaces an idle daemon by itself, and says so rather than guessing when sessions are still on it.

boxy daemon status    which build is running, and how busy it is
boxy daemon restart   replace it with this build
boxy daemon stop      stop it (refused while sessions are running)

watch is the read-only one. Nothing on its connection reaches the payload: the daemon sends it events and reads nothing back, so it cannot type into a session, resize its terminal, or slow it down by falling behind — a watcher that cannot keep up loses events and is told how many, rather than stalling the transfer it is watching. Point it at a run somebody else is attached to and they will not know. Ctrl-C ends the watching and leaves the session running.

Watching a run as it happens

boxy run -d --profile node --allow-host registry.npmjs.org -- npm ci
boxy watch
session 9f3c1ba2  running  9s  npm ci
2 destination(s)  15 connection(s)  2 denied   up 38.4KiB (1.2KiB/s)  down 6.1MiB (2.4MiB/s)

DESTINATION          CONNS  DENIED  ADDRS       SENT   RECEIVED
registry.npmjs.org      14       0      3    38.4KiB     6.1MiB
169.254.169.254          1       1      1         0B         0B

RECENT
00:00.104 dns      resolve registry.npmjs.org                 104.16.0.35 104.16.1.35
00:00.118 tcp      allow   registry.npmjs.org:443
00:00.166 tcp      sni     registry.npmjs.org
00:02.905 tcp      close   registry.npmjs.org:443             up 1.4KiB  down 2.1MiB
00:07.914 net.dns  deny    c2.5f3a1.example.net               hostname not on the allowlist
00:08.002 net.ip   deny    tcp://169.254.169.254:80           link-local / cloud metadata
00:08.002 tcp      deny    169.254.169.254:80                 link-local / cloud metadata

Ctrl-C stops watching; the session keeps running.

The stream is pushed, not polled, because what is worth seeing here is a decision and not a counter. A refusal that happens between two polls leaves nothing behind for the next one to find: the denial count moves by one, and the reason the layer gave — which is the entire thing the person watching is waiting to read — was never in the aggregate to begin with. Refusals are painted red for the same reason, since they are what the screen exists for. The arithmetic still arrives, as a snapshot once a second alongside the events, so a watch that started halfway through a run shows the same totals boxy show will print for it afterwards rather than a sum of the part it happened to see.

The names come from the DNS proxy and the ClientHello, so resolve says which addresses a name is currently answering with and sni says which name the client asked the address for — the two halves of the check described above, side by side, at the moment it is made. Watching a session that has already finished prints its summary instead of flashing up an empty screen, and watching with stdout on a pipe writes one line per event and never repaints, so boxy watch ID | grep deny behaves.

What this can and cannot show

boxy terminates every flow in userspace, and that is the ceiling on the resolution of everything above. The supervisor knows a connection was attempted, where it was aimed, which name it was aimed at, what policy decided and how many bytes have crossed in each direction; byte counters are sampled about three times a second per connection, which is where the rates come from. What it cannot show is a packet, because at this layer there is nothing to show one of: a flow is two TCP implementations, one talking to the sandbox and one talking to the internet, and a retransmission or a stalled window on either side is that side's business and never becomes an event. Nothing is decrypted either — the SNI is read off the wire as it goes past, so what is shown is the name a client asked for, never what it then sent.

When you want packets

boxy run --profile node --pcap npm.pcap -- npm ci
wireshark npm.pcap

--pcap writes every frame that crossed the sandbox's TAP device, in pcap format, which is the one place the packets exist. It is taken at the link endpoint below the stack, so it holds the ARP exchange, every DNS query the sandbox made to boxy's own resolver, and — the reason to reach for it — the SYNs policy refused, answered with a reset and never dialled. Those appear in no capture taken anywhere else, because nothing outside the sandbox ever saw them.

What it does not hold is the other half: the supervisor's own conversation with the internet is an ordinary socket on the host, and belongs to tcpdump if you need both sides. Read the TCP behaviour in the file as the userspace stack's and not the far end's, for the same reason the live view cannot show it. Frames are never truncated — the tools that read this file reassemble streams, and they cannot tell a snapped payload from a lost one. The file is created before the stack is attached, so a capture that was asked for and cannot be written fails the run rather than leaving an empty file to be misread later, and asking for one under --net none is refused outright rather than producing nothing.

Afterwards

--net-log writes every connection, resolved name and SNI. Strings are interned and each connection is a fixed-shape tuple, so a build that makes thousands of connections to a handful of hosts stays small; the file carries its own field list.

$ boxy run --profile node --net-log net.json -- npm install
added 2 packages in 545ms

boxy: network destinations (1)
  DESTINATION         CONNS  DENIED  ADDRS  SENT    RECEIVED
  registry.npmjs.org  2      0       1      5.0KiB  30.2KiB
{
 "version": 1,
 "conn_fields": ["t_ms","proto","verdict","addr","port","host","sni","reason","up","down"],
 "strings": ["registry.npmjs.org", "104.16.1.34", ...],
 "conns": [[289, "tcp", "allow", 1, 443, 0, 0, -1, 2941, 24212]],
 "resolutions": {"registry.npmjs.org": ["104.16.0.34", "...24 addresses..."]},
 "summary": [...]
}

--audit-log separately records each policy decision with the layer that made it (net.ip, net.dns, net.sni, landlock, seccomp, cgroup).

Testing

go test ./...                     # unit + adversarial suite
go test ./test/escape/ -v         # what actually gets blocked

internal/seccomp simulates the assembled BPF in a virtual machine and asserts a verdict for every syscall on the architecture. A seccomp filter is otherwise unobservable — a wrong rule shows up only as a process dying with no explanation — and this filter has several hundred rules in it.

test/escape is adversarial: each case attempts something a hostile payload would attempt and asserts it fails, printing ESCAPED only on success, so a failure always means something got through rather than that an error message changed wording. It covers reading SSH/GPG/cloud credentials, connecting to the agent socket, path traversal, /proc/kcore and the rest of the masked procfs recon surface, a git hook planted in the work directory's .git, mount, nested user namespaces, ptrace, io_uring, bpf, keyctl, PID-namespace isolation, fork bombs, memory limits, timeouts, every private address range including NAT64-encoded ones, and the host allowlist. Terminal injection is covered in internal/seccomp rather than here, against the assembled filter rather than a running sandbox. It also asserts the things that must keep working, since a sandbox that blocks the job is not useful.

Notes and limitations

  • The work directory's .git is not fully writable. A hook in .git/hooks, or a command named by .git/config (core.pager, core.sshCommand, alias.* = !sh, and above all core.hooksPath), runs on the host, as you, with your network and none of this sandbox, the next time you type a git command in that repository — a complete escape, displaced in time. boxy binds hooks, config and config.worktree read-only in every repository it exposes read-write, in submodule gitdirs too, and makes the gitdir a mount point so it cannot be renamed out of the way. Objects, refs and the index stay writable, so git commit works; git config --local, installing a hook and git submodule add do not, and belong on the host anyway. --allow-git-write turns it off; --protect PATH names anything else that must stay read-only inside a writable mount, including a repository more than one level below it.
  • Landlock unions rights across ancestors. A right granted on a directory reaches everything beneath it, and a deeper rule cannot revoke it. Writes are still caught by the read-only bind mount, but connect() on a unix socket is not a mount-level operation. boxy therefore withdraws the socket right from any writable directory that contains a --ro path, so an explicit read-only flag covers the SSH agent. The cost is that unix sockets stop working in that subtree.
  • Directory listing is permitted tree-wide (read_dir on /, nothing else). Programs walk directories constantly. It can reveal entry names that a mistake elsewhere left reachable, never their contents.
  • Stage 2 stays resident as the namespace's init. The payload runs as its child rather than in place of it, which is what makes boxy stop work and what reaps the processes a run orphans. Neither happens otherwise: the kernel discards a signal sent from outside to a PID namespace's init unless that process installed a handler for it, which most programs have not done for SIGTERM, and an init that never calls wait leaves zombies that still hold process IDs and so count against --pids. The payload gets a process group of its own and the terminal's foreground, so job control behaves as it did when it was exec'd in place.
  • Throughput is that of a userspace TCP/IP stack, roughly 1–3 Gbps — ample for package installs, not for bulk transfer.
  • UDP is filtered by address only, so in allowlist mode a UDP destination still has to come from a permitted name, but there is no name check on the wire. QUIC is the case where that gap matters — its hostname is inside the encrypted Initial packet, which would make HTTP/3 the way to reach an unlisted host sharing a CDN address with a permitted one — so allowlist mode refuses UDP/443 outright and clients fall back to TCP, where the name is checked.
  • --seccomp compat downgrades kills to EPERM so a workload that trips the filter can be diagnosed. It is weaker by construction; do not leave it on.
  • A limit with no controller behind it is reported, not faked. On a host where cgroup v2 delegates nothing to the user, --pids falls back to RLIMIT_NPROC, which in a user namespace of the sandbox's own is a genuine equivalent. --memory gets no fallback and says so in the audit log: RLIMIT_AS bounds address space rather than resident memory, and runtimes reserve address space they never touch, so imposing it would fail workloads nowhere near the limit while still not bounding what they use. The scratch tmpfs mounts are sized from --memory regardless, so /tmp cannot reach the host's RAM even when nothing is charging its pages to a cgroup.
  • /proc/cpuinfo and /proc/meminfo are the host's, not the cgroup's, so the sandbox sees every core and all of RAM whatever --memory and --cpus say. boxy does not fake them, on purpose. The same numbers come back from sysinfo(2) and the CPUID instruction, which no mount can touch, so a fabricated file would hide nothing from anyone trying and would instead make build tools size themselves against a different lie. Doing it properly means an LXCFS-style FUSE daemon per sandbox — a /dev/fuse node inside, a host-side server the payload can talk to — which is a lot of new attack surface for an information leak that is not a privilege boundary. There is no /sys either, so container-aware runtimes cannot read the cgroup limits and will size themselves to the host: pass -e GOMAXPROCS=4 or make -j4 when it matters.

Layout

cmd/boxy/            run · watch · ps · show · attach · stop · doctor · profile
internal/daemon/     session ownership, terminal and event fan-out
internal/child/      stage 2: namespaces → landlock → seccomp → exec
internal/mountns/    pivot_root allowlist rootfs
internal/landlockfs/ Landlock V9 policy
internal/seccomp/    filter stack, argument conditions, BPF simulator
internal/netgw/      netstack gateway and forwarders
internal/netpolicy/  address ranges, DNS bindings, SNI parsing
internal/dnsproxy/   resolver, answer filtering, binding
internal/netlog/     interned connection log, live event stream
internal/cgroup/     cgroup v2 limits
internal/env/        environment scrubbing, sandbox PATH
internal/profile/    YAML profiles and presets
test/escape/         adversarial suite

See what people are saying

Contributors

denysvitali

7 commits

Languages

Go

99.6%