From 8ee535f6ae2d3b45de712556b190cb11c1e034d3 Mon Sep 17 00:00:00 2001 From: ducoterra Date: Tue, 27 Oct 2026 17:29:17 -0400 Subject: [PATCH] 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 --- AGENTS.md | 19 ++- PLAN.md | 29 +++- README.md | 53 +++++++- bin/ai-sandbox | 271 ++++++++++++++++++++++++++++++++++++- config/allow.example | 5 +- config/config.example | 19 ++- config/env.example | 6 +- docs/recipes.md | 78 ++++++++--- docs/threat-model.md | 10 ++ install.sh | 2 +- tests/e2e/12_sandbox.sh | 60 ++++++++ tests/lib.sh | 9 ++ tests/run.sh | 4 + tests/unit/08_pi_config.sh | 224 ++++++++++++++++++++++++++++++ 14 files changed, 740 insertions(+), 49 deletions(-) create mode 100644 tests/unit/08_pi_config.sh diff --git a/AGENTS.md b/AGENTS.md index 13b4388..9a3a17c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/PLAN.md b/PLAN.md index 8b23d00..1eb3b70 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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. diff --git a/README.md b/README.md index d566da0..d14d536 100644 --- a/README.md +++ b/README.md @@ -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 && 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, diff --git a/bin/ai-sandbox b/bin/ai-sandbox index 479f0bb..7402b42 100755 --- a/bin/ai-sandbox +++ b/bin/ai-sandbox @@ -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 /models.json and /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 } diff --git a/config/allow.example b/config/allow.example index b5bdefb..e413fda 100644 --- a/config/allow.example +++ b/config/allow.example @@ -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 diff --git a/config/config.example b/config/config.example index 4fc9352..63dfdd7 100644 --- a/config/config.example +++ b/config/config.example @@ -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 diff --git a/config/env.example b/config/env.example index 57b9522..bf03e22 100644 --- a/config/env.example +++ b/config/env.example @@ -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 diff --git a/docs/recipes.md b/docs/recipes.md index f0e056f..e27a9a7 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -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 ``` diff --git a/docs/threat-model.md b/docs/threat-model.md index b2d546d..fdd4a26 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -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 diff --git a/install.sh b/install.sh index 8101582..f992b28 100755 --- a/install.sh +++ b/install.sh @@ -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 diff --git a/tests/e2e/12_sandbox.sh b/tests/e2e/12_sandbox.sh index 299d152..77bd335 100644 --- a/tests/e2e/12_sandbox.sh +++ b/tests/e2e/12_sandbox.sh @@ -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" < "$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" diff --git a/tests/lib.sh b/tests/lib.sh index 89cbc84..7ee8a24 100644 --- a/tests/lib.sh +++ b/tests/lib.sh @@ -22,6 +22,15 @@ sb() { return 0 } +# sb_timeout [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 "$AGENT/models.json" < "$AGENT/settings.json" < "$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" < /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"