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
This commit is contained in:
@@ -2,10 +2,14 @@
|
||||
|
||||
Two separate problems wear the same name. Get the direction right before touching a config.
|
||||
|
||||
| | what it is | possible in `--net allow`? |
|
||||
|---|---|---|
|
||||
| **outbound** | the sandboxed process reaches a service running on your machine (dev postgres, redis, a mock API, a local model server) | **yes** — one allow entry, sometimes plus a proxy bypass |
|
||||
| **inbound** | your browser on the host reaches a server the sandbox started | **no** — the generated ruleset drops it; three workarounds below |
|
||||
| | 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
|
||||
@@ -66,54 +70,141 @@ 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: "I want to see the app the agent is building"
|
||||
## 2. Inbound: publishing a port the sandbox listens on
|
||||
|
||||
There is **no publish flag** (`--publish`/`-p` are not in `parse_args()`). You can push one through
|
||||
the escape hatch — `extra_args = -p 8000:8000` or `AISB_EXTRA_ARGS="-p 8000:8000"` — and then read
|
||||
the ruleset the entrypoint installs: the `input` chain is `policy drop` with room only for `lo`,
|
||||
`established,related` and a few icmpv6 types, so a fresh inbound connection is dropped inside the
|
||||
container. Confirm on the machine instead of trusting that reading:
|
||||
`--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).
|
||||
|
||||
```bash
|
||||
AISB_EXTRA_ARGS="-p 8000:8000" ai-sandbox --shell
|
||||
# inside: python3 -m http.server 8000 &
|
||||
# host: curl -sS -m 3 -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8000/
|
||||
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".
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
Expected on a stock host in `--net allow`: no answer. If yours answers, that is a fact worth adding
|
||||
to `docs/networking.md` — measure and report it, do not build the workflow on it.
|
||||
Comment each one with what runs there — a port without a name is a port a later reader cannot
|
||||
remove. Do **not** commit:
|
||||
|
||||
What to tell the user instead, in order of preference:
|
||||
* 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, nothing is opened, egress stays enforced. This is the answer for 90% of "preview the
|
||||
app" requests.
|
||||
2. **One named preview run**, when the server itself must be the sandboxed process (the agent needs
|
||||
to drive it, seed it, watch its logs):
|
||||
```bash
|
||||
AISB_EXTRA_ARGS="-p 8000:8000" ai-sandbox --net full -- npm run dev
|
||||
```
|
||||
Say out loud what this is: `--net full` means that run has **no egress policy**. It is a
|
||||
preview of a trusted project, not a project setting — which is why it never belongs in
|
||||
`.ai-sandbox/config`.
|
||||
3. **Split the loop**: the agent builds and unit-tests in the sandbox, writes a screenshotable
|
||||
static export or a `make preview` target, and the human runs that target. This keeps the review
|
||||
step where it belongs.
|
||||
`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 user genuinely needs publish-with-enforcement, the honest answer is that it is not
|
||||
implemented: a `--publish PORT` would need an `input` accept rule per port in `gen_rules()`, the
|
||||
port list carried in the session policy, and a threat-model line about who may connect. Offer to
|
||||
write it as a change to `bin/ai-sandbox` — as its own phase, with a `tests/e2e` probe — rather than
|
||||
hacking `extra_args` into a committed config and calling it secure.
|
||||
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.
|
||||
`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.
|
||||
`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.
|
||||
|
||||
@@ -11,9 +11,10 @@ built-in default < ~/.config/ai-sandbox/{config,allow} < ./.ai-sandbox/{conf
|
||||
< AISB_* environment < command-line flag
|
||||
```
|
||||
|
||||
`allow`, `env`, `mount`, `install`, `label` **accumulate** from every source (deduped); every other
|
||||
key replaces. So a repo file cannot revoke a user-wide `allow` line — say so if the user expects a
|
||||
repo config to tighten their own machine.
|
||||
`allow`, `env`, `mount`, `install`, `label`, `publish` **accumulate** from every source (deduped);
|
||||
every other key replaces. So a repo file cannot revoke a user-wide `allow` line — say so if the user
|
||||
expects a repo config to tighten their own machine. It also cannot *un*publish a port the user's
|
||||
own config opens.
|
||||
|
||||
## The container, for orientation
|
||||
|
||||
@@ -43,6 +44,7 @@ directories — the host one is bind-mounted read-only onto the container one at
|
||||
| `home_cache` | `1` | `0` = throwaway tmpfs home (also `--ephemeral-home`) |
|
||||
| `gate_addr` | `100.64.0.1` | the address that *means* the host's loopback inside the container |
|
||||
| `gate_port` | `18081` | preferred first port; next free is used |
|
||||
| `publish_addr` | `127.0.0.1` | the host address a bare `publish = 8000` binds. Machine-wide setting: put it in the user config, never in a repo file, and do not widen it for the user's convenience |
|
||||
| `extra_args` | empty | appended verbatim to `podman run`, word-split. Escape hatch: do not commit it |
|
||||
| `pi_config` | `1` | bridge `~/.pi/agent/{models,settings}.json` with the credential as `$VAR` |
|
||||
| `pi_token_env` | `AI_API_TOKEN` | the variable the bridged config reads |
|
||||
@@ -57,6 +59,7 @@ directories — the host one is bind-mounted read-only onto the container one at
|
||||
| `mount` | — | repeatable; `SRC[:DEST][:ro\|rw]`, default `DEST=/mnt/<basename>` |
|
||||
| `install` | — | repeatable; apt packages, each distinct set is its own image tag |
|
||||
| `label` | — | repeatable; `K=V` on the container |
|
||||
| `publish` | — | repeatable; **ingress**: `[ADDR:]HOST:CONTAINER` forwarded into the container. One port per entry, no ranges, no `/udp`; an address needs three parts. `8000`, `18080:3000`, `0.0.0.0:18080:3000` (LAN-visible: warned, do not commit) |
|
||||
|
||||
Anything else warns (`unknown key`) and is ignored.
|
||||
|
||||
@@ -64,6 +67,7 @@ Anything else warns (`unknown key`) and is ignored.
|
||||
|
||||
```
|
||||
egress --net none|allow|full --allow HOST[:PORT] --allow-file F --proxy HOST:PORT --no-gate
|
||||
ingress --publish [ADDR:]HOST:CONTAINER (no `-p`: that flag belongs to the harness)
|
||||
files --mount SRC[:DEST][:ro] --no-cwd --workdir DIR|auto --read-only
|
||||
--home-cache --ephemeral-home --agents | --no-agents --agents-dir PATH
|
||||
env --env NAME --env NAME=VALUE --env-file F (F should be 0600)
|
||||
@@ -128,6 +132,10 @@ fails loudly, which is the feature.
|
||||
that with `--workdir /tmp/...`; the tmpfs covers it.
|
||||
* The host path may not contain `:` at all — podman cannot parse such a mount spec, and
|
||||
ai-sandbox refuses rather than passing the error along.
|
||||
* `publish` is the only **ingress** setting there is, and it is not an allowlist entry. `allow =
|
||||
100.64.0.1:5432` lets the sandbox reach a service on your machine; `publish = 5432:5432` lets your
|
||||
machine reach a port inside the container. Confusing the two is how a database ends up exposed.
|
||||
Read `references/port-and-loopback.md` before writing either.
|
||||
* Nothing is inherited but `TERM LANG LC_ALL LC_CTYPE TZ` (plus `HOME`, set to the container home,
|
||||
`AISB=1`, `AISB_SESSION`). A tool that needs `PATH`, a dotfile or anything else from your real home
|
||||
needs an explicit grant.
|
||||
|
||||
Reference in New Issue
Block a user