Skip to content

feat(linux): allow the host to reach specific sandboxed loopback ports (network.exposeLoopbackPorts) - #507

Open
tbrandenburg wants to merge 2 commits into
anthropics:mainfrom
tbrandenburg:feat/linux-expose-loopback-ports
Open

feat(linux): allow the host to reach specific sandboxed loopback ports (network.exposeLoopbackPorts)#507
tbrandenburg wants to merge 2 commits into
anthropics:mainfrom
tbrandenburg:feat/linux-expose-loopback-ports

Conversation

@tbrandenburg

Copy link
Copy Markdown

Problem

On Linux, bwrap --unshare-net gives 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 on 127.0.0.1:<port> — there is currently no escape hatch for this specific, narrow case.

Concretely: a launcher spawns myserver --port N under srt, then immediately tries to health-check http://127.0.0.1:N from its own (unsandboxed) process to confirm the server is up. That connect deterministically fails with ConnectError('All connection attempts failed'), because 127.0.0.1:N inside the sandbox's network namespace is not the same loopback the launcher is polling. network.allowLocalBinding doesn'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:

  1. Host side: for each listed port, initializeLinuxPortForwardBridges() spawns socat 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.
  2. That socket is bind-mounted into the bwrap namespace (only for the listed ports — nothing else changes).
  3. Sandbox side: buildSandboxCommand() starts a matching socat 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-net isolation 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 (empty allowedDomains, 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 by initializeLinuxNetworkBridge.
    • LinuxSandboxParams.exposeLoopbackPorts — bind-mounted into bwrap and passed through to buildSandboxCommand(), which starts the in-sandbox listener and generalizes the cleanup trap to 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 existing reset() teardown alongside the HTTP/SOCKS bridge), wiring into wrapCommandWithSandboxLinux.
  • 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 mirroring linux-bridge-spawn-error.test.ts: confirms initializeLinuxPortForwardBridges rejects cleanly (no unhandled uncaughtException) on spawn failure, single- and multi-port.
  • test/sandbox/linux-loopback-port-forward.test.ts — real E2E, no mocks, using the actual bwrap/socat binaries:
    • Positive case: sandboxed process binds a TCP listener under --unshare-net with no HTTP/SOCKS proxy configured; host connects through exposeLoopbackPorts and gets a real response.
    • Regression case: same sandboxed listener, but without exposeLoopbackPorts — host connection is refused/times out, proving --unshare-net isolation still holds when the feature isn't used.
  • Verified no leaked socat/bwrap processes or socket files after repeated runs (ps aux + /tmp/claude-portfwd-* checks, before/after).

Validation run

bun run typecheck   # clean
bun run lint:check  # clean
bun test test/sandbox
# 844 pass, 218 skip, 14 fail
# all 14 failures are pre-existing on main (verified via git stash), unrelated to this
# change: missing vendor/seccomp apply-seccomp binary in this checkout
# (pid-namespace-isolation/execute-only-binary/integration/seccomp-filter tests),
# flaky credential-mask-body content-length assertions, and a pre-existing
# canonical-host-routing/http-proxy `:80`-in-URL quirk + update-config curl
# exit-code-7 case — none touch the code paths this PR changes.

Risks / follow-ups

  • Off by default, purely additive; zero behavior change unless network.exposeLoopbackPorts is explicitly set.
  • No wildcard/range support by design — every exposed port must be named explicitly, so nothing else about the isolation model changes.
  • Not evaluated on macOS/Windows (no --unshare-net equivalent 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-CONNECT client, 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-sandbox UNIX-LISTEN socat replace it via unlink-early; verified live with real bwrap/socat, no leaked processes, before merging the rest of the change.

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 sylvesterkaczmarek left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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
@tbrandenburg

tbrandenburg commented Sep 4, 2026

Copy link
Copy Markdown
Author

Hej @sylvesterkaczmarek, thanks for catching this — you were exactly right, and the impact was real: the placeholder file created for the bwrap --bind precondition made fs.existsSync(socketPath) true from the very first readiness-loop iteration, regardless of whether socat actually bound the TCP port.

Fixed in 42c0b02:

  • Readiness is now tied to a real TCP connect probe against 127.0.0.1:<port> instead of the placeholder file's existence — a successful connect proves socat's TCP-LISTEN actually bound and is accepting, an ECONNREFUSED/timeout means "not up yet, keep polling".
  • Also fixed a related gap on the same code path: the loop was checking ChildProcess#killed, which is only true when we call process.kill() — it never reflects socat exiting on its own (e.g. EADDRINUSE). Now tracked via a flag set in the existing exit handler, so a dead-on-arrival bridge surfaces as a real error (with the exit code/signal included) instead of silently reporting success.
  • Added a regression test that occupies a real port first (plain net.createServer(), no mocks) and asserts initialization now rejects instead of falsely reporting readiness — this reproduces the exact failure mode you described.

bun run typecheck, bun run lint:check, and the full test/sandbox suite are green (same pre-existing, unrelated failures as before — missing apply-seccomp vendor binary and some flaky content-length assertions, neither touched by this change).

@sylvesterkaczmarek sylvesterkaczmarek left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

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.

2 participants