Compare commits

...
2 Commits
Author SHA1 Message Date
ducoterra 8ee535f6ae 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
2026-10-27 17:29:17 -04:00
ducoterra 0242ab5b5e pin the base image to a digest, with TTL refresh and audit labels
A tag is a moving pointer, and three things conspired to make the base
immutable-in-practice while mutable-in-theory: image_hash() hashed the
string "ubuntu:24.04" rather than what it resolves to, ensure_image()
returns early when the tag exists, and podman build's pull policy is
"missing" -- so even 'build --rebuild' rebuilt on the cached base. A
Canonical patch simply never arrived, and nothing said so.

Now the tag is resolved to a registry digest (skopeo, falling back to a
pull unless AISB_BASE_PULL_FALLBACK=0), recorded per-ref under
$AISB_CACHE_DIR/base/ at 0600, and used in FROM *and* in image_hash():
refreshing the base produces a new image instead of mutating one a
sandbox may be running. Freshness is a TTL (AISB_BASE_TTL_DAYS=7, 0 every
build, -1 never) plus an explicit 'update' / 'update --check'.

Two rules keep it usable rather than merely strict. A stale pin beats a
mutable tag: offline or with AISB_BASE_AUTO_UPDATE=0 the recorded digest
is still used, and only a build with no digest at all is allowed --
announced as UNPINNED, labelled that way, warned about by doctor. And
preview modes stay deterministic: --print-* never reach the network,
inspect is the surface that asks the machine now.

Both images carry aisb.base-digest / .node / .pi-package / .pi-version
labels plus /etc/aisb/manifest.txt of resolved apt versions, which answers
"which squid was enforcing this?" after the build log is gone. Tags are
named after the base instead of the hardcoded u24- prefix.

Bug found on the way: POL[base_digest]="$(base_pin ...)" silently produced
unpinned labels, because an assignment inside a command substitution is
discarded with the subshell. Helpers return text; callers assign. Also
fixed: the manifest step used jq on npm's dependency objects as if they
were strings, and a missing Node checksum used to skip verification
quietly -- it now warns.

311 assertions pass (unit + e2e), shellcheck clean.

LLM-Generated: true
2026-10-27 15:41:13 -04:00
16 changed files with 1331 additions and 71 deletions
+27 -9
View File
@@ -13,24 +13,36 @@ allowlist that the sandboxed process cannot lift.
## Non-negotiables ## Non-negotiables
1. **Single script.** The Containerfile, the container entrypoint, the squid 1. **Single script.** The Containerfile, the container entrypoint, the squid
config and the nftables rules are all generated from `bin/ai-sandbox` config, the nftables rules and the bridged pi config are all generated from
(`containerfile()`, `entrypoint_script()`, `write_squid_conf()`, `gen_rules()` `bin/ai-sandbox` (`containerfile()`, `entrypoint_script()`, `write_squid_conf()`,
inside the entrypoint). Keep it that way; do not split policy into files that `gen_rules()` inside the entrypoint, `pi_config_bridge()`). Keep it that way; do
can drift from the script. not split policy into files that can drift from the script.
2. **Fail closed.** Any path where the sandbox cannot guarantee its policy must 2. **Fail closed.** Any path where the sandbox cannot guarantee its policy must
refuse to start rather than degrade: no allowlist in `allow` mode, missing refuse to start rather than degrade: no allowlist in `allow` mode, missing
policy file in the entrypoint (exit 78), ruleset that cannot be installed policy file in the entrypoint (exit 78), ruleset that cannot be installed
(exit 77), `--env NAME` where NAME is unset. Escape hatches exist but must be (exit 77), `--env NAME` where NAME is unset, a bridged pi config whose credential
explicitly named (`--allow-unenforced`, `--insecure-keep-root`, `--net full`). variable is unset *or empty*. Escape hatches exist but must be explicitly named
(`--allow-unenforced`, `--insecure-keep-root`, `--net full`, `--no-pi-config`).
3. **Never leak a secret.** Values go through a `0600` env file, never argv. 3. **Never leak a secret.** Values go through a `0600` env file, never argv.
Nothing that prints (`--dry-run`, `--print-policy`, the session log, error Nothing that prints (`--dry-run`, `--print-policy`, the session log, error
messages) may echo a value. If you add output, add a test that a canary value messages) may echo a value. If you add output, add a test that a canary value
does not appear in it. does not appear in it. The pi bridge is the case that tests this hardest: it reads
a file that may hold a live key, so it rewrites `apiKey` to `"$VAR"`, drops
`!command` values instead of running them, never touches `auth.json`, and
`tests/unit/08_pi_config.sh` asserts a canary secret from the host agent directory
appears nowhere in ai-sandbox state.
4. **No hardcoded machine paths, usernames or ports.** Everything is overridable 4. **No hardcoded machine paths, usernames or ports.** Everything is overridable
via `AISB_*` or a config file; defaults must work for any user on any host. via `AISB_*` or a config file; defaults must work for any user on any host.
5. **Measure, don't assume, about podman.** Rootless networking varies by version 5. **Measure, don't assume, about podman.** Rootless networking varies by version
and distro. Run `tests/spike_network.sh` on a new host and update and distro. Run `tests/spike_network.sh` on a new host and update
`docs/networking.md` when reality disagrees with the design. `docs/networking.md` when reality disagrees with the design.
6. **Nothing about a build may be silently mutable.** The base enters the
Containerfile as a digest, and that digest is part of `image_hash()`; Node is
version-pinned and checksum-verified; `pi` is version-pinned. A refresh is an
explicit, dated event (`update`); a build with no digest is announced as `UNPINNED`
and labelled that way; preview modes must never reach the network. If you add a
dependency, decide which of those three it is and cover it in
`tests/unit/07_base.sh` or `tests/e2e/10_image.sh`.
## Layout ## Layout
@@ -68,9 +80,15 @@ networking or the image, `make e2e` green too.
variables that have no config-file counterpart. Breaking this is the single variables that have no config-file counterpart. Breaking this is the single
easiest way to introduce a precedence bug. easiest way to introduce a precedence bug.
* `POL[...]` holds resolved policy, `P_*` arrays resolved lists, `OPT_*` raw flags, * `POL[...]` holds resolved policy, `P_*` arrays resolved lists, `OPT_*` raw flags,
`CFG_*` config-file values. Do not read raw env vars deep in the code. `CFG_*` config-file values. Do not read raw env vars deep in the code. A helper
called as `X="$(helper)"` runs in a subshell: it can return text, but any `POL[..]`
it assigns is discarded — return a value and assign it in the caller (see
`base_pin_digest()` + `base_ref_of()`).
* Preview paths (`--dry-run`, `--print-policy`, `--print-entrypoint`, * Preview paths (`--dry-run`, `--print-policy`, `--print-entrypoint`,
`--print-containerfile`) must not build images, start containers or write state. `--print-containerfile`) must not build images, start containers, write state or
reach the network. That is why digest resolution has a `cache_only` mode, and why
`inspect` (asks the machine now) is a separate surface from `--print-*` (which stays
deterministic and scriptable).
* Tests use `t_name` + `assert_*` from `tests/lib.sh`; each file gets its own * Tests use `t_name` + `assert_*` from `tests/lib.sh`; each file gets its own
`AISB_STATE_DIR`/`AISB_CACHE_DIR`/`AISB_CONFIG_DIR`. Never let a test touch the `AISB_STATE_DIR`/`AISB_CACHE_DIR`/`AISB_CONFIG_DIR`. Never let a test touch the
real `~/.config`, `~/.local/state` or `~/.cache`, and never override `HOME` real `~/.config`, `~/.local/state` or `~/.cache`, and never override `HOME`
+59 -1
View File
@@ -2,7 +2,7 @@
Execution plan for `ai-sandbox`, with the state each phase leaves behind. Execution plan for `ai-sandbox`, with the state each phase leaves behind.
Every phase ends with a gate that must pass before the next one starts. Every phase ends with a gate that must pass before the next one starts.
Status as of this writing: **phases 1-7 complete**, follow-ups listed at the end. Status as of this writing: **phases 1-9 complete**, follow-ups listed at the end.
| # | Phase | Gate | Status | | # | Phase | Gate | Status |
|---|-------|------|--------| |---|-------|------|--------|
@@ -14,6 +14,8 @@ Status as of this writing: **phases 1-7 complete**, follow-ups listed at the end
| 5 | Environment passing | deny-by-default, `0600` delivery, redaction, fail-closed on unset | ✅ done (`tests/unit/04_env.sh`, `tests/e2e/12_sandbox.sh`) | | 5 | Environment passing | deny-by-default, `0600` delivery, redaction, fail-closed on unset | ✅ done (`tests/unit/04_env.sh`, `tests/e2e/12_sandbox.sh`) |
| 6 | Limits, lifecycle, observability | exit codes, cleanup, caps, session log, `--keep` | ✅ done (`tests/e2e/12_sandbox.sh`) | | 6 | Limits, lifecycle, observability | exit codes, cleanup, caps, session log, `--keep` | ✅ done (`tests/e2e/12_sandbox.sh`) |
| 7 | Harness defaults, docs, portability sweep | alias UX, README/threat model, shellcheck clean, no personal paths | ✅ done | | 7 | Harness defaults, docs, portability sweep | alias UX, README/threat model, shellcheck clean, no personal paths | ✅ done |
| 8 | Supply chain: pinned base, TTL refresh, audit trail | digest in `FROM` and in the content hash; `update`/`--check`; labels + in-image package manifest | ✅ done (`tests/unit/07_base.sh`, `tests/e2e/10_image.sh`) |
| 9 | Pi config bridge: same provider inside, credential by env | host `models.json`/`settings.json` rewritten not copied; canary secret nowhere in state; seeding, refresh and edit-presence asserted in a real container | ✅ done (`tests/unit/08_pi_config.sh`, `tests/e2e/12_sandbox.sh`) |
## Phase detail ## Phase detail
@@ -66,6 +68,48 @@ change the image's home layout and existing volumes keep the old files, so
`ensure_home_volume()` creates it labelled and `clean --volumes` is the documented way `ensure_home_volume()` creates it labelled and `clean --volumes` is the documented way
to drop it. to drop it.
### 8 — Supply chain
The hash made *our* changes traceable but said nothing about the base: `FROM
ubuntu:24.04` is a moving pointer, `podman build`'s pull policy is *missing*, and
`ensure_image()` returns early when the tag exists — so even `build --rebuild` reused
the cached base. Now the tag is resolved to a digest (`skopeo`, falling back to
`podman pull` unless `AISB_BASE_PULL_FALLBACK=0`), recorded per-ref under
`$AISB_CACHE_DIR/base/`, and used in `FROM` *and* in `image_hash()`: a refreshed base
yields a new image rather than mutating one that may be running.
Two rules make it survivable rather than merely strict. **A stale pin beats a mutable
tag** — offline, or with `AISB_BASE_AUTO_UPDATE=0`, the recorded digest is used, and
only a build with no digest at all is allowed, loudly marked `UNPINNED` in the output,
in the image labels and in `doctor`. **Freshness is a TTL** (`AISB_BASE_TTL_DAYS=7`,
`0` every build, `-1` never) rather than a background job, so nothing surprises you
mid-session and `update` stays an explicit, dated event. Both images carry
`aisb.base-digest`/`aisb.node`/`aisb.pi-version` labels and
`/etc/aisb/manifest.txt` with resolved apt versions, which is how you answer "which
squid was enforcing this?" a month later. `--print-*` stays cache-only and
deterministic; `inspect` is the surface that asks the machine now.
One bug worth remembering: `POL[base_digest]="$(base_pin …)"` looked right and
silently produced `unpinned` labels, because the subshell's assignment was discarded.
Helpers return text; callers assign.
### 9 — Pi configuration bridge
The image ships `pi` with no provider and no preferences, so the first run asked you to
configure the model twice. It now reads `~/.pi/agent/{models,settings}.json` and
rewrites them: every provider `apiKey` becomes `"$AI_API_TOKEN"` (pi resolves an env
reference itself, so no value is ever written to disk), `!command` header values are
dropped rather than executed, settings pointing into `$HOME` are dropped because that
path is not mounted, and `auth.json`/`models-store.json`/sessions/extensions are never
read. The pair lands in `/home/sandbox/.pi/agent` at container start, owned by uid 1000,
mode 600, and is a managed file: a host change is re-seeded, a sandbox edit is kept and
reported, a deletion takes the host version again.
Two decisions worth keeping. The token follows the existing env rules — one variable,
passed by name through the `0600` file, announced when auto-passed — and a bridged run
where that variable is unset *or empty* is refused instead of starting a pi that cannot
reach its model, because "sandbox is broken" is a worse bug report than an explicit
refusal. And the bridge never widens the allowlist; it warns with the exact `--allow`
to add, since an agent silently unable to answer looks like a network failure.
## Follow-ups (not yet done) ## Follow-ups (not yet done)
* **`--net unix` mode**: `--network=none` plus a unix-socket proxy and an in-container * **`--net unix` mode**: `--network=none` plus a unix-socket proxy and an in-container
@@ -81,3 +125,17 @@ to drop it.
* **`--record`**: optional pcap/CONNECT-log capture of a session for review. * **`--record`**: optional pcap/CONNECT-log capture of a session for review.
* **`--mount` for agent skills/plugins**: first-class flag instead of a generic mount. * **`--mount` for agent skills/plugins**: first-class flag instead of a generic mount.
* Packaging: a `quadlet`/systemd unit for a long-lived named gate. * Packaging: a `quadlet`/systemd unit for a long-lived named gate.
* Signature verification for the base where the registry signs it (cosign/sigstore),
so a pin is authenticated rather than merely immutable; and an optional local
mirror/`AISB_SKOPEO`-compatible offline source for air-gapped hosts.
* `update --auto` as a timer unit, if TTL-on-next-run turns out to be too lazy for
security response — deliberately not done yet, since a background rebuild that
surprises a running session is its own hazard.
* **Scoped sandbox tokens**: if the gateway can mint a short-lived, per-sandbox key,
`--pi-token-env` should ask for one rather than forwarding the long-lived host
credential. The bridge deliberately does not guess a minting endpoint.
* **Bridging extensions/skills on request**: `--pi-mount-ext` or similar for the
extension paths named in `enabledExtensionPaths`, which are host paths and therefore
currently dropped; needs the same explicit-grant shape as `--mount`.
* **`models.json` review surface**: `inspect` could print the exact diff between host
config and bridged config, so a change is reviewable before a run rather than after.
+82 -11
View File
@@ -10,7 +10,7 @@ language.
``` ```
$ cd ~/Projects/billing-api $ 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 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 [aisb] egress net=allow enforce=nft proxy=[100.64.0.1:18084] direct=0 dns=0
pi 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 ./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 doctor # checks podman, rootless, subuid, pasta, SELinux, uid mapping
ai-sandbox build # optional: build the image now instead of on first run 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 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' alias pi='ai-sandbox'
pi # runs pi inside the sandbox pi # runs pi inside the sandbox
pi --allow api.anthropic.com --env ANTHROPIC_API_KEY --model sonnet pi --allow aipi.reeseapps.com --env AI_API_TOKEN --model turbo
# ^^^^^ sandbox ^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^ for pi # ^^^^^ sandbox ^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^ for pi
pi --shell # a bash shell in the same sandbox pi --shell # a bash shell in the same sandbox
pi --net none -- make test # -- replaces the command entirely 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. | | `--net full` | Nothing restricted. Prints a warning. Never the default. |
```bash ```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 10.20.0.5:5432 # a database: pinned IP and port
ai-sandbox --allow files.pythonhosted.org --allow pypi.org ai-sandbox --allow files.pythonhosted.org --allow pypi.org
ai-sandbox --no-gate --allow example.com # no proxy container: pin resolved IPs instead 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) # .ai-sandbox/allow (one entry per line, # comments fine)
api.anthropic.com aipi.reeseapps.com
registry.npmjs.org 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 ## Environment
Nothing is inherited except `TERM LANG LC_ALL LC_CTYPE TZ`. Nothing is inherited except `TERM LANG LC_ALL LC_CTYPE TZ`.
```bash ```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 FOO=bar # literal (shows up in ps; prefer the two below)
--env-file ~/.config/secrets # a 0600 NAME=VALUE file --env-file ~/.config/secrets # a 0600 NAME=VALUE file
``` ```
@@ -111,12 +115,79 @@ default. Everything else is opt-in:
Files the agent writes land on your disk owned by **your** uid, not root Files the agent writes land on your disk owned by **your** uid, not root
(`--userns=keep-id`, verified by `doctor`). (`--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 ## Image
Built once per distinct policy from `ubuntu:24.04`, tagged by content hash, reused Both images come from one base, `docker.io/library/ubuntu:24.04` — including the gate,
afterwards: git, curl, jq, build tools, python3, Node 22 (checksum-verified which is squid from Ubuntu's apt rather than a third-party image. Built once per
against nodejs.org) and the `pi` CLI. `--install PKG` adds packages and produces a distinct policy and tagged by content hash: git, curl, jq, build tools, python3, Node
different tag, so variants coexist. `--base-image` swaps the base. 22 (tarball verified against nodejs.org `SHASUMS256.txt`) and the `pi` CLI. `--install
PKG` adds packages and produces a different tag, so variants coexist, and
`--base-image` swaps the base.
### The base is pinned by digest
A tag is a moving pointer: `FROM ubuntu:24.04` means "whatever Canonical points at
this week", which makes a build unreproducible and a security patch invisible. So the
tag is resolved to a digest, recorded under `~/.cache/ai-sandbox/base/`, and the
digest goes into the `FROM` line *and* into the content hash — refreshing the base
produces a new image instead of mutating one a sandbox may be running right now.
```bash
ai-sandbox update --check # what upstream has, what you are pinned to, nothing built
ai-sandbox update # re-resolve, rebuild sandbox + gate, keep the old image
ai-sandbox build --refresh-base
```
Freshness is a TTL: every `AISB_BASE_TTL_DAYS` (default 7) the digest is re-resolved,
which is a manifest read (`skopeo`), not a download. Offline, or with
`AISB_BASE_AUTO_UPDATE=0`, the recorded digest is kept — a stale pin beats a mutable
one, and `AISB_BASE_TTL_DAYS=-1` pins until you say otherwise. A build with no digest
is announced as `UNPINNED`, labelled that way, and warned about by `doctor`. Preview
modes (`--dry-run`, `--print-*`) never touch the network, so they stay scriptable and
report the pin you already have; `inspect` is the one that asks the machine now.
### What an image admits to being
`podman image inspect` on any ai-sandbox image shows `aisb.base-digest`, `aisb.node`,
`aisb.pi-package`, `aisb.pi-version` (plus `aisb.role=gate` for the gate), and
`/etc/aisb/manifest.txt` inside it lists every resolved apt package version — the
answer to "which squid was doing the filtering" once the build log is long gone. The
previous image is always kept after an update, so rollback is a tag away.
## Inspecting a run ## Inspecting a run
+546 -23
View File
@@ -11,7 +11,7 @@
# Meant to be aliased to an agent harness: # Meant to be aliased to an agent harness:
# #
# alias pi='ai-sandbox' # alias pi='ai-sandbox'
# cd ~/Projects/foo && pi --allow api.anthropic.com --env ANTHROPIC_API_KEY # cd ~/Projects/foo && pi --allow aipi.reeseapps.com --env AI_API_TOKEN
# #
set -Eeuo pipefail set -Eeuo pipefail
@@ -39,6 +39,7 @@ AISB_PI_PACKAGE="${AISB_PI_PACKAGE:-@earendil-works/pi-coding-agent}"
# environment variables stay unset unless the user really set them. # environment variables stay unset unless the user really set them.
# AISB_NET AISB_HARNESS AISB_PROJECT_DEST AISB_CPUS AISB_MEMORY AISB_PIDS # AISB_NET AISB_HARNESS AISB_PROJECT_DEST AISB_CPUS AISB_MEMORY AISB_PIDS
# AISB_TMPFS_SIZE AISB_BASE_IMAGE AISB_NODE_VERSION AISB_PI_VERSION # AISB_TMPFS_SIZE AISB_BASE_IMAGE AISB_NODE_VERSION AISB_PI_VERSION
# AISB_BASE_TTL_DAYS AISB_BASE_AUTO_UPDATE AISB_BASE_PULL_FALLBACK AISB_SKOPEO
# AISB_SELINUX AISB_GATE_ADDR AISB_GATE_PORT AISB_HOME_CACHE AISB_EXTRA_ARGS # AISB_SELINUX AISB_GATE_ADDR AISB_GATE_PORT AISB_HOME_CACHE AISB_EXTRA_ARGS
AISB_UID="${AISB_UID:-1000}" AISB_UID="${AISB_UID:-1000}"
AISB_GID="${AISB_GID:-1000}" AISB_GID="${AISB_GID:-1000}"
@@ -135,15 +136,16 @@ $PROG $VERSION -- rootless podman sandbox for autonomous agentic development.
USAGE USAGE
$PROG [POLICY ...] [--] [COMMAND ...] $PROG [POLICY ...] [--] [COMMAND ...]
$PROG build [--rebuild] | doctor [--deep] | gate <up|down|status|logs> | ps $PROG build [--rebuild] [--refresh-base] [--gate]
$PROG clean [--all] [--volumes] | inspect | version $PROG doctor [--deep] | gate <up|down|status|logs> | ps | inspect | version
$PROG clean [--all] [--volumes[=GLOB]] | update [--check]
Sandbox flag parsing stops at the first positional argument or unknown flag. Sandbox flag parsing stops at the first positional argument or unknown flag.
Those arguments become arguments *to the harness*, which is what makes an alias Those arguments become arguments *to the harness*, which is what makes an alias
behave like the harness itself: behave like the harness itself:
alias pi='$PROG' alias pi='$PROG'
pi --allow api.anthropic.com --env ANTHROPIC_API_KEY --model sonnet pi --allow aipi.reeseapps.com --env AI_API_TOKEN --model turbo
# ^ sandbox flags ^ arguments for pi # ^ sandbox flags ^ arguments for pi
pi src/main.py # runs: pi src/main.py pi src/main.py # runs: pi src/main.py
@@ -184,11 +186,24 @@ ENVIRONMENT
through the command line, and never into the logs. through the command line, and never into the logs.
IMAGE IMAGE
--base-image REF Default docker.io/library/ubuntu:24.04 --base-image REF Default docker.io/library/ubuntu:24.04. Pinned to the
registry digest at build time; REF may itself already be
a digest (name@sha256:...) to take that decision away.
--install PKG Extra apt package in the image. Repeatable. --install PKG Extra apt package in the image. Repeatable.
Each distinct set gets its own cached image tag. Each distinct set gets its own cached image tag.
--harness NAME Default in-container command: pi | codex | claude | none. --harness NAME Default in-container command: pi | codex | claude | none.
PI CONFIG
Your ~/.pi/agent/{models,settings}.json are rewritten and seeded into the sandbox:
every provider apiKey becomes an environment reference, so no credential is written
into the container, and host commands ('!...') and host paths are dropped instead of
followed. auth.json and the credential store are never bridged.
--pi-token-env NAME Variable the bridged config should read the key from
(default AI_API_TOKEN). Passed by name if it is set.
--no-pi-token-env Do not pass it automatically; then pass it with --env or
the run is refused rather than starting a pi with no key.
--no-pi-config Do not bridge at all; pi starts with its own defaults.
LIMITS LIMITS
--cpus N (4) --memory SIZE (4g) --pids N (512) --tmpfs-size SIZE (1g) --cpus N (4) --memory SIZE (4g) --pids N (512) --tmpfs-size SIZE (1g)
@@ -211,7 +226,10 @@ ESCAPE HATCHES (they weaken the sandbox, which is why they are named this way)
--strict-args Unknown flags are an error instead of a pass-through. --strict-args Unknown flags are an error instead of a pass-through.
SUBCOMMANDS SUBCOMMANDS
build Build or reuse the sandbox image (--rebuild, --gate for the gate image). build Build or reuse the sandbox image (--rebuild, --gate for the gate
image, --refresh-base to re-resolve the base digest now).
update Re-resolve the base digest and rebuild what that implies. The old
image stays put as a rollback. --check only reports.
doctor Check the host: podman, rootless, subuid, helpers, SELinux, uid mapping. doctor Check the host: podman, rootless, subuid, helpers, SELinux, uid mapping.
gate up | down | status | logs -- manage a named long-lived egress gate. gate up | down | status | logs -- manage a named long-lived egress gate.
ps List sandbox containers. ps List sandbox containers.
@@ -227,6 +245,10 @@ CONFIG
Every default can also be set as an environment variable: Every default can also be set as an environment variable:
AISB_NET, AISB_ALLOW (comma list), AISB_ENV (comma list), AISB_CPUS, AISB_MEMORY, AISB_NET, AISB_ALLOW (comma list), AISB_ENV (comma list), AISB_CPUS, AISB_MEMORY,
AISB_PIDS, AISB_HARNESS, AISB_EXTRA_ARGS, AISB_SELINUX, AISB_HOME_CACHE, ... AISB_PIDS, AISB_HARNESS, AISB_EXTRA_ARGS, AISB_SELINUX, AISB_HOME_CACHE, ...
Base images: AISB_BASE_IMAGE, AISB_BASE_TTL_DAYS (7; 0 every build, -1 never),
AISB_BASE_AUTO_UPDATE (0 keeps the recorded digest), AISB_BASE_PULL_FALLBACK
(0 forbids the 'podman pull' used to learn a digest when skopeo is absent),
AISB_SKOPEO.
EXIT STATUS EXIT STATUS
The exit code of the in-container command. 77/78: the sandbox refused to start The exit code of the in-container command. 77/78: the sandbox refused to start
@@ -253,13 +275,16 @@ cfg_read_kv() { # `key = value` file
key="$(trim "${line%%=*}")"; val="$(trim "${line#*=}")" key="$(trim "${line%%=*}")"; val="$(trim "${line#*=}")"
val="${val%\"}"; val="${val#\"}" val="${val%\"}"; val="${val#\"}"
if [[ " $cfg_accum_keys " == *" $key "* ]]; then if [[ " $cfg_accum_keys " == *" $key "* ]]; then
local -n ref="CFG_$key" # A nameref with a name nothing else uses: bash resolves namerefs by name, so a
ref+=("$val") # local called `ref` anywhere nearby is a way to corrupt config reading.
local -n cfg_target="CFG_$key"
cfg_target+=("$val")
continue continue
fi fi
case "$key" in case "$key" in
net|harness|workdir|cpus|memory|pids|tmpfs_size|base_image|node_version|pi_version|\ net|harness|workdir|cpus|memory|pids|tmpfs_size|base_image|node_version|pi_version|\
project_dest|selinux|home_cache|gate_addr|gate_port|extra_args) CFG["$key"]="$val" ;; project_dest|selinux|home_cache|gate_addr|gate_port|extra_args|base_ttl_days|base_auto_update|\
pi_config|pi_token_env|pi_token_auto|pi_agent_dir) CFG["$key"]="$val" ;;
*) warn "$file: unknown key: $key" ;; *) warn "$file: unknown key: $key" ;;
esac esac
done < "$file" done < "$file"
@@ -297,6 +322,7 @@ OPT_NAME="" OPT_PROXY="" OPT_GATE="" OPT_BASE_IMAGE=""
OPT_NO_CWD=0 OPT_SHELL=0 OPT_DRY=0 OPT_PRINT=0 OPT_NO_BUILD=0 OPT_KEEP=0 OPT_READONLY=0 OPT_NO_CWD=0 OPT_SHELL=0 OPT_DRY=0 OPT_PRINT=0 OPT_NO_BUILD=0 OPT_KEEP=0 OPT_READONLY=0
OPT_HOME_CACHE="" OPT_STRICT="${AISB_STRICT_ARGS:-0}" OPT_UNENFORCED=0 OPT_KEEP_ROOT=0 OPT_HOME_CACHE="" OPT_STRICT="${AISB_STRICT_ARGS:-0}" OPT_UNENFORCED=0 OPT_KEEP_ROOT=0
OPT_TTY="" OPT_INTERACTIVE="" OPT_PRINT_EP=0 OPT_PRINT_CF=0 OPT_PASSTHROUGH=0 OPT_TTY="" OPT_INTERACTIVE="" OPT_PRINT_EP=0 OPT_PRINT_CF=0 OPT_PASSTHROUGH=0
OPT_PI_CONFIG="" OPT_PI_TOKEN_AUTO="" OPT_PI_TOKEN_ENV=""
declare -a OPT_allow=() OPT_env=() OPT_mount=() OPT_install=() OPT_label=() OPT_allow_files=() declare -a OPT_allow=() OPT_env=() OPT_mount=() OPT_install=() OPT_label=() OPT_allow_files=()
COMMAND=() COMMAND=()
@@ -306,7 +332,7 @@ KNOWN_OPTS=(
net allow allow-file proxy no-gate env env-file mount no-cwd workdir home-cache ephemeral-home net allow allow-file proxy no-gate env env-file mount no-cwd workdir home-cache ephemeral-home
harness install base-image cpus memory pids tmpfs-size name label keep read-only shell harness install base-image cpus memory pids tmpfs-size name label keep read-only shell
interactive no-tty dry-run print-policy no-build allow-unenforced strict-args insecure-keep-root interactive no-tty dry-run print-policy no-build allow-unenforced strict-args insecure-keep-root
print-entrypoint print-containerfile help version print-entrypoint print-containerfile pi-token-env no-pi-config no-pi-token-env help version
) )
suggest() { suggest() {
local given="$1" o hit=0 hits="" local given="$1" o hit=0 hits=""
@@ -326,6 +352,10 @@ parse_args() {
--net) need "$1" "${2:-}"; OPT_NET="$2"; shift 2 ;; --net) need "$1" "${2:-}"; OPT_NET="$2"; shift 2 ;;
--net=*) OPT_NET="${1#*=}"; shift ;; --net=*) OPT_NET="${1#*=}"; shift ;;
--harness) need "$1" "${2:-}"; OPT_HARNESS="$2"; shift 2 ;; --harness) need "$1" "${2:-}"; OPT_HARNESS="$2"; shift 2 ;;
--no-pi-config) OPT_PI_CONFIG=0; shift ;;
--no-pi-token-env) OPT_PI_TOKEN_AUTO=0; shift ;;
--pi-token-env) need "$1" "${2:-}"; OPT_PI_TOKEN_ENV="$2"; shift 2 ;;
--pi-token-env=*) OPT_PI_TOKEN_ENV="${1#*=}"; shift ;;
--harness=*) OPT_HARNESS="${1#*=}"; shift ;; --harness=*) OPT_HARNESS="${1#*=}"; shift ;;
--allow) need "$1" "${2:-}"; OPT_allow+=("$2"); shift 2 ;; --allow) need "$1" "${2:-}"; OPT_allow+=("$2"); shift 2 ;;
--allow=*) OPT_allow+=("${1#*=}"); shift ;; --allow=*) OPT_allow+=("${1#*=}"); shift ;;
@@ -403,6 +433,28 @@ resolve_opts() {
POL[pids]="${OPT_PIDS:-${AISB_PIDS:-$(cfgv pids 512)}}" POL[pids]="${OPT_PIDS:-${AISB_PIDS:-$(cfgv pids 512)}}"
POL[tmpfs]="${OPT_TMPFS:-${AISB_TMPFS_SIZE:-$(cfgv tmpfs_size 1g)}}" POL[tmpfs]="${OPT_TMPFS:-${AISB_TMPFS_SIZE:-$(cfgv tmpfs_size 1g)}}"
POL[base_image]="${OPT_BASE_IMAGE:-${AISB_BASE_IMAGE:-$(cfgv base_image docker.io/library/ubuntu:24.04)}}" POL[base_image]="${OPT_BASE_IMAGE:-${AISB_BASE_IMAGE:-$(cfgv base_image docker.io/library/ubuntu:24.04)}}"
# --- pi configuration bridge (see pi_config_bridge) -------------------------
POL[pi_agent_dir]="${AISB_PI_AGENT_DIR:-$(cfgv pi_agent_dir "${HOME%/}/.pi/agent")}"
POL[pi_config]="${OPT_PI_CONFIG:-${AISB_PI_CONFIG:-$(cfgv pi_config 1)}}"
case "${POL[pi_config]}" in 0|false|no) POL[pi_config]=0 ;; *) POL[pi_config]=1 ;; esac
POL[pi_token_env]="${OPT_PI_TOKEN_ENV:-${AISB_PI_TOKEN_ENV:-$(cfgv pi_token_env AI_API_TOKEN)}}"
[[ "${POL[pi_token_env]}" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] \
|| die "pi token variable name is not a valid identifier: '${POL[pi_token_env]}'"
POL[pi_token_auto]="${OPT_PI_TOKEN_AUTO:-${AISB_PI_TOKEN_AUTO:-$(cfgv pi_token_auto 1)}}"
case "${POL[pi_token_auto]}" in 0|false|no) POL[pi_token_auto]=0 ;; *) POL[pi_token_auto]=1 ;; esac
POL[base_ttl]="${AISB_BASE_TTL_DAYS:-$(cfgv base_ttl_days 7)}"
[[ "${POL[base_ttl]}" =~ ^-?[0-9]+$ ]] || { warn "base_ttl_days must be an integer, using 7"; POL[base_ttl]=7; }
POL[base_auto]="${AISB_BASE_AUTO_UPDATE:-$(cfgv base_auto_update 1)}"
case "${POL[base_auto]}" in 0|false|no) POL[base_auto]=0 ;; *) POL[base_auto]=1 ;; esac
# Preview modes must never reach the network: they report the pin that is already
# recorded, which is also what makes the generated Containerfile testable.
if (( OPT_DRY == 0 && OPT_PRINT == 0 && OPT_PRINT_EP == 0 && OPT_PRINT_CF == 0 )); then
POL[base_digest]="$(base_pin_digest "${POL[base_image]}")"
else
POL[base_digest]="$(base_pin_digest "${POL[base_image]}" 0 1)"
fi
POL[base_ref]="$(base_ref_of "${POL[base_image]}" "${POL[base_digest]}")"
POL[node_version]="${AISB_NODE_VERSION:-$(cfgv node_version 22.23.4)}" POL[node_version]="${AISB_NODE_VERSION:-$(cfgv node_version 22.23.4)}"
POL[pi_version]="${AISB_PI_VERSION:-$(cfgv pi_version 1.4.0)}" POL[pi_version]="${AISB_PI_VERSION:-$(cfgv pi_version 1.4.0)}"
POL[selinux]="${AISB_SELINUX-$(cfgv selinux "$AISB_SELINUX_FALLBACK")}" POL[selinux]="${AISB_SELINUX-$(cfgv selinux "$AISB_SELINUX_FALLBACK")}"
@@ -452,7 +504,7 @@ validate_opts() {
fi fi
if [[ "${POL[net]}" == allow && ${#P_allow[@]} -eq 0 && -z "${POL[proxy]}" ]]; then if [[ "${POL[net]}" == allow && ${#P_allow[@]} -eq 0 && -z "${POL[proxy]}" ]]; then
die "no egress allowlist configured. Refusing to start wide open. die "no egress allowlist configured. Refusing to start wide open.
add one: $PROG --allow api.openai.com --env OPENAI_API_KEY add one: $PROG --allow aipi.reeseapps.com --env AI_API_TOKEN
or go dark: $PROG --net none or go dark: $PROG --net none
or opt out: $PROG --net full" or opt out: $PROG --net full"
fi fi
@@ -715,6 +767,43 @@ if [[ "$KEEP_ROOT" == 1 ]]; then
fi fi
command -v setpriv >/dev/null || fail "setpriv is missing from the image; rebuild it" 70 command -v setpriv >/dev/null || fail "setpriv is missing from the image; rebuild it" 70
# --- pi configuration bridge -------------------------------------------------
# ai-sandbox may have generated a models.json / settings.json for pi from the host's
# agent directory (with the credential replaced by an environment reference). They are
# seeded into the sandbox user's home, because pi needs that directory writable: it
# keeps sessions, extensions and /settings edits there.
#
# Seeding runs as the sandbox user, not as container root: HOME belongs to that uid and
# the entrypoint has no CAP_DAC_OVERRIDE, so root could not write there anyway. It is
# also the honest identity -- these are the user's own files.
#
# A file is only replaced when it is missing or still identical to what we seeded last
# time, so an edit made inside the sandbox survives; a change on the host wins next run.
if [[ -d "$POLICY_DIR/pi" ]]; then
setpriv --reuid="$UID_TARGET" --regid="$GID_TARGET" --clear-groups -- /bin/bash -c '
dst="$HOME/.pi/agent"
mkdir -p "$dst" || { echo "[aisb] pi config: cannot create $dst" >&2; exit 0; }
for f in models.json settings.json; do
src="$1/pi/$f"
[ -f "$src" ] || continue
tgt="$dst/$f"; mark="$dst/.aisb-$f.seeded"
want="$(sha256sum "$src" | cut -c1-16)"
cur=""; [ -f "$tgt" ] && cur="$(sha256sum "$tgt" | cut -c1-16)"
prev=""; [ -f "$mark" ] && prev="$(cat "$mark" 2>/dev/null)"
if [ -z "$cur" ]; then
install -m 600 "$src" "$tgt" && printf %s "$want" > "$mark"
echo "[aisb] pi config: seeded $f"
elif [ "$cur" = "$prev" ]; then
install -m 600 "$src" "$tgt" && printf %s "$want" > "$mark"
echo "[aisb] pi config: refreshed $f from the host config"
else
echo "[aisb] pi config: left $f alone (changed inside the sandbox; delete it to take the host version again)"
fi
done
' aisb-seed "$POLICY_DIR" || log "pi config: seeding failed"
fi
exec setpriv --reuid="$UID_TARGET" --regid="$GID_TARGET" --clear-groups \ exec setpriv --reuid="$UID_TARGET" --regid="$GID_TARGET" --clear-groups \
--inh-caps=-all --bounding-set=-all --no-new-privs -- "$@" --inh-caps=-all --bounding-set=-all --no-new-privs -- "$@"
AISB_ENTRY AISB_ENTRY
@@ -733,10 +822,120 @@ exec squid -N -f "$CONF"
AISB_GATE AISB_GATE
} }
# ===========================================================================
# base image pinning
# ===========================================================================
# A tag is a moving pointer: "FROM ubuntu:24.04" does not name an image, it names
# whatever Canonical points at that week. That makes a build unreproducible and, more
# importantly, makes a security patch invisible -- you cannot tell an old image from a
# patched one. So the tag is resolved to a digest once, recorded under
# $AISB_CACHE_DIR/base/, and the digest is what goes into the Containerfile *and* into
# the content hash: refreshing the base produces a new image rather than quietly
# mutating one a sandbox may already be running.
#
# Freshness is a TTL, not a promise. Every AISB_BASE_TTL_DAYS days (default 7) the
# digest is re-resolved, which is a manifest read rather than a download. Offline, or
# with AISB_BASE_AUTO_UPDATE=0, the recorded digest is kept -- a stale pin is safer
# than a mutable one. AISB_BASE_TTL_DAYS=0 re-resolves on every build; -1 never does.
# `ai-sandbox update` does it on demand, `update --check` only reports.
#
# One deliberate asymmetry: an unpinned build is allowed, but it is announced as
# UNPINNED, labelled into the image as such, and warned about by doctor. Silent
# non-reproducibility is the failure mode this section exists to prevent.
base_state_path() { printf '%s/base/%s.env' "$AISB_CACHE_DIR" "$(sha12 "$1")"; }
base_state_field() { local f; f="$(base_state_path "$1")"
[[ -r "$f" ]] || return 0
sed -n "s/^$2=//p" "$f" | head -1; }
base_digest_recorded() { base_state_field "$1" digest; }
base_state_write() { # $1=ref $2=digest
local f; f="$(base_state_path "$1")"
mkdir -p "${f%/*}" || return 1
( umask 077; printf 'ref=%s\ndigest=%s\nresolved_at=%s\n' "$1" "$2" "$(date +%s)" > "$f" )
}
base_age_days() { # $1=ref -> whole days since the pin was recorded, empty if unknown
local at; at="$(base_state_field "$1" resolved_at)"
[[ -n "$at" ]] || return 0
printf '%s' $(( ( $(date +%s) - at ) / 86400 ))
}
base_digest_fresh() { # $1=ref -> the recorded digest while it is inside the TTL
local d age ttl="${POL[base_ttl]:-7}"
d="$(base_digest_recorded "$1")"; [[ -n "$d" ]] || return 0
(( ttl < 0 )) && { printf '%s' "$d"; return 0; } # -1: pinned until told otherwise
age="$(base_age_days "$1")"; age="${age:-$ttl}"
(( age <= ttl )) && printf '%s' "$d"
return 0
}
base_digest_online() { # $1=ref -> digest from the registry, empty when unreachable
local ref="$1" d="" sk="${AISB_SKOPEO:-skopeo}"
if command -v "$sk" > /dev/null 2>&1; then
d="$("$sk" inspect --format '{{.Digest}}' "docker://$ref" 2>/dev/null | head -1)" || d=""
fi
# Without skopeo a pull is the only way to learn a digest, and it costs a download.
# That is fine for an update and rude for an ordinary run, so it can be turned off
# with AISB_BASE_PULL_FALLBACK=0 (the tests do exactly that, to stay offline).
if [[ -z "$d" && "${AISB_BASE_PULL_FALLBACK:-1}" == 1 ]]; then
if podman pull -q "$ref" > /dev/null 2>&1; then
d="$(podman image inspect "$ref" --format '{{.Digest}}' 2>/dev/null)"
fi
fi
printf '%s' "$d"
}
# Echoes the digest to pin to -- empty means unpinned. It is *not* written into POL
# directly because callers use command substitution, and a global assigned in a
# subshell is simply lost; combine it with base_ref_of() instead.
# $1=ref $2=force (re-resolve now) $3=cache_only (never touch the network)
base_pin_digest() {
local ref="$1" force="${2:-0}" cache_only="${3:-0}" auto d=""
if [[ "$ref" == *@* ]]; then printf '%s' "${ref#*@}"; return 0; fi # user pinned it
auto="${POL[base_auto]:-1}"
d="$(base_digest_fresh "$ref")"
if [[ -z "$d" ]] && (( cache_only == 0 )); then
if (( auto )) || (( force )); then
log "resolving the current digest of $ref"
d="$(base_digest_online "$ref")"
if [[ -n "$d" ]]; then
base_state_write "$ref" "$d" \
|| log "warning: could not record the digest under $AISB_CACHE_DIR/base"
fi
fi
fi
[[ -n "$d" ]] || d="$(base_digest_recorded "$ref")"
if [[ -z "$d" ]]; then
(( cache_only )) || log "warning: $ref is UNPINNED -- no recorded digest and the registry was unreachable; the build is not reproducible"
fi
printf '%s' "$d"
}
# A ref is name + tag, plus the digest when there is one. A ref the user pinned
# themselves is passed through untouched rather than rebuilt from its parts.
base_ref_of() {
local ref="$1" digest="$2"
[[ "$ref" == *@* || -z "$digest" ]] && { printf '%s' "$ref"; return 0; }
printf '%s@%s' "$ref" "$digest"
}
# Image tags are named after the base so a non-Ubuntu base does not lie about itself.
image_tag_prefix() { local b="${POL[base_image]##*/}"; b="${b%%[:@]*}"; printf '%s' "${b:-base}"; }
containerfile() { containerfile() {
cat <<AISB_CF cat <<AISB_CF
# Generated by $PROG $VERSION -- edit bin/$PROG, not this file. # Generated by $PROG $VERSION -- edit bin/$PROG, not this file.
FROM ${POL[base_image]} FROM ${POL[base_ref]:-${POL[base_image]}}
# Auditable by one 'podman image inspect' on a container found running weeks later.
LABEL ai-sandbox=1 \\
aisb.version="$VERSION" \\
aisb.base="${POL[base_image]}" \\
aisb.base-digest="${POL[base_digest]:-unpinned}" \\
aisb.node="${POL[node_version]}" \\
aisb.pi-package="${AISB_PI_PACKAGE}" \\
aisb.pi-version="${POL[pi_version]}"
ENV DEBIAN_FRONTEND=noninteractive \\ ENV DEBIAN_FRONTEND=noninteractive \\
LANG=C.UTF-8 LANG=C.UTF-8
@@ -800,6 +999,17 @@ RUN set -eux; \\
RUN git config --system --add safe.directory '*' || true RUN git config --system --add safe.directory '*' || true
# A tag answers "which build"; this answers "which squid, which nftables, which pi"
# long after the build log has rotated. Readable from inside as \$AISB_MANIFEST.
RUN mkdir -p /etc/aisb \\
&& { echo "# $PROG $VERSION package manifest"; \\
dpkg-query -W -f='\${Package}=\${Version}\n'; \\
echo "node=\$(node -v 2>/dev/null || echo unknown)"; \\
npm ls -g --depth=0 --json 2>/dev/null \\
| jq -r '.dependencies // {} | to_entries[] | "npm.\\(.key)=\\(.value.version // "?")"' \\
|| echo 'npm=unavailable'; \\
} > /etc/aisb/manifest.txt
COPY aisb-entrypoint /usr/local/lib/aisb/entrypoint.sh COPY aisb-entrypoint /usr/local/lib/aisb/entrypoint.sh
RUN chmod 0755 /usr/local/lib/aisb/entrypoint.sh RUN chmod 0755 /usr/local/lib/aisb/entrypoint.sh
@@ -809,7 +1019,8 @@ ENV HOME=/home/sandbox \\
npm_config_update_notifier=false \\ npm_config_update_notifier=false \\
GIT_TERMINAL_PROMPT=0 \\ GIT_TERMINAL_PROMPT=0 \\
PIP_DISABLE_PIP_VERSION_CHECK=1 \\ PIP_DISABLE_PIP_VERSION_CHECK=1 \\
AISB=1 AISB=1 \\
AISB_MANIFEST=/etc/aisb/manifest.txt
WORKDIR ${POL[workdir]} WORKDIR ${POL[workdir]}
ENTRYPOINT ["/usr/local/lib/aisb/entrypoint.sh"] ENTRYPOINT ["/usr/local/lib/aisb/entrypoint.sh"]
@@ -820,11 +1031,20 @@ AISB_CF
gate_containerfile() { gate_containerfile() {
cat <<AISB_GCF cat <<AISB_GCF
# Generated by $PROG $VERSION -- egress gate image. # Generated by $PROG $VERSION -- egress gate image.
FROM ${POL[base_image]} FROM ${POL[base_ref]:-${POL[base_image]}}
LABEL ai-sandbox=1 \\
aisb.role=gate \\
aisb.version="$VERSION" \\
aisb.base="${POL[base_image]}" \\
aisb.base-digest="${POL[base_digest]:-unpinned}"
ENV DEBIAN_FRONTEND=noninteractive ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update \\ RUN apt-get update \\
&& apt-get install -y --no-install-recommends squid ca-certificates curl iproute2 \\ && apt-get install -y --no-install-recommends squid ca-certificates curl iproute2 jq \\
&& apt-get clean && rm -rf /var/lib/apt/lists/* && apt-get clean && rm -rf /var/lib/apt/lists/* \\
&& mkdir -p /etc/aisb \\
&& { echo "# $PROG $VERSION gate package manifest"; \\
dpkg-query -W -f='\${Package}=\${Version}\n'; \\
} > /etc/aisb/manifest.txt
COPY aisb-gate-entrypoint /usr/local/lib/aisb/gate-entrypoint.sh COPY aisb-gate-entrypoint /usr/local/lib/aisb/gate-entrypoint.sh
RUN chmod 0755 /usr/local/lib/aisb/gate-entrypoint.sh RUN chmod 0755 /usr/local/lib/aisb/gate-entrypoint.sh
ENTRYPOINT ["/usr/local/lib/aisb/gate-entrypoint.sh"] ENTRYPOINT ["/usr/local/lib/aisb/gate-entrypoint.sh"]
@@ -833,16 +1053,18 @@ AISB_GCF
image_hash() { image_hash() {
local h local h
h="${POL[base_image]:-$AISB_BASE_IMAGE}|${POL[node_version]}|${POL[pi_version]}|${AISB_PI_PACKAGE}|v${VERSION}" # base_ref, not base_image: a refreshed base digest has to change the tag, and a
# bare tag change (24.04 -> 24.10) changes it too because the name is in the hash.
h="${POL[base_ref]:-${POL[base_image]}}|${POL[node_version]}|${POL[pi_version]}|${AISB_PI_PACKAGE}|v${VERSION}"
h+="|$(printf '%s ' ${P_install[@]+"${P_install[@]}"})" h+="|$(printf '%s ' ${P_install[@]+"${P_install[@]}"})"
# any change to the generated image definition produces a new tag # any change to the generated image definition produces a new tag
h+="|$(entrypoint_script | sha256sum | cut -d' ' -f1)" h+="|$(entrypoint_script | sha256sum | cut -d' ' -f1)"
h+="|$(containerfile | sha256sum | cut -d' ' -f1)" h+="|$(containerfile | sha256sum | cut -d' ' -f1)"
sha12 "$h" sha12 "$h"
} }
image_ref() { printf '%s:u24-%s\n' "$AISB_IMAGE_REPO" "$(image_hash)"; } image_ref() { printf '%s:%s-%s\n' "$AISB_IMAGE_REPO" "$(image_tag_prefix)" "$(image_hash)"; }
gate_image_ref() { printf '%s:u24-%s\n' "$AISB_GATE_IMAGE_REPO" \ gate_image_ref() { printf '%s:%s-%s\n' "$AISB_GATE_IMAGE_REPO" "$(image_tag_prefix)" \
"$(sha12 "${POL[base_image]:-$AISB_BASE_IMAGE}|v${VERSION}|squid")"; } "$(sha12 "${POL[base_ref]:-${POL[base_image]}}|v${VERSION}|squid")"; }
node_sha256() { node_sha256() {
[[ -n "${AISB_NODE_SHA256:-}" ]] && { printf '%s' "${AISB_NODE_SHA256}"; return 0; } [[ -n "${AISB_NODE_SHA256:-}" ]] && { printf '%s' "${AISB_NODE_SHA256}"; return 0; }
@@ -866,6 +1088,7 @@ ensure_image() { # $1=sandbox|gate
entrypoint_script > "$ctx/aisb-entrypoint" entrypoint_script > "$ctx/aisb-entrypoint"
chmod 755 "$ctx/aisb-entrypoint" chmod 755 "$ctx/aisb-entrypoint"
local sha; sha="$(node_sha256)" local sha; sha="$(node_sha256)"
[[ -n "$sha" ]] || log "warning: no SHA256 for Node ${POL[node_version]} (nodejs.org unreachable?) -- the tarball will NOT be verified. Set AISB_NODE_SHA256 to pin it yourself."
build_in "$ctx" "$ref" \ build_in "$ctx" "$ref" \
--build-arg "AISB_NODE_SHA256=${sha}" \ --build-arg "AISB_NODE_SHA256=${sha}" \
--build-arg "AISB_EXTRA_PACKAGES=$(printf '%s ' ${P_install[@]+"${P_install[@]}"})" --build-arg "AISB_EXTRA_PACKAGES=$(printf '%s ' ${P_install[@]+"${P_install[@]}"})"
@@ -898,15 +1121,20 @@ build_in() {
} }
cmd_build() { cmd_build() {
local force=0 want_gate=0 local force=0 want_gate=0 refresh_base=0
while (($#)); do while (($#)); do
case "$1" in case "$1" in
--rebuild|-f) force=1; shift ;; --rebuild|-f) force=1; shift ;;
--gate) want_gate=1; shift ;; --gate) want_gate=1; shift ;;
--refresh-base|-B) refresh_base=1; shift ;;
*) die "build: unknown argument $1" ;; *) die "build: unknown argument $1" ;;
esac esac
done done
mkdir -p "$AISB_CACHE_DIR" "$AISB_STATE_DIR" mkdir -p "$AISB_CACHE_DIR" "$AISB_STATE_DIR"
if (( refresh_base )); then
POL[base_digest]="$(base_pin_digest "${POL[base_image]}" 1)"
POL[base_ref]="$(base_ref_of "${POL[base_image]}" "${POL[base_digest]}")"
fi
if (( force )); then if (( force )); then
podman rmi -f "$(image_ref)" >/dev/null 2>&1 || true podman rmi -f "$(image_ref)" >/dev/null 2>&1 || true
[[ $want_gate == 1 ]] && podman rmi -f "$(gate_image_ref)" >/dev/null 2>&1 || true [[ $want_gate == 1 ]] && podman rmi -f "$(gate_image_ref)" >/dev/null 2>&1 || true
@@ -916,6 +1144,165 @@ cmd_build() {
if (( want_gate )); then gref="$(ensure_image gate)"; log "gate image: $gref"; fi if (( want_gate )); then gref="$(ensure_image gate)"; log "gate image: $gref"; fi
} }
# ===========================================================================
# pi configuration bridge
# ===========================================================================
# pi reads <agent-dir>/models.json and <agent-dir>/settings.json, the agent directory
# defaulting to ~/.pi/agent. Inside the sandbox that home is a volume, so without a
# bridge you get a virgin pi with no provider and no preferences.
#
# Copying the host files would be worse than useless: models.json may hold a literal
# key, or a "!pass show ..." command that can only run on the host and would leak the
# secret into a container that is meant to be able to talk to nobody but the gate. So
# the bridge rewrites instead of copying:
#
# * every provider apiKey becomes "$AI_API_TOKEN" (name settable) -- pi itself
# resolves that interpolation at request time, so no value is ever written to disk
# * header values that were "!command" are dropped, never executed
# * settings keys pointing at the host home are dropped rather than left dangling
# * auth.json, models-store.json, sessions, extensions and everything else stay on
# the host -- they are credentials or host-side executables, not preferences
#
# The rewritten pair lands in the session dir (which is mounted at /run/aisb) and the
# entrypoint seeds it into the sandbox user's home. Only the variable *name* crosses
# the boundary in the container's environment; the value travels through the 0600
# env file like any other --env.
declare -a PI_PROVIDER_HOSTS=()
PI_GEN_DIR=""
pi_agent_dir() { printf '%s' "${POL[pi_agent_dir]:-${AISB_PI_AGENT_DIR:-${HOME%/}/.pi/agent}}"; }
# Is pi itself the thing we are about to launch? Only then is a missing credential a
# reason to refuse: --shell, --harness make, or a command after -- all run without it.
# codex/claude are excluded because models.json is pi's file, not theirs.
pi_harness_in_use() {
(( OPT_SHELL )) && return 1
(( ${#COMMAND[@]} )) && return 1
case "${POL[harness]:-pi}" in
pi|pi\ *) return 0 ;;
*) return 1 ;;
esac
}
# baseUrl hostnames the bridged config points at, for the egress hint below.
pi_provider_hosts_of() { # $1=models.json
jq -r '[(.providers // {})[]? | (.baseUrl? // empty),
((.models // [])[]? | .baseUrl? // empty)] | .[]' "$1" 2>/dev/null \
| sed -E 's#^[A-Za-z][A-Za-z0-9+.-]*://##; s#[/?].*$##; s#:[0-9]+$##' | sort -u
}
pi_config_bridge() {
PI_GEN_DIR=""
[[ "${POL[pi_config]}" == 1 ]] || return 0
local src dst tok="${POL[pi_token_env]}"
src="$(pi_agent_dir)"
if [[ ! -d "$src" ]]; then
(( OPT_PRINT || OPT_DRY )) || log "pi config: no agent directory at $src -- pi starts with its own defaults"
return 0
fi
if ! command -v jq > /dev/null 2>&1; then
warn "pi config: jq is not installed, so the pi config was not bridged (or use --no-pi-config to say so deliberately)"
return 0
fi
dst="$SESSION_MOUNT/pi"
mkdir -p "$dst"; chmod 755 "$dst"
PI_GEN_DIR="$dst"
# --- models.json: same providers and models, credential from the environment
if [[ -r "$src/models.json" ]]; then
if ! jq -e . "$src/models.json" > /dev/null 2>&1; then
warn "pi config: $src/models.json is not valid JSON -- pi starts without it"
else
jq --arg env "$tok" '
(.providers // {}) |= with_entries(
if (.value | type) == "object" and (.value | has("apiKey"))
then .value.apiKey = ("$" + $env) else . end)
| (.providers // {}) |= with_entries(
if (.value | type) == "object" and (.value | has("headers"))
then .value.headers = (.value.headers | with_entries(
select(((.value | type) == "string" and (.value | test("^!"))) | not)))
else . end)
' "$src/models.json" > "$dst/models.json" || die "pi config: could not rewrite $src/models.json"
chmod 644 "$dst/models.json"
local rewrote dropped
rewrote="$(jq -r '[(.providers // {}) | to_entries[] | select(.value | has("apiKey")) | .key] | join(", ")' "$src/models.json")"
dropped="$(jq -r '[(.providers // {})[]? | .headers? // {} | to_entries[]
| select((.value | type) == "string" and (.value | test("^!"))) | .key] | join(", ")' "$src/models.json")"
[[ -n "$rewrote" ]] && log "pi config: $rewrote -> apiKey \$$tok"
[[ -n "$dropped" ]] && warn "pi config: dropped header(s) $dropped -- a '!command' runs on the host, not in here"
mapfile -t PI_PROVIDER_HOSTS < <(pi_provider_hosts_of "$src/models.json")
fi
fi
# --- settings.json: preferences travel, host paths do not
if [[ -r "$src/settings.json" ]]; then
if ! jq -e . "$src/settings.json" > /dev/null 2>&1; then
warn "pi config: $src/settings.json is not valid JSON -- pi starts without it"
else
local homep="${HOME%/}" dropped
jq --arg home "$homep" '
with_entries(select(((.value | type) == "string" and (.value | contains($home))) | not))
' "$src/settings.json" > "$dst/settings.json" || die "pi config: could not rewrite $src/settings.json"
chmod 644 "$dst/settings.json"
dropped="$(jq -r --arg home "$homep" '[to_entries[]
| select((.value | type) == "string" and (.value | contains($home))) | .key] | join(", ")' "$src/settings.json")"
[[ -n "$dropped" ]] && warn "pi config: dropped settings $dropped -- they point inside the host home, which is not mounted"
fi
fi
[[ -f "$dst/models.json" || -f "$dst/settings.json" ]] || { rmdir "$dst" 2>/dev/null || true; PI_GEN_DIR=""; return 0; }
# --- the credential: exactly one variable, named by the config we just wrote
local spec in_list=0
for spec in ${P_env[@]+"${P_env[@]}"}; do
[[ "$spec" == "$tok" || "$spec" == "$tok="* ]] && in_list=1
done
if (( in_list == 0 )) && (( POL[pi_token_auto] )) && [[ -n "${!tok:-}" ]]; then
P_env+=("$tok")
log "pi config: passing \$$tok from the host because the bridged config asks for it (--no-pi-token-env to stop)"
in_list=1
fi
# A bridged provider with no credential in reach is a run where pi starts and then
# says nothing, so it is refused rather than attempted -- except in preview, which
# reports and carries on so a policy can be inspected before the secret exists.
if (( in_list == 0 )) && pi_harness_in_use && [[ -f "$dst/models.json" ]]; then # unset or empty: both useless to pi
if (( OPT_PRINT || OPT_DRY )); then
warn "pi config: \$$tok will not reach the container -- pi would have no credential"
else
die "pi config asks for \$$tok, but it is not going to reach the container$([[ -n "${!tok:-}" ]] && printf ' (it is set on the host and auto-passing is off)' || printf ' (it is unset or empty in the host environment)').
export $tok=... and re-run
or add to $(cfg_or_home): env = $tok
or pass it explicitly: --env $tok
or stop bridging: --no-pi-config"
fi
fi
return 0
}
cfg_or_home() { printf '%s/config' "$AISB_CONFIG_DIR"; }
# The one failure mode that looks like a broken sandbox rather than a missing
# allowlist entry: pi starts, says nothing, and the user blames the model.
pi_egress_hint() {
[[ "${POL[net]}" == allow ]] || return 0
pi_harness_in_use || return 0
((${#PI_PROVIDER_HOSTS[@]})) || return 0
local h a not_allowed=""
for h in "${PI_PROVIDER_HOSTS[@]}"; do
local hit=0
for a in ${P_allow[@]+"${P_allow[@]}"}; do
[[ "${a%%:*}" == "$h" || "${a%%:*}" == "*.$h" || "${a%%:*}" == "*" ]] && hit=1
done
(( hit )) || not_allowed="${not_allowed:+$not_allowed }$h"
done
[[ -n "$not_allowed" ]] && warn "pi config: the model endpoint is not in the allowlist ($not_allowed). pi will start and be unable to answer -- add --allow ${not_allowed%% *} or put it in .ai-sandbox/allow"
return 0
}
# =========================================================================== # ===========================================================================
# egress gate # egress gate
# =========================================================================== # ===========================================================================
@@ -1057,7 +1444,7 @@ default_command() {
# nothing given at all: just start the harness (or a shell) # nothing given at all: just start the harness (or a shell)
if ((${#hb[@]})); then COMMAND=("${hb[@]}"); else COMMAND=(bash -li); fi if ((${#hb[@]})); then COMMAND=("${hb[@]}"); else COMMAND=(bash -li); fi
elif [[ "${OPT_PASSTHROUGH:-0}" == 1 && ${#hb[@]} -gt 0 ]]; then elif [[ "${OPT_PASSTHROUGH:-0}" == 1 && ${#hb[@]} -gt 0 ]]; then
# arguments meant for the harness, e.g. `pi --model sonnet` # arguments meant for the harness, e.g. `pi --model turbo`
COMMAND=("${hb[@]}" "${COMMAND[@]}") COMMAND=("${hb[@]}" "${COMMAND[@]}")
fi fi
# otherwise -- was used: the command replaces the harness # otherwise -- was used: the command replaces the harness
@@ -1123,6 +1510,10 @@ build_argv() {
fi fi
[[ -s "$ENV_FILE" ]] && ARGV+=(--env-file "$ENV_FILE") [[ -s "$ENV_FILE" ]] && ARGV+=(--env-file "$ENV_FILE")
# The pi bridge rides the policy mount: $SESSION/pi shows up as /run/aisb/pi, where
# the entrypoint picks it up. No extra mount, and it inherits the read-only policy.
[[ -n "${PI_GEN_DIR:-}" && -d "${PI_GEN_DIR}" ]] && ARGV+=(--label aisb.pi-config=bridged)
[[ "${OPT_NO_CWD:-0}" == 1 ]] || ARGV+=(-v "${POL[cwd]}:${POL[workdir]}${SELOPT}") [[ "${OPT_NO_CWD:-0}" == 1 ]] || ARGV+=(-v "${POL[cwd]}:${POL[workdir]}${SELOPT}")
ARGV+=(-v "${SESSION_MOUNT}:/run/aisb:ro${SELOPT_SEP}") ARGV+=(-v "${SESSION_MOUNT}:/run/aisb:ro${SELOPT_SEP}")
if [[ "${POL[home_cache]}" == 1 ]]; then if [[ "${POL[home_cache]}" == 1 ]]; then
@@ -1176,6 +1567,16 @@ print_argv() {
print_policy() { print_policy() {
printf 'image=%s\n' "${POL[image_ref]}" printf 'image=%s\n' "${POL[image_ref]}"
printf 'gate_image=%s\n' "$(gate_image_ref)" printf 'gate_image=%s\n' "$(gate_image_ref)"
if [[ -n "${PI_GEN_DIR:-}" ]]; then
printf 'pi_config=%s\n' "bridged from $(pi_agent_dir)"
local _ptv="${POL[pi_token_env]}"
printf 'pi_token_env=%s (%s)\n' "$_ptv" \
"$([[ -n "${!_ptv:-}" ]] && printf set || { [[ -v "$_ptv" ]] && printf 'set but empty' || printf 'NOT SET'; })"
((${#PI_PROVIDER_HOSTS[@]})) && printf 'pi_endpoints=%s\n' "${PI_PROVIDER_HOSTS[*]}"
fi
printf 'base=%s\n' "${POL[base_image]}"
printf 'base_digest=%s\n' "${POL[base_digest]:-unpinned}"
printf 'base_age_days=%s\n' "$(base_age_days "${POL[base_image]}")"
printf 'container=%s\n' "${POL[name]}" printf 'container=%s\n' "${POL[name]}"
printf 'net=%s\n' "${POL[net]}" printf 'net=%s\n' "${POL[net]}"
printf 'proxy=%s\n' "${PROXY_IPS[*]-none}:${PROXY_PORTS[*]-none}" printf 'proxy=%s\n' "${PROXY_IPS[*]-none}:${PROXY_PORTS[*]-none}"
@@ -1228,8 +1629,10 @@ cmd_run() {
fi fi
resolve_proxy_endpoints resolve_proxy_endpoints
pi_config_bridge
build_env_file build_env_file
write_policy_files write_policy_files
pi_egress_hint
build_argv build_argv
if (( OPT_PRINT )); then print_policy; exit 0; fi if (( OPT_PRINT )); then print_policy; exit 0; fi
@@ -1305,11 +1708,53 @@ cmd_doctor() {
local ref; ref="$(image_ref)" local ref; ref="$(image_ref)"
if podman image exists "$ref"; then chk PASS "sandbox image present: $ref" if podman image exists "$ref"; then chk PASS "sandbox image present: $ref"
else chk WARN "sandbox image not built: $ref (run: $PROG build)"; fi else chk WARN "sandbox image not built: $ref (run: $PROG build)"; fi
# Base pinning: an image built against a *different* digest than the one recorded now
# is an image of the previous base -- worth saying out loud, that is how a patched
# base quietly fails to arrive.
local pdig; pdig="${POL[base_digest]:-unpinned}"
if [[ "$pdig" == unpinned ]]; then
chk WARN "base image ${POL[base_image]} is unpinned (no digest recorded, registry unreachable?)"
else
chk INFO "base pin: ${POL[base_image]} @ ${pdig:0:19}..., $(base_age_days "${POL[base_image]}") day(s) old (ttl ${POL[base_ttl]})"
fi
if command -v "${AISB_SKOPEO:-skopeo}" > /dev/null 2>&1; then
chk OK "digest lookups available: $("${AISB_SKOPEO:-skopeo}" --version | cut -d' ' -f1-3)"
else
chk WARN "skopeo is missing: refreshing a base digest would need a full 'podman pull'"
fi
if podman image exists "$ref"; then
local built; built="$(podman image inspect "$ref" --format '{{ index .Config.Labels "aisb.base-digest" }}' 2>/dev/null)"
case "$built" in
"") chk INFO "image $ref predates label tracking" ;;
"$pdig") chk OK "image $ref was built on the pinned base" ;;
*) chk WARN "image $ref was built on ${built:0:19}... but the pin is now ${pdig:0:19}... (run: $PROG update)" ;;
esac
fi
if podman image exists "$(gate_image_ref)"; then chk PASS "gate image present: $(gate_image_ref)" if podman image exists "$(gate_image_ref)"; then chk PASS "gate image present: $(gate_image_ref)"
else chk WARN "gate image not built: $(gate_image_ref) (built on first use)"; fi else chk WARN "gate image not built: $(gate_image_ref) (built on first use)"; fi
chk INFO "policy: net=${POL[net]} harness=${POL[harness]} limits=cpus:${POL[cpus]},memory:${POL[memory]},pids:${POL[pids]}" chk INFO "policy: net=${POL[net]} harness=${POL[harness]} limits=cpus:${POL[cpus]},memory:${POL[memory]},pids:${POL[pids]}"
chk INFO "dirs: config=$AISB_CONFIG_DIR state=$AISB_STATE_DIR cache=$AISB_CACHE_DIR" chk INFO "dirs: config=$AISB_CONFIG_DIR state=$AISB_STATE_DIR cache=$AISB_CACHE_DIR"
# pi bridge, reported read-only: doctor must not generate a session to say this.
local padir="${POL[pi_agent_dir]}" ptok="${POL[pi_token_env]}"
if [[ "${POL[pi_config]}" != 1 ]]; then
chk INFO "pi config: bridging is off (pi_config=0 or --no-pi-config)"
elif [[ ! -d "$padir" ]]; then
chk INFO "pi config: no agent directory at $padir -- pi starts with its own defaults"
else
local -a ph=()
if command -v jq > /dev/null 2>&1 && [[ -r "$padir/models.json" ]]; then
mapfile -t ph < <(pi_provider_hosts_of "$padir/models.json")
fi
chk INFO "pi config: bridged from $padir (endpoints: ${ph[*]:-none})"
if [[ -n "${!ptok:-}" ]]; then
chk OK "pi credential: \$$ptok is set and will be passed by name"
else
chk WARN "pi credential: \$$ptok is unset or empty -- a bridged pi run is refused (export it, add it to $(cfg_or_home), or use --no-pi-config)"
fi
fi
if ((${#P_allow[@]})); then if ((${#P_allow[@]})); then
chk INFO "allowlist from config/env: ${P_allow[*]}" chk INFO "allowlist from config/env: ${P_allow[*]}"
else else
@@ -1347,6 +1792,24 @@ cmd_doctor() {
rm -rf "$d" rm -rf "$d"
fi fi
# With --deep, ask the registry whether the pin is still current. Deliberately
# skopeo-only here: the podman fallback would download the whole base inside a
# diagnostic, which is not what someone typing --deep signed up for.
if (( deep )); then
if command -v "${AISB_SKOPEO:-skopeo}" > /dev/null 2>&1; then
local remote; remote="$(base_digest_online "${POL[base_image]}")"
if [[ -z "$remote" ]]; then
chk WARN "deep check: the registry is unreachable, cannot confirm the base is current"
elif [[ "$remote" == "${POL[base_digest]:-}" ]]; then
chk PASS "deep check: the base pin matches upstream"
else
chk WARN "deep check: upstream is ${remote:0:19}..., the pin is ${POL[base_digest]:-none} (run: $PROG update)"
fi
else
chk INFO "deep check: skipped the upstream digest comparison (needs skopeo)"
fi
fi
if (( deep )) && (($DOC_FAIL == 0)); then if (( deep )) && (($DOC_FAIL == 0)); then
chk INFO "deep check: does the sandbox actually block non-allowlisted egress?" chk INFO "deep check: does the sandbox actually block non-allowlisted egress?"
local out rc=0 local out rc=0
@@ -1422,17 +1885,76 @@ cmd_clean() {
log "clean done ($(podman ps -a --filter label=ai-sandbox=1 -q | wc -l) sandbox containers left)" log "clean done ($(podman ps -a --filter label=ai-sandbox=1 -q | wc -l) sandbox containers left)"
} }
# Re-resolve the base digest on demand, and build whatever that implies. The old
# image is deliberately left in place: if the new base misbehaves, the previous tag is
# still there to fall back to, and pruning it is a decision for after the check.
cmd_update() {
local check=0 want_gate=1
while (($#)); do
case "$1" in
--check) check=1; shift ;;
--no-gate) want_gate=0; shift ;;
*) die "update: unknown argument $1 (try --check)" ;;
esac
done
mkdir -p "$AISB_CACHE_DIR" "$AISB_STATE_DIR"
local old_ref old_gref old_digest new_ref new_gref
old_ref="$(image_ref)"; old_gref="$(gate_image_ref)"; old_digest="${POL[base_digest]:-none}"
if (( check )); then
local remote; remote="$(base_digest_online "${POL[base_image]}")"
printf 'base=%s\n' "${POL[base_image]}"
printf 'recorded=%s\n' "${POL[base_digest]:-unpinned}"
printf 'upstream=%s\n' "${remote:-unreachable}"
if [[ -z "$remote" ]]; then
printf 'status=could not reach the registry\n'; return 0
elif [[ "$remote" == "${POL[base_digest]}" ]]; then
printf 'status=up to date\nimage=%s\n' "$old_ref"; return 0
else
# Show the tag the update would produce without recording anything, so the
# decision to rebuild is at least an informed one.
POL[base_ref]="${POL[base_image]}@$remote"
printf 'status=an update is available (a rebuild would produce a new tag)\n'
printf 'rebuilt_image=%s\n' "$(image_ref)"
return 0
fi
fi
POL[base_digest]="$(base_pin_digest "${POL[base_image]}" 1)"
POL[base_ref]="$(base_ref_of "${POL[base_image]}" "${POL[base_digest]}")"
new_ref="$(image_ref)"; new_gref="$(gate_image_ref)"
printf 'base %s\n was %s\n now %s\n' "${POL[base_image]}" "$old_digest" "${POL[base_digest]:-unpinned}"
if [[ "$new_ref" == "$old_ref" ]]; then
printf 'sandbox image unchanged: %s\n' "$new_ref"
else
printf 'sandbox image %s -> %s\n' "$old_ref" "$new_ref"
fi
if (( want_gate )); then
if [[ "$new_gref" == "$old_gref" ]]; then
printf 'gate image unchanged: %s\n' "$new_gref"
else
printf 'gate image %s -> %s\n' "$old_gref" "$new_gref"
fi
fi
local ref gref
ref="$(ensure_image sandbox)"; log "built $ref"
if (( want_gate )); then gref="$(ensure_image gate)"; log "built $gref"; fi
printf 'the previous image is kept for rollback; remove it with: podman rmi %s\n' "$old_ref"
}
cmd_inspect() { cmd_inspect() {
mkdir -p "$AISB_STATE_DIR" mkdir -p "$AISB_STATE_DIR" "$AISB_CACHE_DIR"
load_config load_config
parse_args "$@" parse_args "$@"
session_init session_init
trap teardown EXIT
resolve_opts resolve_opts
set_selinux "${POL[selinux]}" set_selinux "${POL[selinux]}"
POL[image_ref]="$(image_ref)" POL[image_ref]="$(image_ref)"
classify_allows classify_allows
GATE_PORT="${POL[gate_port]}" GATE_PORT="${POL[gate_port]}"
resolve_proxy_endpoints resolve_proxy_endpoints
pi_config_bridge
build_env_file build_env_file
print_policy print_policy
} }
@@ -1447,6 +1969,7 @@ main() {
gate) shift; cmd_gate "$@" ;; gate) shift; cmd_gate "$@" ;;
ps) shift; cmd_ps ;; ps) shift; cmd_ps ;;
clean) shift; cmd_clean "$@" ;; clean) shift; cmd_clean "$@" ;;
update) shift; load_config; session_init; resolve_opts; cmd_update "$@" ;;
inspect) shift; cmd_inspect "$@" ;; inspect) shift; cmd_inspect "$@" ;;
version|-V) printf '%s %s\n' "$PROG" "$VERSION" ;; version|-V) printf '%s %s\n' "$PROG" "$VERSION" ;;
help|-h|--help) usage ;; help|-h|--help) usage ;;
+3 -2
View File
@@ -10,11 +10,12 @@
# 10.20.0.0/24 pinned subnet (default ports 80,443 unless :port given) # 10.20.0.0/24 pinned subnet (default ports 80,443 unless :port given)
# [fd00::5]:8443 pinned ipv6 # [fd00::5]:8443 pinned ipv6
api.anthropic.com aipi.reeseapps.com # your model gateway
registry.npmjs.org registry.npmjs.org
# typical additions, by kind: # typical additions, by kind:
# api.openai.com # another gateway or a LAN inference host:
# gateway.internal:8443
# github.com # git push, if you also mount ~/.ssh # github.com # git push, if you also mount ~/.ssh
# pypi.org # pypi.org
# files.pythonhosted.org # files.pythonhosted.org
+30 -2
View File
@@ -33,9 +33,37 @@ net = allow
# tmpfs_size = 1g # tmpfs_size = 1g
# --- image ----------------------------------------------------------------- # --- image -----------------------------------------------------------------
# One base for both images (the gate is squid from this distro's apt, not a
# third-party image). base_image may itself be pinned: name@sha256:...
# base_image = docker.io/library/ubuntu:24.04 # base_image = docker.io/library/ubuntu:24.04
# node_version = 22.23.4 # node_version = 22.23.4
# pi_version = 1.4.0 # pi_version = 1.4.0
#
# The tag is resolved to a registry digest and that digest becomes part of the
# image tag, so builds are reproducible and a base patch is visible instead of
# quietly folding into an existing image. Every base_ttl_days the digest is
# re-resolved -- a manifest read, not a download. 0 re-resolves on every build,
# -1 never does.
# base_ttl_days = 7
#
# 0 keeps the recorded digest even past its TTL: nothing reaches the registry
# unless you say so. An explicit refresh is `ai-sandbox update [--check]`.
# base_auto_update = 1
# --- pi --------------------------------------------------------------------
# Bridge ~/.pi/agent/{models,settings}.json into the sandbox, with every provider
# apiKey replaced by an environment reference (nothing secret is written into the
# container). 0 = pi starts with its own defaults.
# pi_config = 1
#
# The variable the bridged config reads the credential from, and whether ai-sandbox
# passes that one variable automatically when it is set. If it reaches neither, the
# run is refused instead of launching a pi that cannot talk to its model.
# pi_token_env = AI_API_TOKEN
# pi_token_auto = 1
#
# Where to read the host config from (default ~/.pi/agent).
# pi_agent_dir = ~/.pi/agent
# --- behaviour ------------------------------------------------------------- # --- behaviour -------------------------------------------------------------
# SELinux bind-mount option: ':z' (shared), ':Z' (private) or empty to disable. # SELinux bind-mount option: ':z' (shared), ':Z' (private) or empty to disable.
@@ -52,11 +80,11 @@ net = allow
# --- repeatable keys ------------------------------------------------------- # --- repeatable keys -------------------------------------------------------
# These may appear more than once, and are merged with --allow / --env / etc. # These may appear more than once, and are merged with --allow / --env / etc.
# #
# allow = api.anthropic.com # allow = aipi.reeseapps.com
# allow = registry.npmjs.org:443 # allow = registry.npmjs.org:443
# allow = 10.20.0.5:5432 # allow = 10.20.0.5:5432
# #
# env = ANTHROPIC_API_KEY # env = AI_API_TOKEN
# env = CI_JOB_TOKEN # env = CI_JOB_TOKEN
# #
# mount = ~/.ssh:ro # mount = ~/.ssh:ro
+3 -3
View File
@@ -6,6 +6,6 @@
# Nothing here is passed to the container unless you ask for it with --env or # Nothing here is passed to the container unless you ask for it with --env or
# --env-file, so listing a variable is not the same as granting it. # --env-file, so listing a variable is not the same as granting it.
# #
# ANTHROPIC_API_KEY=sk-ant-... # AI_API_TOKEN=... your gateway's token
# OPENAI_API_KEY=sk-... # AI_API_TOKEN_FILE=... if a tool prefers a file over a variable
# ANTHROPIC_MODEL=sonnet # AI_MODEL=turbo model id your gateway serves
+59 -19
View File
@@ -7,7 +7,14 @@ Concrete invocations. `pi` below is whatever you aliased to `ai-sandbox`.
```bash ```bash
cd ~/Projects/myapp cd ~/Projects/myapp
ai-sandbox doctor # is this machine ready? ai-sandbox doctor # is this machine ready?
pi --allow api.anthropic.com --env ANTHROPIC_API_KEY pi --allow aipi.reeseapps.com --env AI_API_TOKEN
```
With your pi config bridged, the token is found on its own, so the allowlist is the
only thing you have to say:
```bash
pi --allow aipi.reeseapps.com
``` ```
Same thing, but remembered by the repo (so teammates review the policy, not just Same thing, but remembered by the repo (so teammates review the policy, not just
@@ -15,25 +22,58 @@ your shell history):
```bash ```bash
mkdir -p .ai-sandbox mkdir -p .ai-sandbox
printf 'api.anthropic.com\n' > .ai-sandbox/allow printf 'aipi.reeseapps.com\n' > .ai-sandbox/allow
pi --env ANTHROPIC_API_KEY pi
``` ```
## Pi's own configuration
The bridge rewrites your host `~/.pi/agent/{models,settings}.json` instead of copying
it: providers, models, thinking budgets and preferences come along, the credential
becomes `"$AI_API_TOKEN"`, and anything that only works on the host (a `!command`
header, a path into `~`) is dropped with a warning. `auth.json` is never bridged.
```bash
# the everyday case: same provider as the host, token from the environment
pi --allow aipi.reeseapps.com
# a model your gateway serves, chosen per run
pi --allow aipi.reeseapps.com --model juggernaut
# keep the model's endpoint pinned by IP as well, on a LAN inference host
pi --allow aipi.reeseapps.com --allow 10.20.0.7:11434
# a different variable name for the credential
pi --allow aipi.reeseapps.com --pi-token-env GW_TOKEN
# do not hand the token over automatically: the run then fails closed unless you
# pass it yourself, which is what you want in shared scripts
pi --allow aipi.reeseapps.com --env AI_API_TOKEN --no-pi-token-env
# no bridge: pi starts with its own defaults inside the volume
pi --no-pi-config --allow aipi.reeseapps.com
```
Edits made inside the sandbox are kept; changes to the host config are picked up on
the next run; deleting `~/.pi/agent/models.json` inside the sandbox re-seeds it.
## Per harness ## Per harness
```bash ```bash
# pi (baked into the image) # pi (baked into the image, config bridged)
pi --allow aipi.reeseapps.com
# pi with a model chosen on the command line
pi --harness 'pi --model turbo' --allow aipi.reeseapps.com
# a gateway that speaks a different protocol still needs only its host
pi --allow gateway.internal:8443 --env AI_API_TOKEN
# OpenAI Codex CLI / Claude Code: they read their own config files, so mount or
# generate what they need and allow their endpoints; the pi bridge does not apply
pi --allow chatgpt.com --env CODEX_API_KEY
pi --allow api.anthropic.com --env ANTHROPIC_API_KEY pi --allow api.anthropic.com --env ANTHROPIC_API_KEY
# pi with a proxy/harness you pass explicitly
pi --harness 'pi --model sonnet' --allow api.anthropic.com --env ANTHROPIC_API_KEY
# OpenAI Codex CLI (install it into the image with --install or use a custom base)
pi --allow chatgpt.com --allow api.openai.com --env OPENAI_API_KEY
# Claude Code
pi --allow api.anthropic.com --env ANTHROPIC_API_KEY --env CLAUDE_CODE_OAUTH_TOKEN
# just a shell in the sandbox # just a shell in the sandbox
pi --shell pi --shell
@@ -64,7 +104,7 @@ pi --allow proxy.golang.org --allow sum.golang.org
pi --allow registry-1.docker.io --allow auth.docker.io --allow production.cloudflare.docker.com pi --allow registry-1.docker.io --allow auth.docker.io --allow production.cloudflare.docker.com
# a project that also needs its dev database (non-HTTP: pinned IP + port) # a project that also needs its dev database (non-HTTP: pinned IP + port)
pi --allow api.anthropic.com --allow 10.20.0.5:5432 --env ANTHROPIC_API_KEY pi --allow aipi.reeseapps.com --allow 10.20.0.5:5432
``` ```
## Git ## Git
@@ -77,7 +117,7 @@ pi --allow github.com --env GH_TOKEN -- git push origin main
pi --allow github.com --mount ~/.ssh:ro -- git push origin main pi --allow github.com --mount ~/.ssh:ro -- git push origin main
# read-only view of a sibling repo for context # read-only view of a sibling repo for context
pi --allow api.anthropic.com --env ANTHROPIC_API_KEY --mount ../docs:/docs:ro pi --allow aipi.reeseapps.com --mount ../docs:/docs:ro
``` ```
## Tightening a run ## Tightening a run
@@ -96,12 +136,12 @@ pi --strict-args --net allow --allow-file .ai-sandbox/allow --env-file .ci/env -
## Long-lived gate shared by several sandboxes ## Long-lived gate shared by several sandboxes
```bash ```bash
ai-sandbox gate up --allow api.anthropic.com --allow registry.npmjs.org ai-sandbox gate up --allow aipi.reeseapps.com --allow registry.npmjs.org
# name=aisb-gate # name=aisb-gate
# port=18081 # port=18081
# proxy_env=export HTTPS_PROXY=http://100.64.0.1:18081 # proxy_env=export HTTPS_PROXY=http://100.64.0.1:18081
ai-sandbox --proxy 100.64.0.1:18081 --allow api.anthropic.com --shell ai-sandbox --proxy 100.64.0.1:18081 --allow aipi.reeseapps.com --shell
ai-sandbox gate logs # what did it allow/deny? ai-sandbox gate logs # what did it allow/deny?
ai-sandbox gate down ai-sandbox gate down
``` ```
@@ -132,13 +172,13 @@ harness = pi
cpus = 8 cpus = 8
memory = 8g memory = 8g
mount = ../specs:/specs:ro mount = ../specs:/specs:ro
env = ANTHROPIC_API_KEY env = AI_API_TOKEN
``` ```
`.ai-sandbox/allow`: `.ai-sandbox/allow`:
``` ```
api.anthropic.com aipi.reeseapps.com
registry.npmjs.org registry.npmjs.org
``` ```
+10
View File
@@ -70,6 +70,16 @@ Be honest about these, and design your work accordingly.
10. **Rootless podman still means shared kernel and shared UID namespace.** Files 10. **Rootless podman still means shared kernel and shared UID namespace.** Files
the container writes are owned by *you*. There is no uid boundary between the container writes are owned by *you*. There is no uid boundary between
"agent's file" and "your file" beyond ownership metadata and your own discipline. "agent's file" and "your file" beyond ownership metadata and your own discipline.
11. **The pi bridge reads a file that may hold a live key.** It rewrites `apiKey` to
an environment reference and never copies `auth.json`, but the *endpoint* in your
`models.json` becomes an endpoint the sandbox can be pointed at, and the token it
uses is your real one. So the bridged config is a convenience, not a narrower
credential: prefer a per-sandbox token from your gateway if it can mint one, and
note that a bridged run whose token variable is unset or empty is refused rather
than started. Sandboxed edits to `~/.pi/agent` persist in the home volume and are
kept over later host changes (see the README), which is convenience with a
consequence: a hostile run could rewrite the sandboxed pi config for later runs of
that volume. `--ephemeral-home` removes that.
## Residual risk rating ## Residual risk rating
+1 -1
View File
@@ -56,7 +56,7 @@ else
echo echo
echo "To use it as a harness wrapper, add this to $rcfile:" echo "To use it as a harness wrapper, add this to $rcfile:"
echo " alias pi='$target'" echo " alias pi='$target'"
echo "then: source $rcfile && cd your/project && pi --allow api.anthropic.com --env ANTHROPIC_API_KEY" echo "then: source $rcfile && cd your/project && pi --allow aipi.reeseapps.com --env AI_API_TOKEN"
fi fi
echo echo
+25
View File
@@ -23,6 +23,31 @@ sb --print-policy --net none --install hexd > /dev/null
IMG3="$("$AISB" --print-policy --net none --install hexd | grep '^image=' | cut -d= -f2)" IMG3="$("$AISB" --print-policy --net none --install hexd | grep '^image=' | cut -d= -f2)"
[[ "$IMG3" != "$IMG" ]] && _ok "extra packages produce a different tag" || _bad "extra packages change the tag" [[ "$IMG3" != "$IMG" ]] && _ok "extra packages produce a different tag" || _bad "extra packages change the tag"
t_name "the image says what it was built from"
BASE_DIGEST="$("$AISB" --print-policy --net none 2>/dev/null | grep '^base_digest=' | cut -d= -f2)"
if curl -m8 -sS -o /dev/null http://example.com 2>/dev/null && [[ "$BASE_DIGEST" == sha256:* ]]; then
_ok "the base was pinned to $BASE_DIGEST"
LABEL_DIGEST="$(podman image inspect "$IMG" --format '{{ index .Config.Labels "aisb.base-digest" }}' 2>/dev/null)"
assert_eq "$BASE_DIGEST" "$LABEL_DIGEST" "the image label carries the same digest"
else
echo " (skipped the digest assertions: registry unreachable or base unpinned)"
fi
OUT="$(podman image inspect "$IMG" --format '{{ range $k, $v := .Config.Labels }}{{$k}}={{$v}} {{end}}' 2>&1)"
assert_contains "aisb.node=" "the Node version is recorded on the image"
assert_contains "aisb.pi-version=" "and the harness version"
OUT="$(podman run --rm --network none --entrypoint cat "$IMG" /etc/aisb/manifest.txt 2>&1)"
assert_contains "nftables=" "the manifest records the resolved nftables version"
assert_contains "node=v" "and the Node version actually installed"
assert_contains "pi-coding-agent" "and the harness package version"
sb build --gate
GIMG="$("$AISB" --print-policy --net none 2>/dev/null | grep '^gate_image=' | cut -d= -f2)"
podman image exists "$GIMG" && _ok "the gate image exists: $GIMG" || _bad "the gate image exists"
OUT="$(podman image inspect "$GIMG" --format '{{ index .Config.Labels "aisb.role" }}' 2>&1)"
assert_eq "gate" "$(printf '%s' "$OUT" | tail -1)" "the gate image is labelled as the gate"
OUT="$(podman run --rm --network none --entrypoint cat "$GIMG" /etc/aisb/manifest.txt 2>&1)"
assert_contains "squid=" "and its manifest records the squid version doing the filtering"
t_name "the entrypoint fails closed without a policy" t_name "the entrypoint fails closed without a policy"
podman run --rm --network none --cap-drop=ALL "$IMG" true > "$WORK/fc.out" 2>&1 podman run --rm --network none --cap-drop=ALL "$IMG" true > "$WORK/fc.out" 2>&1
assert_eq 78 "$?" "no /run/aisb policy -> exit 78, nothing ran" assert_eq 78 "$?" "no /run/aisb policy -> exit 78, nothing ran"
+61
View File
@@ -147,8 +147,69 @@ LOG="$(cat "$AISB_STATE_DIR/log" 2>/dev/null || true)"
printf '%s' "$LOG" | grep -q 'net=none' && _ok "the log records the run" || _bad "the log records the run" printf '%s' "$LOG" | grep -q 'net=none' && _ok "the log records the run" || _bad "the log records the run"
printf '%s' "$LOG" | grep -q 'canary-value-42' && _bad "secrets must never reach the log" || _ok "no secrets in the log" printf '%s' "$LOG" | grep -q 'canary-value-42' && _bad "secrets must never reach the log" || _ok "no secrets in the log"
t_name "the pi config bridge lands inside the container"
AGENT="$WORK/agent"; mkdir -p "$AGENT"
cat > "$AGENT/models.json" <<EOF
{ "providers": { "gateway": { "baseUrl": "https://aipi.reeseapps.com/v1",
"api": "openai-completions", "apiKey": "!pass show gateway",
"models": [ { "id": "turbo", "contextWindow": 262144 } ] } } }
EOF
printf '{ "defaultProvider": "gateway", "defaultModel": "turbo", "theme": "dark/dark" }\n' > "$AGENT/settings.json"
printf '{ "gateway": "HOSTSECRET-must-not-travel" }' > "$AGENT/auth.json"
export AISB_PI_AGENT_DIR="$AGENT" AISB_PI_TOKEN_ENV=AI_API_TOKEN AISB_PI_TOKEN_AUTO=1
export AI_API_TOKEN=container-token-777
export AISB_HOME_VOLUME=aisb-test-pihome
sb $DARK --harness pi -- bash -lc '
f="$HOME/.pi/agent/models.json"
[ -f "$f" ] && echo seeded=yes || echo seeded=no
[ "$(id -u)" = 1000 ] || echo uid=wrong
echo "mode=$(stat -c %a "$f" 2>/dev/null)"
grep -q "\$AI_API_TOKEN" "$f" && echo ref=env || echo ref=other
grep -q "pass show" "$f" && echo LEAKED_CMD || echo cmd=gone
grep -q "HOSTSECRET" "$f" && echo LEAKED_KEY || echo key=gone
[ -f "$HOME/.pi/agent/auth.json" ] && echo LEAKED_AUTH || echo auth=absent
grep -q "turbo" "$HOME/.pi/agent/settings.json" && echo settings=yes
[ -n "$AI_API_TOKEN" ] && echo token=present || echo token=missing'
assert_contains "seeded=yes" "pi's config is seeded into the sandbox home"
assert_contains "mode=600" "and only the sandbox user can read it"
assert_contains "ref=env" "the credential stays an environment reference"
assert_contains "cmd=gone" "the host '!command' did not cross"
assert_contains "key=gone" "nor did the literal key"
assert_contains "auth=absent" "auth.json was not bridged"
assert_contains "settings=yes" "settings.json is in place"
assert_contains "token=present" "and the token reaches pi by env"
assert_absent "container-token-777" "while its value is never printed"
assert_absent "uid=wrong" "seeded files belong to the sandbox user"
# an edit made inside the sandbox survives; a change on the host does not get lost
sb $DARK --harness pi -- bash -c 'sed -i "s/turbo/juggernaut/" "$HOME/.pi/agent/models.json"; echo edited' >/dev/null
sb $DARK --harness pi -- bash -lc 'grep -q juggernaut "$HOME/.pi/agent/models.json" && echo edit_kept || echo edit_lost'
assert_contains "edit_kept" "a change made inside the sandbox is not clobbered"
sb $DARK --harness pi -- bash -lc 'grep -q juggernaut "$HOME/.pi/agent/models.json" && echo still_here' 2>/dev/null
sed -i 's/262144/524288/' "$AGENT/models.json"
sb $DARK --harness pi -- bash -lc '
grep -q 524288 "$HOME/.pi/agent/models.json" && echo host_change_ignored_fully || echo host_change_partly_ignored
grep -q juggernaut "$HOME/.pi/agent/models.json" && echo edit_still_kept'
assert_contains "edit_still_kept" "the sandbox edit still wins over the bridged file"
sb $DARK --harness pi -- bash -c 'rm -f "$HOME/.pi/agent/models.json"; echo reset' >/dev/null
sb $DARK --harness pi -- bash -lc 'grep -q 524288 "$HOME/.pi/agent/models.json" && echo reseeded || echo not_reseeded'
assert_contains "reseeded" "deleting it inside the sandbox takes the host version again"
unset AI_API_TOKEN
sb_timeout 90 $DARK --harness pi
assert_rc 1 "a run without the credential is refused before anything starts"
assert_contains "not going to reach the container" "and it says why"
sb_env AI_API_TOKEN= --net none --harness pi
assert_rc 1 "an empty token is refused too -- pi would send an empty credential"
unset AISB_PI_AGENT_DIR AISB_PI_TOKEN_ENV AISB_PI_TOKEN_AUTO AISB_HOME_VOLUME
sb clean --all --volumes=aisb-test-pihome > /dev/null
t_name "doctor" t_name "doctor"
sb doctor sb doctor
assert_rc 0 "doctor reports no problems" assert_rc 0 "doctor reports no problems"
assert_contains "bind mount + uid mapping" "doctor verifies uid mapping" assert_contains "bind mount + uid mapping" "doctor verifies uid mapping"
assert_contains "base pin:" "doctor reports which base digest the image was built on"
assert_contains "rootless" "doctor verifies rootless podman" assert_contains "rootless" "doctor verifies rootless podman"
+9
View File
@@ -22,6 +22,15 @@ sb() {
return 0 return 0
} }
# sb_timeout <seconds> [args...] -- like sb, but bounded. Use it when a test expects
# the script to refuse: the refusal happens before podman, and the timeout is there in
# case a regression turns "refused instantly" into "hung forever".
sb_timeout() {
local t="$1"; shift
OUT="$(timeout "$t" "$AISB" "$@" 2>&1 </dev/null)" && RC=$? || RC=$?
return 0
}
# sb_env VAR=val ... -- args (env prefix, without leaking into the test shell) # sb_env VAR=val ... -- args (env prefix, without leaking into the test shell)
sb_env() { sb_env() {
local -a envs=() local -a envs=()
+10
View File
@@ -66,6 +66,16 @@ for f in "${FILES[@]}"; do
( (
export AISB_STATE_DIR="$W/state" AISB_CACHE_DIR="$W/cache" AISB_CONFIG_DIR="$W/cfg" export AISB_STATE_DIR="$W/state" AISB_CACHE_DIR="$W/cache" AISB_CONFIG_DIR="$W/cfg"
export WORK="$W" TESTS="$HERE" AISB_TEST_COUNTS="$COUNTS" export WORK="$W" TESTS="$HERE" AISB_TEST_COUNTS="$COUNTS"
# Unit files must not touch a registry: pins come from the seeded cache and the
# digest lookup is a stub. e2e files keep the real behaviour.
case "$f" in
*/unit/*) export AISB_BASE_AUTO_UPDATE="${AISB_BASE_AUTO_UPDATE:-0}" \
AISB_BASE_PULL_FALLBACK="${AISB_BASE_PULL_FALLBACK:-0}" ;;
esac
# Never bridge the developer's own ~/.pi/agent into a test: tests that exercise the
# bridge point AISB_PI_AGENT_DIR at a fixture of their own. Same for a real token.
export AISB_PI_AGENT_DIR="${AISB_PI_AGENT_DIR:-$W/absent-pi-agent-dir}"
unset AI_API_TOKEN AISB_PI_TOKEN_ENV AISB_PI_CONFIG AISB_PI_TOKEN_AUTO
# NOTE: HOME is deliberately not overridden -- podman storage lives there. # NOTE: HOME is deliberately not overridden -- podman storage lives there.
unset AISB_ALLOW AISB_ENV AISB_MOUNT AISB_INSTALL AISB_NET AISB_EXTRA_ARGS AISB_SESSION_OVERRIDE unset AISB_ALLOW AISB_ENV AISB_MOUNT AISB_INSTALL AISB_NET AISB_EXTRA_ARGS AISB_SESSION_OVERRIDE
source "$f" source "$f"
+182
View File
@@ -0,0 +1,182 @@
#!/usr/bin/env bash
# tests/unit/07_base.sh -- base image pinning: reproducibility, TTL, offline fallbacks.
#
# No registry traffic: the digest lookup binary is injectable (AISB_SKOPEO), so the
# whole refresh path is testable with a two-line stub.
source "$TESTS/lib.sh"
BASE="docker.io/library/ubuntu:24.04"
PIN1="sha256:$(printf '1%.0s' $(seq 64))"
PIN2="sha256:$(printf '2%.0s' $(seq 64))"
PIN3="sha256:$(printf '3%.0s' $(seq 64))"
PINX="sha256:$(printf '9%.0s' $(seq 64))"
# The stub stands in for skopeo and prints whatever digest is in this file, so a test
# can decide what "upstream" says -- including "unreachable", by deleting it.
FAKE="$WORK/fake-skopeo"
export FAKE_DIGEST_FILE="$WORK/digest"
cat > "$FAKE" <<'EOS'
#!/usr/bin/env bash
case "${1:-}" in
--version) echo "skopeo version test-stub" ;;
inspect) [[ -r "$FAKE_DIGEST_FILE" ]] || exit 1
cat "$FAKE_DIGEST_FILE" ;;
*) exit 1 ;;
esac
EOS
chmod +x "$FAKE"
export AISB_SKOPEO="$FAKE"
export AISB_BASE_AUTO_UPDATE=0 # nothing reaches the network by accident here
export AISB_BASE_TTL_DAYS=7
export AISB_BASE_PULL_FALLBACK=0 # never fall back to a real 'podman pull' in a unit test
state_of() { printf '%s/base/%s.env' "$AISB_CACHE_DIR" "$(printf '%s' "$1" | sha256sum | cut -c1-12)"; }
seed_pin() { # $1=ref $2=digest $3=age_days
local f; f="$(state_of "$1")"; mkdir -p "${f%/*}"
printf 'ref=%s\ndigest=%s\nresolved_at=%s\n' "$1" "$2" \
"$(( $(date +%s) - (${3:-0} * 86400) ))" > "$f"
}
clear_pin() { rm -f "$(state_of "$1")"; }
says_pin() { printf '%s' "$OUT" | grep -qF -- "$1"; }
cf() { sb --print-containerfile "$@"; }
policy() { sb --print-policy --net none "$@"; }
t_name "an unpinned base builds from the tag and says so"
clear_pin "$BASE"
cf
assert_contains 'aisb.base-digest="unpinned"' "the image records that it had no digest"
if says_pin "FROM ${BASE}@"; then
_bad "FROM stays a plain tag when nothing is recorded"
else
_ok "FROM stays a plain tag when nothing is recorded"
fi
t_name "a recorded digest is what lands in FROM"
printf '%s\n' "$PIN1" > "$FAKE_DIGEST_FILE"
seed_pin "$BASE" "$PIN1" 0
cf
assert_contains "FROM ${BASE}@${PIN1}" "the Containerfile is pinned to the digest"
assert_contains "aisb.base-digest=\"$PIN1\"" "and labels the image with it"
assert_contains "aisb.node=" "the Node version is labelled too"
assert_contains "aisb.pi-version=" "and the harness version"
assert_contains "/etc/aisb/manifest.txt" "the package manifest is written into the image"
t_name "preview modes never touch the registry"
printf '%s\n' "$PINX" > "$FAKE_DIGEST_FILE" # upstream would answer something else
export AISB_BASE_AUTO_UPDATE=1
cf
assert_contains "$PIN1" "--print-containerfile used the recorded digest, not the registry"
assert_absent "$PINX" "nothing from the stub leaked into the Containerfile"
policy
assert_contains "base_digest=$PIN1" "--print-policy likewise"
export AISB_BASE_AUTO_UPDATE=0
t_name "the pin is part of the image identity"
# refs are read at top level: sb() sets OUT, and a command substitution would throw
# that assignment away.
refs() { printf '%s %s' "$(printf '%s' "$OUT" | grep '^image=' | cut -d= -f2)" \
"$(printf '%s' "$OUT" | grep '^gate_image=' | cut -d= -f2)"; }
clear_pin "$BASE"; policy; read -r UNPINNED_IMG UNPINNED_GATE < <(refs)
seed_pin "$BASE" "$PIN1" 0; policy; read -r ONE ONE_GATE < <(refs)
seed_pin "$BASE" "$PIN2" 0; policy; read -r TWO TWO_GATE < <(refs)
[[ "$ONE" != "$UNPINNED_IMG" ]] && _ok "pinning changes the tag" || _bad "pinning changes the tag"
[[ -n "$UNPINNED_GATE" && "$UNPINNED_GATE" != "$ONE_GATE" ]] \
&& _ok "the gate image tag moves with the pin too" || _bad "the gate image tag moves with the pin too"
[[ "$ONE" != "$TWO" ]] && _ok "a new base digest means a new image, not a mutated one" || _bad "a new base digest means a new image, not a mutated one"
[[ "$ONE_GATE" != "$TWO_GATE" ]] && _ok "the gate image follows the same base" || _bad "the gate image follows the same base"
assert_eq "yes" "$([[ "$TWO" == localhost/ai-sandbox:ubuntu-* ]] && echo yes || echo no)" "the tag is named after the base, not hardcoded"
policy --base-image docker.io/library/debian:12
assert_contains "image=localhost/ai-sandbox:debian-" "a different base gets an honest tag"
t_name "a base the user pinned by digest is used verbatim"
cf --base-image "alpine@${PIN3}"
assert_contains "FROM alpine@${PIN3}" "FROM is exactly what was asked for"
policy --base-image "alpine@${PIN3}"
assert_contains "base_digest=$PIN3" "and reported as the pin"
if [[ -e "$(state_of "alpine@${PIN3}")" ]]; then
_bad "no state is written for a ref that is already pinned"
else
_ok "no state is written for a ref that is already pinned"
fi
# --print-policy is the deterministic preview surface and never resolves; `inspect`
# is the "ask the machine now" surface, so TTL behaviour is asserted through it.
t_name "the TTL decides when the digest is re-resolved"
printf '%s\n' "$PIN3" > "$FAKE_DIGEST_FILE"
clear_pin "$BASE"; seed_pin "$BASE" "$PIN1" 30 # 30 days old
policy
assert_contains "$PIN1" "an old pin is kept when auto-update is off"
export AISB_BASE_AUTO_UPDATE=1
AISB_BASE_TTL_DAYS=-1 sb inspect --net none
assert_contains "$PIN1" "ttl -1 pins until told otherwise"
AISB_BASE_TTL_DAYS=7 sb inspect --net none
assert_contains "$PIN3" "past the TTL the upstream digest replaces the stale pin"
grep -qF "$PIN3" "$(state_of "$BASE")" && _ok "and the new digest is recorded" || _bad "and the new digest is recorded"
AISB_BASE_TTL_DAYS=7 sb inspect --net none
assert_contains "$PIN3" "inside the new TTL nothing is re-resolved"
printf '%s\n' "$PIN3" > "$FAKE_DIGEST_FILE" # (unchanged, for clarity of intent)
AISB_BASE_TTL_DAYS=7 policy
assert_contains "$PIN3" "the preview surface still reports the recorded pin"
clear_pin "$BASE"
AISB_BASE_TTL_DAYS=7 sb inspect --net none
assert_contains "base_digest=$PIN3" "a first run resolves and records"
t_name "an unreachable registry falls back to the recorded digest"
seed_pin "$BASE" "$PIN1" 99
rm -f "$FAKE_DIGEST_FILE" # the stub now fails
export AISB_BASE_TTL_DAYS=0
sb inspect --net none
assert_contains "base_digest=$PIN1" "the stale pin is preferred over a mutable tag"
cf
assert_contains "FROM ${BASE}@${PIN1}" "and the build stays pinned"
t_name "nothing recorded and no registry: unpinned, with a warning"
clear_pin "$BASE"
sb inspect --net none
assert_contains "UNPINNED" "the run warns that the build is not reproducible"
cf
assert_contains 'aisb.base-digest="unpinned"' "and the image carries that fact"
t_name "update --check reports without rebuilding"
printf '%s\n' "$PIN3" > "$FAKE_DIGEST_FILE"
seed_pin "$BASE" "$PIN1" 0
sb update --check
assert_rc 0 "a check succeeds"
assert_contains "base=$BASE" "it names the base"
assert_contains "recorded=$PIN1" "it shows the recorded digest"
assert_contains "upstream=$PIN3" "it shows the upstream digest"
assert_contains "update is available" "and says an update exists"
sb update --check --no-gate 2>/dev/null
printf '%s' "$PIN3" > "$FAKE_DIGEST_FILE"; seed_pin "$BASE" "$PIN3" 0
sb update --check
assert_contains "up to date" "and says nothing to do when the pin already matches"
rm -f "$FAKE_DIGEST_FILE"
sb update --check
assert_contains "could not reach the registry" "and says so when the registry is unreachable"
assert_rc 0 "an unreachable registry is reported, not fatal"
t_name "a bad ttl in config is refused, not obeyed blindly"
# the environment outranks config by design, so drop it to exercise the config path
unset AISB_BASE_TTL_DAYS
mkdir -p .ai-sandbox
printf 'base_ttl_days = soon\n' > .ai-sandbox/config
policy
assert_contains "must be an integer" "it says the value is not usable"
assert_contains "$PIN3" "and it carries on with the default"
rm -rf .ai-sandbox
export AISB_BASE_TTL_DAYS=7
t_name "the policy output explains the pin"
export AISB_BASE_AUTO_UPDATE=0 AISB_BASE_TTL_DAYS=7
seed_pin "$BASE" "$PIN1" 2
policy
assert_contains "base=$BASE" "the policy names the base tag"
assert_contains "base_digest=$PIN1" "the pin in force"
assert_contains "base_age_days=2" "and how old the pin is"
t_name "doctor's warning when a built image predates the pin"
# doctor itself starts a container, so check the logic it reports on, not the run:
# an image labelled with a different digest than the pin is the silent-staleness case.
seed_pin "$BASE" "$PIN2" 0
cf
assert_contains "aisb.base-digest=\"$PIN2\"" "a rebuild would be labelled with the new pin"
+224
View File
@@ -0,0 +1,224 @@
#!/usr/bin/env bash
# tests/unit/08_pi_config.sh -- the pi configuration bridge.
#
# The bridge rewrites the host agent directory instead of copying it, so what matters
# most here is what does NOT cross the boundary: the credential, host commands, host
# paths, auth.json. Everything is preview (`--print-policy` / `--dry-run`) plus the
# refusal paths, so no container is started and no token is real.
source "$TESTS/lib.sh"
AGENT="$WORK/agent"; mkdir -p "$AGENT"
export AISB_PI_AGENT_DIR="$AGENT" AISB_PI_TOKEN_AUTO=1
export AI_API_TOKEN=sandbox-only-token-4242
CANARY_SECRET='REALSECRET-nev3r-lef7s-the-host'
CANARY_CMD='pass show services/gateway | head -1'
cat > "$AGENT/models.json" <<EOF
{
"providers": {
"gateway": {
"baseUrl": "https://aipi.reeseapps.com/v1",
"api": "openai-completions",
"apiKey": "!$CANARY_CMD",
"headers": { "X-Trace": "!date +%s", "X-Team": "self" },
"models": [
{ "id": "juggernaut", "reasoning": true, "contextWindow": 131072 },
{ "id": "turbo", "reasoning": true, "contextWindow": 262144 }
]
},
"lab-ollama": {
"baseUrl": "http://10.20.0.7:11434/v1",
"api": "openai-completions",
"models": [ { "id": "small" } ]
}
}
}
EOF
# sessionDir points at the *real* host home on purpose: that is the case the bridge
# has to drop, and HOME must stay the real home for podman's sake.
cat > "$AGENT/settings.json" <<EOF
{
"defaultProvider": "gateway",
"defaultModel": "turbo",
"theme": "dark/dark",
"defaultThinkingLevel": "xhigh",
"enableInstallTelemetry": false,
"thinkingBudgets": { "xhigh": 65536 },
"sessionDir": "$HOME/pi-sessions"
}
EOF
printf '{ "gateway": "%s" }' "$CANARY_SECRET" > "$AGENT/auth.json"
printf '{}' > "$AGENT/models-store.json"
clear_sessions() { rm -rf "${AISB_STATE_DIR:?}"/sessions/*; }
gen_file() { find "$AISB_STATE_DIR/sessions" -path "*/pi/$1" -print -quit 2>/dev/null; }
restore_models() { cat > "$AGENT/models.json" <<EOF
{ "providers": { "gateway": { "baseUrl": "https://aipi.reeseapps.com/v1",
"api": "openai-completions", "apiKey": "literal-key-never-copied",
"models": [ { "id": "turbo" } ] } } }
EOF
}
t_name "the credential becomes an environment reference, not a copy"
clear_sessions
sb --keep --print-policy --net none
assert_contains "pi_config=bridged from $AGENT" "the bridge reports where the config came from"
assert_contains "pi_token_env=AI_API_TOKEN (set)" "and whether the token is in reach"
M="$(gen_file models.json)"
[[ -n "$M" ]] && _ok "models.json was generated" || _bad "models.json was generated"
if [[ -n "$M" ]]; then
grep -qF '"apiKey": "$AI_API_TOKEN"' "$M" && _ok "apiKey is the variable; pi resolves it at request time" || _bad "apiKey is the variable; pi resolves it at request time"
grep -qF "$CANARY_CMD" "$M" && _bad "the host '!command' is gone" || _ok "the host '!command' is gone"
grep -qF "$CANARY_SECRET" "$M" && _bad "the literal key is gone" || _ok "the literal key is gone"
grep -qF 'literal-key-never-copied' "$M" && _bad "no literal key from the host file" || _ok "no literal key from the host file"
grep -qF '"X-Team": "self"' "$M" && _ok "an ordinary header survives" || _bad "an ordinary header survives"
grep -qF 'X-Trace' "$M" && _bad "a header built by a host command is dropped" || _ok "a header built by a host command is dropped"
fi
assert_contains "dropped header(s) X-Trace" "and it says which one it dropped"
if printf '%s' "$OUT" | grep -qF "$AI_API_TOKEN"; then
_bad "the token value never appears in the policy output"
else
_ok "the token value never appears in the policy output"
fi
t_name "providers and models are carried over untouched"
if [[ -n "$M" ]]; then
grep -qF '"baseUrl": "https://aipi.reeseapps.com/v1"' "$M" && _ok "the gateway endpoint" || _bad "the gateway endpoint"
grep -qF '"id": "juggernaut"' "$M" && grep -qF '"id": "turbo"' "$M" && _ok "both models" || _bad "both models"
grep -qF '"contextWindow": 262144' "$M" && _ok "with their limits" || _bad "with their limits"
grep -qF '"lab-ollama"' "$M" && _ok "and the second provider" || _bad "and the second provider"
fi
JSON_CHECK="$(python3 - "${M:-/nonexistent}" <<'PY'
import json, sys
try:
prov = json.load(open(sys.argv[1]))["providers"]
except Exception as e:
print(f"invalid:{e}"); raise SystemExit
if "apiKey" in prov.get("lab-ollama", {}):
print("gained-a-key")
elif prov["gateway"]["apiKey"] != "$AI_API_TOKEN":
print("wrong-key-ref")
else:
print("json-valid")
PY
)"
assert_eq "json-valid" "$JSON_CHECK" "valid JSON, and a provider without a key does not gain one"
assert_contains "pi_endpoints=10.20.0.7 aipi.reeseapps.com" "both endpoints are reported"
t_name "settings travel, host paths do not"
S="$(gen_file settings.json)"
if [[ -n "$S" ]]; then
for pair in '"defaultProvider": "gateway"' '"defaultModel": "turbo"' '"theme": "dark/dark"' \
'"defaultThinkingLevel": "xhigh"' '"xhigh": 65536' '"enableInstallTelemetry": false'; do
grep -qF "$pair" "$S" && _ok "kept $pair" || _bad "kept $pair"
done
grep -qF 'sessionDir' "$S" && _bad "a value pointing at the host home is dropped" || _ok "a value pointing at the host home is dropped"
fi
assert_contains "dropped settings sessionDir" "and it says so"
t_name "auth.json and the credential store are never bridged"
if find "$AISB_STATE_DIR/sessions" \( -name auth.json -o -name models-store.json \) | grep -q .; then
_bad "only models.json and settings.json are bridged"
else
_ok "only models.json and settings.json are bridged"
fi
if grep -rlF "$CANARY_SECRET" "$AISB_STATE_DIR" "$AISB_CACHE_DIR" > /dev/null 2>&1; then
_bad "the host credential appears nowhere in ai-sandbox state"
else
_ok "the host credential appears nowhere in ai-sandbox state"
fi
t_name "the token is passed by name, once, and announced"
assert_contains "passing \$AI_API_TOKEN from the host" "the auto-pass is announced"
assert_eq "env_names=AI_API_TOKEN" "$(printf '%s' "$OUT" | grep '^env_names=' || true)" "exactly one variable is passed"
assert_absent "$AI_API_TOKEN" "and only its name is reported"
t_name "--no-pi-token-env leaves it out and says so"
clear_sessions
sb --keep --no-pi-token-env --print-policy --net none
assert_eq "env_names=" "$(printf '%s' "$OUT" | grep '^env_names=' || true)" "nothing is passed"
assert_contains "will not reach the container" "preview warns instead of launching a pi with no credential"
t_name "a bridged provider with no credential in reach is refused, not attempted"
unset AI_API_TOKEN
sb --net none --harness pi
assert_rc 1 "the run refuses to start"
assert_contains 'asks for $AI_API_TOKEN, but it is not going to reach the container' "it names the variable and why"
assert_contains "env = AI_API_TOKEN" "it shows the config-file way"
assert_contains "--no-pi-config" "and the way to opt out"
assert_eq "0" "$([[ -n "$(podman ps -q --filter label=ai-sandbox=1)" ]] && echo 1 || echo 0)" "and nothing was started"
t_name "an empty token is treated as missing"
sb_env AI_API_TOKEN= --net none --harness pi # set, but empty
assert_rc 1 "an empty credential is refused, not sent"
assert_contains "unset or empty" "and it says which"
sb_env AI_API_TOKEN= --net none --print-policy
assert_contains "pi_token_env=AI_API_TOKEN (set but empty)" "preview distinguishes the two states"
t_name "but only when pi is the thing being launched"
export AI_API_TOKEN=x
sb --net none --dry-run -- make test
assert_absent "not going to reach the container" "a replaced command needs no credential"
sb --net none --dry-run --shell
assert_rc 0 "--shell works without a token"
assert_absent "not going to reach the container" "and does not demand one"
sb --net none --dry-run --harness codex
assert_absent "not going to reach the container" "codex does not read pi's models.json"
t_name "--no-pi-config opts out cleanly"
sb --no-pi-config --net none --print-policy
assert_absent "pi_config=" "no bridge is reported"
assert_absent "not going to reach the container" "and nothing is required"
clear_sessions
sb --keep --no-pi-config --net none -- true
if [[ -n "$(gen_file models.json)" ]]; then _bad "nothing was generated"; else _ok "nothing was generated"; fi
t_name "the token variable name is yours to choose"
clear_sessions
sb --keep --pi-token-env GW_TOKEN --print-policy --net none
assert_contains "pi_token_env=GW_TOKEN (NOT SET)" "reported under the new name"
clear_sessions
sb_env GW_TOKEN=whatever-gw --keep --pi-token-env GW_TOKEN --print-policy --net none
assert_contains "pi_token_env=GW_TOKEN (set)" "it is recognised as in reach"
assert_contains "passing \$GW_TOKEN" "and announced under that name"
M2="$(gen_file models.json)"
grep -qF '"apiKey": "$GW_TOKEN"' "${M2:-/nonexistent}" && _ok "the generated config references it" || _bad "the generated config references it"
sb --net none --harness pi --pi-token-env GW_TOKEN # GW_TOKEN is not set
assert_rc 1 "and it is enforced under that name too"
t_name "a missing agent directory is a skip, not an error"
sb_env AISB_PI_AGENT_DIR="$WORK/nope" --net none --print-policy
assert_rc 0 "nothing to bridge, nothing demanded"
assert_absent "pi_config=" "and no bridge is claimed"
sb_env AISB_PI_AGENT_DIR="$WORK/nope" --net none --harness pi
assert_rc 0 "pi may still be launched, with its own defaults"
t_name "broken JSON is reported, not propagated"
printf '{ "providers": oops' > "$AGENT/models.json"
clear_sessions
sb --keep --net none --print-policy
assert_contains "models.json is not valid JSON" "it says the file is broken"
if grep -qF 'oops' "${AISB_STATE_DIR}/sessions"/*/mnt/pi/models.json 2>/dev/null; then
_bad "no invalid file is placed in front of pi"
else
_ok "no invalid file is placed in front of pi"
fi
restore_models
t_name "the model endpoint has to be allowed, and says when it is not"
sb --net allow --allow registry.npmjs.org --print-policy
assert_contains "the model endpoint is not in the allowlist" "a pi that cannot reach its gateway is a warning, not a mystery"
assert_contains "add --allow aipi.reeseapps.com" "and it names the host to add"
sb --net allow --allow aipi.reeseapps.com --allow 10.20.0.7 --print-policy
assert_absent "not in the allowlist" "silent once both endpoints are allowed"
t_name "the bridge never widens the allowlist by itself"
sb --net allow --allow aipi.reeseapps.com --print-policy
assert_contains "gate_domains=aipi.reeseapps.com" "what you asked for is what you got"
assert_absent "10.20.0.7 80,443" "the discovered endpoint was not added on your behalf"
t_name "an invalid token variable name is rejected"
sb --net none --pi-token-env '9bad name' --print-policy
assert_rc 1 "rejected"
assert_contains "not a valid identifier" "with a clear reason"