Files
ai-sandbox/.agents/skills/right-sandbox/references/port-and-loopback.md
T
ducoterra 144e322391 Add --publish: the one ingress control
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
2026-10-30 22:53:57 -04:00

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:

  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:<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: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 = <other>:<container> 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".

# .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

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"

  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.