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:
2026-10-30 22:53:57 -04:00
parent ac2f310d10
commit 144e322391
21 changed files with 993 additions and 79 deletions
+33 -13
View File
@@ -1,6 +1,6 @@
---
name: right-sandbox
description: Analyzes a project directory and writes that project's ai-sandbox policy — .ai-sandbox/allow plus .ai-sandbox/config (egress allowlist, environment variable names, bind mounts, working directory, resource limits, harness, extra image packages, access to services on the host) — so that a plain `ai-sandbox` run in the directory is the correct, reviewable invocation. Use when the user asks to set up, configure, write, fix, review or tighten ai-sandbox or the sandbox for a project, wants a .ai-sandbox config committed with the code, or asks why a sandboxed agent cannot reach a package registry, a database, a localhost port or its model gateway. Writes policy files only — never builds, never starts a container, never puts a credential value in a file.
description: Analyzes a project directory and writes that project's ai-sandbox policy — .ai-sandbox/allow plus .ai-sandbox/config (egress allowlist, environment variable names, bind mounts, published ports so a local web server can be run and previewed from the host, working directory, resource limits, harness, extra image packages, access to services on the host) — so that a plain `ai-sandbox` run in the directory is the correct, reviewable invocation. Use when the user asks to set up, configure, write, fix, review or tighten ai-sandbox or the sandbox for a project, wants a .ai-sandbox config committed with the code, wants to run a dev server or expose a port from inside the sandbox, or asks why a sandboxed agent cannot reach a package registry, a database, a localhost port or its model gateway. Writes policy files only — never builds, never starts a container, never puts a credential value in a file.
---
# right-sandbox
@@ -73,6 +73,7 @@ made deliberately.
| package installs | lockfiles (`pnpm-lock.yaml`, `uv.lock`, `Cargo.lock`, `go.sum`, `Gemfile.lock`, `pom.xml`…) | `allow` entries for the registries that ecosystem really contacts — see the table in `references/settings-map.md` |
| model gateway | `~/.pi/agent/models.json` `baseUrl` host, or the base URL the user's harness is configured with | `allow = <gateway host>`; without it a bridged pi starts and cannot answer |
| a database / queue / cache | `docker-compose*.yml` published ports, `.env` URLs, `DATABASE_URL` | `allow = 100.64.0.1:<published port>` + an env var that points the app at `100.64.0.1` — see `references/port-and-loopback.md` |
| a server the project runs, to be seen from the host (dev server, storybook, API docs, a preview the agent should be able to start *and* drive) | `scripts.dev` / `start` in `package.json`, `vite.config.*`, `manage.py runserver`, `uvicorn …`, a `Dockerfile` `EXPOSE`, the port in the project's own README | `publish = <port>` (loopback by default) — ingress, not an `allow` line. `references/port-and-loopback.md` has the bind-`0.0.0.0` requirement, which is what makes the difference between configured and working |
| a non-HTTP backend | pinned host:port (a LAN inference box, an internal API) | `allow = <ip or cidr>:<port>` (IP/CIDR become netfilter rules; domains go through the gate) |
| credential the harness needs | `.env.example`, CI `secrets.*`, `--env NAME` in the user's launcher | `env = NAME` (name only), and list what the user must export |
| reference material / sibling repo | imports or docs that read `../something` | `mount = ../something:/something:ro` |
@@ -128,6 +129,10 @@ env = @./.ai-sandbox/env
# apt packages the test suite needs that the image lacks
install = postgresql-client
# ingress: the dev server this project runs, previewable on the host at
# http://127.0.0.1:8000 (loopback only; the server binds 0.0.0.0 inside)
publish = 8000
```
`env = @path` is the config-file form of `--env-file` (`--env-file F` becomes the entry `@F`), so
@@ -150,6 +155,9 @@ to put in it.
opened**; weaker for CDNs and rotating IPs. `--proxy host:port` uses a proxy you already run
(default port 3128). Neither belongs in a committed config without a comment justifying it.
* An empty allowlist in `allow` mode is a hard error. Never "fix" that by widening net.
* `publish` is the one **ingress** key and it is not an allow entry: `allow = 100.64.0.1:5432` lets
the sandbox reach your machine, `publish = 5432:5432` lets your machine reach the sandbox. Writing
the wrong one of these is how a service ends up open on the wrong side.
## 5. Verify (no builds, no containers, no network)
@@ -176,7 +184,10 @@ Check, in this order, and fix the files instead of explaining the output:
4. `mounts=` and `agents=` are what you wrote and nothing more.
5. the dry run shows `--network pasta:--map-host-loopback,100.64.0.1`, `--cap-drop=ALL`,
`--security-opt no-new-privileges`, `--userns keep-id:…` and no secret values.
6. `--print-policy | grep -i` for each credential name from §1: the *name* may appear, a *value*
6. if you wrote `publish`: the `# ingress: publish=[…]` line shows `127.0.0.1` on every entry unless
the user asked for an address, and the project's dev command binds `0.0.0.0` — a published port
pointing at a `127.0.0.1`-only server is configured, silent and useless.
7. `--print-policy | grep -i` for each credential name from §1: the *name* may appear, a *value*
must not.
Then, with the user watching (this builds the image the first time and starts a container):
@@ -184,12 +195,14 @@ Then, with the user watching (this builds the image the first time and starts a
```bash
ai-sandbox --shell # egress, mounts, env present
ai-sandbox --net none -- make test # the offline half of the toolchain
ai-sandbox doctor # host readiness
ai-sandbox doctor # host readiness, incl. the ports it would publish
```
Probe the two or three endpoints the project actually needs from inside (`curl -sS -m 5 -o
/dev/null -w '%{http_code}\n' https://registry.npmjs.org/`) rather than assuming an allowlist entry
means a working toolchain.
means a working toolchain. If you wrote `publish`, probe the other direction too, from the host,
while a sandbox is still running (`references/port-and-loopback.md` §2 has the two-shell probe): a
published port nobody answered during the review is not a verified setting.
## 6. Report
@@ -198,19 +211,26 @@ Finish with exactly these five points, so the user can act without reading the f
1. **Files written** — paths, plus the `.gitignore` change if any.
2. **`cd <dir> && ai-sandbox`** is now the invocation (and the alias note if they use one).
3. **What they must export** first (`env =` names that are unset), and where values may go.
4. **What is deliberately not allowed** — the git remote first, then anything you declined to open
because the honest answer was "not without weakening the sandbox" (inbound port publishing is
usually on this list).
4. **What is deliberately not allowed, and what is open** — the git remote first, then anything you
declined to open because the honest answer was "not without weakening the sandbox". If you wrote
`publish`, name each port, its host address and what listens there: an open port belongs in the
report even though it was requested.
5. **What you verified and how** — the two preview commands and any live probe, with results.
## Ports, localhost, "the app must be reachable"
Read `references/port-and-loopback.md` before promising anything about ports. Short version: a
sandboxed process reaches *services on your machine* through the mapped loopback address
`100.64.0.1`, which needs an `allow` entry **and** a client that does not send that request to the
egress proxy; and a port the sandbox listens on is **not** reachable from the host in `--net allow`
mode, because the generated ruleset's input chain is `policy drop`. Verify on the machine rather
than repeating that from memory — the file has the probes.
Read `references/port-and-loopback.md` before promising anything about ports. Two directions, two
settings, and the wrong one is how a service ends up open on the wrong side:
* **the sandbox → a service on your machine**: `allow = 100.64.0.1:<port>` (the mapped loopback
address), plus a client that does not send that request to the egress proxy.
* **your machine → the sandbox**: `publish = <port>`, bound to `127.0.0.1` unless the spec names an
address. It is ingress: egress enforcement is untouched by it, and the agent inside cannot open or
widen a port of its own. The server has to bind `0.0.0.0` *inside* — that is the difference between
a configured preview and a working one, and it is measured, not folklore.
Verify on the machine rather than repeating any of this from memory: `--dry-run` shows
`# ingress: publish=[…]`, and `tests/e2e/13_publish.sh` is what the claims rest on.
## References