Files
ai-sandbox/.agents/skills/right-sandbox/SKILL.md
T
ducoterra 34f94e38be 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
2026-10-29 08:37:51 -04:00

224 lines
14 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, 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.
---
# 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 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
```
`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.
## 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. `--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
```
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.
## 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** — 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).
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.
## 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).