Files
ai-sandbox/.agents/skills/right-sandbox/SKILL.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

244 lines
16 KiB
Markdown

---
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, 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
Produce the two files ai-sandbox reads from a project, and nothing else:
| file | contents | committed |
|---|---|---|
| `.ai-sandbox/allow` | one egress target per line, `#` comments ok | yes — the policy gets reviewed with the code |
| `.ai-sandbox/config` | `key = value`: net, harness, limits, mounts, env names, image | yes |
| `.ai-sandbox/env` | optional `NAME=VALUE`, mode `600` | **never** — gitignore it |
`load_config()` reads `./.ai-sandbox/{config,allow}` (after `~/.config/ai-sandbox/{config,allow}`)
and nothing else, so do not invent `sandbox.yaml`, `local`, `env.json` or a `phases/` directory in
there — a file nobody reads is a policy nobody has.
Precedence for every key: built-in default < user config < repo config < `AISB_*` env < CLI flag.
The repeatable keys (`allow`, `env`, `mount`, `install`, `label`) **accumulate** across all five
sources rather than replacing, so a repo config adds to the user's, it does not silence it.
## What makes a config right
1. **Names, never values.** Both committed files end up in the repository, in `--print-policy` and
in a reviewer's diff. Credentials live in the environment or in `.ai-sandbox/env` (`chmod 600`,
gitignored); the config says `env = AI_API_TOKEN`. A literal `NAME=VALUE` is only for things
that are not secrets, e.g. `env = DATABASE_URL=postgresql://dev@100.64.0.1:5432/app`.
2. **Every key and flag must be real.** An unknown config key is warned about and ignored — a typo
is a silently missing policy, which is worse than an error. The authority is the installed
program: `ai-sandbox --help`, plus `cfg_read_kv()`'s key list in the script.
`references/settings-map.md` holds the list as of writing — check it, do not trust it.
Known trap: `project_dest` is accepted by the parser and read by nothing; the key is `workdir`.
3. **The git remote stays out.** No `github.com`, `gitlab.com`, `bitbucket.org` or ssh host in
`allow` unless the user asks after hearing the cost: an allowlisted remote plus a token or a
mounted `~/.ssh` lets a prompt-injected agent ship code nobody reviewed (threat model #1).
`git status`, `diff`, `log`, `blame` all work without it, and `ai-sandbox-autophase` commits and
pushes on the host precisely so that this line can stay out. If a dependency genuinely has to be
fetched over a git host, say which dependency in a comment above the line and prefer a
read-only, short-lived token.
4. **Escape hatches never go in a committed config**: `net = full`, `extra_args`,
`--allow-unenforced`, `--insecure-keep-root`. They are per-run decisions named to be
uncomfortable, and a repo file outlives the run that justified it. A run refusing to start
(exit 77) is the policy working — debug the host, do not route around it.
5. **Least mount.** The cwd is already mounted read-write at its host path, and `~/.agents` is
shared read-only with no flag to change it. Never propose a writable skills mount: a sandbox
that can write a `SKILL.md` has written an instruction the user follows later on the host.
Every extra `mount` line needs a one-line reason in the file.
6. **Verify before reporting success** (§5). "It should work" is not an acceptable result for a
security policy; `--print-policy` and `--dry-run` answer without building or starting anything.
## 1. Survey the project
```bash
bash scripts/survey.sh [project-dir] # default: $PWD; resolve scripts/ against this skill's directory
```
It is read-only and prints no secret values — variable *names*, remote *hosts*, registry *hosts*
and service *ports*. Read its output, then read the files it names: the lockfiles, the compose
file, `.env.example`, the dev-server config, CI, and the Makefile or task runner. Prefer evidence
over inference: a `pyproject.toml` with an `[[tool.uv.index]]` is a private index, an empty
`node_modules` tells you nothing.
Also read what already exists — `.ai-sandbox/config`, `.ai-sandbox/allow`, the user's
`~/.config/ai-sandbox/{config,allow}` — and extend it rather than replacing a decision the user
made deliberately.
## 2. Decide each setting from evidence
| need | evidence | setting |
|---|---|---|
| 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` |
| push over ssh | `~/.ssh` and an `ssh://` remote | do **not** add by default; rule 3 |
| a tool the run needs that the image lacks | build logs, `apt-get` in the project's Dockerfile, `rg`, `jq`, a DB client, `ffmpeg` | `install = <apt package>` (each distinct set gets its own cached image tag). Decide it **here**: inside, the harness is uid 1000 with no root and no package manager, so `apt-get` is not something it can do later |
| heavy build / flaky cap | repo size, test suite runtime, the user's machine | `cpus`, `memory`, `pids`, `tmpfs_size` |
| harness that is not pi | `.claude/`, `codex` config, the user's habit | `harness = pi \| codex \| claude \| none \| "<your command>"` |
| paths must look like the host's | a harness that titles by cwd; logs the user pastes | leave `workdir = auto`; set it only when the host path buries an image path |
| project-specific skills/prompts | a project `.agents/` | nothing — it arrives with the project mount |
| untrusted-code review | the user says so | `net = none`, `agents = 0`, `home_cache = 0`, `read-only` on the CLI, not in the file |
Two decisions the tool already makes that you should not repeat in the file: the thinking level is
`xhigh` policy for a bridged pi, and the pi config bridge is on with the credential by environment
reference. Only write `thinking` or `pi_token_env` when the project genuinely differs, with a
comment saying why.
## 3. Write the files
`.ai-sandbox/allow` — one target per line, grouped and commented, because this file is the diff a
human reviews:
```
# model gateway -- without it the sandboxed harness cannot answer
gateway.example.com
# dependencies
registry.npmjs.org
nodejs.org # node-gyp / prebuild downloads
# services on this machine, reached through the mapped loopback address (§ports)
100.64.0.1:5432 # postgres, `docker compose up db`
```
`.ai-sandbox/config` — only the keys this project needs; a comment per non-obvious one:
```ini
# ai-sandbox policy for <project>. Committed on purpose: review it like code.
# Values belong in .ai-sandbox/env (0600, gitignored) or the environment, never here.
net = allow
harness = pi
# the toolchain needs a bit more than the default 4 cpu / 4g
cpus = 8
memory = 8g
# design docs live beside the repo
mount = ../specs:/specs:ro
# names only; ai-sandbox passes them by name through a 0600 file
env = AI_API_TOKEN
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
it works today and fails closed if the file is missing — note in the file that a newcomer must
`cp .ai-sandbox/env.example .ai-sandbox/env && chmod 600` it. If you create `env.example`, put
names with dummy values in it and add `.ai-sandbox/env` to `.gitignore` (never overwrite an
existing ignore file's rules — append).
Never write `.ai-sandbox/env` yourself with real values: write the example, then tell the user what
to put in it.
## 4. Egress in one paragraph (get the shape right)
* A **domain** entry is matched by the gate at CONNECT time, including subdomains. Its `:port` does
not add a netfilter rule and the gate only relays **80, 443, 8443** (`Safe_ports`/`SSL_ports` in
the generated squid config), so `--allow host:8080` will not do what it looks like.
* An **IP, CIDR or `[ipv6]`** entry becomes a direct netfilter rule, default ports 80,443 unless you
write `:port` — this is how non-HTTP traffic (postgres, redis, a LAN model box) is allowed.
* `--no-gate` skips the proxy: domains are resolved once, on the host, at start and **DNS is
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)
```bash
cd <project>
ai-sandbox --dry-run # the exact podman invocation + the # egress / # env header lines;
# works with no podman and leaves no session directory behind
ai-sandbox --print-policy # net=, allow=, gate_domains=, direct=, env_names=, env_missing=,
# mounts=, workdir=, agents=, thinking= -- as key=value lines
```
Start with `--dry-run`: it is the one preview path with no side effects at all. `--print-policy`
gives the friendlier key=value form but also ensures the home-cache volume exists, so it needs
podman present — treat a failure there as "this machine has no podman", not as a policy problem.
Check, in this order, and fix the files instead of explaining the output:
1. no `warning: --env NAME requested but not set` for a name nobody intended (unset names are fine
to ship — say so in the report — but each must be a deliberate line, not a typo).
2. every allow entry appears in `domains=[…]` or `direct=[…]` (or `gate_domains=`/`direct=` under
`--print-policy`) — an entry in neither is a parse problem.
3. `workdir=` is the host path, not `/project`, unless the fallback was intended (`workdir_note=`
says why).
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. 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):
```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, 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. 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
Finish with exactly these five points, so the user can act without reading the files:
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, 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. 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
* `references/settings-map.md` — every accepted config key and flag, defaults, per-ecosystem
allowlists, the survey→policy cheat sheet.
* `references/port-and-loopback.md` — inbound vs outbound, `100.64.0.1`, the gate's port limits,
probes, the three workable dev-server patterns.
* `scripts/survey.sh` — the read-only evidence gatherer (prints no values).
* The tool's own docs, if this repo is present: `README.md`, `docs/networking.md` (measurements),
`docs/threat-model.md` (what is *not* protected), `docs/recipes.md` (invocations).