Problem
Network isolation today is binary: unshare_net = true gives the sandbox zero
network, unshare_net = false (default) gives it the full host network.
Nothing in between. A common real-world need is:
- Allow outbound to
api.anthropic.com (Claude Code), github.com (git),
the company VPN range, PyPI, etc.
- Block everything else, so a compromised tool or dependency can't exfiltrate
to an arbitrary host.
Where the privilege boundary actually sits
When bwrap --unshare-user --unshare-net maps the host uid to uid 0 inside
a new user namespace, the new net namespace is owned by that user ns, and
uid 0 in the owning user ns holds CAP_NET_ADMIN over that netns. Inside
the sandbox we can legitimately run iptables -A OUTPUT ... on the sandbox's
own chains.
What we cannot do unprivileged is touch the host netns (create veth pairs,
set up host-side NAT). That's where slirp4netns comes in.
Architecture
sandbox (new userns, uid 0, new netns)
│
├── tap0 (only interface) ←──────────────────┐
│ │ │
│ iptables OUTPUT chain (the filter) │
│ │ │
│ v │
└── slirp4netns userspace TCP/IP stack ←── plumbing
│
v
host socket() as real uid → internet
Two layers, cleanly separated:
- slirp4netns — plumbing. Userspace TCP/IP stack (forked from QEMU
SLIRP). Creates a tap device inside the sandbox netns, parses packets
in userspace, relays connections via socket() on the host. No root
needed. Packaged on every major distro, used by podman/buildah for
rootless containers. Does essentially no filtering — flags like
--disable-host-loopback, --enable-sandbox, --enable-seccomp
harden slirp4netns itself, not the sandbox's egress.
- iptables in the sandbox netns — policy. Installed inside the
sandbox before the shell exec, generated from the TOML allowlist. This
is what firejail's --netfilter does.
Proposed TOML
[sandbox]
unshare_net = true # now meaningful default-on when net_allow is set
[sandbox.net_allow]
hosts = [
"api.anthropic.com",
"github.com",
"*.pypi.org",
"files.pythonhosted.org",
]
# cidrs = ["10.0.0.0/8"] # optional: raw CIDR allow
# log_denials = true # optional: log dropped connections
Implementation sketch
- Add
slirp4netns to optional deps (pwrap --check-deps detects).
- When
[sandbox.net_allow] is present:
- Build bwrap args with
--unshare-user --unshare-net (+ --uid 0 if
not already mapped by vault code).
- Pre-shell setup step:
a. Resolve each hosts entry to IPs (DNS at rule-install time).
b. Generate iptables-restore input: default OUTPUT DROP, then
ACCEPT for each resolved IP + established/related, plus DNS
allow to slirp4netns's forwarder.
c. Apply via iptables-restore (inside the sandbox netns, using the
CAP_NET_ADMIN we hold there).
- Launch
slirp4netns with --configure --mtu=65520 --disable-host-loopback; pass tap fd to bwrap via --net-socket.
- Write
/etc/resolv.conf pointing at slirp4netns's DNS (default 10.0.2.3).
Caveats
- IP staleness: CDN-hosted hosts rotate IPs. Options, ranked:
- Re-resolve periodically inside the sandbox and refresh rules.
ipset referenced by iptables + DNS-driven set updates.
- Hardcoded-IP bypass: attacker with a baked-in exfil IP skips DNS.
Mitigation: filtering DNS stub in the netns that returns NXDOMAIN for
non-allowlisted names, combined with default-drop iptables.
- Shared IPs / SNI: IP-only rules can't distinguish tenants on shared
CDN IPs. True hostname filtering needs an SNI-aware proxy — out of scope.
- ICMP / non-TCP: slirp4netns handles ICMP via host
SOCK_DGRAM;
filter like any other protocol in the rules.
Non-goals
Prior art
- firejail
--netfilter — ships iptables templates, loads inside the
sandbox netns. Same mechanics; pwrap would generate rules from TOML.
- podman / buildah rootless — slirp4netns as plumbing (no egress
filter, containers trusted).
- flatpak network sandbox — bwrap + slirp4netns in production.
Why now
Issue #23 closed the at-rest leak surface (deny-by-default home,
clean_env = true). The remaining exfil surface is the network: code
running in the sandbox can still reach arbitrary hosts. Egress control
closes that loop.
Earlier (incorrect) framing
An earlier draft of this issue claimed iptables was unreachable because
bwrap drops CAP_NET_ADMIN. That's wrong for the relevant case: when
--unshare-user maps uid to 0 in a new user ns, the new netns is owned
by that user ns and we hold CAP_NET_ADMIN on the sandbox netns, just
not on the host's. The proposal above relies on exactly that.
Problem
Network isolation today is binary:
unshare_net = truegives the sandbox zeronetwork,
unshare_net = false(default) gives it the full host network.Nothing in between. A common real-world need is:
api.anthropic.com(Claude Code),github.com(git),the company VPN range, PyPI, etc.
to an arbitrary host.
Where the privilege boundary actually sits
When
bwrap --unshare-user --unshare-netmaps the host uid to uid 0 insidea new user namespace, the new net namespace is owned by that user ns, and
uid 0 in the owning user ns holds CAP_NET_ADMIN over that netns. Inside
the sandbox we can legitimately run
iptables -A OUTPUT ...on the sandbox'sown chains.
What we cannot do unprivileged is touch the host netns (create veth pairs,
set up host-side NAT). That's where slirp4netns comes in.
Architecture
Two layers, cleanly separated:
SLIRP). Creates a tap device inside the sandbox netns, parses packets
in userspace, relays connections via
socket()on the host. No rootneeded. Packaged on every major distro, used by podman/buildah for
rootless containers. Does essentially no filtering — flags like
--disable-host-loopback,--enable-sandbox,--enable-seccompharden slirp4netns itself, not the sandbox's egress.
sandbox before the shell exec, generated from the TOML allowlist. This
is what firejail's
--netfilterdoes.Proposed TOML
Implementation sketch
slirp4netnsto optional deps (pwrap --check-depsdetects).[sandbox.net_allow]is present:--unshare-user --unshare-net(+--uid 0ifnot already mapped by vault code).
a. Resolve each
hostsentry to IPs (DNS at rule-install time).b. Generate
iptables-restoreinput: defaultOUTPUT DROP, thenACCEPTfor each resolved IP + established/related, plus DNSallow to slirp4netns's forwarder.
c. Apply via
iptables-restore(inside the sandbox netns, using theCAP_NET_ADMIN we hold there).
slirp4netnswith--configure --mtu=65520 --disable-host-loopback; pass tap fd to bwrap via--net-socket./etc/resolv.confpointing at slirp4netns's DNS (default 10.0.2.3).Caveats
ipsetreferenced by iptables + DNS-driven set updates.Mitigation: filtering DNS stub in the netns that returns NXDOMAIN for
non-allowlisted names, combined with default-drop iptables.
CDN IPs. True hostname filtering needs an SNI-aware proxy — out of scope.
SOCK_DGRAM;filter like any other protocol in the rules.
Non-goals
net_deny(allow-all-except). Denylists rot for the same reasons thehome blacklist did (Hardened defaults: template + README pass #23).
Prior art
--netfilter— ships iptables templates, loads inside thesandbox netns. Same mechanics; pwrap would generate rules from TOML.
filter, containers trusted).
Why now
Issue #23 closed the at-rest leak surface (deny-by-default home,
clean_env = true). The remaining exfil surface is the network: coderunning in the sandbox can still reach arbitrary hosts. Egress control
closes that loop.
Earlier (incorrect) framing
An earlier draft of this issue claimed iptables was unreachable because
bwrap drops CAP_NET_ADMIN. That's wrong for the relevant case: when
--unshare-usermaps uid to 0 in a new user ns, the new netns is ownedby that user ns and we hold CAP_NET_ADMIN on the sandbox netns, just
not on the host's. The proposal above relies on exactly that.