Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ See [Git remotes between host and sandbox](doc/GIT_REMOTES.md) for the full work
- [Sandbox profiles](doc/CONFIGURATION.md#profiles)
- [Non-interactive oneshot runs](doc/ONESHOT.md)
- [Network filtering](doc/NETWORK_FILTERING.md)
- [Exposing host services to a sandbox](doc/HOST_SERVICES.md)
- [Architecture and trade-offs](doc/ARCHITECTURE.md)
- [Claude Code setup](doc/CLAUDE_CODE_HOWTO.md)
- [Troubleshooting](doc/TROUBLESHOOTING.md)
3 changes: 3 additions & 0 deletions cmd/sand/HELP.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ sand new [flags] [SANDBOX-NAME]
- `-e, --env-file` _`<file-path>`_ - legacy env file path used when no default profile is configured (default: `.env`)
- `--rm` - remove the sandbox after the command terminates
- `--allowed-domains-file` _`<file-path>`_ - path to allowed-domains.txt file for DNS egress filtering (overrides the init image default)
- `--host-port` _`<port>`_ - expose a host-loopback TCP port to the sandbox at 127.0.0.1:<port> (repeatable)
- `--mount` _`<source=...,target=...[,readonly]>`_ - bind mount a host directory (can be specified multiple times)
- `--clone-mount` _`<source=...,target=...[,readonly]>`_ - copy-on-write clone a host directory and bind mount the clone (can be specified multiple times)
- `--cpu` _`2`_ - number of CPUs to allocate to the container (default: `2`)
Expand Down Expand Up @@ -83,6 +84,7 @@ sand oneshot [flags] <PROMPT>
- `-e, --env-file` _`<file-path>`_ - legacy env file path used when no default profile is configured (default: `.env`)
- `--rm` - remove the sandbox after the command terminates
- `--allowed-domains-file` _`<file-path>`_ - path to allowed-domains.txt file for DNS egress filtering (overrides the init image default)
- `--host-port` _`<port>`_ - expose a host-loopback TCP port to the sandbox at 127.0.0.1:<port> (repeatable)
- `--mount` _`<source=...,target=...[,readonly]>`_ - bind mount a host directory (can be specified multiple times)
- `--clone-mount` _`<source=...,target=...[,readonly]>`_ - copy-on-write clone a host directory and bind mount the clone (can be specified multiple times)
- `--cpu` _`2`_ - number of CPUs to allocate to the container (default: `2`)
Expand Down Expand Up @@ -130,6 +132,7 @@ sand exec [flags] <SANDBOX-NAME> <ARG>...
- `-e, --env-file` _`<file-path>`_ - legacy env file path used when no default profile is configured (default: `.env`)
- `--rm` - remove the sandbox after the command terminates
- `--allowed-domains-file` _`<file-path>`_ - path to allowed-domains.txt file for DNS egress filtering (overrides the init image default)
- `--host-port` _`<port>`_ - expose a host-loopback TCP port to the sandbox at 127.0.0.1:<port> (repeatable)
- `--mount` _`<source=...,target=...[,readonly]>`_ - bind mount a host directory (can be specified multiple times)
- `--clone-mount` _`<source=...,target=...[,readonly]>`_ - copy-on-write clone a host directory and bind mount the clone (can be specified multiple times)
- `--cpu` _`2`_ - number of CPUs to allocate to the container (default: `2`)
Expand Down
73 changes: 73 additions & 0 deletions doc/HOST_SERVICES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# Exposing host services to a sandbox

The `--host-port` flag on `sand new` (repeatable) makes a TCP service bound to
your Mac's loopback (`127.0.0.1:<port>`) reachable from inside a sandbox at
`http://host.sand:<port>/`.

## Example: Figma MCP

The Figma desktop app exposes an MCP server at `http://127.0.0.1:3845/mcp` on
your Mac. Expose it to a sandbox:

```sh
sand new --host-port 3845 -a claude
```

Inside the sandbox:

```sh
curl -v http://host.sand:3845/mcp
```

Configure the agent's MCP client to use the same URL. For Claude Code:

```sh
claude mcp add --transport http figma http://host.sand:3845/mcp
```

Multiple ports:

```sh
sand new --host-port 3845 --host-port 5173
```

## How it works

Apple's `container` CLI puts each sandbox on a vmnet bridge with its own IP.
From inside the sandbox, `127.0.0.1` is the sandbox itself, not your Mac;
your Mac is the bridge gateway (typically `192.168.64.1`).

For each requested port `--host-port` does the following:

1. The `sand` daemon starts a TCP forwarder listening on the sandbox's
bridge gateway IP. The listener is scoped to the bridge interface only
(not `0.0.0.0`), so other machines on your LAN cannot reach it. The
forwarder targets `127.0.0.1:<port>` on the Mac.
2. The forwarder is HTTP-aware: when a client speaks HTTP/1.x it rewrites
the `Host:` header to `127.0.0.1:<port>` on the way to the upstream.
That keeps servers that validate `Host` (Figma's MCP among them) happy
without any client-side workarounds. Non-HTTP traffic is forwarded as
plain TCP.
3. An `/etc/hosts` entry is added inside the sandbox mapping `host.sand`
to the gateway IP, so `http://host.sand:<port>/` Just Works.
4. Optionally, a best-effort `iptables` DNAT is installed inside the
sandbox so `127.0.0.1:<port>` is transparently redirected to
`host.sand:<port>`. Apple's container runtime currently does not grant
`CAP_NET_ADMIN`, so this step usually fails silently; the host.sand
path remains the supported entry point.

Forwarders, the `/etc/hosts` entry, and any iptables rules are torn down
when the sandbox stops or is removed.

## Security

`--host-port` is opt-in per port, per sandbox. It does, by design, punch a
hole in the sandbox's network isolation — comparable in trust to
`--ssh-agent` forwarding. Only forward ports you would already trust the
sandbox to reach on your machine.

## Interaction with `--allowed-domains-file`

The eBPF egress filter installed by `--allowed-domains-file` already permits
RFC1918 destinations (including the bridge gateway IP), so no additional
allowlist entries are required.
1 change: 1 addition & 0 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ type SandboxCreationFlags struct {
EnvFile string `short:"e" default:".env" placeholder:"<file-path>" help:"legacy env file path used when no default profile is configured"`
Rm bool `help:"remove the sandbox after the command terminates"`
AllowedDomainsFile string `placeholder:"<file-path>" help:"path to allowed-domains.txt file for DNS egress filtering (overrides the init image default)"`
HostPort []int `name:"host-port" placeholder:"<port>" help:"expose a host-loopback TCP port to the sandbox at 127.0.0.1:<port> (repeatable)"`
Mount []string `sep:"none" placeholder:"<source=...,target=...[,readonly]>" help:"bind mount a host directory (can be specified multiple times)"`
CloneMount []string `sep:"none" placeholder:"<source=...,target=...[,readonly]>" help:"copy-on-write clone a host directory and bind mount the clone (can be specified multiple times)"`
CPU int `help:"number of CPUs to allocate to the container" default:"2"`
Expand Down
1 change: 1 addition & 0 deletions internal/cli/new_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ func (c *NewCmd) Run(k *kong.Kong, cctx *CLIContext) error {
Agent: c.Agent,
SSHAgent: c.SSHAgent,
AllowedDomains: allowedDomains,
HostPorts: append([]int(nil), c.HostPort...),
Mounts: c.Mount,
CloneMounts: c.CloneMount,
SharedCaches: cctx.SharedCaches,
Expand Down
1 change: 1 addition & 0 deletions internal/cli/oneshot_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ func (c *OneshotCmd) Run(cctx *CLIContext) error {
Agent: c.Agent,
SSHAgent: c.SSHAgent,
AllowedDomains: allowedDomains,
HostPorts: append([]int(nil), c.HostPort...),
Mounts: c.Mount,
CloneMounts: c.CloneMount,
SharedCaches: cctx.SharedCaches,
Expand Down
5 changes: 5 additions & 0 deletions internal/daemon/daemon_client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"fmt"
"net"
"os"
"path/filepath"
Expand Down Expand Up @@ -248,6 +249,7 @@ func TestCreateSandboxOptsProtoRoundTrip(t *testing.T) {
Username: "dev",
Uid: "501",
AllowedDomains: []string{"example.com", "api.example.com"},
HostPorts: []int{3845, 5173},
Mounts: []string{"source=/host,target=/container,readonly"},
CloneMounts: []string{"source=/src/data,target=/data,readonly"},
SharedCaches: sandtypes.SharedCacheConfig{Mise: true, APK: true},
Expand All @@ -273,6 +275,9 @@ func TestCreateSandboxOptsProtoRoundTrip(t *testing.T) {
if strings.Join(got.AllowedDomains, ",") != strings.Join(opts.AllowedDomains, ",") {
t.Fatalf("round trip allowed domains = %+v, want %+v", got.AllowedDomains, opts.AllowedDomains)
}
if fmt.Sprintf("%v", got.HostPorts) != fmt.Sprintf("%v", opts.HostPorts) {
t.Fatalf("round trip host ports = %+v, want %+v", got.HostPorts, opts.HostPorts)
}
if strings.Join(got.Mounts, ",") != strings.Join(opts.Mounts, ",") {
t.Fatalf("round trip mounts = %+v, want %+v", got.Mounts, opts.Mounts)
}
Expand Down
24 changes: 24 additions & 0 deletions internal/daemon/daemon_grpc_streams.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ func createSandboxOptsToProto(opts CreateSandboxOpts) *daemonpb.CreateSandboxReq
Username: opts.Username,
Uid: opts.Uid,
AllowedDomains: append([]string(nil), opts.AllowedDomains...),
HostPorts: intsToInt32s(opts.HostPorts),
Mounts: append([]string(nil), opts.Mounts...),
CloneMounts: append([]string(nil), opts.CloneMounts...),
SharedCaches: &daemonpb.SharedCacheConfig{
Expand All @@ -119,6 +120,7 @@ func createSandboxOptsFromProto(req *daemonpb.CreateSandboxRequest) CreateSandbo
Username: req.GetUsername(),
Uid: req.GetUid(),
AllowedDomains: append([]string(nil), req.GetAllowedDomains()...),
HostPorts: int32sToInts(req.GetHostPorts()),
Mounts: append([]string(nil), req.GetMounts()...),
CloneMounts: append([]string(nil), req.GetCloneMounts()...),
CPUs: int(req.GetCpus()),
Expand All @@ -132,3 +134,25 @@ func createSandboxOptsFromProto(req *daemonpb.CreateSandboxRequest) CreateSandbo
}
return opts
}

func intsToInt32s(in []int) []int32 {
if len(in) == 0 {
return nil
}
out := make([]int32, len(in))
for i, v := range in {
out[i] = int32(v)
}
return out
}

func int32sToInts(in []int32) []int {
if len(in) == 0 {
return nil
}
out := make([]int, len(in))
for i, v := range in {
out[i] = int(v)
}
return out
}
2 changes: 2 additions & 0 deletions internal/daemon/daemon_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,7 @@ type CreateSandboxOpts struct {
Uid string `json:"uid,omitempty"`

AllowedDomains []string `json:"allowedDomains,omitempty"`
HostPorts []int `json:"hostPorts,omitempty"`
Mounts []string `json:"mounts,omitempty"`
CloneMounts []string `json:"cloneMounts,omitempty"`
SharedCaches sandtypes.SharedCacheConfig `json:"sharedCaches,omitempty"`
Expand Down Expand Up @@ -707,6 +708,7 @@ func (d *Daemon) createSandbox(ctx context.Context, opts CreateSandboxOpts, prog
Username: opts.Username,
Uid: opts.Uid,
AllowedDomains: opts.AllowedDomains,
HostPorts: opts.HostPorts,
Mounts: opts.Mounts,
CloneMounts: opts.CloneMounts,
SharedCaches: opts.SharedCaches,
Expand Down
14 changes: 12 additions & 2 deletions internal/daemon/daemonpb/daemon.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions internal/daemon/daemonpb/daemon.proto
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ message CreateSandboxRequest {
int32 memory = 13;
string profile_name = 14;
repeated string clone_mounts = 15;
repeated int32 host_ports = 16;
}

message CreateSandboxResponse {
Expand Down
Loading
Loading