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,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