feat(linux): allow the host to reach specific sandboxed loopback ports (network.exposeLoopbackPorts) - #507
Conversation
Add network.exposeLoopbackPorts (Linux only), an opt-in, narrowly-scoped reverse port-forward: for each listed port, a host-side socat listens on 127.0.0.1:<port> and forwards into a Unix socket that gets bind-mounted into the bwrap network namespace, where a matching in-sandbox socat forwards it to the sandboxed process's own TCP server. Only the exact ports listed are bridged; everything else remains governed by the existing domain allowlist/proxy mechanism and --unshare-net isolation exactly as before. This addresses the case where an unsandboxed launcher process needs to poll/drive an HTTP (or other TCP) server that only exists inside the sandbox -- e.g. spawning `opencode serve --port N` under srt and then health-checking http://127.0.0.1:N from the launcher itself, which previously deadlocked with ConnectError('All connection attempts failed') since the sandboxed port lives in a fully isolated network namespace with no escape hatch. - sandbox-config.ts: new NetworkConfigSchema.exposeLoopbackPorts field - linux-sandbox-utils.ts: initializeLinuxPortForwardBridges() (host-side bridge spawner) + LinuxSandboxParams.exposeLoopbackPorts (sandbox-side bind-mount + in-namespace socat listener in buildSandboxCommand); works independently of whether an HTTP/SOCKS proxy is also configured - sandbox-manager.ts: config accessor, bridge init/cleanup lifecycle, wiring into wrapCommandWithSandboxLinux - Real E2E test (real bwrap + socat, no mocks) proving both the positive case and that --unshare-net isolation remains intact when the feature isn't used - README: documents the new config option and bridge mechanism 🤖 Generated with OpenCode
sylvesterkaczmarek
left a comment
There was a problem hiding this comment.
The readiness check looks vacuous now that socketPath is created as a placeholder before socat starts. fs.existsSync(socketPath) is therefore true on the first iteration even if the host-side socat has failed to bind the TCP port and is about to exit, so initialization can report success while the exposed port is dead. Could readiness be tied to the bridge process/listening port rather than existence of the placeholder?
Addresses review feedback on anthropics#507: the readiness check for initializeLinuxPortForwardBridges was vacuous. socketPath is created as an empty placeholder file *before* socat even starts (required for bwrap's --bind precondition), so fs.existsSync(socketPath) was true on the very first loop iteration regardless of whether socat's TCP-LISTEN actually bound the requested port. A dead-on-arrival bridge (e.g. EADDRINUSE because the port was already taken) was silently reported as successfully initialized. - Replace the existence check with a real TCP connect probe against 127.0.0.1:<port>: a successful connect proves socat is actually listening, an ECONNREFUSED/timeout means retry. - Track real process exit via the existing 'exit' handler instead of ChildProcess#killed, which is only true when we call process.kill() ourselves and never reflects socat exiting on its own. - Guard against a probe racing an occupying process's own listener: a brief grace period re-checks exit state before trusting a successful probe, since the connect can otherwise succeed against whatever else is on the port a moment before socat's own bind failure surfaces. - Add a regression test that occupies a real port first (plain net.createServer(), no mocks) and asserts initialization now rejects instead of falsely reporting readiness. 🤖 Generated with OpenCode
|
Hej @sylvesterkaczmarek, thanks for catching this — you were exactly right, and the impact was real: the placeholder file created for the bwrap Fixed in 42c0b02:
|
sylvesterkaczmarek
left a comment
There was a problem hiding this comment.
Re-checked the readiness path I flagged. Startup now probes the actual TCP listener and also watches process exit/error rather than relying on the pre-created Unix-socket placeholder. The bind-conflict and spawn-error regressions cover the false-ready cases directly. That finding is resolved.
Problem
On Linux,
bwrap --unshare-netgives the sandboxed process a fully isolated network namespace. This is correct for outbound isolation, but it also means an unsandboxed launcher process has no way to reach a TCP server the sandboxed process itself binds on127.0.0.1:<port>— there is currently no escape hatch for this specific, narrow case.Concretely: a launcher spawns
myserver --port Nundersrt, then immediately tries to health-checkhttp://127.0.0.1:Nfrom its own (unsandboxed) process to confirm the server is up. That connect deterministically fails withConnectError('All connection attempts failed'), because127.0.0.1:Ninside the sandbox's network namespace is not the same loopback the launcher is polling.network.allowLocalBindingdoesn't help — it only permits binding inside the sandbox's own (already-isolated) namespace, it doesn't bridge that port back out.(Reported against a downstream consumer here, filed against this repo since upstream issue filing was restricted at the time: tbrandenburg/cade#45)
Fix
Add
network.exposeLoopbackPorts: number[](Linux only, opt-in, default: none) — a narrowly-scoped reverse port-forward, reusing the exact same Unix-socket-bridge mechanism this repo already uses for outbound HTTP/SOCKS proxying, just in reverse:initializeLinuxPortForwardBridges()spawnssocat TCP-LISTEN:<port>,fork,reuseaddr,bind=127.0.0.1 UNIX-CONNECT:<sock>— the host listens on the real port and dials into a Unix socket.buildSandboxCommand()starts a matchingsocat UNIX-LISTEN:<sock>,fork,reuseaddr,unlink-early TCP:127.0.0.1:<port>that connects the bridged socket to the sandboxed process's own server.Only the exact ports listed are bridged. Every other port, and all other traffic, is governed by the existing domain allowlist/proxy and
--unshare-netisolation exactly as before — this does not weaken or regress the network-isolation guarantees from prior hardening work. It also works independently of whether an HTTP/SOCKS proxy is configured, so a fully network-blocked sandbox (emptyallowedDomains, no proxy) can still expose specific loopback ports.Implementation
src/sandbox/sandbox-config.ts:NetworkConfigSchema.exposeLoopbackPorts?: number[]src/sandbox/linux-sandbox-utils.ts:LinuxPortForwardBridge+initializeLinuxPortForwardBridges()— host-side bridge spawner, mirrors the robustness patterns (error/exit handlers registered before pid-check, socket-readiness polling, partial-failure cleanup) already used byinitializeLinuxNetworkBridge.LinuxSandboxParams.exposeLoopbackPorts— bind-mounted into bwrap and passed through tobuildSandboxCommand(), which starts the in-sandbox listener and generalizes the cleanuptrapto kill however many background socat jobs are running (previously hardcoded to%1 %2).src/sandbox/sandbox-manager.ts: config accessor, bridge init/cleanup lifecycle (participates in the existingreset()teardown alongside the HTTP/SOCKS bridge), wiring intowrapCommandWithSandboxLinux.README.md: documents the config option and bridge mechanism in "Network Isolation Architecture" and the network config option list.Testing
test/sandbox/linux-port-forward-bridge-spawn-error.test.ts— unit test mirroringlinux-bridge-spawn-error.test.ts: confirmsinitializeLinuxPortForwardBridgesrejects cleanly (no unhandleduncaughtException) on spawn failure, single- and multi-port.test/sandbox/linux-loopback-port-forward.test.ts— real E2E, no mocks, using the actualbwrap/socatbinaries:--unshare-netwith no HTTP/SOCKS proxy configured; host connects throughexposeLoopbackPortsand gets a real response.exposeLoopbackPorts— host connection is refused/times out, proving--unshare-netisolation still holds when the feature isn't used.socat/bwrap processes or socket files after repeated runs (ps aux+/tmp/claude-portfwd-*checks, before/after).Validation run
Risks / follow-ups
network.exposeLoopbackPortsis explicitly set.--unshare-netequivalent exists there, so the field is Linux-only and documented as a no-op elsewhere).Note on process
This PR was built through an internal step-by-step implementation plan (config schema → host-side bridge → manager wiring → sandbox-side bind-mount/listener → tests → docs), with an integration review after each step. One correction happened during integration: the initial host-side bridge direction placed the host as a
UNIX-CONNECTclient, so nothing ever created the socket file bwrap needs to bind-mount — this was only caught once a real (non-mocked) E2E test was run. Fixed by having the host side create a placeholder file up front and having the in-sandboxUNIX-LISTENsocat replace it viaunlink-early; verified live with realbwrap/socat, no leaked processes, before merging the rest of the change.