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

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

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

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

LLM-Generated: true
2026-10-27 17:29:17 -04:00

1983 lines
86 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# ai-sandbox -- rootless podman sandbox for autonomous agentic development.
#
# One script. It builds a small Ubuntu 24.04 image, then runs any command inside
# it with: only the current directory mounted, no ambient environment variables,
# no root and no capabilities inside the container, hard resource caps, and
# outbound network access limited to an explicit allowlist that the in-container
# process cannot lift.
#
# Meant to be aliased to an agent harness:
#
# alias pi='ai-sandbox'
# cd ~/Projects/foo && pi --allow aipi.reeseapps.com --env AI_API_TOKEN
#
set -Eeuo pipefail
VERSION="0.1.0"
PROG="${0##*/}"
# ===========================================================================
# defaults (precedence: built-in < config file < AISB_* env < CLI flag)
# ===========================================================================
XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}"
XDG_STATE_HOME="${XDG_STATE_HOME:-$HOME/.local/state}"
XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}"
AISB_CONFIG_DIR="${AISB_CONFIG_DIR:-$XDG_CONFIG_HOME/ai-sandbox}"
AISB_STATE_DIR="${AISB_STATE_DIR:-$XDG_STATE_HOME/ai-sandbox}"
AISB_CACHE_DIR="${AISB_CACHE_DIR:-$XDG_CACHE_HOME/ai-sandbox}"
AISB_IMAGE_REPO="${AISB_IMAGE_REPO:-localhost/ai-sandbox}"
AISB_GATE_IMAGE_REPO="${AISB_GATE_IMAGE_REPO:-localhost/ai-sandbox-gate}"
AISB_PI_PACKAGE="${AISB_PI_PACKAGE:-@earendil-works/pi-coding-agent}"
# The knobs below are deliberately NOT defaulted here. A value set in the
# environment has to beat a config file, and a config file has to beat the
# built-in default -- so the built-in defaults live in resolve_opts() and the
# environment variables stay unset unless the user really set them.
# AISB_NET AISB_HARNESS AISB_PROJECT_DEST AISB_CPUS AISB_MEMORY AISB_PIDS
# AISB_TMPFS_SIZE AISB_BASE_IMAGE AISB_NODE_VERSION AISB_PI_VERSION
# AISB_BASE_TTL_DAYS AISB_BASE_AUTO_UPDATE AISB_BASE_PULL_FALLBACK AISB_SKOPEO
# AISB_SELINUX AISB_GATE_ADDR AISB_GATE_PORT AISB_HOME_CACHE AISB_EXTRA_ARGS
AISB_UID="${AISB_UID:-1000}"
AISB_GID="${AISB_GID:-1000}"
# SELinux relabel mode: ':z' (shared label) is what an SELinux host needs; hosts
# without SELinux get no option at all. Only a fallback -- AISB_SELINUX and the
# config file both win over it.
if command -v getenforce > /dev/null 2>&1; then AISB_SELINUX_FALLBACK=":z"
else AISB_SELINUX_FALLBACK=""; fi
AISB_GATE_KEEP="${AISB_GATE_KEEP:-0}"
AISB_HOME_VOLUME="${AISB_HOME_VOLUME:-aisb-home}"
# Nothing is inherited from the host environment except these.
AISB_IMPLICIT_ENV=(TERM LANG LC_ALL LC_CTYPE TZ)
# ===========================================================================
# helpers
# ===========================================================================
log() { printf '%s: %s\n' "$PROG" "$*" >&2; }
warn() { printf '%s: warning: %s\n' "$PROG" "$*" >&2; }
die() { printf '%s: error: %s\n' "$PROG" "$*" >&2; exit 1; }
have() { command -v "$1" >/dev/null 2>&1; }
sha12() { printf '%s' "$1" | sha256sum | cut -c1-12; }
need() { [[ -n "${2:-}" ]] || die "$1 requires a value (see --help)"; }
trim() { local s="$1"; s="${s#"${s%%[![:space:]]*}"}"; s="${s%"${s##*[![:space:]]}"}"; printf '%s' "$s"; }
is_v4() { [[ "${1%%/*}" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; }
is_v4cidr() { [[ "$1" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}/([0-9]|[12][0-9]|3[0-2])$ ]]; }
is_v6() { [[ "$1" == *:* ]]; }
resolve_v4() { getent ahostsv4 "$1" 2>/dev/null | awk '{print $1; exit}'; }
node_arch() { case "$(uname -m)" in x86_64) echo x64 ;; aarch64) echo arm64 ;; *) uname -m ;; esac; }
session_id() { printf '%s%04x' "$(date +%s | tail -c 6)" "$((RANDOM % 65536))"; }
# Split HOST[:PORT] / [IPv6]:PORT / CIDR -> TARGET_HOST TARGET_PORT
split_target() {
local t="$1"
TARGET_HOST="$t"; TARGET_PORT=""
if [[ "$t" == \[* ]]; then
TARGET_HOST="${t%]*}"; TARGET_HOST="${TARGET_HOST#\[}"
[[ "$t" == *]:* ]] && TARGET_PORT="${t##*]:}"
elif [[ "$t" != */* && "$t" != *:*:* ]]; then
if [[ "$t" == *:* ]]; then TARGET_HOST="${t%%:*}"; TARGET_PORT="${t#*:}"; fi
fi
return 0
}
# SELinux relabel option, normalised: SELOPT ':z' | '' and SELOPT_SEP ',z' | ''
SELOPT="" SELOPT_SEP=""
set_selinux() {
SELOPT="${1:-}"
if [[ -n "${SELOPT#:}" ]]; then SELOPT_SEP=",${SELOPT#:}"; else SELOPT_SEP=""; fi
}
# Shell-quote an argument only when it is not already safe. Unlike printf %q this
# leaves ',', ':' and '/' alone, which keeps the dry-run output readable.
shquote() {
local s="$1"
if [[ -n "$s" && "$s" =~ ^[A-Za-z0-9_@%+=:,./-]+$ ]]; then printf '%s' "$s"
else printf '%q' "$s"; fi
}
# value on one line, no leading/trailing blank space
vline() { printf '%s' "$*" | sed 's/ */ /g; s/^ //; s/ $//'; }
# join <separator> <values...> -- no trailing separator
join() {
local sep="$1" out="" v
shift
for v in "$@"; do
[[ -n "$out" ]] && out+="$sep"
out+="$v"
done
printf '%s' "$out"
}
dedup() { # dedup <name-of-array>
local -n _dd_in="$1"
local v out=() seen=" "
for v in ${_dd_in[@]+"${_dd_in[@]}"}; do
[[ "$seen" == *" $v "* ]] && continue
out+=("$v"); seen="$seen$v "
done
_dd_in=(${out[@]+"${out[@]}"})
}
# ===========================================================================
# usage
# ===========================================================================
usage() {
cat <<EOF
$PROG $VERSION -- rootless podman sandbox for autonomous agentic development.
USAGE
$PROG [POLICY ...] [--] [COMMAND ...]
$PROG build [--rebuild] [--refresh-base] [--gate]
$PROG doctor [--deep] | gate <up|down|status|logs> | ps | inspect | version
$PROG clean [--all] [--volumes[=GLOB]] | update [--check]
Sandbox flag parsing stops at the first positional argument or unknown flag.
Those arguments become arguments *to the harness*, which is what makes an alias
behave like the harness itself:
alias pi='$PROG'
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
pi -- bash -lc 'make' # -- replaces the command entirely
--strict-args turns an unknown flag into an error instead of a pass-through.
NETWORK
--net none|allow|full Egress mode (default: allow).
none No network namespace at all.
allow Only the allowlist is reachable: an in-container netfilter ruleset
(installed by the entrypoint, unremovable by the unprivileged
command) plus an HTTP(S) gate on the host that filters by domain.
full Unrestricted, with a warning. Never the default.
--allow HOST[:PORT] Allow a domain, IPv4/IPv6 or CIDR. Repeatable.
Domains match that name and anything below it.
--allow-file FILE More allow entries, one per line (# comments allowed).
--proxy HOST:PORT Use an existing HTTP(S) proxy instead of the built-in gate.
--no-gate No gate container: enforce the allowlist as IPs only.
Domains are resolved once, on the host, at start, and
DNS is opened up. Weaker than the gate for CDNs.
FILESYSTEM
The current directory is bind-mounted at /project and is the working directory.
Nothing else on the host is visible unless you add it.
--mount PATH[:DEST][:ro] Extra bind mount. Repeatable. Default rw, DEST=/mnt/<name>.
--no-cwd Do not mount the current directory.
--workdir DIR Working directory in the container. Default /project.
--home-cache Persist /home/sandbox in a named volume (default).
--ephemeral-home Discard /home/sandbox on exit.
--read-only Read-only root filesystem (plus tmpfs /tmp).
ENVIRONMENT
Inherited by default: TERM LANG LC_ALL LC_CTYPE TZ. Nothing else.
--env NAME Pass NAME from the host environment. Repeatable.
--env NAME=VALUE Set a literal value (visible in 'ps'; prefer --env/--env-file).
--env-file FILE Pass every NAME=VALUE in FILE. chmod 600 it.
Values reach the container through a 0600 file, never
through the command line, and never into the logs.
IMAGE
--base-image REF Default docker.io/library/ubuntu:24.04. Pinned to the
registry digest at build time; REF may itself already be
a digest (name@sha256:...) to take that decision away.
--install PKG Extra apt package in the image. Repeatable.
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)
RUN
--shell Run bash instead of the harness.
--name NAME Container name (default aisb-<session>).
--label K=V Extra label. Repeatable.
--keep Keep the container after it exits.
--interactive | --no-tty Attachment. A TTY is allocated when stdin+stdout are TTYs.
--dry-run, -n Print the exact podman invocation (secrets redacted), exit 0.
--print-policy Print the effective policy as key=value lines, exit 0.
--print-entrypoint Print the script that is baked into the image as the
container entrypoint (the enforcement half), exit 0.
--print-containerfile Print the generated Containerfile, exit 0.
ESCAPE HATCHES (they weaken the sandbox, which is why they are named this way)
--allow-unenforced Start even if the egress ruleset cannot be installed.
Default: refuse to start (exit 77).
--insecure-keep-root Run the command as root with capabilities intact.
--strict-args Unknown flags are an error instead of a pass-through.
SUBCOMMANDS
build Build or reuse the sandbox image (--rebuild, --gate for the gate
image, --refresh-base to re-resolve the base digest now).
update Re-resolve the base digest and rebuild what that implies. The old
image stays put as a rollback. --check only reports.
doctor Check the host: podman, rootless, subuid, helpers, SELinux, uid mapping.
gate up | down | status | logs -- manage a named long-lived egress gate.
ps List sandbox containers.
clean Remove leftover sandbox containers, gates and session files.
--all also removes running ones; --volumes[=GLOB] then reclaims
home-cache volumes that no container references (npm/tool caches,
what 'npm i -g' put there). Run it when /home/sandbox looks stale
after an image change.
inspect Effective policy for this directory.
CONFIG
\$HOME/.config/ai-sandbox/{config,allow} and ./.ai-sandbox/{config,allow}
Every default can also be set as an environment variable:
AISB_NET, AISB_ALLOW (comma list), AISB_ENV (comma list), AISB_CPUS, AISB_MEMORY,
AISB_PIDS, AISB_HARNESS, AISB_EXTRA_ARGS, AISB_SELINUX, AISB_HOME_CACHE, ...
Base images: AISB_BASE_IMAGE, AISB_BASE_TTL_DAYS (7; 0 every build, -1 never),
AISB_BASE_AUTO_UPDATE (0 keeps the recorded digest), AISB_BASE_PULL_FALLBACK
(0 forbids the 'podman pull' used to learn a digest when skopeo is absent),
AISB_SKOPEO.
EXIT STATUS
The exit code of the in-container command. 77/78: the sandbox refused to start
because it could not enforce its own policy.
EOF
}
# ===========================================================================
# config files
# ===========================================================================
declare -A CFG=()
declare -a CFG_allow=() CFG_env=() CFG_mount=() CFG_install=() CFG_label=()
cfg_accum_keys="allow env mount install label"
cfg_read_kv() { # `key = value` file
local file="$1" line key val
[[ -r "$file" ]] || return 0
while IFS= read -r line || [[ -n "$line" ]]; do
line="${line%%#*}"
line="$(trim "$line")"
[[ -n "$line" ]] || continue
[[ "$line" == *=* ]] || { warn "$file: ignoring line without '=': $line"; continue; }
key="$(trim "${line%%=*}")"; val="$(trim "${line#*=}")"
val="${val%\"}"; val="${val#\"}"
if [[ " $cfg_accum_keys " == *" $key "* ]]; then
# A nameref with a name nothing else uses: bash resolves namerefs by name, so a
# local called `ref` anywhere nearby is a way to corrupt config reading.
local -n cfg_target="CFG_$key"
cfg_target+=("$val")
continue
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|\
pi_config|pi_token_env|pi_token_auto|pi_agent_dir) CFG["$key"]="$val" ;;
*) warn "$file: unknown key: $key" ;;
esac
done < "$file"
}
cfg_read_allowlist() { # one target per line
local file="$1" line
[[ -r "$file" ]] || return 0
while IFS= read -r line || [[ -n "$line" ]]; do
line="$(trim "${line%%#*}")"
[[ -n "$line" ]] && CFG_allow+=("$line")
done < "$file"
}
load_config() {
cfg_read_kv "$AISB_CONFIG_DIR/config"
cfg_read_allowlist "$AISB_CONFIG_DIR/allow"
cfg_read_kv "./.ai-sandbox/config"
cfg_read_allowlist "./.ai-sandbox/allow"
local e
local IFS=','
for e in ${AISB_ALLOW:-}; do [[ -n "$e" ]] && CFG_allow+=("$(trim "$e")"); done
for e in ${AISB_ENV:-}; do [[ -n "$e" ]] && CFG_env+=("$(trim "$e")"); done
for e in ${AISB_MOUNT:-}; do [[ -n "$e" ]] && CFG_mount+=("$(trim "$e")"); done
for e in ${AISB_INSTALL:-}; do [[ -n "$e" ]] && CFG_install+=("$(trim "$e")"); done
IFS=$' \t\n'
return 0
}
# ===========================================================================
# option state + parsing
# ===========================================================================
OPT_NET="" OPT_HARNESS="" OPT_WORKDIR="" OPT_CPUS="" OPT_MEMORY="" OPT_PIDS="" OPT_TMPFS=""
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=()
strict_on() { [[ "${OPT_STRICT:-0}" == 1 || "${AISB_STRICT_ARGS:-0}" == 1 ]]; }
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 pi-token-env no-pi-config no-pi-token-env help version
)
suggest() {
local given="$1" o hit=0 hits=""
for o in "${KNOWN_OPTS[@]}"; do
if [[ "--$o" == "$given"* ]]; then hits="${hits:+$hits }--$o"; hit=1; fi
done
(( hit )) && printf 'did you mean %s? ' "$hits"
return 0
}
parse_args() {
while (($#)); do
case "$1" in
--) shift; COMMAND=("$@"); return ;;
-h|--help) usage; exit 0 ;;
-V|--version) printf '%s %s\n' "$PROG" "$VERSION"; exit 0 ;;
--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 ;;
--allow-file) need "$1" "${2:-}"; OPT_allow_files+=("$2"); shift 2 ;;
--allow-file=*) OPT_allow_files+=("${1#*=}"); shift ;;
--proxy) need "$1" "${2:-}"; OPT_PROXY="$2"; shift 2 ;;
--proxy=*) OPT_PROXY="${1#*=}"; shift ;;
--no-gate) OPT_GATE=1; shift ;;
--env) need "$1" "${2:-}"; OPT_env+=("$2"); shift 2 ;;
--env=*) OPT_env+=("${1#*=}"); shift ;;
--env-file) need "$1" "${2:-}"; OPT_env+=("@${2}"); shift 2 ;;
--env-file=*) OPT_env+=("@${1#*=}"); shift ;;
--mount) need "$1" "${2:-}"; OPT_mount+=("$2"); shift 2 ;;
--mount=*) OPT_mount+=("${1#*=}"); shift ;;
--no-cwd) OPT_NO_CWD=1; shift ;;
--workdir) need "$1" "${2:-}"; OPT_WORKDIR="$2"; shift 2 ;;
--workdir=*) OPT_WORKDIR="${1#*=}"; shift ;;
--home-cache) OPT_HOME_CACHE=1; shift ;;
--ephemeral-home) OPT_HOME_CACHE=0; shift ;;
--install) need "$1" "${2:-}"; OPT_install+=("$2"); shift 2 ;;
--install=*) OPT_install+=("${1#*=}"); shift ;;
--base-image) need "$1" "${2:-}"; OPT_BASE_IMAGE="$2"; shift 2 ;;
--base-image=*) OPT_BASE_IMAGE="${1#*=}"; shift ;;
--cpus) need "$1" "${2:-}"; OPT_CPUS="$2"; shift 2 ;;
--cpus=*) OPT_CPUS="${1#*=}"; shift ;;
--memory) need "$1" "${2:-}"; OPT_MEMORY="$2"; shift 2 ;;
--memory=*) OPT_MEMORY="${1#*=}"; shift ;;
--pids) need "$1" "${2:-}"; OPT_PIDS="$2"; shift 2 ;;
--pids=*) OPT_PIDS="${1#*=}"; shift ;;
--tmpfs-size) need "$1" "${2:-}"; OPT_TMPFS="$2"; shift 2 ;;
--tmpfs-size=*) OPT_TMPFS="${1#*=}"; shift ;;
--name) need "$1" "${2:-}"; OPT_NAME="$2"; shift 2 ;;
--name=*) OPT_NAME="${1#*=}"; shift ;;
--label) need "$1" "${2:-}"; OPT_label+=("$2"); shift 2 ;;
--label=*) OPT_label+=("${1#*=}"); shift ;;
--keep) OPT_KEEP=1; shift ;;
--read-only) OPT_READONLY=1; shift ;;
--shell) OPT_SHELL=1; shift ;;
--interactive) OPT_INTERACTIVE=1; shift ;;
--no-tty) OPT_TTY=0; shift ;;
--dry-run|-n) OPT_DRY=1; shift ;;
--print-policy) OPT_PRINT=1; shift ;;
--print-entrypoint) OPT_PRINT_EP=1; shift ;;
--print-containerfile) OPT_PRINT_CF=1; shift ;;
--no-build) OPT_NO_BUILD=1; shift ;;
--allow-unenforced) OPT_UNENFORCED=1; shift ;;
--insecure-keep-root) OPT_KEEP_ROOT=1; shift ;;
--strict-args) OPT_STRICT=1; shift ;;
-*)
if strict_on; then
die "unknown option: $1 $(suggest "$1")(see --help)"
fi
# First unrecognised flag: it belongs to the harness, and so does the rest.
COMMAND=("$@"); OPT_PASSTHROUGH=1; return ;;
*)
COMMAND=("$@"); OPT_PASSTHROUGH=1; return ;;
esac
done
}
# ===========================================================================
# resolve
# ===========================================================================
declare -A POL=()
declare -a P_allow=() P_env=() P_mount=() P_install=() P_label=()
cfgv() { local k="$1" d="$2"; printf '%s' "${CFG[$k]:-$d}"; }
resolve_opts() {
POL[net]="${OPT_NET:-${AISB_NET:-$(cfgv net allow)}}"
POL[harness]="${OPT_HARNESS:-${AISB_HARNESS:-$(cfgv harness pi)}}"
POL[workdir]="${OPT_WORKDIR:-${AISB_PROJECT_DEST:-$(cfgv workdir /project)}}"
POL[cpus]="${OPT_CPUS:-${AISB_CPUS:-$(cfgv cpus 4)}}"
POL[memory]="${OPT_MEMORY:-${AISB_MEMORY:-$(cfgv memory 4g)}}"
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)}"
case "${POL[base_auto]}" in 0|false|no) POL[base_auto]=0 ;; *) POL[base_auto]=1 ;; esac
# Preview modes must never reach the network: they report the pin that is already
# recorded, which is also what makes the generated Containerfile testable.
if (( OPT_DRY == 0 && OPT_PRINT == 0 && OPT_PRINT_EP == 0 && OPT_PRINT_CF == 0 )); then
POL[base_digest]="$(base_pin_digest "${POL[base_image]}")"
else
POL[base_digest]="$(base_pin_digest "${POL[base_image]}" 0 1)"
fi
POL[base_ref]="$(base_ref_of "${POL[base_image]}" "${POL[base_digest]}")"
POL[node_version]="${AISB_NODE_VERSION:-$(cfgv node_version 22.23.4)}"
POL[pi_version]="${AISB_PI_VERSION:-$(cfgv pi_version 1.4.0)}"
POL[selinux]="${AISB_SELINUX-$(cfgv selinux "$AISB_SELINUX_FALLBACK")}"
POL[home_cache]="${OPT_HOME_CACHE:-${AISB_HOME_CACHE:-$(cfgv home_cache 1)}}"
POL[gate_addr]="${AISB_GATE_ADDR:-$(cfgv gate_addr 100.64.0.1)}"
POL[gate_port]="${AISB_GATE_PORT:-$(cfgv gate_port 18081)}"
POL[extra_args]="${AISB_EXTRA_ARGS:-$(cfgv extra_args '')}"
POL[proxy]="${OPT_PROXY}"
POL[no_gate]="${OPT_GATE:-0}"
POL[shell]="$OPT_SHELL"
POL[readonly]="$OPT_READONLY"
POL[unenforced]="$OPT_UNENFORCED"
POL[keep_root]="$OPT_KEEP_ROOT"
POL[name]="${OPT_NAME:-aisb-${SESSION:-$(session_id)}}"
POL[cwd]="$PWD"
P_allow=(${CFG_allow[@]+"${CFG_allow[@]}"} ${OPT_allow[@]+"${OPT_allow[@]}"})
P_env=(${CFG_env[@]+"${CFG_env[@]}"} ${OPT_env[@]+"${OPT_env[@]}"})
P_mount=(${CFG_mount[@]+"${CFG_mount[@]}"} ${OPT_mount[@]+"${OPT_mount[@]}"})
P_install=(${CFG_install[@]+"${CFG_install[@]}"} ${OPT_install[@]+"${OPT_install[@]}"})
P_label=(${CFG_label[@]+"${CFG_label[@]}"} ${OPT_label[@]+"${OPT_label[@]}"})
local f line
for f in ${OPT_allow_files[@]+"${OPT_allow_files[@]}"}; do
[[ -r "$f" ]] || die "--allow-file not readable: $f"
while IFS= read -r line || [[ -n "$line" ]]; do
line="$(trim "${line%%#*}")"
[[ -n "$line" ]] && P_allow+=("$line")
done < "$f"
done
dedup P_allow; dedup P_env; dedup P_mount; dedup P_install; dedup P_label
set_selinux "${POL[selinux]}"
}
validate_opts() {
case "${POL[net]}" in
none|allow|full) ;;
*) die "--net must be none, allow or full (got '${POL[net]}')" ;;
esac
[[ "${POL[pids]}" =~ ^[0-9]+$ ]] || die "--pids must be an integer (got '${POL[pids]}')"
[[ "${POL[cpus]}" =~ ^[0-9.]+$ ]] || die "--cpus must be a number (got '${POL[cpus]}')"
[[ "${POL[memory]}" =~ ^[0-9]+([bkmgBKMG]?)$ ]] || die "--memory must look like 512m or 4g (got '${POL[memory]}')"
[[ -d "${POL[cwd]}" ]] || die "current directory does not exist"
if [[ "${POL[net]}" == full ]]; then
warn "--net full: the container has unrestricted internet access"
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 aipi.reeseapps.com --env AI_API_TOKEN
or go dark: $PROG --net none
or opt out: $PROG --net full"
fi
}
# ===========================================================================
# session, env file, policy files
# ===========================================================================
SESSION="" SESSION_DIR="" SESSION_MOUNT="" ENV_FILE="" GATE_DIR=""
GATE_NAME="" GATE_PORT="" GATE_STARTED=0
declare -a ENV_NAMES=()
declare -a DIRECT4=() DIRECT6=() GATE_DOMAINS=() GATE_IPS=() PROXY_IPS=() PROXY_PORTS=()
session_init() {
SESSION="${AISB_SESSION_OVERRIDE:-$(session_id)}"
SESSION_DIR="$AISB_STATE_DIR/sessions/$SESSION"
SESSION_MOUNT="$SESSION_DIR/mnt"
GATE_DIR="$SESSION_DIR/gate"
ENV_FILE="$SESSION_DIR/env"
mkdir -p "$SESSION_MOUNT" "$GATE_DIR"
# The session dir holds the secret env file and stays 0700. The two directories
# that get bind-mounted must be world-readable: the container starts as root but
# with --cap-drop=ALL, so it has no DAC_OVERRIDE and container root is *not* your
# uid under --userns=keep-id. Nothing secret goes in here (policy and gate config
# only -- both describe the allowlist, never credentials).
chmod 700 "$SESSION_DIR"
chmod 755 "$SESSION_MOUNT" "$GATE_DIR"
}
teardown() {
local rc=$?
trap - EXIT
[[ -n "${ENV_FILE:-}" && -f "${ENV_FILE:-}" ]] && rm -f "$ENV_FILE"
if [[ -n "${GATE_NAME:-}" && "${GATE_STARTED:-0}" == 1 && "${AISB_GATE_KEEP:-0}" != 1 ]]; then
podman rm -f "$GATE_NAME" >/dev/null 2>&1 || true
fi
# --keep leaves the session dir (env file, policy, gate config) for inspection
if [[ "${OPT_KEEP:-0}" != 1 && -n "${SESSION_DIR:-}" && -d "${SESSION_DIR:-}" ]]; then
rm -rf "$SESSION_DIR"
fi
return $rc
}
declare -a MISSING_ENV=()
build_env_file() {
: > "$ENV_FILE"; chmod 600 "$ENV_FILE"
local spec name value line f mode
local -a missing=()
for spec in ${P_env[@]+"${P_env[@]}"}; do
if [[ "$spec" == @* ]]; then # --env-file
f="${spec#@}"
[[ -r "$f" ]] || die "--env-file not readable: $f"
mode="$(stat -c '%a' "$f" 2>/dev/null || echo 644)"
[[ "$mode" == "600" || "$mode" == "400" ]] || warn "--env-file $f is mode $mode; chmod 600 recommended"
while IFS= read -r line || [[ -n "$line" ]]; do
line="$(trim "$line")"
[[ -n "$line" && "$line" != \#* ]] || continue
[[ "$line" == *=* ]] || { warn "ignoring env-file line without '=': $line"; continue; }
[[ "$line" != *$'\n'* ]] || die "--env-file $f: value for ${line%%=*} spans lines"
printf '%s\n' "$line" >> "$ENV_FILE"
ENV_NAMES+=("${line%%=*}")
done < "$f"
continue
fi
if [[ "$spec" == *=* ]]; then
printf '%s\n' "$spec" >> "$ENV_FILE"
ENV_NAMES+=("${spec%%=*}")
continue
fi
name="$spec"
[[ "$name" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || die "--env: invalid variable name: $name"
if [[ -z "${!name+x}" ]]; then missing+=("$name"); continue; fi
value="${!name}"
[[ "$value" != *$'\n'* ]] || die "--env $name: value contains a newline (not supported by podman --env-file)"
printf '%s=%s\n' "$name" "$value" >> "$ENV_FILE"
ENV_NAMES+=("$name")
done
if ((${#missing[@]})); then
# --dry-run / --print-policy report the problem instead of failing, so that a
# policy can be inspected before the credentials exist in the environment.
if (( OPT_DRY || OPT_PRINT )); then
MISSING_ENV=(${missing[@]+"${missing[@]}"})
ENV_NAMES+=(${missing[@]+"${missing[@]}"})
warn "--env ${missing[*]} requested but not set in the host environment"
else
die "--env ${missing[*]} requested but not set in the host environment (not passed; fail closed)"
fi
fi
chmod 600 "$ENV_FILE"
}
# allow entries -> gate ACLs + direct netfilter entries
classify_allows() {
DIRECT4=() DIRECT6=() GATE_DOMAINS=() GATE_IPS=()
local t ports
for t in ${P_allow[@]+"${P_allow[@]}"}; do
split_target "$t"
local host="$TARGET_HOST" port="${TARGET_PORT}"
[[ -z "$port" || "$port" =~ ^[0-9]{1,5}$ ]] || die "--allow: bad port in '$t'"
ports="${port:-80,443}"
ports="${ports// /}"
if is_v4 "$host"; then
DIRECT4+=("$host $ports"); GATE_IPS+=("$host")
elif is_v4cidr "$host"; then
DIRECT4+=("$host $ports"); GATE_IPS+=("$host")
elif is_v6 "$host"; then
DIRECT6+=("$host $ports"); GATE_IPS+=("$host")
elif [[ "${POL[no_gate]}" == 1 ]]; then
local ip; ip="$(resolve_v4 "$host" || true)"
[[ -n "$ip" ]] || die "--allow $host: could not resolve it (with --no-gate, domains are resolved on the host at start)"
DIRECT4+=("$ip $ports")
else
GATE_DOMAINS+=("$host")
fi
done
dedup DIRECT4; dedup DIRECT6; dedup GATE_IPS; dedup GATE_DOMAINS
}
resolve_proxy_endpoints() {
PROXY_IPS=(); PROXY_PORTS=()
ALLOW_DNS=0
case "${POL[net]}" in
none|full) return 0 ;;
esac
if [[ -n "${POL[proxy]}" ]]; then
split_target "${POL[proxy]}"
local ip="$TARGET_HOST"
is_v4 "$ip" || ip="$(resolve_v4 "$ip" || true)"
[[ -n "$ip" ]] || die "--proxy: cannot resolve ${POL[proxy]}"
PROXY_IPS=("$ip"); PROXY_PORTS=("${TARGET_PORT:-3128}")
elif [[ "${POL[no_gate]}" != 1 ]]; then
PROXY_IPS=("${POL[gate_addr]}"); PROXY_PORTS=("${GATE_PORT:-${POL[gate_port]}}")
else
ALLOW_DNS=1
warn "--no-gate: DNS is reachable (direct egress is still limited to the resolved IPs)"
fi
return 0
}
write_policy_files() {
{
printf 'AISB_NET=%s\n' "${POL[net]}"
printf 'AISB_UID=%s\n' "$AISB_UID"
printf 'AISB_GID=%s\n' "$AISB_GID"
printf 'AISB_UNENFORCED=%s\n' "${POL[unenforced]}"
printf 'AISB_KEEP_ROOT=%s\n' "${POL[keep_root]}"
printf 'AISB_ALLOW_ICMP=%s\n' "${AISB_ALLOW_ICMP:-1}"
printf 'AISB_ALLOW_DNS=%s\n' "$ALLOW_DNS"
printf 'AISB_PROXY_IPS=%s\n' "${PROXY_IPS[*]-}"
printf 'AISB_PROXY_PORTS=%s\n' "${PROXY_PORTS[*]-}"
} > "$SESSION_MOUNT/policy.env"
chmod 644 "$SESSION_MOUNT/policy.env"
printf '%s\n' ${DIRECT4[@]+"${DIRECT4[@]}"} > "$SESSION_MOUNT/direct4.list"
printf '%s\n' ${DIRECT6[@]+"${DIRECT6[@]}"} > "$SESSION_MOUNT/direct6.list"
chmod 644 "$SESSION_MOUNT/direct4.list" "$SESSION_MOUNT/direct6.list"
}
# ===========================================================================
# image assets (single source of truth: this script)
# ===========================================================================
entrypoint_script() {
cat <<'AISB_ENTRY'
#!/bin/bash
# ai-sandbox container entrypoint.
#
# 1. read the policy the host wrote
# 2. install the egress ruleset (needs CAP_NET_ADMIN, which this process has)
# 3. drop every capability and switch to the unprivileged sandbox user
#
# Step 3 is what makes step 2 stick: the command cannot flush the ruleset,
# create a new network namespace, or regain privileges through a setuid helper.
set -Eeuo pipefail
POLICY_DIR="${AISB_POLICY_DIR:-/run/aisb}"
POLICY="$POLICY_DIR/policy.env"
log() { printf '[aisb] %s\n' "$*" >&2; }
fail() { printf '[aisb] FATAL: %s\n' "$1" >&2; exit "${2:-1}"; }
[[ -r "$POLICY" ]] || fail "policy file $POLICY is missing -- not starting" 78
# shellcheck source=/dev/null
source "$POLICY"
NET="${AISB_NET:-none}"
UID_TARGET="${AISB_UID:-1000}"
GID_TARGET="${AISB_GID:-1000}"
UNENFORCED="${AISB_UNENFORCED:-0}"
KEEP_ROOT="${AISB_KEEP_ROOT:-0}"
gen_rules() {
local dest ports fam
echo 'table inet aisb {'
echo ' chain output {'
echo ' type filter hook output priority filter; policy drop;'
echo ' oifname "lo" accept comment "aisb-lo"'
echo ' ct state established,related accept comment "aisb-est"'
if [[ "${AISB_ALLOW_ICMP:-0}" == 1 ]]; then
echo ' meta l4proto icmp icmp type echo-request counter accept comment "aisb-icmp"'
echo ' meta l4proto ipv6-icmp icmpv6 type { echo-request, echo-reply, nd-neighbor-solicit, nd-neighbor-advert, nd-router-solicit, nd-router-advert } counter accept comment "aisb-icmp6"'
fi
local f
for f in "$POLICY_DIR/direct4.list" "$POLICY_DIR/direct6.list"; do
[[ -s "$f" ]] || continue
while read -r dest ports || [[ -n "${dest:-}" ]]; do
[[ -n "${dest:-}" ]] || continue
ports="${ports:-80,443}"
fam=ip; [[ "$dest" == *:* ]] && fam=ip6
echo " $fam daddr $dest tcp dport { ${ports// /,} } counter accept comment \"aisb-direct\""
done < "$f"
done
local ip port
for ip in ${AISB_PROXY_IPS:-}; do
for port in ${AISB_PROXY_PORTS:-}; do
echo " ip daddr $ip tcp dport $port counter accept comment \"aisb-proxy\""
done
done
if [[ "${AISB_ALLOW_DNS:-0}" == 1 ]]; then
echo ' meta l4proto { tcp, udp } th dport 53 counter accept comment "aisb-dns"'
fi
echo ' counter drop comment "aisb-default-drop"'
echo ' }'
echo ' chain input {'
echo ' type filter hook input priority filter; policy drop;'
echo ' iifname "lo" accept'
echo ' ct state established,related accept'
echo ' meta l4proto ipv6-icmp icmpv6 type { nd-neighbor-advert, nd-router-advert, echo-reply } accept'
echo ' }'
echo '}'
}
ENFORCE="not-enforced"
if [[ "$NET" == allow ]]; then
command -v nft >/dev/null || fail "nftables is missing from the image; rebuild it (ai-sandbox build --rebuild)" 70
if [[ "$UNENFORCED" == 1 ]]; then
ENFORCE="SKIPPED-BY-REQUEST"
log "WARNING: egress ruleset skipped by --allow-unenforced"
else
RULES="$(mktemp)"
gen_rules > "$RULES"
nft delete table inet aisb 2>/dev/null || true
if ! err="$(nft -f "$RULES" 2>&1)"; then
rm -f "$RULES"
fail "cannot install the egress ruleset (need CAP_NET_ADMIN): $err
Refusing to run unenforced. Debug with --allow-unenforced, or use --net none." 77
fi
rm -f "$RULES"
nft list table inet aisb 2>/dev/null | grep -q 'aisb-default-drop' \
|| fail "egress ruleset did not take effect -- refusing to start" 77
ENFORCE="nft"
fi
fi
n_direct=$(cat "$POLICY_DIR"/direct4.list "$POLICY_DIR"/direct6.list 2>/dev/null | grep -cvE '^[[:space:]]*(#|$)' || true)
log "egress net=$NET enforce=$ENFORCE proxy=[${AISB_PROXY_IPS:-none}:${AISB_PROXY_PORTS:-none}] direct=$n_direct dns=${AISB_ALLOW_DNS:-0}"
if [[ "$KEEP_ROOT" == 1 ]]; then
log "WARNING: --insecure-keep-root: running as uid $(id -u) with capabilities"
exec "$@"
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
}
gate_entrypoint_script() {
cat <<'AISB_GATE'
#!/bin/bash
# ai-sandbox egress gate: a squid that speaks to the outside world only for
# allowlisted destinations. Config is bind-mounted read-only from the host.
set -Eeuo pipefail
CONF="${AISB_SQUID_CONF:-/etc/squid/squid.conf}"
[[ -r "$CONF" ]] || { echo "[aisb-gate] FATAL: $CONF missing" >&2; exit 78; }
squid -k parse -f "$CONF" 2>&1 | grep -v 'WARNING' >&2 || { echo "[aisb-gate] FATAL: invalid config" >&2; exit 77; }
exec squid -N -f "$CONF"
AISB_GATE
}
# ===========================================================================
# base image pinning
# ===========================================================================
# A tag is a moving pointer: "FROM ubuntu:24.04" does not name an image, it names
# whatever Canonical points at that week. That makes a build unreproducible and, more
# importantly, makes a security patch invisible -- you cannot tell an old image from a
# patched one. So the tag is resolved to a digest once, recorded under
# $AISB_CACHE_DIR/base/, and the digest is what goes into the Containerfile *and* into
# the content hash: refreshing the base produces a new image rather than quietly
# mutating one a sandbox may already be running.
#
# Freshness is a TTL, not a promise. Every AISB_BASE_TTL_DAYS days (default 7) the
# digest is re-resolved, which is a manifest read rather than a download. Offline, or
# with AISB_BASE_AUTO_UPDATE=0, the recorded digest is kept -- a stale pin is safer
# than a mutable one. AISB_BASE_TTL_DAYS=0 re-resolves on every build; -1 never does.
# `ai-sandbox update` does it on demand, `update --check` only reports.
#
# One deliberate asymmetry: an unpinned build is allowed, but it is announced as
# UNPINNED, labelled into the image as such, and warned about by doctor. Silent
# non-reproducibility is the failure mode this section exists to prevent.
base_state_path() { printf '%s/base/%s.env' "$AISB_CACHE_DIR" "$(sha12 "$1")"; }
base_state_field() { local f; f="$(base_state_path "$1")"
[[ -r "$f" ]] || return 0
sed -n "s/^$2=//p" "$f" | head -1; }
base_digest_recorded() { base_state_field "$1" digest; }
base_state_write() { # $1=ref $2=digest
local f; f="$(base_state_path "$1")"
mkdir -p "${f%/*}" || return 1
( umask 077; printf 'ref=%s\ndigest=%s\nresolved_at=%s\n' "$1" "$2" "$(date +%s)" > "$f" )
}
base_age_days() { # $1=ref -> whole days since the pin was recorded, empty if unknown
local at; at="$(base_state_field "$1" resolved_at)"
[[ -n "$at" ]] || return 0
printf '%s' $(( ( $(date +%s) - at ) / 86400 ))
}
base_digest_fresh() { # $1=ref -> the recorded digest while it is inside the TTL
local d age ttl="${POL[base_ttl]:-7}"
d="$(base_digest_recorded "$1")"; [[ -n "$d" ]] || return 0
(( ttl < 0 )) && { printf '%s' "$d"; return 0; } # -1: pinned until told otherwise
age="$(base_age_days "$1")"; age="${age:-$ttl}"
(( age <= ttl )) && printf '%s' "$d"
return 0
}
base_digest_online() { # $1=ref -> digest from the registry, empty when unreachable
local ref="$1" d="" sk="${AISB_SKOPEO:-skopeo}"
if command -v "$sk" > /dev/null 2>&1; then
d="$("$sk" inspect --format '{{.Digest}}' "docker://$ref" 2>/dev/null | head -1)" || d=""
fi
# Without skopeo a pull is the only way to learn a digest, and it costs a download.
# That is fine for an update and rude for an ordinary run, so it can be turned off
# with AISB_BASE_PULL_FALLBACK=0 (the tests do exactly that, to stay offline).
if [[ -z "$d" && "${AISB_BASE_PULL_FALLBACK:-1}" == 1 ]]; then
if podman pull -q "$ref" > /dev/null 2>&1; then
d="$(podman image inspect "$ref" --format '{{.Digest}}' 2>/dev/null)"
fi
fi
printf '%s' "$d"
}
# Echoes the digest to pin to -- empty means unpinned. It is *not* written into POL
# directly because callers use command substitution, and a global assigned in a
# subshell is simply lost; combine it with base_ref_of() instead.
# $1=ref $2=force (re-resolve now) $3=cache_only (never touch the network)
base_pin_digest() {
local ref="$1" force="${2:-0}" cache_only="${3:-0}" auto d=""
if [[ "$ref" == *@* ]]; then printf '%s' "${ref#*@}"; return 0; fi # user pinned it
auto="${POL[base_auto]:-1}"
d="$(base_digest_fresh "$ref")"
if [[ -z "$d" ]] && (( cache_only == 0 )); then
if (( auto )) || (( force )); then
log "resolving the current digest of $ref"
d="$(base_digest_online "$ref")"
if [[ -n "$d" ]]; then
base_state_write "$ref" "$d" \
|| log "warning: could not record the digest under $AISB_CACHE_DIR/base"
fi
fi
fi
[[ -n "$d" ]] || d="$(base_digest_recorded "$ref")"
if [[ -z "$d" ]]; then
(( cache_only )) || log "warning: $ref is UNPINNED -- no recorded digest and the registry was unreachable; the build is not reproducible"
fi
printf '%s' "$d"
}
# A ref is name + tag, plus the digest when there is one. A ref the user pinned
# themselves is passed through untouched rather than rebuilt from its parts.
base_ref_of() {
local ref="$1" digest="$2"
[[ "$ref" == *@* || -z "$digest" ]] && { printf '%s' "$ref"; return 0; }
printf '%s@%s' "$ref" "$digest"
}
# Image tags are named after the base so a non-Ubuntu base does not lie about itself.
image_tag_prefix() { local b="${POL[base_image]##*/}"; b="${b%%[:@]*}"; printf '%s' "${b:-base}"; }
containerfile() {
cat <<AISB_CF
# Generated by $PROG $VERSION -- edit bin/$PROG, not this file.
FROM ${POL[base_ref]:-${POL[base_image]}}
# Auditable by one 'podman image inspect' on a container found running weeks later.
LABEL ai-sandbox=1 \\
aisb.version="$VERSION" \\
aisb.base="${POL[base_image]}" \\
aisb.base-digest="${POL[base_digest]:-unpinned}" \\
aisb.node="${POL[node_version]}" \\
aisb.pi-package="${AISB_PI_PACKAGE}" \\
aisb.pi-version="${POL[pi_version]}"
ENV DEBIAN_FRONTEND=noninteractive \\
LANG=C.UTF-8
RUN apt-get update \\
&& apt-get install -y --no-install-recommends \\
ca-certificates curl wget git openssh-client xz-utils \\
nftables iproute2 iputils-ping util-linux procps \\
jq less nano vim-tiny tree file unzip zip patch \\
python3 python3-venv make gcc g++ pkg-config \\
tzdata locales \\
&& apt-get clean && rm -rf /var/lib/apt/lists/*
ARG AISB_EXTRA_PACKAGES=""
RUN if [ -n "\$AISB_EXTRA_PACKAGES" ]; then \\
apt-get update \\
&& apt-get install -y --no-install-recommends \$AISB_EXTRA_PACKAGES \\
&& apt-get clean && rm -rf /var/lib/apt/lists/*; \\
fi
# Node.js LTS, checksum-verified against nodejs.org when the host could fetch it.
ARG AISB_NODE_VERSION=${POL[node_version]}
ARG AISB_NODE_SHA256=""
RUN set -eux; \\
case "\$(uname -m)" in \\
x86_64) nodearch=x64 ;; \\
aarch64) nodearch=arm64 ;; \\
*) echo "unsupported architecture: \$(uname -m)" >&2; exit 1 ;; \\
esac; \\
tarball="node-v\${AISB_NODE_VERSION}-linux-\${nodearch}.tar.xz"; \\
curl -fsSL -o "/tmp/\$tarball" "https://nodejs.org/dist/v\${AISB_NODE_VERSION}/\${tarball}"; \\
if [ -n "\$AISB_NODE_SHA256" ]; then \\
echo "\${AISB_NODE_SHA256} /tmp/\$tarball" | sha256sum -c -; \\
fi; \\
tar -xJf "/tmp/\$tarball" -C /usr/local --strip-components=1; \\
rm -f "/tmp/\$tarball"; \\
node --version; npm --version
ARG AISB_PI_PACKAGE=${AISB_PI_PACKAGE}
ARG AISB_PI_VERSION=${POL[pi_version]}
RUN set -eux; \\
npm config set update-notifier false; \\
if [ -n "\$AISB_PI_VERSION" ]; then npm install -g "\${AISB_PI_PACKAGE}@\${AISB_PI_VERSION}"; fi
# The sandbox user. Base images already ship a uid 1000 (ubuntu:24.04 has
# 'ubuntu'); that account is removed so the uid, the name in /etc/passwd and the
# home directory agree about who is running: logs print a name, and tools that
# resolve a home directory read the passwd entry rather than the HOME variable.
# (Comments in a Containerfile go through host variable expansion too, so keep
# dollar signs out of them.)
RUN set -eux; \\
userdel -r ubuntu >/dev/null 2>&1 || true; \\
getent group ${AISB_GID} >/dev/null 2>&1 || groupadd -g ${AISB_GID} sandbox; \\
getent passwd ${AISB_UID} >/dev/null 2>&1 || useradd -M -u ${AISB_UID} -g ${AISB_GID} -d /home/sandbox -s /bin/bash sandbox; \\
mkdir -p /project /run/aisb /home/sandbox; \\
chown ${AISB_UID}:${AISB_GID} /project /home/sandbox; chmod 0755 /home/sandbox; \\
printf '%s\\n' 'PS1="sandbox:\\w\\$ "' > /home/sandbox/.bashrc; \\
printf '%s\\n' 'export PATH="/home/sandbox/.local/bin:\$PATH"' >> /home/sandbox/.bashrc; \\
printf '%s\\n' 'if [ -n "\$BASH_VERSION" ] && [ -f "\$HOME/.bashrc" ]; then . "\$HOME/.bashrc"; fi' > /home/sandbox/.profile; \\
chown -R ${AISB_UID}:${AISB_GID} /home/sandbox
RUN git config --system --add safe.directory '*' || true
# A tag answers "which build"; this answers "which squid, which nftables, which pi"
# long after the build log has rotated. Readable from inside as \$AISB_MANIFEST.
RUN mkdir -p /etc/aisb \\
&& { echo "# $PROG $VERSION package manifest"; \\
dpkg-query -W -f='\${Package}=\${Version}\n'; \\
echo "node=\$(node -v 2>/dev/null || echo unknown)"; \\
npm ls -g --depth=0 --json 2>/dev/null \\
| jq -r '.dependencies // {} | to_entries[] | "npm.\\(.key)=\\(.value.version // "?")"' \\
|| echo 'npm=unavailable'; \\
} > /etc/aisb/manifest.txt
COPY aisb-entrypoint /usr/local/lib/aisb/entrypoint.sh
RUN chmod 0755 /usr/local/lib/aisb/entrypoint.sh
ENV HOME=/home/sandbox \\
USER=sandbox \\
LOGNAME=sandbox \\
npm_config_update_notifier=false \\
GIT_TERMINAL_PROMPT=0 \\
PIP_DISABLE_PIP_VERSION_CHECK=1 \\
AISB=1 \\
AISB_MANIFEST=/etc/aisb/manifest.txt
WORKDIR ${POL[workdir]}
ENTRYPOINT ["/usr/local/lib/aisb/entrypoint.sh"]
CMD ["bash", "-li"]
AISB_CF
}
gate_containerfile() {
cat <<AISB_GCF
# Generated by $PROG $VERSION -- egress gate image.
FROM ${POL[base_ref]:-${POL[base_image]}}
LABEL ai-sandbox=1 \\
aisb.role=gate \\
aisb.version="$VERSION" \\
aisb.base="${POL[base_image]}" \\
aisb.base-digest="${POL[base_digest]:-unpinned}"
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update \\
&& apt-get install -y --no-install-recommends squid ca-certificates curl iproute2 jq \\
&& apt-get clean && rm -rf /var/lib/apt/lists/* \\
&& mkdir -p /etc/aisb \\
&& { echo "# $PROG $VERSION gate package manifest"; \\
dpkg-query -W -f='\${Package}=\${Version}\n'; \\
} > /etc/aisb/manifest.txt
COPY aisb-gate-entrypoint /usr/local/lib/aisb/gate-entrypoint.sh
RUN chmod 0755 /usr/local/lib/aisb/gate-entrypoint.sh
ENTRYPOINT ["/usr/local/lib/aisb/gate-entrypoint.sh"]
AISB_GCF
}
image_hash() {
local h
# base_ref, not base_image: a refreshed base digest has to change the tag, and a
# bare tag change (24.04 -> 24.10) changes it too because the name is in the hash.
h="${POL[base_ref]:-${POL[base_image]}}|${POL[node_version]}|${POL[pi_version]}|${AISB_PI_PACKAGE}|v${VERSION}"
h+="|$(printf '%s ' ${P_install[@]+"${P_install[@]}"})"
# any change to the generated image definition produces a new tag
h+="|$(entrypoint_script | sha256sum | cut -d' ' -f1)"
h+="|$(containerfile | sha256sum | cut -d' ' -f1)"
sha12 "$h"
}
image_ref() { printf '%s:%s-%s\n' "$AISB_IMAGE_REPO" "$(image_tag_prefix)" "$(image_hash)"; }
gate_image_ref() { printf '%s:%s-%s\n' "$AISB_GATE_IMAGE_REPO" "$(image_tag_prefix)" \
"$(sha12 "${POL[base_ref]:-${POL[base_image]}}|v${VERSION}|squid")"; }
node_sha256() {
[[ -n "${AISB_NODE_SHA256:-}" ]] && { printf '%s' "${AISB_NODE_SHA256}"; return 0; }
local t
t="node-v${POL[node_version]}-linux-$(node_arch).tar.xz"
curl -fsSL --max-time 25 "https://nodejs.org/dist/v${POL[node_version]}/SHASUMS256.txt" 2>/dev/null \
| awk -v t="$t" '$2 == t {print $1}'
return 0
}
ensure_image() { # $1=sandbox|gate
local kind="$1" ref ctx
if [[ "$kind" == sandbox ]]; then
ref="$(image_ref)"
[[ "${OPT_IMAGE_EXISTS_OK:-0}" == 1 ]] && podman image exists "$ref" && { printf '%s\n' "$ref"; return 0; }
if podman image exists "$ref"; then printf '%s\n' "$ref"; return 0; fi
[[ "${OPT_NO_BUILD:-0}" == 1 ]] && die "image $ref is not built (and --no-build was given). Run: $PROG build"
ctx="$AISB_CACHE_DIR/build/sandbox-$(image_hash)"
rm -rf "$ctx"; mkdir -p "$ctx"
containerfile > "$ctx/Containerfile"
entrypoint_script > "$ctx/aisb-entrypoint"
chmod 755 "$ctx/aisb-entrypoint"
local sha; sha="$(node_sha256)"
[[ -n "$sha" ]] || log "warning: no SHA256 for Node ${POL[node_version]} (nodejs.org unreachable?) -- the tarball will NOT be verified. Set AISB_NODE_SHA256 to pin it yourself."
build_in "$ctx" "$ref" \
--build-arg "AISB_NODE_SHA256=${sha}" \
--build-arg "AISB_EXTRA_PACKAGES=$(printf '%s ' ${P_install[@]+"${P_install[@]}"})"
else
ref="$(gate_image_ref)"
if podman image exists "$ref"; then printf '%s\n' "$ref"; return 0; fi
[[ "${OPT_NO_BUILD:-0}" == 1 ]] && die "gate image is not built (and --no-build was given). Run: $PROG build --gate"
ctx="$AISB_CACHE_DIR/build/gate-$(sha12 "$ref")"
rm -rf "$ctx"; mkdir -p "$ctx"
gate_containerfile > "$ctx/Containerfile"
gate_entrypoint_script > "$ctx/aisb-gate-entrypoint"
chmod 755 "$ctx/aisb-gate-entrypoint"
build_in "$ctx" "$ref"
fi
printf '%s\n' "$ref"
}
build_in() {
local ctx="$1" ref="$2"; shift 2
local logf
mkdir -p "$AISB_STATE_DIR"
logf="$AISB_STATE_DIR/build-$(basename "$ctx").log"
if podman build -t "$ref" "$@" "$ctx" > "$logf" 2>&1; then
log "built $ref"
rm -rf "$ctx"
else
tail -25 "$logf" >&2
die "image build failed for $ref (full log: $logf)"
fi
}
cmd_build() {
local force=0 want_gate=0 refresh_base=0
while (($#)); do
case "$1" in
--rebuild|-f) force=1; shift ;;
--gate) want_gate=1; shift ;;
--refresh-base|-B) refresh_base=1; shift ;;
*) die "build: unknown argument $1" ;;
esac
done
mkdir -p "$AISB_CACHE_DIR" "$AISB_STATE_DIR"
if (( refresh_base )); then
POL[base_digest]="$(base_pin_digest "${POL[base_image]}" 1)"
POL[base_ref]="$(base_ref_of "${POL[base_image]}" "${POL[base_digest]}")"
fi
if (( force )); then
podman rmi -f "$(image_ref)" >/dev/null 2>&1 || true
[[ $want_gate == 1 ]] && podman rmi -f "$(gate_image_ref)" >/dev/null 2>&1 || true
fi
local ref gref
ref="$(ensure_image sandbox)"; log "sandbox image: $ref"
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
# ===========================================================================
port_free() { ! ss -H -ltn 2>/dev/null | awk '{print $4}' | grep -qE "[:.]${1}\$"; }
pick_port() {
local p="${POL[gate_port]}" i
for ((i = 0; i < 64; i++)); do
port_free "$p" && { printf '%s\n' "$p"; return 0; }
p=$((p + 1))
done
die "no free TCP port at or above ${POL[gate_port]}"
}
write_squid_conf() { # $1=port $2=path
local port="$1" dest="$2"
local domains="${GATE_DOMAINS[*]-}" ips="${GATE_IPS[*]-}"
{
echo "# generated by $PROG $VERSION -- do not edit"
echo "http_port 127.0.0.1:$port"
echo "visible_hostname aisb-gate"
echo "cache_mem 8 MB"
echo "access_log stdio:/dev/stdout"
echo "pid_filename /tmp/aisb-squid.pid" # the gate runs unprivileged: no /run write
echo "cache_log /dev/stderr"
echo "debug_options NONE"
# squid 6: 'manager' is a built-in ACL; obsolete directives are hard errors
echo "acl SSL_ports port 443 8443"
echo "acl Safe_ports port 80 443 8443"
echo "acl CONNECT method CONNECT"
[[ -n "$domains" ]] && echo "acl aisb_domain dstdomain $domains"
[[ -n "$ips" ]] && echo "acl aisb_ip dst $ips"
echo "http_access deny manager"
echo "http_access deny !Safe_ports"
echo "http_access deny CONNECT !SSL_ports"
[[ -n "$domains" ]] && echo "http_access allow aisb_domain"
[[ -n "$ips" ]] && echo "http_access allow aisb_ip"
echo "http_access deny all"
} > "$dest"
chmod 644 "$dest" # bind-mounted into the gate container, which has no caps
}
gate_up() { # $1=name $2=port
local name="$1" port="$2" ref conf
ref="$(ensure_image gate)"
conf="$GATE_DIR/squid.conf"
write_squid_conf "$port" "$conf"
podman rm -f "$name" >/dev/null 2>&1 || true
podman run -d --name "$name" \
--network=host \
--user proxy \
--stop-signal SIGTERM --stop-timeout 3 \
--label ai-sandbox=1 --label aisb.role=gate \
--label "aisb.session=$SESSION" \
--label "aisb.allow=$(sha12 "${domains[*]-}|${ips[*]-}")" \
--security-opt no-new-privileges \
--cap-drop=ALL \
--memory 512m --pids-limit 128 \
-v "${conf}:/etc/squid/squid.conf:ro${SELOPT_SEP}" \
"$ref" > /dev/null
local i up=0
for ((i = 0; i < 80; i++)); do
if ! podman ps --format '{{.Names}}' | grep -qx "$name"; then
die "egress gate $name exited during startup:
$(podman logs "$name" 2>&1 | tail -8)"
fi
# any HTTP answer from the proxy -- including a 403 for the probe host -- means
# it is up and enforcing.
if curl -sS -m 2 -x "http://127.0.0.1:$port" -o /dev/null "http://aisb-probe.invalid/"; then
up=1; break
fi
sleep 0.25
done
(( up )) || die "egress gate on 127.0.0.1:$port never answered
$(podman logs "$name" 2>&1 | tail -8)"
GATE_STARTED=1
log "egress gate $name -> 127.0.0.1:$port (${#GATE_DOMAINS[@]} domains, ${#GATE_IPS[@]} ip/cidr entries)"
}
cmd_gate() {
local action="${1:-status}"; shift || true
load_config
session_init
OPT_allow=() # config allow entries arrive through CFG_allow
while (($#)); do
case "$1" in
--allow) OPT_allow+=("${2:-}"); shift 2 ;;
--allow=*) OPT_allow+=("${1#*=}"); shift ;;
*) die "gate: unexpected argument: $1" ;;
esac
done
resolve_opts
classify_allows
local name="${AISB_GATE_NAME:-aisb-gate}"
case "$action" in
up)
[[ ${#P_allow[@]} -gt 0 ]] || die "the gate needs an allowlist: --allow HOST[:PORT]"
GATE_PORT="$(pick_port)"
gate_up "$name" "$GATE_PORT"
printf 'name=%s\nport=%s\nproxy=export HTTPS_PROXY=http://%s:%s\n' \
"$name" "$GATE_PORT" "${POL[gate_addr]}" "$GATE_PORT"
;;
down) podman rm -f "$name" >/dev/null 2>&1 && log "gate down: $name" || log "gate $name was not running" ;;
status)
if podman ps --format '{{.Names}}' | grep -qx "$name"; then
podman ps --filter "name=^${name}\$" --format '{{.Names}} {{.Status}}'
curl -sS -m 3 -x "http://127.0.0.1:${AISB_GATE_PORT}" -o /dev/null http://aisb-probe.invalid/ 2>&1 | sed 's/^/probe: /' || true
else
echo "gate $name: not running"
fi
;;
logs) podman logs --tail "${AISB_TAIL:-100}" "$name" ;;
*) die "gate: unknown action '$action' (up | down | status | logs)" ;;
esac
}
# ===========================================================================
# podman argv
# ===========================================================================
declare -a ARGV=()
harness_argv() { # the harness command as words (empty for none/bash)
case "${POL[harness]}" in
pi) printf 'pi\n' ;;
codex) printf 'codex\n' ;;
claude) printf 'claude\n' ;;
none|bash) printf '\n' ;;
*) printf '%s\n' "${POL[harness]}" ;;
esac
}
default_command() {
local -a hb=()
if [[ "${POL[shell]}" == 1 ]]; then COMMAND=(bash -li); return; fi
if [[ "${POL[harness]}" != none && "${POL[harness]}" != bash ]]; then
read -r -a hb <<< "$(harness_argv)"
fi
if [[ ${#COMMAND[@]} -eq 0 ]]; then
# 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 turbo`
COMMAND=("${hb[@]}" "${COMMAND[@]}")
fi
# otherwise -- was used: the command replaces the harness
}
ensure_home_volume() { # $1=name
local name="$1"
[[ -n "$name" ]] || return 0
podman volume inspect "$name" > /dev/null 2>&1 && return 0
podman volume create --label ai-sandbox=1 "$name" > /dev/null 2>&1 \
|| die "could not create the home cache volume: $name"
}
build_argv() {
local net_arg
case "${POL[net]}" in
none) net_arg="none" ;;
full) net_arg="pasta" ;;
allow) net_arg="pasta:--map-host-loopback,${POL[gate_addr]}" ;;
esac
ARGV=(podman run --name "${POL[name]}")
ARGV+=(--label ai-sandbox=1 --label "aisb.session=${SESSION}"
--label "aisb.net=${POL[net]}" --label "aisb.harness=${POL[harness]}")
local l
for l in ${P_label[@]+"${P_label[@]}"}; do ARGV+=(--label "$l"); done
[[ "${OPT_KEEP:-0}" == 1 ]] || ARGV+=(--rm)
ARGV+=(--hostname aisb)
ARGV+=(--userns "keep-id:uid=${AISB_UID},gid=${AISB_GID}")
ARGV+=(-u 0:0) # entrypoint drops to $AISB_UID before the command runs
ARGV+=(--workdir "${POL[workdir]}")
# Only the entrypoint ever holds these; setpriv clears the bounding set before
# the harness runs, so the command itself starts with zero capabilities.
ARGV+=(--cap-drop=ALL --cap-add=setuid --cap-add=setgid --cap-add=setpcap)
[[ "${POL[net]}" == allow ]] && ARGV+=(--cap-add=NET_ADMIN)
ARGV+=(--security-opt no-new-privileges)
ARGV+=(--pids-limit "${POL[pids]}" --memory "${POL[memory]}" --cpus "${POL[cpus]}")
ARGV+=(--tmpfs "/tmp:rw,nosuid,nodev,size=${POL[tmpfs]}")
ARGV+=(--network "$net_arg")
ARGV+=(--stop-signal SIGTERM --pull missing)
if [[ -n "${OPT_TTY:-}" ]]; then
[[ "${OPT_TTY}" == 1 ]] && ARGV+=(-t)
elif [[ -t 0 && -t 1 ]]; then
ARGV+=(-t)
fi
if [[ -n "${OPT_INTERACTIVE:-}" ]]; then
[[ "${OPT_INTERACTIVE}" == 1 ]] && ARGV+=(-i)
elif [[ -t 0 ]]; then
ARGV+=(-i)
fi
local v
for v in "${AISB_IMPLICIT_ENV[@]}"; do [[ -n "${!v+x}" ]] && ARGV+=(--env "$v"); done
ARGV+=(--env "HOME=/home/sandbox" --env "AISB=1" --env "AISB_SESSION=${SESSION}")
if ((${#PROXY_IPS[@]})); then
local px="http://${PROXY_IPS[0]}:${PROXY_PORTS[0]}"
ARGV+=(--env "HTTP_PROXY=$px" --env "http_proxy=$px"
--env "HTTPS_PROXY=$px" --env "https_proxy=$px"
--env "ALL_PROXY=$px" --env "all_proxy=$px"
--env "NO_PROXY=localhost,127.0.0.1,::1"
--env "no_proxy=localhost,127.0.0.1,::1")
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
# created up front with our label (podman has no --volume-label on --mount), so
# 'clean --volumes' can recognise it; the aisb- name prefix covers older volumes.
# --dry-run must stay side-effect free, so it is skipped there.
[[ "${OPT_DRY}" == 1 ]] || ensure_home_volume "${AISB_HOME_VOLUME}"
ARGV+=(--mount "type=volume,source=${AISB_HOME_VOLUME},target=/home/sandbox")
else
ARGV+=(--tmpfs "/home/sandbox:rw,nosuid,nodev,size=${POL[tmpfs]}")
fi
[[ "${POL[readonly]}" == 1 ]] && ARGV+=(--read-only)
local m src dst opt rest
for m in ${P_mount[@]+"${P_mount[@]}"}; do
src="${m%%:*}"; rest="${m#*:}"; dst=""; opt=""
if [[ "$rest" == *:* ]]; then dst="${rest%%:*}"; opt="${rest##*:}";
elif [[ -n "$rest" && "$rest" != "$m" ]]; then dst="$rest"; fi
[[ -e "$src" ]] || die "--mount: path does not exist: $src"
[[ -n "$dst" ]] || dst="/mnt/$(basename -- "$src")"
[[ -z "$opt" || "$opt" == ro || "$opt" == rw ]] || die "--mount: option must be ro or rw (got '$opt')"
if [[ -n "$opt" ]]; then ARGV+=(-v "${src}:${dst}:${opt}${SELOPT_SEP}")
else ARGV+=(-v "${src}:${dst}${SELOPT}"); fi
done
default_command # handles: nothing given, pass-through args, and -- replacement
if [[ -n "${POL[extra_args]}" ]]; then
local -a xa; read -r -a xa <<< "${POL[extra_args]}"
ARGV+=("${xa[@]}")
fi
ARGV+=("${POL[image_ref]}" "${COMMAND[@]}")
}
print_argv() {
local a
printf '# %s %s -- effective podman invocation (env values redacted)\n' "$PROG" "$VERSION"
printf '# egress: net=%s proxy=%s domains=[%s] direct=[%s]\n' \
"${POL[net]}" "${PROXY_IPS[0]-none}:${PROXY_PORTS[0]-none}" \
"$(printf '%s ' ${GATE_DOMAINS[@]+"${GATE_DOMAINS[@]}"})" \
"$(printf '%s ' ${DIRECT4[@]+"${DIRECT4[@]}"})"
printf '# env (%d, passed via 0600 file): %s\n' "${#ENV_NAMES[@]}" \
"$(printf '%s ' ${ENV_NAMES[@]+"${ENV_NAMES[@]}"})"
for a in "${ARGV[@]}"; do
if [[ "$a" == "$ENV_FILE" ]]; then printf '<env-file:0600> '
else printf '%s ' "$(shquote "$a")"; fi
done
printf '\n'
}
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]}")"
printf 'container=%s\n' "${POL[name]}"
printf 'net=%s\n' "${POL[net]}"
printf 'proxy=%s\n' "${PROXY_IPS[*]-none}:${PROXY_PORTS[*]-none}"
printf 'harness=%s\n' "${POL[harness]}"
printf 'command=%s\n' "$(printf '%s ' ${COMMAND[@]+"${COMMAND[@]}"})"
printf 'workdir=%s\n' "${POL[workdir]}"
printf 'cwd=%s\n' "${POL[cwd]}"
printf 'cwd_mounted=%s\n' "$([[ ${OPT_NO_CWD:-0} == 1 ]] && echo no || echo yes)"
printf 'limits=cpus:%s,memory:%s,pids:%s\n' "${POL[cpus]}" "${POL[memory]}" "${POL[pids]}"
printf 'home_cache=%s\n' "${POL[home_cache]}"
printf 'selinux=%s\n' "${POL[selinux]:-off}"
printf 'allow=%s\n' "$(join ' ' ${P_allow[@]+"${P_allow[@]}"})"
printf 'gate_domains=%s\n' "$(join ' ' ${GATE_DOMAINS[@]+"${GATE_DOMAINS[@]}"})"
printf 'direct=%s\n' "$(join ';' ${DIRECT4[@]+"${DIRECT4[@]}"})"
printf 'direct6=%s\n' "$(join ';' ${DIRECT6[@]+"${DIRECT6[@]}"})"
printf 'env_names=%s\n' "$(join ' ' ${ENV_NAMES[@]+"${ENV_NAMES[@]}"})"
printf 'env_missing=%s\n' "$(join ' ' ${MISSING_ENV[@]+"${MISSING_ENV[@]}"})"
printf 'mounts=%s\n' "$(join ';' ${P_mount[@]+"${P_mount[@]}"})"
}
# ===========================================================================
# run
# ===========================================================================
cmd_run() {
mkdir -p "$AISB_STATE_DIR" "$AISB_CACHE_DIR"
load_config
parse_args "$@"
session_init
trap teardown EXIT
resolve_opts
(( OPT_PRINT_EP )) && { entrypoint_script; exit 0; }
(( OPT_PRINT_CF )) && { containerfile; exit 0; }
validate_opts
set_selinux "${POL[selinux]}"
POL[image_ref]="$(image_ref)"
classify_allows
# Decide the egress path before anything is started: gate, external proxy, or
# a bare IP-pinned allowlist (--no-gate).
local want_gate=0
if [[ "${POL[net]}" == allow && "${POL[no_gate]}" != 1 && -z "${POL[proxy]}" ]]; then
want_gate=1
GATE_NAME="aisb-gate-$SESSION"
if (( OPT_PRINT || OPT_DRY )); then
GATE_PORT="${POL[gate_port]}" # preview: never pick, never start
else
GATE_PORT="$(pick_port)"
fi
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
if (( OPT_DRY )); then print_argv; exit 0; fi
ensure_image sandbox > /dev/null
# The gate only comes up once we know the sandbox image exists: a gate with no
# sandbox behind it is an open door for nothing.
if (( want_gate )); then gate_up "$GATE_NAME" "$GATE_PORT"; fi
local rc=0
"${ARGV[@]}" || rc=$?
log_session "$rc"
exit "$rc"
}
log_session() {
printf '%s session=%s net=%s allow=%d env=%d rc=%s cmd=%s\n' \
"$(date -Is)" "$SESSION" "${POL[net]}" "${#P_allow[@]}" "${#ENV_NAMES[@]}" "$1" \
"${COMMAND[*]-}" >> "$AISB_STATE_DIR/log"
}
# ===========================================================================
# doctor
# ===========================================================================
declare -i DOC_FAIL=0 DOC_WARN=0
chk() {
local lvl="$1"; shift
case "$lvl" in
FAIL) DOC_FAIL+=1; printf ' [FAIL] %s\n' "$*" ;;
WARN) DOC_WARN+=1; printf ' [WARN] %s\n' "$*" ;;
PASS) printf ' [ ok ] %s\n' "$*" ;;
*) printf ' [info] %s\n' "$*" ;;
esac
}
cmd_doctor() {
mkdir -p "$AISB_STATE_DIR" "$AISB_CACHE_DIR" # so the smoke test can write its log
local deep=0
[[ "${1:-}" == --deep || "${1:-}" == -d ]] && deep=1
printf '%s %s -- doctor (uid %s, %s)\n' "$PROG" "$VERSION" "$(id -u)" "$(uname -srm)"
if ! have podman; then chk FAIL "podman is not installed"; exit 1; fi
chk INFO "podman $(podman --version | awk '{print $3}')"
if [[ "$(id -u)" == 0 ]]; then
chk FAIL "running as root: ai-sandbox is built for rootless podman"
else
chk PASS "rootless (uid $(id -u))"
fi
if grep -q "^$(id -un):" /etc/subuid 2>/dev/null && grep -q "^$(id -un):" /etc/subgid 2>/dev/null; then
chk PASS "subuid/subgid ranges present"
else
chk FAIL "no /etc/subuid range for $(id -un); rootless podman cannot work
fix: sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 $(id -un)"
fi
if have pasta; then
chk PASS "network helper: $(pasta --version 2>/dev/null | head -1)"
elif have slirp4netns; then
chk PASS "network helper: slirp4netns"
else
chk WARN "neither pasta nor slirp4netns found; --net allow and --net full need one"
fi
if have getenforce; then
case "$(getenforce 2>/dev/null)" in
Enforcing) chk PASS "SELinux enforcing; bind mounts labelled '${POL[selinux]:-none}'" ;;
Permissive) chk WARN "SELinux is permissive" ;;
*) chk INFO "SELinux: $(getenforce 2>/dev/null)" ;;
esac
fi
if ! podman info > /dev/null 2>&1; then
chk FAIL "'podman info' failed -- the container runtime is not usable"; exit 1
fi
local ref; ref="$(image_ref)"
if podman image exists "$ref"; then chk PASS "sandbox image present: $ref"
else chk WARN "sandbox image not built: $ref (run: $PROG build)"; fi
# Base pinning: an image built against a *different* digest than the one recorded now
# is an image of the previous base -- worth saying out loud, that is how a patched
# base quietly fails to arrive.
local pdig; pdig="${POL[base_digest]:-unpinned}"
if [[ "$pdig" == unpinned ]]; then
chk WARN "base image ${POL[base_image]} is unpinned (no digest recorded, registry unreachable?)"
else
chk INFO "base pin: ${POL[base_image]} @ ${pdig:0:19}..., $(base_age_days "${POL[base_image]}") day(s) old (ttl ${POL[base_ttl]})"
fi
if command -v "${AISB_SKOPEO:-skopeo}" > /dev/null 2>&1; then
chk OK "digest lookups available: $("${AISB_SKOPEO:-skopeo}" --version | cut -d' ' -f1-3)"
else
chk WARN "skopeo is missing: refreshing a base digest would need a full 'podman pull'"
fi
if podman image exists "$ref"; then
local built; built="$(podman image inspect "$ref" --format '{{ index .Config.Labels "aisb.base-digest" }}' 2>/dev/null)"
case "$built" in
"") chk INFO "image $ref predates label tracking" ;;
"$pdig") chk OK "image $ref was built on the pinned base" ;;
*) chk WARN "image $ref was built on ${built:0:19}... but the pin is now ${pdig:0:19}... (run: $PROG update)" ;;
esac
fi
if podman image exists "$(gate_image_ref)"; then chk PASS "gate image present: $(gate_image_ref)"
else chk WARN "gate image not built: $(gate_image_ref) (built on first use)"; fi
chk INFO "policy: net=${POL[net]} harness=${POL[harness]} limits=cpus:${POL[cpus]},memory:${POL[memory]},pids:${POL[pids]}"
chk INFO "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
chk INFO "no allowlist configured yet (--allow, .ai-sandbox/allow, $AISB_CONFIG_DIR/allow)"
fi
if [[ -d "$PWD/.git" ]]; then
if [[ -n "$(git -C "$PWD" status --porcelain 2>/dev/null)" ]]; then
chk WARN "the working tree has uncommitted changes: the sandbox can rewrite them (it has rw access)"
else
chk PASS "working tree is clean"
fi
fi
if podman image exists "$ref"; then
local d pol rc=0
d="$(mktemp -d)"
# a minimal, net=none policy so the entrypoint has something to check
pol="$AISB_STATE_DIR/doctor-policy"; mkdir -p "$pol"
printf 'AISB_NET=none\nAISB_UID=%s\nAISB_GID=%s\nAISB_ALLOW_ICMP=0\nAISB_ALLOW_DNS=0\nAISB_PROXY_IPS=\nAISB_PROXY_PORTS=\n' \
"$AISB_UID" "$AISB_GID" > "$pol/policy.env"
: > "$pol/direct4.list"; : > "$pol/direct6.list"
chmod 755 "$pol"; chmod 644 "$pol"/*
podman run --rm --network none --cap-drop=ALL --cap-add=setuid --cap-add=setgid --cap-add=setpcap \
--userns "keep-id:uid=${AISB_UID},gid=${AISB_GID}" \
-v "${d}:/project${SELOPT}" -v "${pol}:/run/aisb:ro${SELOPT_SEP}" -w /project "$ref" \
bash -lc 'touch /project/.aisb-doctor' > "$AISB_STATE_DIR/doctor.out" 2>&1 || rc=$?
if (( rc != 0 )); then
chk FAIL "container smoke test failed (see $AISB_STATE_DIR/doctor.out)"
elif [[ "$(stat -c %u "$d/.aisb-doctor" 2>/dev/null)" == "$(id -u)" ]]; then
chk PASS "bind mount + uid mapping: files land on the host owned by uid $(id -u)"
else
chk FAIL "file written in the container is owned by uid $(stat -c %u "$d/.aisb-doctor" 2>/dev/null), expected $(id -u)"
fi
rm -rf "$d"
fi
# With --deep, ask the registry whether the pin is still current. Deliberately
# skopeo-only here: the podman fallback would download the whole base inside a
# diagnostic, which is not what someone typing --deep signed up for.
if (( deep )); then
if command -v "${AISB_SKOPEO:-skopeo}" > /dev/null 2>&1; then
local remote; remote="$(base_digest_online "${POL[base_image]}")"
if [[ -z "$remote" ]]; then
chk WARN "deep check: the registry is unreachable, cannot confirm the base is current"
elif [[ "$remote" == "${POL[base_digest]:-}" ]]; then
chk PASS "deep check: the base pin matches upstream"
else
chk WARN "deep check: upstream is ${remote:0:19}..., the pin is ${POL[base_digest]:-none} (run: $PROG update)"
fi
else
chk INFO "deep check: skipped the upstream digest comparison (needs skopeo)"
fi
fi
if (( deep )) && (($DOC_FAIL == 0)); then
chk INFO "deep check: does the sandbox actually block non-allowlisted egress?"
local out rc=0
out="$("$PROG" --net allow --no-gate --allow example.com --print-policy 2>&1)" || rc=$?
chk INFO "policy under test: $(printf '%s' "$out" | grep '^direct=' || true)"
out="$("$PROG" --net allow --no-gate --allow example.com -- \
bash -c 'curl -sS -m8 -o /dev/null -w "%{http_code}" http://example.com; echo; \
curl -sS -m8 -o /dev/null http://example.org && echo NOT_BLOCKED || echo BLOCKED' 2>&1)" || true
printf '%s\n' "$out" | sed 's/^/ /'
if printf '%s' "$out" | grep -q 'NOT_BLOCKED'; then
chk FAIL "egress to a non-allowlisted host succeeded"
elif printf '%s' "$out" | grep -q 'BLOCKED'; then
chk PASS "egress outside the allowlist is blocked"
fi
fi
if (( DOC_FAIL )); then
printf '\ndoctor: %d problem(s), %d warning(s)\n' "$DOC_FAIL" "$DOC_WARN"; exit 1
fi
printf '\ndoctor: no problems found (%d warning(s))\n' "$DOC_WARN"
}
# ===========================================================================
# ps / clean / inspect
# ===========================================================================
cmd_ps() {
podman ps -a --filter label=ai-sandbox=1 --format '{{.Names}}\t{{.Status}}\t{{.Image}}'
}
cmd_clean() {
local all=0 volumes=0 volpat="" ttl="${AISB_TTL_MIN:-360}" n v
while (($#)); do
case "$1" in
--all|-a) all=1 ;;
--volumes|-V) volumes=1 ;;
--volumes=*) volumes=1; volpat="${1#*=}" ;;
*) die "clean: unknown option: $1 (try --all or --volumes)" ;;
esac
shift
done
while IFS= read -r n; do
[[ -n "$n" ]] || continue
if podman ps --format '{{.Names}}' | grep -qx "$n" && (( all == 0 )); then
continue # never kill a live container unless --all
fi
if [[ -d "$AISB_STATE_DIR/sessions/${n#aisb-gate-}" ]] && (( all == 0 )); then
continue # a session dir still exists: it may be in use
fi
podman rm -f "$n" > /dev/null 2>&1 && log "removed container $n"
done < <(podman ps -a --filter label=ai-sandbox=1 --format '{{.Names}}')
if [[ -d "$AISB_STATE_DIR/sessions" ]]; then
find "$AISB_STATE_DIR/sessions" -mindepth 1 -maxdepth 1 -type d -mmin +"$ttl" -exec rm -rf {} + 2>/dev/null || true
log "pruned session dirs older than $ttl min"
fi
if (( volumes )); then
# Two guards before anything is deleted: the name has to be in our namespace (the
# aisb- prefix, or labelled by us) and no container may reference it, so a sandbox
# that is still running never loses its home. Note this runs *after* the sweep
# above, which removes stopped leftovers: a --keep'ed run is a leftover, and with
# --volumes its cache goes with it. A pattern narrows it further:
# ai-sandbox clean --volumes=aisb-home
while IFS= read -r v; do
[[ -n "$v" ]] || continue
# shellcheck disable=SC2053 # glob matching is the point here
[[ -z "$volpat" || "$v" == $volpat ]] || continue
podman volume rm "$v" > /dev/null 2>&1 && log "removed volume $v" \
|| log "volume $v is still in use, left alone"
done < <({
podman volume ls --filter dangling=true --format '{{.Name}}'
podman volume ls --filter label=ai-sandbox=1 --filter dangling=true --format '{{.Name}}'
} | grep -E '^aisb' | sort -u)
fi
log "clean done ($(podman ps -a --filter label=ai-sandbox=1 -q | wc -l) sandbox containers left)"
}
# Re-resolve the base digest on demand, and build whatever that implies. The old
# image is deliberately left in place: if the new base misbehaves, the previous tag is
# still there to fall back to, and pruning it is a decision for after the check.
cmd_update() {
local check=0 want_gate=1
while (($#)); do
case "$1" in
--check) check=1; shift ;;
--no-gate) want_gate=0; shift ;;
*) die "update: unknown argument $1 (try --check)" ;;
esac
done
mkdir -p "$AISB_CACHE_DIR" "$AISB_STATE_DIR"
local old_ref old_gref old_digest new_ref new_gref
old_ref="$(image_ref)"; old_gref="$(gate_image_ref)"; old_digest="${POL[base_digest]:-none}"
if (( check )); then
local remote; remote="$(base_digest_online "${POL[base_image]}")"
printf 'base=%s\n' "${POL[base_image]}"
printf 'recorded=%s\n' "${POL[base_digest]:-unpinned}"
printf 'upstream=%s\n' "${remote:-unreachable}"
if [[ -z "$remote" ]]; then
printf 'status=could not reach the registry\n'; return 0
elif [[ "$remote" == "${POL[base_digest]}" ]]; then
printf 'status=up to date\nimage=%s\n' "$old_ref"; return 0
else
# Show the tag the update would produce without recording anything, so the
# decision to rebuild is at least an informed one.
POL[base_ref]="${POL[base_image]}@$remote"
printf 'status=an update is available (a rebuild would produce a new tag)\n'
printf 'rebuilt_image=%s\n' "$(image_ref)"
return 0
fi
fi
POL[base_digest]="$(base_pin_digest "${POL[base_image]}" 1)"
POL[base_ref]="$(base_ref_of "${POL[base_image]}" "${POL[base_digest]}")"
new_ref="$(image_ref)"; new_gref="$(gate_image_ref)"
printf 'base %s\n was %s\n now %s\n' "${POL[base_image]}" "$old_digest" "${POL[base_digest]:-unpinned}"
if [[ "$new_ref" == "$old_ref" ]]; then
printf 'sandbox image unchanged: %s\n' "$new_ref"
else
printf 'sandbox image %s -> %s\n' "$old_ref" "$new_ref"
fi
if (( want_gate )); then
if [[ "$new_gref" == "$old_gref" ]]; then
printf 'gate image unchanged: %s\n' "$new_gref"
else
printf 'gate image %s -> %s\n' "$old_gref" "$new_gref"
fi
fi
local ref gref
ref="$(ensure_image sandbox)"; log "built $ref"
if (( want_gate )); then gref="$(ensure_image gate)"; log "built $gref"; fi
printf 'the previous image is kept for rollback; remove it with: podman rmi %s\n' "$old_ref"
}
cmd_inspect() {
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
}
# ===========================================================================
# main
# ===========================================================================
main() {
case "${1:-run}" in
build) shift; load_config; resolve_opts_min; cmd_build "$@" ;;
doctor) shift; load_config; resolve_opts_min; cmd_doctor "$@" ;;
gate) shift; cmd_gate "$@" ;;
ps) shift; cmd_ps ;;
clean) shift; cmd_clean "$@" ;;
update) shift; load_config; session_init; resolve_opts; cmd_update "$@" ;;
inspect) shift; cmd_inspect "$@" ;;
version|-V) printf '%s %s\n' "$PROG" "$VERSION" ;;
help|-h|--help) usage ;;
*) cmd_run "$@" ;;
esac
}
resolve_opts_min() { SESSION="${AISB_SESSION_OVERRIDE:-$(session_id)}"; resolve_opts; }
main "$@"