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
1983 lines
86 KiB
Bash
Executable File
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 "$@"
|