Everything else in the file gates egress, which left an everyday request unanswered: the agent starts a dev server inside the sandbox and a human wants to look at it. This puts a listener on the host that forwards into the container, so a browser, curl or an API tester reaches the server the sandboxed process runs. `--publish [ADDR:]HOST:CONTAINER`, config `publish` and AISB_PUBLISH, repeatable, with the cost named in the flag: it is ingress, so whatever that server serves is reachable from the address it binds. The default address is 127.0.0.1 because a bare podman -p binds every interface (measured) -- a port committed in a repo config must not quietly be a port on the LAN. publish_addr changes the default machine-wide; a spec with a non-loopback address warns on every run. The grammar is smaller than podman's on purpose: one port per entry, no ranges, no /udp, no `*` -- this list is reviewed line by line, and typing 0.0.0.0 is the whole warning. An address means three parts, so `127.0.0.1:8000` is refused rather than guessed. No -p alias: that flag belongs to the harness (claude -p prints). Fail closed, as elsewhere: --net none cannot forward a port and podman discards the mapping while starting the container anyway, so "it ran" would mean "nothing is listening"; a host port already in use is named before the container starts, not after a build; pick_port() will not hand the gate a port this run publishes. The container is labelled with the ports it was given, and doctor reports them. Inside, publish_rules() accepts tcp on the published ports in the input chain and never the output one. Measured (docs/networking.md): pasta delivers the connection over loopback, so that rule counts 0 packets on the reference host; it is there for a host or a bridge network that delivers on the interface, and so that `nft list table inet aisb` answers "what can reach this container". The ports are validated again inside the entrypoint, because a ruleset is not somewhere to splice an unvalidated string. And the measurement that makes a configured preview look broken: a server bound to 127.0.0.1 inside answers inside and refuses the published port outside, so dev servers must bind 0.0.0.0 -- both skills say so in the change that writes the port. Tests. Unit: the grammar and every port list that looks right and is not, config/env/flag precedence and dedup, the canonical spec in --print-policy, the `# ingress:` header, the flags reaching the invocation before the image, egress enforcement byte-identical with a port open, which chain the rule lands in and that a non-numeric port never reaches nft as code. e2e (tests/e2e/13_publish.sh): one real container with enforce=nft where the host gets its 200 and the same run still refuses a domain outside the allowlist at 403, the server runs as uid 1000 with no capabilities so it cannot widen its own port, and the host listener is gone when the container exits. LLM-Generated: true
11 KiB
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:<port> — an ordinary allow entry |
egress |
| inbound | your browser on the host reaches a server the sandbox started | publish = <port> / --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> (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:
- The ruleset. Only the gate's port is routable through that address by default. Add:
(IP entries become direct netfilter rules — the right shape for a non-HTTP protocol.)
allow = 100.64.0.1:5432 # postgres published by docker compose on the host - The proxy variables. When a gate is in use, the container gets
HTTP(S)_PROXY/ALL_PROXYandNO_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:<port> |
| HTTP on 80, 443 or 8443 | allow = 100.64.0.1:<port> (the gate relays it) |
| HTTP on any other port (3000, 8000, 5173, 9200…) | allow = 100.64.0.1:<port> 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:
# .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:
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:
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:8000is refused rather than guessed at. --net nonerefuses 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 = <other>:<container>rather than telling the user to go findssoutput. - No
-pflag.-ppasses through to the harness (claude -pprints), which is deliberate. - No
*. podman's shorthand for "every interface" is refused; write0.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.shasserts 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_ADMINand 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".
# .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.0in a repo config. It changes the default for every barepublishline in that directory, which is the same mistake wearing a different hat. It belongs in~/.config/ai-sandbox/configif anywhere.- a port for a service the project does not actually run, "in case".
Verify it, in this order
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):
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"
- Run the server on the host. The project mount is their disk: the agent edits, the host's
npm run dev/runserverpicks 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. - 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. - Split the loop: the agent builds and unit-tests in the sandbox, writes a static export or a
make previewtarget, 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 statusandgate logsshow a long-lived one.pick_port()skips ports this run publishes, so apublish = 18081does not lose the run. NO_PROXYcoveringlocalhostis correct and must stay — tooling that refuses to proxy the container's own loopback (dev databases, auth servers, vite HMR) breaks otherwise.--no-gateopens DNS inside the container; the mapped loopback address still needs itsallowline.--net nonehas no interface at all: neither direction works, which makes it the right mode formake teston a toolchain that is already installed — and the reason it refuses--publish.ai-sandbox doctorreports the resolvedpublishlines and warns about a non-loopback bind or a host port already in use. It never binds anything itself.