# Ports and loopback Two separate problems wear the same name. Get the direction right before touching a config. | | what it is | how | direction | |---|---|---|---| | **outbound** | the sandboxed process reaches a service running on your machine (dev postgres, redis, a mock API, a local model server) | `allow = 100.64.0.1:` — an ordinary allow entry | egress | | **inbound** | your browser on the host reaches a server the sandbox started | `publish = ` / `--publish` | ingress | They are not the same setting and one does not imply the other. `allow = 100.64.0.1:5432` does not open a port; `publish = 5432:5432` does not allow egress. Getting these backwards is how a database ends up reachable from the wrong side. Container-to-container is a third case, already measured on the reference host: two containers on a shared user-defined network **cannot reach each other**, so do not design a sidecar proxy, a service-mesh sandbox or a "sandbox plus its own database container" topology. The database runs on the host (compose) and is reached as in §1. ## 1. Outbound: a service on your machine With `--net allow` the container is started as `--network pasta:--map-host-loopback,` (`gate_addr`, default `100.64.0.1`). Inside the container, **`100.64.0.1:PORT` lands on the host's `127.0.0.1:PORT`**. `127.0.0.1` inside the container is the container itself and almost never what you meant. Two layers have to agree, and this is where every "it still does not work" comes from: 1. **The ruleset.** Only the gate's port is routable through that address by default. Add: ``` allow = 100.64.0.1:5432 # postgres published by docker compose on the host ``` (IP entries become direct netfilter rules — the right shape for a non-HTTP protocol.) 2. **The proxy variables.** When a gate is in use, the container gets `HTTP(S)_PROXY`/`ALL_PROXY` and `NO_PROXY=localhost,127.0.0.1,::1`. A client that honours them sends the request to the gate, which allows the destination if it is in the allowlist **and the port is one of 80, 443, 8443**. So: | the service | what the config needs | |---|---| | postgres, redis, amqp, ssh, a raw socket — anything that ignores proxy variables | `allow = 100.64.0.1:` | | HTTP on 80, 443 or 8443 | `allow = 100.64.0.1:` (the gate relays it) | | HTTP on any other port (3000, 8000, 5173, 9200…) | `allow = 100.64.0.1:` **and** a proxy bypass for that address, and measure which of the two `NO_PROXY` values wins | The bypass, and the honest way to confirm it: ```ini # .ai-sandbox/config -- the dev API on the host is not reachable through the egress gate env = NO_PROXY=100.64.0.1,localhost,127.0.0.1,::1 env = no_proxy=100.64.0.1,localhost,127.0.0.1,::1 ``` ai-sandbox sets `NO_PROXY` with `--env` *before* the env file, so the interaction between that and an env-file value is podman's flag handling, not ai-sandbox's. Do not reason about it: run `ai-sandbox --dry-run`, look at the order of the flags, and probe for real: ```bash ai-sandbox --allow 100.64.0.1:3000 --shell curl -sS -m 3 -o /dev/null -w '%{http_code}\n' http://100.64.0.1:3000/health env | grep -i -E 'proxy|no_proxy' ``` Then point the app at the mapped address without putting a secret in a committed file — a URL with no password in it is not a secret: ```ini env = DATABASE_URL=postgresql://dev@100.64.0.1:5432/app_dev ``` If `100.64.0.1` collides with a real network the project must reach, change `gate_addr` (and remember the gate itself is reached at that address too). ## 2. Inbound: publishing a port the sandbox listens on `--publish` (config key `publish`, repeatable) forwards a host port into the container, so a client on the host — a browser, curl, an API tester — reaches a server the sandboxed process runs. It is the only ingress setting in the tool, and the ruleset names the ports it opened (`nft list table inet aisb` → the `aisb-publish` rule). Shapes, and what they bind. **The default address is `127.0.0.1`**, because a bare podman `-p` binds every interface (measured) and a port in a committed config must not quietly be a port on the LAN: | spec | host side | container side | |---|---|---| | `8000` | `127.0.0.1:8000` | `8000` | | `18080:8000` | `127.0.0.1:18080` | `8000` | | `0.0.0.0:18080:8000` | every interface — **warns on every run** | `8000` | | `[::1]:18080:8000` | ipv6 loopback | `8000` | Rules the parser enforces, and the reasons to repeat them to the user: * **One port per entry.** No ranges, no `/udp`: this list is reviewed line by line, and a range is a line nobody reads. * **An address means three parts.** `127.0.0.1:8000` is refused rather than guessed at. * **`--net none` refuses to publish.** Measured: podman discards port mappings there with one line on its own stderr and starts the container anyway, so "it ran" would mean "nothing is listening". * **A busy host port is named before the container starts**, not after a build. Suggest `publish = :` rather than telling the user to go find `ss` output. * **No `-p` flag.** `-p` passes through to the harness (`claude -p` prints), which is deliberate. * **No `*`.** podman's shorthand for "every interface" is refused; write `0.0.0.0`. Typing the address is the warning. ### The one thing that makes a published port look broken **The server has to bind `0.0.0.0` inside the container.** Measured: a server bound to `127.0.0.1` inside answers `200` from inside and refuses the published port from the host, because pasta delivers the connection to the container's own address, not to its loopback. So the config is not enough — the project's dev command has to cooperate: | stack | what the preview command needs | |---|---| | vite | `npm run dev -- --host 0.0.0.0` | | Next / webpack dev servers | `--hostname 0.0.0.0` | | Django | `runserver 0.0.0.0:8000` | | Flask | `flask --app … run --host 0.0.0.0 -p 8000` | | uvicorn / FastAPI | `uvicorn --host 0.0.0.0 --port 8000 …` | | a bare static server | `python3 -m http.server 8000 --bind 0.0.0.0` | A dev-server config file that pins `host: 127.0.0.1` (`vite.config.*`, `webpack-dev-server`, `gunicorn -b 127.0.0.1:8000`) needs that changed, or the preview command overrides it on the command line. Say which, or the user will conclude the sandbox is broken. ### What publishing does not do * **It does not weaken egress.** The same run answers the host and still refuses every domain outside the allowlist; the entrypoint still drops all capabilities before the command runs. `tests/e2e/13_publish.sh` asserts both halves in one container, so this is not a claim to re-derive. * **It does not let the agent open more ports.** The mapping is decided on the host before the container starts; inside, the command has no `CAP_NET_ADMIN` and cannot add or widen one. * **It does not survive the run.** The listener is pasta's, removed with the container. If a port is still bound afterwards, something was started with `--keep` — `ai-sandbox ps`, `ai-sandbox clean`. ### What to write, and what not to Loopback publishing is a legitimate project setting: it says "this project runs a server on this port, and previewing it is part of working on it". ```ini # .ai-sandbox/config publish = 8000 # the app: http://127.0.0.1:8000 publish = 18080:3000 # the storybook, on a host port that is free ``` Comment each one with what runs there — a port without a name is a port a later reader cannot remove. Do **not** commit: * a **non-loopback** address (`0.0.0.0:…`, `192.168.x.x:…`). It warns on every run because it means what it says: anything that can route to the machine can reach whatever the sandbox serves. If the user genuinely needs a phone on their WiFi to reach the preview, that is a per-run flag, said out loud, not a line in a file that outlives the run. * `publish_addr = 0.0.0.0` in a repo config. It changes the default for every bare `publish` line in that directory, which is the same mistake wearing a different hat. It belongs in `~/.config/ai-sandbox/config` if anywhere. * a port for a service the project does not actually run, "in case". ### Verify it, in this order ```bash ai-sandbox --dry-run # '# ingress: publish=[127.0.0.1:8000:8000]' and # '--publish 127.0.0.1:8000:8000' in the invocation ai-sandbox --print-policy | grep '^publish=' # canonical addr:host:container, loopback unless you # wrote an address ``` Then the only check that answers the real question, with the user watching (a real container; the port exists only while it runs): ```bash ai-sandbox --publish 8000 --shell # inside: python3 -m http.server 8000 --bind 0.0.0.0 >/tmp/s.log 2>&1 & # inside: curl -sS -o /dev/null -m2 -w 'inside=%{http_code}\n' http://127.0.0.1:8000/ # host: curl -sS -o /dev/null -m3 -w 'host=%{http_code}\n' http://127.0.0.1:8000/ ``` `host=200` is the result. `host=000` with `inside=200` is the bind address, not the sandbox — go back to the table above. A refusal naming the port means something else holds it. `--print-entrypoint | grep -A6 'chain input'` shows the accept rule shape; on the reference host pasta forwards over loopback, so that rule's packet counter stays at 0 while the port serves — the rule is the audit trail and the portability guarantee, not what carries the traffic there. ### When the honest answer is still "don't publish" 1. **Run the server on the host.** The project mount *is* their disk: the agent edits, the host's `npm run dev` / `runserver` picks it up, the browser hits a normal host port. Nothing is published and nothing is opened. Still the right answer when the agent does not need to drive the server — and that is most "preview the app" requests. 2. **Publish it**, when the server itself has to be the sandboxed process: the agent seeds a database it starts, runs a migration then serves, needs to watch the server's own logs, or the test *is* hitting the endpoint. Loopback, named in `.ai-sandbox/config`, with the bind address fixed in the same change. 3. **Split the loop**: the agent builds and unit-tests in the sandbox, writes a static export or a `make preview` target, and the human runs that. Keeps the review step where it belongs. If the ask is "publish it on the LAN so I can check it on my phone", that is a per-run `--publish 0.0.0.0:18080:8000` with the warning read out, never the committed config — and if the user wants it permanently, say plainly that the sandbox is then a server on their network. ## 3. Notes that keep coming up * The gate binds the **host's loopback** only, on a free port at or above `gate_port` (18081); `ai-sandbox gate status` and `gate logs` show a long-lived one. `pick_port()` skips ports this run publishes, so a `publish = 18081` does not lose the run. * `NO_PROXY` covering `localhost` is correct and must stay — tooling that refuses to proxy the container's own loopback (dev databases, auth servers, vite HMR) breaks otherwise. * `--no-gate` opens DNS inside the container; the mapped loopback address still needs its `allow` line. * `--net none` has no interface at all: neither direction works, which makes it the right mode for `make test` on a toolchain that is already installed — and the reason it refuses `--publish`. * `ai-sandbox doctor` reports the resolved `publish` lines and warns about a non-loopback bind or a host port already in use. It never binds anything itself.