expose --host-port#103
Conversation
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>
|
Relevant open issue from apple/container: apple/container#1320 ("[Request]: Option to prevent host access on internal networks")
From your chat transcript (thanks!) it looks like the agent realized this when describing how the daemon-side TCP forwarder works:
Also of note: In the discussion on that issue, they mention an upcoming 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. |
|
One potential issue I can see with the daemon-side TCP proxy approach: the host service listening on The concern is that the host service cannot distinguish: 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:
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. |
|
all i can say about 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 |
Add
--host-port: expose host loopback services to sandboxesProblem
Services bound to
127.0.0.1on the Mac (Figma's MCP server, local LLM gateways, dev API mocks) are unreachable from inside a sandbox. From the sandbox,127.0.0.1is itself; the Mac is the bridge gateway.--publishis the wrong direction (sandbox→host).--allowed-domains-fileis DNS-only.Solution
Inside the sandbox:
curl http://host.sand:3845/mcpreaches the service on the Mac.How
For each
--host-port:ipv4Gateway(not0.0.0.0— LAN can't reach it), forwards to127.0.0.1:<port>on the Mac.Host:to127.0.0.1:<port>before forwarding. Non-HTTP traffic = plain TCP pipe. WebSocket upgrades handled./etc/hostsin the sandbox mapshost.sand→ gateway IP.127.0.0.1:<port>redirect inside the sandbox. Apple's runtime doesn't grantCAP_NET_ADMINtoday, so this silently no-ops;host.sandis 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. testsinternal/cli/cli.go—--host-portflag, repeatableinternal/daemon/internal/boxer/boxer.go—setupHostPortsafter start hooksCreateSandboxRequest.host_ports(field 16)000010_host_portsdoc/HOST_SERVICES.mdTests
Forwarderproxy + HTTP Host rewriteManagerlifecycle (start/stop/cleanup)HostPortsgo test ./...passes.Manual test
Security
Opt-in per port, per sandbox. Listener scoped to bridge interface only. Comparable trust posture to
--ssh-agentforwarding. Documented indoc/HOST_SERVICES.md.Anticipated questions
0.0.0.0? Requires user to reconfigure every service they want to forward; many (Figma desktop) can't be reconfigured at all.Host(Figma, anything behind common dev frameworks) reject the request. Sniff-based detection keeps non-HTTP protocols untouched.--publish?--publishwraps Apple's container CLI, which only does container→host. This is the reverse.socatorssh -R? Both work but require manual setup per sandbox and don't tear down cleanly. This integrates with sand's lifecycle.