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.
| Filesystem | A 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. |
| procfs | The 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-in | A 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. |
| Environment | Allowlisted, 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. |
| Syscalls | A seccomp-bpf allowlist with argument inspection: no mount, no ptrace, no io_uring, no new namespaces, no terminal injection. |
| Landlock | The 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. |
| Network | A 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. |
| Resources | cgroup v2 memory, PID and CPU limits, applied before the payload starts. |
| Visibility | Every policy decision and every connection, hostname and SNI is recorded, streamed live to anyone watching the session, and — with --pcap — captured packet for packet. |
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]
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
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.
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.
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:
/dev/net/tun is still reachable, and hand the
descriptor to the supervisor over a SCM_RIGHTS control socket.CAP_NET_ADMIN).pivot_root (needs CAP_SYS_ADMIN).landlock_* syscalls.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.
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.
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.
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.
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.
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.
--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).
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.
.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.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.read_dir on /, nothing else).
Programs walk directories constantly. It can reveal entry names that a
mistake elsewhere left reachable, never their contents.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.--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.--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.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
7 commits
Hacker News (1)
Go
99.6%
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.
| Filesystem | A 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. |
| procfs | The 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-in | A 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. |
| Environment | Allowlisted, 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. |
| Syscalls | A seccomp-bpf allowlist with argument inspection: no mount, no ptrace, no io_uring, no new namespaces, no terminal injection. |
| Landlock | The 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. |
| Network | A 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. |
| Resources | cgroup v2 memory, PID and CPU limits, applied before the payload starts. |
| Visibility | Every policy decision and every connection, hostname and SNI is recorded, streamed live to anyone watching the session, and — with --pcap — captured packet for packet. |
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]
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
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.
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.
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:
/dev/net/tun is still reachable, and hand the
descriptor to the supervisor over a SCM_RIGHTS control socket.CAP_NET_ADMIN).pivot_root (needs CAP_SYS_ADMIN).landlock_* syscalls.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.
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.
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.
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.
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.
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.
--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).
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.
.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.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.read_dir on /, nothing else).
Programs walk directories constantly. It can reveal entry names that a
mistake elsewhere left reachable, never their contents.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.--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.--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.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
Hacker News (1)
7 commits
Go
99.6%