rootless mode: pasta + SNI proxy + nft-in-userns (no sudo)#16
Conversation
Implements issue #15. Drops every privileged operation from the runtime path: no sudo, no setcap, no sysctl tweaks, no persistent host state. Network model: - pasta launches the sandbox inside an unprivileged user namespace; we are userns-root over our own namespace, no host privilege. - Inside the namespace, only a /32 route to the gateway is configured. No default route, so the kernel rejects anything else with no-route-to-host. CAP_NET_ADMIN is dropped by bwrap before user code runs. - An in-namespace forwarder binds 127.0.0.1:443 (free as userns-root) and splices to gateway:<proxy-port>; pasta's --map-host-loopback bridges that to the host's loopback. - A new in-tree Go SNI proxy on host loopback peeks each ClientHello, matches SNI against the allowlist, and splices to the real upstream. No TLS termination, no MITM — bytes flow through verbatim and the client validates the real upstream certificate. - /etc/hosts inside the sandbox maps allowed hostnames to 127.0.0.1 so apps dial <host>:443 normally; resolv.conf is blanked. Removed: Netns/veth/MASQUERADE/nft setup, sudo/runuser invocation, orphan reaper, --reap flag, resolveHosts/firstIPFor plumbing. Added: proxy.go, forwarder.go, sandbox_init.go (the agentpen __forwarder and __sandbox-init internal subcommands). Capability check now requires bwrap, pasta, ip, seccomp. Known limitation documented in notes.md: pasta's userns maps a single uid, so the forwarder runs at the same kernel-side uid as user code and is SIGKILL-able by it. That breaks outbound for the rest of the session — self-DoS, not a privilege escalation.
There was a problem hiding this comment.
Pull request overview
This PR reworks the untrusted sandbox to run fully rootless by replacing the prior privileged netns/veth/NAT/nftables path with a pasta-managed userns+netns plus a host-loopback SNI-based TLS splice proxy that enforces an outbound hostname allowlist without terminating TLS.
Changes:
- Replace sudo-based netns/veth/NAT/nft egress filtering with pasta + in-namespace forwarder + host SNI proxy.
- Add internal subcommands (
__sandbox-init,__forwarder) and update/etcstaging to map allowed hosts to127.0.0.1with DNS disabled. - Update capability checks, tests, and documentation to match the new rootless networking model.
Reviewed changes
Copilot reviewed 13 out of 14 changed files in this pull request and generated 11 comments.
Show a summary per file
| File | Description |
|---|---|
| sandbox_init.go | New in-namespace init: static IP/route setup and forwarder spawn before exec’ing bwrap wrapper. |
| forwarder.go | New in-namespace TCP splicer from 127.0.0.1:443 to gateway:<proxy-port>. |
| proxy.go | New host-loopback SNI “peek then splice” proxy enforcing hostname allowlist. |
| proxy_test.go | New tests covering SNI peek behavior and invariants. |
| network.go | Replace old netns/veth/NAT orchestration with pasta launch wrapper. |
| network_test.go | Remove tests for deleted netns implementation. |
| main.go | Dispatch internal subcommands, start SNI proxy, and run pasta-based sandbox path; remove --reap flow. |
| fs.go | Stage /etc with allowed hostnames mapped to 127.0.0.1 and blank resolv.conf. |
| fs_test.go | Update /etc/hosts staging expectations for the new mapping model. |
| capabilities.go | Update required capabilities for rootless mode (bwrap/pasta/ip/seccomp). |
| capabilities_test.go | Update capability requirement and validation tests. |
| README.md | Document rootless pasta + SNI proxy model and updated runtime requirements. |
| notes.md | Add detailed “Rootless model” design notes and rationale. |
| .gitignore | Ignore rootless-test/ directory. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- proxy: bump bufio reader to 5+max-record so peeking a maximum-sized ClientHello can't trip ErrBufferFull. Promote 16384 to a named constant. - proxy + forwarder: half-close splice. Both directions now run as siblings and CloseWrite when one EOFs, so the other can drain in-flight bytes instead of being truncated. Factored out spliceConns/closeWrite. - proxy: bound per-connection lifetime (1h) instead of clearing the deadline post-handshake — sandbox can't park idle conns to chew host resources indefinitely. - main: don't call os.Exit inside run() — bypasses defers (temp dir, proxy.Close). Bubble exit code via *exitError; main() unwraps with errors.As. - main: normalizeHosts trims/lowercases/dedupes allowedHosts and rejects any entry containing whitespace or control characters. Stops "a.com b.com" from silently producing two /etc/hosts aliases on one line and from confusing the SNI allowlist. - sandbox_init: bring lo up before everything else — fresh netns has it down, so the in-namespace forwarder bind on 127.0.0.1 and the readiness probe both fail otherwise. - sandbox_init: drop unused sandboxNetCIDR; add sandboxNetMaskBits used in pastaArgs so the netmask isn't a magic literal. - sandbox_init: docstring no longer claims the forwarder runs as a different uid (it doesn't; pasta's userns has only one uid mapped — the same-uid kill is the documented limitation). - main, capabilities: drop stale references to setpriv and the old sudo+runuser pattern in comments. - new tests: normalizeHosts (trim/lowercase/dedupe, bad-input rejection, order preservation), exitError unwrapping.
There was a problem hiding this comment.
Pull request overview
This PR migrates agentpen’s “untrusted” network isolation from privileged netns/veth/nft/iptables setup to a fully rootless pasta-based model, using an in-tree host-loopback SNI proxy plus an in-namespace forwarder to enforce hostname allowlisting without TLS termination.
Changes:
- Replace sudo-managed netns/veth/NAT/nft egress filtering with
pasta+ a host-side SNI-gating TCP proxy and an in-namespace forwarder. - Update CLI/runtime plumbing (
__sandbox-init,__forwarder) and/etcstaging so allowed hostnames resolve to the forwarder (127.0.0.1). - Refresh capability checks/docs/tests to match the new dependency set (
bwrap,pasta,ip,seccomp) and validate SNI parsing behavior.
Reviewed changes
Copilot reviewed 14 out of 15 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| sandbox_init.go | New in-namespace init: configure loopback/eth0, spawn forwarder, exec inner command. |
| forwarder.go | New __forwarder subcommand that splices 127.0.0.1:443 to the gateway proxy port. |
| proxy.go | New host-loopback SNI-sniffing TCP proxy with allowlist gating and bidirectional splice. |
| proxy_test.go | New tests validating SNI peeking against real captured ClientHello and error cases. |
| network.go | Replaced privileged netns orchestration with pasta launcher plumbing. |
| main.go | Dispatch internal subcommands, start/close SNI proxy, run pasta pipeline, normalize allowed hosts. |
| main_test.go | Tests for host normalization and exit-code propagation via typed error. |
| fs.go | /etc/hosts staging now maps allowed hostnames to 127.0.0.1 (forwarder); removed IP-resolution path. |
| fs_test.go | Updated assertions for the new /etc/hosts behavior. |
| capabilities.go | Updated capability inventory/requirements for the rootless stack. |
| capabilities_test.go | Updated expectations for new required capabilities. |
| README.md | Updated user-facing explanation/requirements for the rootless model and known limitation. |
| notes.md | Expanded design notes documenting the rootless model and rationale. |
| network_test.go | Removed tests tied to deleted netns/veth implementation. |
| .gitignore | Ignore rootless-test/ directory. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Pasta names the tap interface after the host's outbound interface (eno1, enp3s0, wlan0, etc.) by default. Hardcoding "eth0" only worked inside the Docker test container because Docker happens to use that name. On a real host with eno1, agentpen __sandbox-init failed with `Cannot find device "eth0"`. Fix: findSandboxIface() walks net.Interfaces() inside the namespace and returns the single non-loopback interface — pasta creates exactly one tap per namespace, so the choice is unambiguous regardless of what name pasta picked. configureNetns then operates on that name. Validated end-to-end on bare-metal Ubuntu 22.04 with passt built from source: TLS handshake to real api.anthropic.com via the SNI proxy returns http=404 ssl_verify=0; non-allowed hosts fail at DNS as expected. Also added install-from-source instructions to the README for hosts where passt isn't packaged (notably Ubuntu 22.04). Calls out why curl-piping a binary off the internet is the wrong move for a tool whose job is to gate egress.
- spawnForwarder: race the child's Wait() against the dial loop. If the forwarder dies before binding (EADDRINUSE etc.), surface its real exit status instead of letting the loop time out with a generic error. - runPasta: detect signal-terminated children via WaitStatus.Signaled() and map to the conventional 128+signum exit code. Without this, ExitCode() returns -1 on signal which would propagate as os.Exit(255) and lose all signal context. - proxy: log SNI with %q. SNI bytes are attacker-controlled and could inject newlines or control chars into single-line logs. - proxy_test: captureClientHello now reads the 5-byte TLS record header, parses the length, and io.ReadFull's the body. The previous single-Read could capture a partial record on TCP fragmentation or for ClientHellos larger than 4096 bytes — flake risk.
There was a problem hiding this comment.
Pull request overview
This PR refactors the sandbox runtime to be fully rootless by replacing the previous privileged netns/veth/NAT/nftables approach with a pasta-managed userns+netns plus a host-loopback SNI-gating TCP proxy, while keeping bubblewrap + seccomp for isolation.
Changes:
- Replace sudo-driven netns/veth/NAT/nftables setup with
pasta+ in-namespace init (__sandbox-init) and forwarder (__forwarder). - Add a host-side SNI-peeking TCP splicer (
proxy.go) to gate outbound TLS by allowlisted SNI without terminating TLS. - Update /etc staging and capability checks/tests/docs to match the new rootless model.
Reviewed changes
Copilot reviewed 14 out of 15 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| sandbox_init.go | Adds in-namespace network setup and forwarder spawning before exec’ing bwrap wrapper. |
| forwarder.go | Adds in-namespace TCP forwarder that splices 127.0.0.1:443 to the host proxy via gateway. |
| proxy.go | Adds host-loopback SNI proxy that peeks ClientHello SNI, allowlists, and splices to <sni>:443. |
| proxy_test.go | Adds tests for SNI peeking correctness and robustness using captured ClientHello records. |
| network.go | Replaces old privileged netns machinery with pasta launcher and exit-code mapping. |
| network_test.go | Removes tests tied to the deleted netns/veth model. |
| main.go | Wires up SNI proxy + pasta launch path; adds internal subcommand dispatch and host normalization. |
| main_test.go | Adds tests for normalizeHosts and exitError unwrapping. |
| fs.go | Updates /etc/hosts staging to map allowed hosts to 127.0.0.1 (forwarder). |
| fs_test.go | Updates tests for the new /etc/hosts behavior. |
| capabilities.go | Updates runtime capability set to bwrap, pasta, ip, seccomp. |
| capabilities_test.go | Updates capability-related test expectations. |
| README.md | Documents the new rootless networking model and requirements. |
| notes.md | Updates internal design notes for the rootless model and its limitations. |
| .gitignore | Ignores rootless-test/ directory. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Pasta's --map-host-loopback translates the gateway IP to host loopback but does not constrain the destination port. That meant the sandbox could dial 192.0.2.1:<any-port> and reach every service bound on the host's 127.0.0.1 — databases, dev servers, SSH, IPC-over-TCP. The "only the proxy is reachable" claim was wrong. Fix: install an nft ruleset inside the userns before exec'ing bwrap. Policy drop on output; accept established/related, accept oifname lo (forwarder needs intra-namespace loopback), accept tcp dst gateway:port where port is the SNI proxy's bound port; everything else gets a TCP reset. nft inside an unprivileged userns just needs CAP_NET_ADMIN within that userns — which we hold for free as userns-root — so this runs without sudo. Rules live in the namespace's tables and disappear when the namespace is torn down. Adds nft to required capabilities. The original "drop nft" goal was downstream of "drop sudo": the privileged piece was `sudo nft -f` on the host's filter tables, with the binary guilty by association. nft-in-userns is sudo-free and namespace-scoped; bringing it back is consistent with the original spirit of the change. Doc fixes: previous text in README/notes/sandbox_init claimed user code could SIGKILL the in-namespace forwarder (self-DoS limitation). That's wrong — bwrap's --unshare-pid puts user code in a child PID namespace where the forwarder's pid isn't visible at all, so there's nothing to signal. Removed the limitation text everywhere. Verified end-to-end on bare metal: host listener on 127.0.0.1:9999 is unreachable from inside the sandbox via 192.0.2.1:9999 (connection refused), while the allowed path to api.anthropic.com:443 still completes a real TLS handshake against the real upstream cert.
There was a problem hiding this comment.
Pull request overview
This PR reworks agentpen’s network isolation to be fully rootless at runtime by launching the sandbox under pasta and enforcing hostname-based egress gating via an in-tree host-loopback SNI proxy plus an in-namespace forwarder and nft output filter.
Changes:
- Replace the sudo/netns/veth/NAT path with
pasta -> __sandbox-init -> bwrap, removing privileged host operations. - Add host-loopback SNI proxy + in-namespace forwarder + sandbox-init netns setup (routes + nft egress rules).
- Update
/etcstaging to map allowed hostnames to127.0.0.1, revise capability checks/docs/tests accordingly.
Reviewed changes
Copilot reviewed 15 out of 16 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
main.go |
Dispatch internal subcommands, start SNI proxy, run pasta, add hostname normalization, update CLI help text. |
network.go |
Implement pasta argument construction and launcher with signal-aware exit code mapping. |
sandbox_init.go |
Configure netns (ip), install nft egress filter, spawn forwarder, exec inner command. |
forwarder.go |
Add in-namespace TCP splicer binding 127.0.0.1:443 to gateway proxy port. |
proxy.go |
Add host-loopback SNI-sniffing TCP splice proxy with bounded connection lifetime. |
fs.go |
Update /etc staging so allowed hostnames map to 127.0.0.1 (forwarder). |
capabilities.go |
Update required/checked capabilities for the new rootless runtime path. |
README.md |
Document the new rootless pasta/SNI-proxy model and updated runtime requirements. |
notes.md |
Expand design notes for the rootless model and rationale (including nft-in-userns). |
capabilities_test.go |
Update tests for new capability set (one test expectation currently inconsistent). |
main_test.go |
Add tests for hostname normalization and exitError unwrapping. |
proxy_test.go |
Add SNI parser tests including real ClientHello capture and invariants. |
sandbox_init_test.go |
Add unit tests for nft egress rules generation. |
fs_test.go |
Update tests for /etc/hosts behavior under hostname->127.0.0.1 mapping. |
network_test.go |
Remove tests for the legacy netns/veth-based implementation. |
.gitignore |
Ignore rootless-test/ artifacts. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- forwarder: net.DialTimeout with a 10s ceiling on upstream connect.
Without it, hung upstreams (SYN retries take ~75s on Linux) tied up
one goroutine per inbound — easy goroutine accumulation surface.
- main: reject loopback names and IP literals in normalizeHosts.
--allow localhost or --allow 127.0.0.1 would have steered the
host-side SNI proxy into dialing host loopback services, defeating
the kernel-level egress containment for anyone who reused those
config values. Tests cover localhost (cased), .localhost suffix,
IPv4/IPv6 loopback, and other IP literals.
- main: usage text now reads "pasta + bwrap + nft + seccomp" to match
the actual layer set (and the capability list).
- README: tightened the network-isolation bullet to call out the
allowlist's loopback/IP-literal rejection so the doc claim matches
enforcement.
- capabilities: rewrote the layer-comment to drop a paren that read
ambiguously when the second line was viewed in isolation.
- capabilities_test: TestDetectCapabilities_ReturnsAllKnownNames now
asserts {bwrap, pasta, nft, ip, seccomp}; was stale on nft.
There was a problem hiding this comment.
Pull request overview
Implements a rootless runtime path for the untrusted sandbox profile by replacing the prior sudo/netns/veth/NAT setup with a pasta-launched userns+netns, an in-namespace forwarder, and a host-loopback SNI-peeking TCP splice proxy to enforce hostname allowlisting without TLS termination.
Changes:
- Replace the privileged netns/veth/iptables path with
pasta+__sandbox-initorchestration (network.go,sandbox_init.go), and add an in-namespace forwarder subcommand (forwarder.go). - Add a host-loopback SNI proxy that peeks ClientHello SNI and splices to
<sni>:443, enforcing an allowlist (proxy.go) with accompanying tests. - Update host capability detection,
/etc/hostsstaging behavior, docs, and tests to match the new model (capabilities.go,fs.go,README.md,notes.md, etc.).
Reviewed changes
Copilot reviewed 15 out of 16 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
main.go |
Dispatch internal subcommands, normalize allowlist hostnames, start SNI proxy, and launch the sandbox via pasta. |
main_test.go |
Adds tests for hostname normalization and exit-code propagation via exitError. |
network.go |
Introduces pastaArgs/runPasta to run __sandbox-init inside a rootless userns+netns. |
sandbox_init.go |
Configures the netns, installs in-namespace nft egress filter, spawns the forwarder, then execs the inner command. |
sandbox_init_test.go |
Unit-tests the generated nft ruleset text for expected invariants and port parameterization. |
forwarder.go |
Adds __forwarder subcommand to splice 127.0.0.1:443 to the gateway proxy port inside the namespace. |
proxy.go |
Adds host SNI proxy implementation (ClientHello SNI peek + allowlist gate + splice). |
proxy_test.go |
Adds tests for SNI peeking correctness, non-consumption, and malformed/non-TLS rejection. |
fs.go |
Updates /etc/hosts staging to map allowed hostnames to 127.0.0.1 (forwarder) and removes resolved-IP plumbing. |
fs_test.go |
Updates /etc/hosts staging tests to assert 127.0.0.1 <allowed-host> entries. |
capabilities.go |
Updates capability list/requirements for rootless mode (bwrap+pasta+nft+ip+seccomp). |
capabilities_test.go |
Updates tests for the new required capabilities and reporting behavior. |
README.md |
Updates the threat model/requirements documentation for the new rootless networking approach. |
notes.md |
Documents the new “Rootless model” design and rationale (including nft-in-userns). |
network_test.go |
Removes tests for deleted netns/veth/orphan-reaper implementation. |
.gitignore |
Adds rootless-test/ ignore entry. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- proxy: peekSNI now handles ClientHellos fragmented across multiple TLS records. Refactored into peekHandshake (record-level reassembly, Peek-only so the splice still gets verbatim bytes) + parseClientHelloSNI (handshake-level parsing). bufio reader sized for up to 64 KiB of assembled handshake plus per-record framing. Test added that re-frames a captured single-record ClientHello as two records and confirms peekSNI extracts the SNI without consuming reader bytes. - main: replace `bash -c` wrapper with `sh -c`. The snippet (`exec bwrap "$@" 3<"$0"`) is pure POSIX, so removing the bash dependency from the runtime path is free. Other call sites already fall back to /bin/sh. - sandbox_init: docstring now says "lo and the namespace's tap interface" with a pointer to findSandboxIface, instead of "lo and eth0" — the iface name is discovered, not hardcoded. - README: clarify the nft requirement framing. We dropped the host-privileged `sudo nft -f` invocation; the `nft` binary itself is still required and runs inside the sandbox's unprivileged userns. The original "drop nft" goal in #15 was downstream of "drop sudo," not a binary-removal demand.
There was a problem hiding this comment.
Pull request overview
This PR replaces the prior sudo-based netns/veth/NAT setup with a fully rootless runtime path using pasta plus a host-loopback SNI-gated TCP splice proxy, while keeping the existing bwrap/seccomp confinement model.
Changes:
- Swap privileged netns/veth/iptables flow for
pasta-launched userns+netns with in-namespace static IP/route setup and an in-namespace nft egress filter. - Add a host-side SNI “peek + splice” proxy and an in-namespace forwarder (
127.0.0.1:443 -> gateway:<proxyPort>). - Update
/etcstaging, capability detection, tests, and docs to match the new rootless model.
Reviewed changes
Copilot reviewed 15 out of 16 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| sandbox_init.go | Implements __sandbox-init: netns config, nft egress rules, forwarder spawn, then exec inner command |
| sandbox_init_test.go | Unit tests for nft rules generation |
| forwarder.go | Implements __forwarder TCP splicer inside the pasta netns |
| proxy.go | Adds host-side SNI peek + allowlist + splice proxy |
| proxy_test.go | Adds tests for TLS ClientHello capture and SNI parsing/peek invariants |
| network.go | Replaces old netns/veth/sudo orchestration with pasta launcher + exit-code mapping |
| main.go | Adds internal subcommand dispatch, host normalization, SNI proxy lifecycle, pasta launch, and exit-code plumbing |
| main_test.go | Tests for hostname normalization and exitError unwrap behavior |
| fs.go | Updates /etc/hosts staging to map allowed hosts to 127.0.0.1 (forwarder) |
| fs_test.go | Updates tests for revised /etc staging behavior |
| capabilities.go | Updates capability model to bwrap + pasta + nft + ip + seccomp |
| capabilities_test.go | Updates tests to match the new required capabilities |
| README.md | Documents the new rootless networking model and updated runtime requirements |
| notes.md | Updates architecture notes with the new rootless model details and rationale |
| .gitignore | Adds /rootless-test/ |
| network_test.go | Removes tests for the deleted sudo/netns implementation |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- proxy: defend against DNS rebinding. Resolve the SNI to an IP, reject
anything not globally routable (loopback, RFC1918, CGN, link-local,
multicast, documentation/benchmark/reserved blocks), then dial the
chosen IP directly so a rebind between resolve and dial can't slip
through. Test covers the public/non-routable boundary across IPv4
and IPv6 (loopback, private, CGN, link-local, multicast, docs,
benchmark, reserved class E, ULA).
- proxy: peekHandshake budget check is now against len(hs) (assembled
handshake bytes) instead of pos (which also counts per-record framing).
A fragmented ClientHello at exactly the cap was being wrongly rejected
due to header overhead.
- forwarder: bound per-connection lifetime via SetDeadline at
forwarderConnMaxLifetime (1h), mirroring proxyConnMaxLifetime. Without
it, sandbox code could open many idle 127.0.0.1:443 connections and
accumulate goroutines/FDs in the forwarder.
- main: normalizeHosts trims a single trailing dot before the loopback
check so DNS-FQDN forms ("localhost.", "foo.localhost.") don't bypass
rejection. Tests updated to cover both forms.
- sandbox_init: sandboxNetMaskBits 29 -> 32 to match the in-namespace
/32 design. Pasta accepts /32; verified via end-to-end smoke. The /29
was a holdover from earlier prototyping that would otherwise install
a connected route for 192.0.2.0/29 and weaken the "no route to host
except gateway" property.
There was a problem hiding this comment.
Pull request overview
This PR replaces the prior sudo-based netns/veth/NAT egress model with a rootless runtime path using pasta (passt) + an in-namespace forwarder + a host-loopback SNI-gating TCP proxy, while keeping bwrap+seccomp for sandbox isolation.
Changes:
- Swap network setup to
pasta-managed userns+netns, with an in-netns__sandbox-initsubcommand configuring routes and an in-netns__forwarderbinding127.0.0.1:443. - Add a host-side SNI proxy that peeks TLS ClientHello SNI, allowlists by hostname, resolves and dials only globally-routable IPs, and splices bytes without TLS termination.
- Update capability detection/tests and
/etc/hostsstaging to support the new hostname→127.0.0.1 forwarding model.
Reviewed changes
Copilot reviewed 15 out of 16 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| sandbox_init_test.go | Adds unit tests for the nft egress rules emitted in-netns. |
| sandbox_init.go | Implements __sandbox-init: netns config, nft egress filter, forwarder spawn, exec into inner command. |
| proxy_test.go | Adds tests for SNI peeking/parsing invariants and public-IP filtering helpers. |
| proxy.go | Implements host-loopback SNI proxy, TLS ClientHello peeker/parser, DNS rebind-resistant dialing, and bidirectional splicing. |
| notes.md | Documents the new rootless model and rationale/tradeoffs vs prior sudo-based approach. |
| network_test.go | Removes tests tied to the deleted netns/veth implementation. |
| network.go | Adds pasta launcher and exit-code mapping logic; removes sudo/netns plumbing. |
| main_test.go | Adds tests for hostname normalization and exit-code error unwrapping. |
| main.go | Adds internal subcommand dispatch, switches to rootless execution path, adds normalizeHosts, and wires SNI proxy + pasta + forwarder flow. |
| fs_test.go | Updates /etc/hosts staging tests for hostname→127.0.0.1 mapping behavior. |
| fs.go | Updates stageEtc to map allowed hostnames to 127.0.0.1 and removes resolved-IP plumbing. |
| forwarder.go | Adds in-namespace TCP splicer (__forwarder) with timeouts and shared splice helper usage. |
| capabilities_test.go | Updates expected required capabilities for the rootless path (bwrap+pasta+nft+ip+seccomp). |
| capabilities.go | Updates capability descriptions and required set to match the new runtime dependencies. |
| README.md | Updates documentation for the rootless network model and new runtime requirements; adds passt install notes. |
| .gitignore | Ignores /rootless-test/ artifacts. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- sandbox_init: validate proxy port range (1-65535) right after Atoi. In current architecture the value can only come from our own ephemeral net.Listen, but rejecting nonsense early is cheaper than tracing it through nft rule generation later. - sandbox_init: use sandboxNetMaskBits in configureNetns instead of a hardcoded "/32". Same value today, but eliminates drift risk if the constant ever changes.
The SNI proxy was logging an ALLOW/BLOCK line per connection to stderr, which mangles interactive TUIs (claude code, codex, etc.) by interleaving with the agent's redraws. Forwarder also printed a startup banner. Default is now silent on both. New flag `-v` / `--verbose` re-enables the per-connection proxy log lines for debugging. Errors still surface through the normal path (spawn failures, bind errors, etc.) — only the ALLOW/BLOCK and forwarder-startup chatter is gated.
There was a problem hiding this comment.
Pull request overview
This PR replaces the prior sudo-driven netns/veth/NAT approach with a fully rootless runtime path using pasta (passt) + an in-namespace forwarder + a host-loopback SNI-gated TCP splicing proxy, while retaining bwrap+seccomp isolation and using nft only inside the unprivileged userns.
Changes:
- Replace privileged netns/veth/iptables orchestration with
pastalaunch +__sandbox-initin-namespace setup +__forwardersplicer. - Add host-side SNI proxy that peeks ClientHello, allowlists by SNI, and splices to
<sni>:443with DNS-rebind defenses. - Update
/etc/hostsstaging, capability detection/tests, and documentation to reflect the new rootless design and dependencies.
Reviewed changes
Copilot reviewed 15 out of 16 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| sandbox_init.go | Adds __sandbox-init to configure the pasta netns, load nft egress rules, and start the forwarder before exec’ing the inner command. |
| sandbox_init_test.go | Unit tests for nft rule text generation and port parameterization. |
| forwarder.go | Adds __forwarder in-namespace TCP splicer that binds 127.0.0.1:443 and forwards to gateway:<proxy-port>. |
| proxy.go | Adds host-loopback SNI proxy with TLS ClientHello peeking, allowlist enforcement, DNS rebinding mitigation, and bidirectional half-close splicing. |
| proxy_test.go | Adds tests for SNI peeking correctness, fragmentation handling, non-TLS rejection, normalization, and IP routability checks. |
| network.go | Replaces old netns helpers with pastaArgs + runPasta wrapper (including signal exit-code mapping). |
| network_test.go | Removes tests for the deleted netns/veth-based implementation. |
| main.go | Wires in internal subcommands, starts the SNI proxy, launches pasta pipeline, adds --verbose, and introduces normalizeHosts. |
| main_test.go | Adds tests for normalizeHosts behavior and exitError unwrap semantics. |
| fs.go | Updates /etc/hosts staging to map all allowed hosts to 127.0.0.1 (forwarder) and removes old host-IP resolution plumbing. |
| fs_test.go | Updates /etc staging tests to match new 127.0.0.1 <host> behavior. |
| capabilities.go | Updates capability inventory/requirements to bwrap, pasta, nft, ip, seccomp. |
| capabilities_test.go | Updates capability tests for the new required set and missing-cap reporting. |
| README.md | Documents the rootless model, new runtime requirements, and passt install guidance. |
| notes.md | Updates design notes to describe the rootless pasta + forwarder + SNI proxy + nft-in-userns model. |
| .gitignore | Ignores /rootless-test/ workspace. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // in a bash snippet that reopens the BPF file as FD 3 before exec'ing | ||
| // bwrap, since Go's syscall.Exec from __sandbox-init doesn't let us inject | ||
| // FDs as cleanly. The bash hop is the same trick the old sudo-based path | ||
| // used; the privileged scaffolding around it is what's gone. |
| } | ||
| defer proxy.Close() | ||
|
|
||
| // Launch pasta -> __sandbox-init -> bash -> bwrap -> user command. |
| // Trim a single trailing dot so DNS-FQDN form ("localhost.") is | ||
| // normalized before the loopback check, which otherwise would miss it. | ||
| h = strings.TrimSuffix(h, ".") | ||
| if h == "" { | ||
| return nil, fmt.Errorf("invalid hostname %q: empty after normalization", raw) | ||
| } | ||
| if net.ParseIP(h) != nil { | ||
| return nil, fmt.Errorf("invalid hostname %q: IP literals are not allowed (use a hostname so SNI matching works)", raw) | ||
| } | ||
| if h == "localhost" || strings.HasSuffix(h, ".localhost") { | ||
| return nil, fmt.Errorf("invalid hostname %q: loopback names are not allowed", raw) | ||
| } |
| func forwardOne(client net.Conn, upstreamAddr string) { | ||
| defer client.Close() | ||
| upstream, err := net.DialTimeout("tcp", upstreamAddr, forwarderDialTimeout) | ||
| if err != nil { | ||
| return | ||
| } | ||
| defer upstream.Close() | ||
| deadline := time.Now().Add(forwarderConnMaxLifetime) | ||
| _ = client.SetDeadline(deadline) | ||
| _ = upstream.SetDeadline(deadline) |
Closes #15.
Drops every privileged operation from the runtime path. No
sudo, nosetcap, no sysctl tweaks, no persistent host state.How it works
EHOSTUNREACH.CAP_NET_ADMINis dropped by bwrap before user code runs, so the agent can't add routes to bypass.agentpen __forwardersubcommand) binds127.0.0.1:443— free as userns-root — and splices togateway:<proxy-port>. Pasta's--map-host-loopbackbridges that to the host's loopback.<sni>:443. No TLS termination, no MITM — bytes flow through verbatim and the client validates the real upstream cert./etc/hostsinside the sandbox maps allowed hostnames to127.0.0.1so apps dial<host>:443normally;resolv.confis blanked.End-to-end: an SDK calls
https://api.anthropic.com/, hits the in-namespace forwarder, traverses pasta to the host SNI proxy, splices to the real Anthropic, completes a real TLS handshake against the real cert. NoHTTPS_PROXY, noNODE_OPTIONS, no language-specific shim.Diff shape
Netns/veth/MASQUERADE/nft setup, sudo/runuser invocation, orphan reaper,--reapflag,resolveHosts/firstIPForplumbing.proxy.go(SNI parser + splicer),forwarder.go(the__forwardersubcommand),sandbox_init.go(the__sandbox-initsubcommand).bwrap,pasta,ip,seccomp.Net change: ~300 lines deleted, ~400 added.
Why not the alternatives
HTTPS_PROXYpre-Node-24, and 3 of 4 registered agents are Node. Would have forced agentpen to ship aNODE_OPTIONSshim — language-specific knowledge leaking into a Go binary. Rejected.setcap CAP_NET_BIND_SERVICEon the binary: clean operationally but adds persistent file capability. Rejected because the goal is "no persistent host state."net.ipv4.ip_unprivileged_port_start=0: system-wide, affects every process on the box. Rejected.:443 -> :high-port: works without sudo (CAP_NET_ADMIN exists in the userns for free) but reintroduces nftables as a runtime dep, which the issue explicitly wanted gone.Background in
notes.mdunder "Rootless model".Known limitation
Pasta's userns maps a single uid, so the forwarder runs at the same kernel-side uid as user code. Cap-dropped user code can SIGKILL it, breaking its own outbound until the next
agentpeninvocation.Self-DoS only. The kernel /32 route still blocks any direct egress, and the SNI proxy still gates anything that reaches it. No privilege boundary is crossed.
Future option (not in this PR): a watchdog in
agentpen-on-host that nsenter's into the netns and revives the forwarder on death.Test plan
go vet ./...cleango test -count=1 ./...passes (new SNI parser tests cover real ClientHello capture, peek-doesn't-consume invariant, malformed/truncated/non-TLS rejection, lowercase normalization)go build .cleanagentpen --checkshows the new capability list inside a freshdebian:trixie+passt+bubblewrapcontaineragentpen --allow api.anthropic.com -- curl -sS https://api.anthropic.com/on a host withpasstinstalled. The Docker harness used during prototyping hits an unrelated bwrap-in-pasta interaction (open /proc/N/ns/ns failed) that doesn't reproduce on bare-metal Linux, but the final smoke is best done by a reviewer with a passt-equipped host.