Skip to content

rootless mode: pasta + SNI proxy + nft-in-userns (no sudo)#16

Merged
cwage merged 10 commits intomasterfrom
rootless-mode
Apr 25, 2026
Merged

rootless mode: pasta + SNI proxy + nft-in-userns (no sudo)#16
cwage merged 10 commits intomasterfrom
rootless-mode

Conversation

@cwage
Copy link
Copy Markdown
Owner

@cwage cwage commented Apr 25, 2026

Closes #15.

Drops every privileged operation from the runtime path. No sudo, no setcap, no sysctl tweaks, no persistent host state.

How it works

[host (no privilege)]
agentpen
├─ goroutine: SNI proxy on 127.0.0.1:<ephemeral>
└─ pasta -- agentpen __sandbox-init <port> -- bash -c (open FD3) -- bwrap -- claude
              [pasta's userns+netns]
              ├─ ip link set eth0 up; ip addr add 192.0.2.2/32; ip route add 192.0.2.1
              ├─ agentpen __forwarder 127.0.0.1:443 192.0.2.1:<port>
              └─ bwrap (drops caps, unshares ns) -> claude
  • pasta creates the userns+netns. We are userns-root over our own namespace; no host privilege.
  • The namespace gets a /32 route to the gateway only. No default route, so anything not destined for the gateway is rejected by the kernel with EHOSTUNREACH. CAP_NET_ADMIN is dropped by bwrap before user code runs, so the agent can't add routes to bypass.
  • An in-namespace forwarder (agentpen __forwarder subcommand) 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 TLS ClientHello, matches SNI against the allowlist, and splices to <sni>:443. No TLS termination, no MITM — bytes flow through verbatim and the client validates the real upstream cert.
  • /etc/hosts inside the sandbox maps allowed hostnames to 127.0.0.1 so apps dial <host>:443 normally; resolv.conf is 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. No HTTPS_PROXY, no NODE_OPTIONS, no language-specific shim.

Diff shape

  • Removed: Netns/veth/MASQUERADE/nft setup, sudo/runuser invocation, orphan reaper, --reap flag, resolveHosts/firstIPFor plumbing.
  • Added: proxy.go (SNI parser + splicer), forwarder.go (the __forwarder subcommand), sandbox_init.go (the __sandbox-init subcommand).
  • Capability check now requires bwrap, pasta, ip, seccomp.

Net change: ~300 lines deleted, ~400 added.

Why not the alternatives

  • HTTPS_PROXY + CONNECT proxy (the original issue text): Node's global fetch ignores HTTPS_PROXY pre-Node-24, and 3 of 4 registered agents are Node. Would have forced agentpen to ship a NODE_OPTIONS shim — language-specific knowledge leaking into a Go binary. Rejected.
  • setcap CAP_NET_BIND_SERVICE on the binary: clean operationally but adds persistent file capability. Rejected because the goal is "no persistent host state."
  • Sysctl net.ipv4.ip_unprivileged_port_start=0: system-wide, affects every process on the box. Rejected.
  • nft inside the userns to redirect :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.md under "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 agentpen invocation.

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 ./... clean
  • go 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 . clean
  • agentpen --check shows the new capability list inside a fresh debian:trixie + passt + bubblewrap container
  • Prototype validation: pasta + /32 route + forwarder + SNI proxy demonstrated end-to-end with a real cert-valid 404 from Anthropic
  • Bare-metal smoke test: agentpen --allow api.anthropic.com -- curl -sS https://api.anthropic.com/ on a host with passt installed. 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.

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.
Copilot AI review requested due to automatic review settings April 25, 2026 02:26
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 /etc staging to map allowed hosts to 127.0.0.1 with 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.

Comment thread fs.go
Comment thread capabilities.go Outdated
Comment thread forwarder.go Outdated
Comment thread proxy.go Outdated
Comment thread main.go Outdated
Comment thread sandbox_init.go
Comment thread sandbox_init.go Outdated
Comment thread sandbox_init.go
Comment thread proxy.go Outdated
Comment thread proxy.go Outdated
- 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.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 /etc staging 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.

Comment thread sandbox_init.go Outdated
Comment thread network.go Outdated
Comment thread proxy.go Outdated
Comment thread proxy_test.go
cwage added 2 commits April 24, 2026 21:53
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.
Copilot AI review requested due to automatic review settings April 25, 2026 03:00
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread sandbox_init.go Outdated
Comment thread README.md Outdated
Comment thread notes.md Outdated
Comment thread network.go
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.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 /etc staging to map allowed hostnames to 127.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.

Comment thread capabilities_test.go Outdated
Comment thread main.go Outdated
Comment thread main.go
Comment thread README.md Outdated
Comment thread forwarder.go
Comment thread capabilities.go Outdated
- 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.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-init orchestration (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/hosts staging 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.

Comment thread main.go Outdated
Comment thread README.md
Comment thread sandbox_init.go Outdated
Comment thread proxy.go Outdated
- 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.
@cwage cwage changed the title rootless mode: pasta + SNI proxy, drop sudo/nft rootless mode: pasta + SNI proxy + nft-in-userns (no sudo) Apr 25, 2026
@cwage cwage requested a review from Copilot April 25, 2026 13:34
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 /etc staging, 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.

Comment thread proxy.go Outdated
Comment thread sandbox_init.go Outdated
Comment thread main.go
Comment thread forwarder.go
Comment thread proxy.go Outdated
- 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.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-init subcommand configuring routes and an in-netns __forwarder binding 127.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/hosts staging 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.

Comment thread sandbox_init.go Outdated
Comment thread sandbox_init.go
Comment thread main.go
Comment thread proxy.go
cwage added 2 commits April 25, 2026 08:59
- 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.
Copilot AI review requested due to automatic review settings April 25, 2026 15:11
@cwage cwage merged commit 047c434 into master Apr 25, 2026
5 checks passed
@cwage cwage deleted the rootless-mode branch April 25, 2026 15:16
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 pasta launch + __sandbox-init in-namespace setup + __forwarder splicer.
  • Add host-side SNI proxy that peeks ClientHello, allowlists by SNI, and splices to <sni>:443 with DNS-rebind defenses.
  • Update /etc/hosts staging, 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.

Comment thread main.go
Comment on lines +256 to +259
// 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.
Comment thread main.go
}
defer proxy.Close()

// Launch pasta -> __sandbox-init -> bash -> bwrap -> user command.
Comment thread main.go
Comment on lines +327 to +338
// 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)
}
Comment thread forwarder.go
Comment on lines +48 to +57
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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add rootless execution mode (no sudo required)

2 participants