bridge the host pi config, with the credential by env only

The image ships pi with no provider and no preferences, so a sandboxed run
meant configuring the model twice. ai-sandbox now reads the host
~/.pi/agent/{models,settings}.json and rewrites them instead of copying them:
every provider apiKey becomes "$AI_API_TOKEN" (pi resolves an env reference
itself, so no value is written to disk), '!command' header values are dropped
rather than executed, settings that point into $HOME are dropped because that
path is not mounted, and auth.json / models-store.json / sessions / extensions
are never read.

The rewritten pair is seeded into /home/sandbox/.pi/agent at container start,
owned by uid 1000 and mode 600, and is treated as managed: a host change is
re-seeded, an edit made inside the sandbox is kept and reported, a deletion
takes the host version again.

The token follows the existing environment rules -- one variable, passed by
name through the 0600 env file, announced when auto-passed. A bridged run
whose token variable is unset or empty is refused instead of starting a pi
that cannot reach its model, and the bridge never widens the allowlist: it
warns with the exact --allow to add.

Docs and examples now use a self-hosted gateway (https://aipi.reeseapps.com)
instead of vendor API endpoints, since that is the only thing this tool is
used against. 391 assertions pass (make lint, ./tests/run.sh --e2e).

LLM-Generated: true
This commit is contained in:
2026-10-27 17:29:17 -04:00
parent 0242ab5b5e
commit 8ee535f6ae
14 changed files with 740 additions and 49 deletions
+46 -7
View File
@@ -10,7 +10,7 @@ language.
```
$ cd ~/Projects/billing-api
$ pi --allow api.anthropic.com --env ANTHROPIC_API_KEY
$ pi --allow aipi.reeseapps.com --env AI_API_TOKEN
[aisb] egress gate aisb-gate-7f3c1a -> 127.0.0.1:18084 (1 domains, 0 ip/cidr entries)
[aisb] egress net=allow enforce=nft proxy=[100.64.0.1:18084] direct=0 dns=0
pi
@@ -27,7 +27,7 @@ git clone <this repo> && cd ai-sandbox
./install.sh --alias pi # symlinks to ~/.local/bin and adds the alias to ~/.bashrc
ai-sandbox doctor # checks podman, rootless, subuid, pasta, SELinux, uid mapping
ai-sandbox build # optional: build the image now instead of on first run
make test # 160+ assertions, no containers needed
make test # 390+ assertions (unit needs no containers; --e2e builds and runs)
```
Requirements: podman ≥ 5 running rootless (with a `/etc/subuid` range for your
@@ -43,8 +43,8 @@ those become **arguments to the harness**:
alias pi='ai-sandbox'
pi # runs pi inside the sandbox
pi --allow api.anthropic.com --env ANTHROPIC_API_KEY --model sonnet
# ^^^^^ sandbox ^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^ for pi
pi --allow aipi.reeseapps.com --env AI_API_TOKEN --model turbo
# ^^^^^ sandbox ^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^ for pi
pi --shell # a bash shell in the same sandbox
pi --net none -- make test # -- replaces the command entirely
```
@@ -61,7 +61,7 @@ which is what you want in scripts.
| `--net full` | Nothing restricted. Prints a warning. Never the default. |
```bash
ai-sandbox --allow api.anthropic.com --allow registry.npmjs.org --env ANTHROPIC_API_KEY
ai-sandbox --allow aipi.reeseapps.com --allow registry.npmjs.org --env AI_API_TOKEN
ai-sandbox --allow 10.20.0.5:5432 # a database: pinned IP and port
ai-sandbox --allow files.pythonhosted.org --allow pypi.org
ai-sandbox --no-gate --allow example.com # no proxy container: pin resolved IPs instead
@@ -77,16 +77,20 @@ a silent free-for-all. Keep it in the repo so the policy is reviewable:
```
# .ai-sandbox/allow (one entry per line, # comments fine)
api.anthropic.com
aipi.reeseapps.com
registry.npmjs.org
```
If you bridge your pi config (below), ai-sandbox tells you when the model endpoint
your own `models.json` points at is not in the allowlist -- pi would otherwise start
and sit there unable to answer, which reads like a broken sandbox.
## Environment
Nothing is inherited except `TERM LANG LC_ALL LC_CTYPE TZ`.
```bash
--env ANTHROPIC_API_KEY # from your environment
--env AI_API_TOKEN # from your environment
--env FOO=bar # literal (shows up in ps; prefer the two below)
--env-file ~/.config/secrets # a 0600 NAME=VALUE file
```
@@ -111,6 +115,41 @@ default. Everything else is opt-in:
Files the agent writes land on your disk owned by **your** uid, not root
(`--userns=keep-id`, verified by `doctor`).
## Pi inside the sandbox
The image has `pi` baked in, and its home is a volume, so out of the box the sandboxed
pi has no provider and no preferences. Rather than making you configure it twice,
`ai-sandbox` **bridges** your host `~/.pi/agent/{models,settings}.json` -- rewriting
them, because copying them would ship your credential into a container whose entire
purpose is to be unable to use it:
| your host config | what the sandbox gets |
|---|---|
| `apiKey: "sk-..."` or `apiKey: "!pass show ..."` | `apiKey: "$AI_API_TOKEN"` -- pi resolves it itself, no value is written to disk |
| a header like `X-Trace: "!date +%s"` | dropped, and reported: a host command is not run in the sandbox |
| `baseUrl: "https://aipi.reeseapps.com/v1"` | unchanged, along with every model, limit and thinking level |
| settings: provider, model, theme, thinking budgets, telemetry | unchanged |
| a settings value pointing into `~` | dropped, and reported: that path is not mounted |
| `auth.json`, `models-store.json`, sessions, extensions | never touched |
The rewritten pair is seeded into `/home/sandbox/.pi/agent` at container start, owned by
your uid, mode `600`. It is a managed file: a change on the host is picked up on the
next run, an edit made *inside* the sandbox is left alone, and deleting it inside
re-seeds from the host.
The token itself follows the normal rule -- it is the one variable the bridged config
names, so it is passed by name (never by value, and never into `ps`):
```bash
pi --allow aipi.reeseapps.com # AI_API_TOKEN is picked up and announced
pi --no-pi-token-env ... # don't pass it; the run then fails closed
pi --pi-token-env GW_TOKEN ... # a different variable name
pi --no-pi-config --shell # no bridge at all
```
Refusing rather than launching a pi with no credential is deliberate -- the alternative
is an agent that silently cannot reach its model.
## Image
Both images come from one base, `docker.io/library/ubuntu:24.04` — including the gate,