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
16 KiB
name, description
| name | description |
|---|---|
| right-sandbox | 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
- Names, never values. Both committed files end up in the repository, in
--print-policyand in a reviewer's diff. Credentials live in the environment or in.ai-sandbox/env(chmod 600, gitignored); the config saysenv = AI_API_TOKEN. A literalNAME=VALUEis only for things that are not secrets, e.g.env = DATABASE_URL=postgresql://dev@100.64.0.1:5432/app. - 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, pluscfg_read_kv()'s key list in the script.references/settings-map.mdholds the list as of writing — check it, do not trust it. Known trap:project_destis accepted by the parser and read by nothing; the key isworkdir. - The git remote stays out. No
github.com,gitlab.com,bitbucket.orgor ssh host inallowunless the user asks after hearing the cost: an allowlisted remote plus a token or a mounted~/.sshlets a prompt-injected agent ship code nobody reviewed (threat model #1).git status,diff,log,blameall work without it, andai-sandbox-autophasecommits 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. - 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. - Least mount. The cwd is already mounted read-write at its host path, and
~/.agentsis shared read-only with no flag to change it. Never propose a writable skills mount: a sandbox that can write aSKILL.mdhas written an instruction the user follows later on the host. Every extramountline needs a one-line reason in the file. - Verify before reporting success (§5). "It should work" is not an acceptable result for a
security policy;
--print-policyand--dry-runanswer without building or starting anything.
1. Survey the project
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:
# 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
:portdoes not add a netfilter rule and the gate only relays 80, 443, 8443 (Safe_ports/SSL_portsin the generated squid config), so--allow host:8080will 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-gateskips the proxy: domains are resolved once, on the host, at start and DNS is opened; weaker for CDNs and rotating IPs.--proxy host:portuses a proxy you already run (default port 3128). Neither belongs in a committed config without a comment justifying it.- An empty allowlist in
allowmode is a hard error. Never "fix" that by widening net. publishis the one ingress key and it is not an allow entry:allow = 100.64.0.1:5432lets the sandbox reach your machine,publish = 5432:5432lets 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)
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:
- no
warning: --env NAME requested but not setfor 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). - every allow entry appears in
domains=[…]ordirect=[…](orgate_domains=/direct=under--print-policy) — an entry in neither is a parse problem. workdir=is the host path, not/project, unless the fallback was intended (workdir_note=says why).mounts=andagents=are what you wrote and nothing more.- 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. - if you wrote
publish: the# ingress: publish=[…]line shows127.0.0.1on every entry unless the user asked for an address, and the project's dev command binds0.0.0.0— a published port pointing at a127.0.0.1-only server is configured, silent and useless. --print-policy | grep -ifor 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):
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:
- Files written — paths, plus the
.gitignorechange if any. cd <dir> && ai-sandboxis now the invocation (and the alias note if they use one).- What they must export first (
env =names that are unset), and where values may go. - 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. - 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 to127.0.0.1unless 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 bind0.0.0.0inside — 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).