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

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

  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 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 :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)

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):

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).