add right-sandbox: write a project's own ai-sandbox policy

Turns a project directory into that project's policy, so a plain
`ai-sandbox` in the directory is the correct, reviewable invocation. It
writes the two files load_config() actually reads -- .ai-sandbox/allow
and .ai-sandbox/config -- and nothing else: no sandbox.yaml, no local
override, no invented schema, because a file nobody reads is a policy
nobody has.

scripts/survey.sh gathers the evidence read-only and prints no values:
variable NAMES, remote and registry HOSTS, service PORTS, with URL
credentials stripped before anything is printed -- the same rule
--dry-run, --print-policy and the session log hold themselves to. The
agent decides from that evidence, then verifies with --dry-run and
--print-policy rather than assuming an allowlist entry means a working
toolchain.

references/settings-map.md maps every accepted key and flag to its
default, plus per-ecosystem allowlists and the survey-to-policy cheat
sheet. references/port-and-loopback.md is the honest answer on ports:
outbound to a service on the machine via the mapped loopback address
100.64.0.1 (needs an allow entry and a client that does not hand the
request to the egress proxy), and why a port the sandbox listens on is
not reachable from the host in --net allow -- with probes, because that
half is a reading of gen_rules(), not a measurement.

Policy files only. Never builds, never starts a container, and never
writes a real credential: it writes .ai-sandbox/env.example and tells
the user what to put in the gitignored 0600 file.

LLM-Generated: true
This commit is contained in:
2026-10-29 08:37:51 -04:00
parent b273bfef09
commit 34f94e38be
4 changed files with 708 additions and 0 deletions
@@ -0,0 +1,119 @@
# Ports and loopback
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 |
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:
```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: "I want to see the app the agent is building"
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:
```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/
```
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.
What to tell the user instead, in order of preference:
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.
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.
## 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.
* `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.