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

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

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

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

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

LLM-Generated: true
This commit is contained in:
2026-10-27 17:29:17 -04:00
parent 0242ab5b5e
commit 8ee535f6ae
14 changed files with 740 additions and 49 deletions
+12 -7
View File
@@ -13,19 +13,24 @@ allowlist that the sandboxed process cannot lift.
## Non-negotiables
1. **Single script.** The Containerfile, the container entrypoint, the squid
config and the nftables rules are all generated from `bin/ai-sandbox`
(`containerfile()`, `entrypoint_script()`, `write_squid_conf()`, `gen_rules()`
inside the entrypoint). Keep it that way; do not split policy into files that
can drift from the script.
config, the nftables rules and the bridged pi config are all generated from
`bin/ai-sandbox` (`containerfile()`, `entrypoint_script()`, `write_squid_conf()`,
`gen_rules()` inside the entrypoint, `pi_config_bridge()`). Keep it that way; do
not split policy into files that can drift from the script.
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
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
explicitly named (`--allow-unenforced`, `--insecure-keep-root`, `--net full`).
(exit 77), `--env NAME` where NAME is unset, a bridged pi config whose credential
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.
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
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
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
+28 -1
View File
@@ -2,7 +2,7 @@
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.
Status as of this writing: **phases 1-8 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 |
|---|-------|------|--------|
@@ -15,6 +15,7 @@ Status as of this writing: **phases 1-8 complete**, follow-ups listed at the end
| 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 |
| 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
@@ -91,6 +92,24 @@ 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)
* **`--net unix` mode**: `--network=none` plus a unix-socket proxy and an in-container
@@ -112,3 +131,11 @@ Helpers return text; callers assign.
* `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.
+46 -7
View File
@@ -10,7 +10,7 @@ language.
```
$ cd ~/Projects/billing-api
$ pi --allow api.anthropic.com --env ANTHROPIC_API_KEY
$ pi --allow aipi.reeseapps.com --env AI_API_TOKEN
[aisb] egress gate aisb-gate-7f3c1a -> 127.0.0.1:18084 (1 domains, 0 ip/cidr entries)
[aisb] egress net=allow enforce=nft proxy=[100.64.0.1:18084] direct=0 dns=0
pi
@@ -27,7 +27,7 @@ git clone <this repo> && cd ai-sandbox
./install.sh --alias pi # symlinks to ~/.local/bin and adds the alias to ~/.bashrc
ai-sandbox doctor # checks podman, rootless, subuid, pasta, SELinux, uid mapping
ai-sandbox build # optional: build the image now instead of on first run
make test # 160+ assertions, no containers needed
make test # 390+ assertions (unit needs no containers; --e2e builds and runs)
```
Requirements: podman ≥ 5 running rootless (with a `/etc/subuid` range for your
@@ -43,8 +43,8 @@ those become **arguments to the harness**:
alias pi='ai-sandbox'
pi # runs pi inside the sandbox
pi --allow api.anthropic.com --env ANTHROPIC_API_KEY --model sonnet
# ^^^^^ sandbox ^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^ for pi
pi --allow aipi.reeseapps.com --env AI_API_TOKEN --model turbo
# ^^^^^ sandbox ^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^ for pi
pi --shell # a bash shell in the same sandbox
pi --net none -- make test # -- replaces the command entirely
```
@@ -61,7 +61,7 @@ which is what you want in scripts.
| `--net full` | Nothing restricted. Prints a warning. Never the default. |
```bash
ai-sandbox --allow api.anthropic.com --allow registry.npmjs.org --env ANTHROPIC_API_KEY
ai-sandbox --allow aipi.reeseapps.com --allow registry.npmjs.org --env AI_API_TOKEN
ai-sandbox --allow 10.20.0.5:5432 # a database: pinned IP and port
ai-sandbox --allow files.pythonhosted.org --allow pypi.org
ai-sandbox --no-gate --allow example.com # no proxy container: pin resolved IPs instead
@@ -77,16 +77,20 @@ a silent free-for-all. Keep it in the repo so the policy is reviewable:
```
# .ai-sandbox/allow (one entry per line, # comments fine)
api.anthropic.com
aipi.reeseapps.com
registry.npmjs.org
```
If you bridge your pi config (below), ai-sandbox tells you when the model endpoint
your own `models.json` points at is not in the allowlist -- pi would otherwise start
and sit there unable to answer, which reads like a broken sandbox.
## Environment
Nothing is inherited except `TERM LANG LC_ALL LC_CTYPE TZ`.
```bash
--env ANTHROPIC_API_KEY # from your environment
--env AI_API_TOKEN # from your environment
--env FOO=bar # literal (shows up in ps; prefer the two below)
--env-file ~/.config/secrets # a 0600 NAME=VALUE file
```
@@ -111,6 +115,41 @@ default. Everything else is opt-in:
Files the agent writes land on your disk owned by **your** uid, not root
(`--userns=keep-id`, verified by `doctor`).
## Pi inside the sandbox
The image has `pi` baked in, and its home is a volume, so out of the box the sandboxed
pi has no provider and no preferences. Rather than making you configure it twice,
`ai-sandbox` **bridges** your host `~/.pi/agent/{models,settings}.json` -- rewriting
them, because copying them would ship your credential into a container whose entire
purpose is to be unable to use it:
| your host config | what the sandbox gets |
|---|---|
| `apiKey: "sk-..."` or `apiKey: "!pass show ..."` | `apiKey: "$AI_API_TOKEN"` -- pi resolves it itself, no value is written to disk |
| a header like `X-Trace: "!date +%s"` | dropped, and reported: a host command is not run in the sandbox |
| `baseUrl: "https://aipi.reeseapps.com/v1"` | unchanged, along with every model, limit and thinking level |
| settings: provider, model, theme, thinking budgets, telemetry | unchanged |
| a settings value pointing into `~` | dropped, and reported: that path is not mounted |
| `auth.json`, `models-store.json`, sessions, extensions | never touched |
The rewritten pair is seeded into `/home/sandbox/.pi/agent` at container start, owned by
your uid, mode `600`. It is a managed file: a change on the host is picked up on the
next run, an edit made *inside* the sandbox is left alone, and deleting it inside
re-seeds from the host.
The token itself follows the normal rule -- it is the one variable the bridged config
names, so it is passed by name (never by value, and never into `ps`):
```bash
pi --allow aipi.reeseapps.com # AI_API_TOKEN is picked up and announced
pi --no-pi-token-env ... # don't pass it; the run then fails closed
pi --pi-token-env GW_TOKEN ... # a different variable name
pi --no-pi-config --shell # no bridge at all
```
Refusing rather than launching a pi with no credential is deliberate -- the alternative
is an agent that silently cannot reach its model.
## Image
Both images come from one base, `docker.io/library/ubuntu:24.04` — including the gate,
+264 -7
View File
@@ -11,7 +11,7 @@
# Meant to be aliased to an agent harness:
#
# 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
@@ -145,7 +145,7 @@ USAGE
behave like the harness itself:
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
pi src/main.py # runs: pi src/main.py
@@ -193,6 +193,17 @@ IMAGE
Each distinct set gets its own cached image tag.
--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
--cpus N (4) --memory SIZE (4g) --pids N (512) --tmpfs-size SIZE (1g)
@@ -272,7 +283,8 @@ cfg_read_kv() { # `key = value` file
fi
case "$key" in
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|base_ttl_days|base_auto_update) 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" ;;
esac
done < "$file"
@@ -310,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_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_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=()
COMMAND=()
@@ -319,7 +332,7 @@ KNOWN_OPTS=(
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
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() {
local given="$1" o hit=0 hits=""
@@ -339,6 +352,10 @@ parse_args() {
--net) need "$1" "${2:-}"; OPT_NET="$2"; shift 2 ;;
--net=*) OPT_NET="${1#*=}"; shift ;;
--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 ;;
--allow) need "$1" "${2:-}"; OPT_allow+=("$2"); shift 2 ;;
--allow=*) OPT_allow+=("${1#*=}"); shift ;;
@@ -416,6 +433,16 @@ resolve_opts() {
POL[pids]="${OPT_PIDS:-${AISB_PIDS:-$(cfgv pids 512)}}"
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)}}"
# --- 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)}"
@@ -477,7 +504,7 @@ validate_opts() {
fi
if [[ "${POL[net]}" == allow && ${#P_allow[@]} -eq 0 && -z "${POL[proxy]}" ]]; then
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 opt out: $PROG --net full"
fi
@@ -740,6 +767,43 @@ if [[ "$KEEP_ROOT" == 1 ]]; then
fi
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 \
--inh-caps=-all --bounding-set=-all --no-new-privs -- "$@"
AISB_ENTRY
@@ -1080,6 +1144,165 @@ cmd_build() {
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
# ===========================================================================
@@ -1221,7 +1444,7 @@ default_command() {
# nothing given at all: just start the harness (or a shell)
if ((${#hb[@]})); then COMMAND=("${hb[@]}"); else COMMAND=(bash -li); fi
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[@]}")
fi
# otherwise -- was used: the command replaces the harness
@@ -1287,6 +1510,10 @@ build_argv() {
fi
[[ -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}")
ARGV+=(-v "${SESSION_MOUNT}:/run/aisb:ro${SELOPT_SEP}")
if [[ "${POL[home_cache]}" == 1 ]]; then
@@ -1340,6 +1567,13 @@ print_argv() {
print_policy() {
printf 'image=%s\n' "${POL[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]}")"
@@ -1395,8 +1629,10 @@ cmd_run() {
fi
resolve_proxy_endpoints
pi_config_bridge
build_env_file
write_policy_files
pi_egress_hint
build_argv
if (( OPT_PRINT )); then print_policy; exit 0; fi
@@ -1500,6 +1736,25 @@ cmd_doctor() {
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"
# 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
chk INFO "allowlist from config/env: ${P_allow[*]}"
else
@@ -1688,16 +1943,18 @@ cmd_update() {
}
cmd_inspect() {
mkdir -p "$AISB_STATE_DIR"
mkdir -p "$AISB_STATE_DIR" "$AISB_CACHE_DIR"
load_config
parse_args "$@"
session_init
trap teardown EXIT
resolve_opts
set_selinux "${POL[selinux]}"
POL[image_ref]="$(image_ref)"
classify_allows
GATE_PORT="${POL[gate_port]}"
resolve_proxy_endpoints
pi_config_bridge
build_env_file
print_policy
}
+3 -2
View File
@@ -10,11 +10,12 @@
# 10.20.0.0/24 pinned subnet (default ports 80,443 unless :port given)
# [fd00::5]:8443 pinned ipv6
api.anthropic.com
aipi.reeseapps.com # your model gateway
registry.npmjs.org
# 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
# pypi.org
# files.pythonhosted.org
+17 -2
View File
@@ -50,6 +50,21 @@ net = allow
# 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 -------------------------------------------------------------
# SELinux bind-mount option: ':z' (shared), ':Z' (private) or empty to disable.
# Defaults to ':z' when getenforce exists.
@@ -65,11 +80,11 @@ net = allow
# --- repeatable keys -------------------------------------------------------
# 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 = 10.20.0.5:5432
#
# env = ANTHROPIC_API_KEY
# env = AI_API_TOKEN
# env = CI_JOB_TOKEN
#
# 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
# --env-file, so listing a variable is not the same as granting it.
#
# ANTHROPIC_API_KEY=sk-ant-...
# OPENAI_API_KEY=sk-...
# ANTHROPIC_MODEL=sonnet
# AI_API_TOKEN=... your gateway's token
# AI_API_TOKEN_FILE=... if a tool prefers a file over a variable
# 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
cd ~/Projects/myapp
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
@@ -15,25 +22,58 @@ your shell history):
```bash
mkdir -p .ai-sandbox
printf 'api.anthropic.com\n' > .ai-sandbox/allow
pi --env ANTHROPIC_API_KEY
printf 'aipi.reeseapps.com\n' > .ai-sandbox/allow
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
```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 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
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
# 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
@@ -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
# 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
@@ -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
```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
# port=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 down
```
@@ -132,13 +172,13 @@ harness = pi
cpus = 8
memory = 8g
mount = ../specs:/specs:ro
env = ANTHROPIC_API_KEY
env = AI_API_TOKEN
```
`.ai-sandbox/allow`:
```
api.anthropic.com
aipi.reeseapps.com
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
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.
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
+1 -1
View File
@@ -56,7 +56,7 @@ else
echo
echo "To use it as a harness wrapper, add this to $rcfile:"
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
echo
+60
View File
@@ -147,6 +147,66 @@ 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 '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"
sb doctor
assert_rc 0 "doctor reports no problems"
+9
View File
@@ -22,6 +22,15 @@ sb() {
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() {
local -a envs=()
+4
View File
@@ -72,6 +72,10 @@ for f in "${FILES[@]}"; do
*/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.
unset AISB_ALLOW AISB_ENV AISB_MOUNT AISB_INSTALL AISB_NET AISB_EXTRA_ARGS AISB_SESSION_OVERRIDE
source "$f"
+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"