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:
@@ -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.
|
||||
@@ -0,0 +1,135 @@
|
||||
# Settings map
|
||||
|
||||
Check this against the installed program before writing a config: `ai-sandbox --help`, and
|
||||
`cfg_read_kv()` / `resolve_opts()` in the script. Where they disagree, the script wins and this
|
||||
file is the bug.
|
||||
|
||||
## Precedence
|
||||
|
||||
```
|
||||
built-in default < ~/.config/ai-sandbox/{config,allow} < ./.ai-sandbox/{config,allow}
|
||||
< AISB_* environment < command-line flag
|
||||
```
|
||||
|
||||
`allow`, `env`, `mount`, `install`, `label` **accumulate** from every source (deduped); every other
|
||||
key replaces. So a repo file cannot revoke a user-wide `allow` line — say so if the user expects a
|
||||
repo config to tighten their own machine.
|
||||
|
||||
## The container, for orientation
|
||||
|
||||
Anything named "container" below is inside the sandbox: uid/gid **1000**, the image's own `sandbox`
|
||||
account, its home the value of `HOME` in there (`--userns=keep-id` makes files the agent writes
|
||||
owned by *you* on the host). So `~/.agents` exists on both sides of the wall and they are different
|
||||
directories — the host one is bind-mounted read-only onto the container one at `agents_dest`.
|
||||
`/project` is the image's own directory and the fallback working directory, not the normal one.
|
||||
|
||||
## Config keys (`key = value`, `#` comments)
|
||||
|
||||
| key | default | notes |
|
||||
|---|---|---|
|
||||
| `net` | `allow` | `none` \| `allow` \| `full`. `allow` requires a non-empty allowlist. |
|
||||
| `harness` | `pi` | `pi` \| `codex` \| `claude` \| `none` \| a command line, e.g. `pi --model turbo` |
|
||||
| `workdir` | `auto` | `auto` mirrors the host path; or an absolute path. **`project_dest` is parsed and never read — use `workdir`.** |
|
||||
| `cpus` | `4` | |
|
||||
| `memory` | `4g` | `512m`/`4g` shape |
|
||||
| `pids` | `512` | |
|
||||
| `tmpfs_size` | `1g` | sizes `/tmp` and, with `home_cache = 0`, the container home |
|
||||
| `base_image` | `docker.io/library/ubuntu:24.04` | may itself be `name@sha256:…` |
|
||||
| `node_version` | `22.23.4` | checksum-verified against nodejs.org |
|
||||
| `pi_version` | `1.4.0` | npm package version baked into the image |
|
||||
| `base_ttl_days` | `7` | `0` re-resolve every build, `-1` never |
|
||||
| `base_auto_update` | `1` | `0` keeps the recorded digest offline |
|
||||
| `selinux` | `:z` when `getenforce` exists | `:z`, `:Z` or empty |
|
||||
| `home_cache` | `1` | `0` = throwaway tmpfs home (also `--ephemeral-home`) |
|
||||
| `gate_addr` | `100.64.0.1` | the address that *means* the host's loopback inside the container |
|
||||
| `gate_port` | `18081` | preferred first port; next free is used |
|
||||
| `extra_args` | empty | appended verbatim to `podman run`, word-split. Escape hatch: do not commit it |
|
||||
| `pi_config` | `1` | bridge `~/.pi/agent/{models,settings}.json` with the credential as `$VAR` |
|
||||
| `pi_token_env` | `AI_API_TOKEN` | the variable the bridged config reads |
|
||||
| `pi_token_auto` | `1` | pass that variable automatically when set |
|
||||
| `pi_agent_dir` | `~/.pi/agent` | where to read the host config from |
|
||||
| `thinking` | `xhigh` | `off minimal low medium high xhigh max host`; pi only, re-applied every run |
|
||||
| `agents` | `1` | share `~/.agents` read-only when the directory exists |
|
||||
| `agents_dir` | `~/.agents` | which directory; a project's own `.agents/` needs nothing |
|
||||
| `agents_dest` | the container's `~/.agents` | keep it under the container `$HOME` — that is the only place a harness looks for user-level skills |
|
||||
| `allow` | — | repeatable; same grammar as `--allow` |
|
||||
| `env` | — | repeatable; `NAME`, `NAME=VALUE` (no secret in a committed file), or `@path` = an env file |
|
||||
| `mount` | — | repeatable; `SRC[:DEST][:ro\|rw]`, default `DEST=/mnt/<basename>` |
|
||||
| `install` | — | repeatable; apt packages, each distinct set is its own image tag |
|
||||
| `label` | — | repeatable; `K=V` on the container |
|
||||
|
||||
Anything else warns (`unknown key`) and is ignored.
|
||||
|
||||
## Flags
|
||||
|
||||
```
|
||||
egress --net none|allow|full --allow HOST[:PORT] --allow-file F --proxy HOST:PORT --no-gate
|
||||
files --mount SRC[:DEST][:ro] --no-cwd --workdir DIR|auto --read-only
|
||||
--home-cache --ephemeral-home --agents | --no-agents --agents-dir PATH
|
||||
env --env NAME --env NAME=VALUE --env-file F (F should be 0600)
|
||||
image --base-image REF --install PKG --no-build
|
||||
harness --harness NAME --shell --thinking LEVEL --pi-token-env NAME --no-pi-token-env
|
||||
--no-pi-config
|
||||
limits --cpus N --memory SIZE --pids N --tmpfs-size SIZE
|
||||
run --name NAME --label K=V --keep --interactive | --no-interactive --no-tty
|
||||
preview --dry-run|-n --print-policy --print-entrypoint --print-containerfile
|
||||
hatches --allow-unenforced --insecure-keep-root --strict-args
|
||||
```
|
||||
|
||||
Parsing stops at the first positional or unknown argument, which becomes an argument **to the
|
||||
harness** — that is what makes `alias pi=ai-sandbox` behave like the harness. `--` replaces the
|
||||
command entirely. `--strict-args` makes an unknown flag an error instead (use it in scripts).
|
||||
|
||||
Subcommands: `build [--rebuild] [--gate] [--refresh-base]`, `update [--check]`, `doctor [--deep]`,
|
||||
`gate up|down|status|logs`, `ps`, `clean [--all] [--volumes[=GLOB]]`, `inspect`, `version`.
|
||||
|
||||
Exit status: the command's own code; **77** the egress ruleset could not be installed; **78** the
|
||||
policy file was missing in the container. Both refusals are the sandbox working.
|
||||
|
||||
## Allow-entry grammar
|
||||
|
||||
| entry | effect |
|
||||
|---|---|
|
||||
| `example.com` | gate matches that name and anything below it, at CONNECT time |
|
||||
| `example.com:8443` | same; the port does **not** create a netfilter rule, and the gate only relays 80/443/8443 |
|
||||
| `10.0.0.5:5432` | direct netfilter rule for that host and port — the way to allow non-HTTP |
|
||||
| `10.20.0.0/16` | direct rule for the subnet, default ports 80,443 unless `:port` |
|
||||
| `[fd00::5]:8443` | direct ipv6 rule |
|
||||
| `localhost` | resolves on the host → a direct rule for `127.0.0.1`, i.e. the *container's* loopback. For your machine's services use `100.64.0.1` |
|
||||
|
||||
## Per-ecosystem starting points
|
||||
|
||||
Add only what the project's own lockfiles and build files prove it contacts. Everything left out
|
||||
fails loudly, which is the feature.
|
||||
|
||||
| stack | entries |
|
||||
|---|---|
|
||||
| npm | `registry.npmjs.org` (+ `nodejs.org` and, for prebuilds, `release-assets.githubusercontent.com` — read the git-host warning before adding anything from `github.com`) |
|
||||
| python | `pypi.org`, `files.pythonhosted.org` (the file CDN is separate; both are needed). A private index: whatever host `--index-url`, `PIP_INDEX_URL`, `[[tool.uv.index]]` or `tool.poetry.source` names |
|
||||
| rust | `crates.io`, `static.crates.io`, `index.crates.io` (+ `static.rust-lang.org` if it installs toolchains) |
|
||||
| go | `proxy.golang.org`, `sum.golang.org` (+ the host of a private module path, `allow = <host>` and `GOPRIVATE` in the env) |
|
||||
| ruby | `rubygems.org`, `index.rubygems.org` |
|
||||
| php | `repo.packagist.org`, `api.github.com` (composer metadata; check before allowing) |
|
||||
| jvm | `repo1.maven.org`, `repo.maven.apache.org`, `plugins.gradle.org`, `services.gradle.org`, `dl.google.com` |
|
||||
| dotnet | `api.nuget.org` |
|
||||
| terraform | `registry.terraform.io`, `releases.hashicorp.com` |
|
||||
| playwright / puppeteer | their download hosts (`cdn.playwright.dev`, `storage.googleapis.com`) — or commit the browsers and go `--net none` |
|
||||
| container builds inside the sandbox | `registry-1.docker.io`, `auth.docker.io`, `production.cloudflare.docker.com` |
|
||||
|
||||
## Gotchas worth a comment in the file
|
||||
|
||||
* `--env NAME=VALUE` shows up in `ps`; the name-only and `--env-file` forms do not.
|
||||
* An unset variable named with `--env` **fails the run** (in preview it is reported as
|
||||
`env_missing=` instead) — that fail-closed behaviour is why preview output is worth reading.
|
||||
* The image's home is a named volume (`aisb-home`) that outlives the image: after a rebuild the
|
||||
home may be stale. `ai-sandbox clean --volumes` reclaims unused `aisb-*` volumes.
|
||||
* A project under `/tmp`, `/usr`, `/etc`, `/var`, `/opt`, `/run`, `/home` itself or the container's
|
||||
own home cannot mirror its host path and lands in `/project`, with a line saying so. Do not "fix"
|
||||
that with `--workdir /tmp/...`; the tmpfs covers it.
|
||||
* The host path may not contain `:` at all — podman cannot parse such a mount spec, and
|
||||
ai-sandbox refuses rather than passing the error along.
|
||||
* Nothing is inherited but `TERM LANG LC_ALL LC_CTYPE TZ` (plus `HOME`, set to the container home,
|
||||
`AISB=1`, `AISB_SESSION`). A tool that needs `PATH`, a dotfile or anything else from your real home
|
||||
needs an explicit grant.
|
||||
* `.env` files inside the project are readable by the agent — that is the point of the mount, and
|
||||
it is why the *sandbox* token should be a narrow one.
|
||||
Reference in New Issue
Block a user