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
+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
}