Skip to content

expose --host-port#103

Open
erikswedberg wants to merge 4 commits into
banksean:mainfrom
erikswedberg:eswedberg/shelley-expose-host-port
Open

expose --host-port#103
erikswedberg wants to merge 4 commits into
banksean:mainfrom
erikswedberg:eswedberg/shelley-expose-host-port

Conversation

@erikswedberg

Copy link
Copy Markdown
Contributor

Add --host-port: expose host loopback services to sandboxes

Problem

Services bound to 127.0.0.1 on the Mac (Figma's MCP server, local LLM gateways, dev API mocks) are unreachable from inside a sandbox. From the sandbox, 127.0.0.1 is itself; the Mac is the bridge gateway. --publish is the wrong direction (sandbox→host). --allowed-domains-file is DNS-only.

Solution

sand new --host-port 3845

Inside the sandbox: curl http://host.sand:3845/mcp reaches the service on the Mac.

How

For each --host-port:

  1. Daemon-side TCP forwarder binds to the sandbox's ipv4Gateway (not 0.0.0.0 — LAN can't reach it), forwards to 127.0.0.1:<port> on the Mac.
  2. HTTP-aware: sniffs first bytes; for HTTP/1.x, rewrites Host: to 127.0.0.1:<port> before forwarding. Non-HTTP traffic = plain TCP pipe. WebSocket upgrades handled.
  3. /etc/hosts in the sandbox maps host.sand → gateway IP.
  4. Best-effort iptables DNAT for transparent 127.0.0.1:<port> redirect inside the sandbox. Apple's runtime doesn't grant CAP_NET_ADMIN today, so this silently no-ops; host.sand is the supported entry point.

Lifecycle: set up by a start hook, torn down on stop / soft-delete / daemon close.

Files

  • internal/hostport/Forwarder + Manager, ~250 lines incl. tests
  • internal/cli/cli.go--host-port flag, repeatable
  • internal/daemon/internal/boxer/boxer.gosetupHostPorts after start hooks
  • proto: CreateSandboxRequest.host_ports (field 16)
  • migration 000010_host_ports
  • doc/HOST_SERVICES.md

Tests

  • Forwarder proxy + HTTP Host rewrite
  • Manager lifecycle (start/stop/cleanup)
  • iptables script builder
  • gRPC round-trip for HostPorts
  • DB serializer round-trip

go test ./... passes.

Manual test

# host: Figma desktop running (or any service on 127.0.0.1:3845)
sand new --host-port 3845
# inside sandbox:
curl http://host.sand:3845/mcp
# → {"jsonrpc":"2.0","error":{"code":-32001,"message":"Invalid sessionId"},"id":null}
# (same response you'd get on the host — proves end-to-end)

Security

Opt-in per port, per sandbox. Listener scoped to bridge interface only. Comparable trust posture to --ssh-agent forwarding. Documented in doc/HOST_SERVICES.md.

Anticipated questions

  • Why not just rebind the host service to 0.0.0.0? Requires user to reconfigure every service they want to forward; many (Figma desktop) can't be reconfigured at all.
  • Why HTTP Host rewrite in a TCP forwarder? Without it, servers that validate Host (Figma, anything behind common dev frameworks) reject the request. Sniff-based detection keeps non-HTTP protocols untouched.
  • Why a new flag instead of extending --publish? --publish wraps Apple's container CLI, which only does container→host. This is the reverse.
  • Why not just document socat or ssh -R? Both work but require manual setup per sandbox and don't tear down cleanly. This integrates with sand's lifecycle.

Erik Swedberg and others added 4 commits May 21, 2026 23:18
Apple's container CLI puts each sandbox on a vmnet bridge with its own
IP. Services bound to 127.0.0.1 on the host (like the Figma desktop
app's MCP server at 127.0.0.1:3845) are therefore unreachable from
inside a sandbox.

This adds a repeatable --host-port flag to 'sand new' (and the
oneshot/exec creation paths). For each requested port:

  * The daemon spawns an in-process TCP forwarder bound to the sandbox's
    bridge gateway IP, forwarding to 127.0.0.1:<port> on the host. The
    listener is scoped to the bridge interface (not 0.0.0.0).
  * An iptables DNAT + MASQUERADE rule is installed inside the sandbox
    so 127.0.0.1:<port> is rewritten to <gateway>:<port>, with
    route_localnet=1 to allow the kernel to route the redirected
    loopback packet. The agent sees the service at the same loopback
    address it would use on the host -- no client reconfiguration.

Lifecycle: forwarders and rules are set up by a start hook and torn
down on StopContainer / SoftDelete / daemon Close.

  * new package internal/hostport: Forwarder + Manager
  * new migration 000010_host_ports + sqlc regen
  * proto: CreateSandboxRequest.host_ports (field 16)
  * doc/HOST_SERVICES.md

Co-authored-by: Shelley <shelley@exe.dev>
The container hook abstraction execs as the container's default user,
which by the time post-bootstrap hooks run is usually the non-root
agent user. iptables (and route_localnet sysctl) need CAP_NET_ADMIN /
root, so the hook was failing with:

  iptables: Could not fetch rule set generation id: Permission denied
  (you must be root)

Switch to calling ContainerService.Exec directly with User="0" so
the script always runs as uid 0 inside the sandbox.

Co-authored-by: Shelley <shelley@exe.dev>
Recent sand changes mean the container's default user is no longer
root, so ExecContainer.User=0 alone wasn't enough. The base image
configures 'permit nopass :wheel' for doas and adds the sandbox user
to wheel, so switch to 'doas sh -c ...' for both the iptables setup
and the /etc/hosts edit.

Also make the iptables step best-effort: Apple's container runtime
typically does not grant CAP_NET_ADMIN, so DNAT will fail anyway.
When it does, fall back to a 'host.sand' /etc/hosts entry pointing
at the bridge gateway IP. Agents can then reach the host service at
http://host.sand:<port>/ instead of http://127.0.0.1:<port>/.

Co-authored-by: Shelley <shelley@exe.dev>
The proxy now sniffs the first bytes of each connection; if they look
like an HTTP/1.x request and a rewrite target is configured (always
the case when started via the Manager), it parses each request,
rewrites the Host header to 127.0.0.1:<port>, and re-serializes it
upstream. Non-HTTP traffic falls back to a plain TCP pipe. WebSocket
and other Upgrade requests have their initial Host header rewritten
and then switch to raw passthrough.

This means a sandbox client can use http://host.sand:<port>/ with no
custom headers; servers like Figma's MCP that validate Host are happy.

Also: * Demote the expected iptables/CAP_NET_ADMIN failure from a warning
    to an info log; print a single positive '[sand] host services
    exposed at...' line on start.
  * Rewrite doc/HOST_SERVICES.md to lead with host.sand:<port> as the
    user-facing entry point and document the HTTP Host rewrite.
Co-authored-by: Shelley <shelley@exe.dev>
@banksean

banksean commented Jun 2, 2026

Copy link
Copy Markdown
Owner

Relevant open issue from apple/container: apple/container#1320 ("[Request]: Option to prevent host access on internal networks")

On --internal networks, the host gateway IP (e.g. 192.168.128.1) is reachable from containers. Any host service bound to 0.0.0.0 is accessible from inside the VM. This is a concern for security-sensitive use cases like sandboxing AI coding agents, where if the agent runs as root inside the VM then it should not be able to reach the host.

From your chat transcript (thanks!) it looks like the agent realized this when describing how the daemon-side TCP forwarder works:

Listener is bound only to the bridge-facing IP, not 0.0.0.0 — so other sandboxes / random LAN can't hit it.

Also of note: In the discussion on that issue, they mention an upcoming container system pf command for managing packet filters, which we should also keep an eye on. IIUC it's still in review.

I'm not sure how (or if) we should reconcile all of the above with your PR yet; just noting it here for future reference.

@banksean

banksean commented Jun 2, 2026

Copy link
Copy Markdown
Owner

One potential issue I can see with the daemon-side TCP proxy approach: the host service listening on --host-port (e.g. Figma MCP) will always see a remote IP address of 127.0.0.1, regardless of whether that connection actually originated on the host or in a container.

The concern is that the host service cannot distinguish:

real host-local client -> 127.0.0.1:<service>
sandbox ID "foo" via sand proxy -> 127.0.0.1:<service>
sandbox ID "bar" via sand proxy -> 127.0.0.1:<service>

So any service-side policy, logging, rate limiting, CSRF-ish origin assumptions, or audit trail based on socket peer address loses the container identity. The only place that still knows the original container IP is the sand forwarder, not the upstream service.

None of these are necessarily blockers, but I think they deserve some further consideration:

  • Document clearly that exposed services see connections as loopback from the sand daemon.
  • Logging client RemoteAddr() in the forwarder when proxying, at least at debug/info level.
  • Do not imply this preserves network-level client identity; instead clearly say that it obfuscates the remote IP of the container.
  • If identity matters later, add an opt-in metadata mechanism for HTTP, such as X-Sand-Sandbox-ID / X-Forwarded-For, but that should be carefully scoped because some local services may reject unexpected headers or treat them as trust signals. Furthermore, it should consider that a future general-purpose shared HTTP proxy might also set its own headers.

The bigger practical concern is trust boundary confusion: many loopback-only services assume “local user only.” This feature deliberately extends that trust to the sandbox (making loopback no longer imply "local", for trust purposes). That might be fine if explicit, but the docs should say it plainly.

@erikswedberg

erikswedberg commented Jun 2, 2026

Copy link
Copy Markdown
Contributor Author

all i can say about --host-port is i'm still using it for now. for both figma mcp :3845 and ADB android debug bridge :5037

i presumed you would probably close this PR and write your own more focused on how you want to handle it. but for me it allows the agent to reach out and touch services running on the host.

the agent finds these at sand.local:3845 and sand.local:5037

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