#!/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: the current directory mounted (the only writable mount) plus the host
# ~/.agents read-only for skills, 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_PI_CONFIG AISB_PI_TOKEN_ENV AISB_PI_TOKEN_AUTO AISB_PI_AGENT_DIR AISB_THINKING
#   AISB_AGENTS AISB_AGENTS_DIR AISB_AGENTS_DEST
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"; }

# Expand a leading ~ the way a shell would. Config files are not shells, so a
# `agents_dir = ~/.agents` line has to be understood here rather than taken literally.
# shellcheck disable=SC2088  # the literal ~ is the input here: that is what we expand
expand_home() { local p="$1"; [[ "$p" == "~" || "$p" == "~/"* ]] && p="${HOME%/}${p#\~}"; printf '%s' "$p"; }

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

# Destination a `src[:dst[:opt]]` mount spec resolves to, exactly as build_argv mounts
# it. One definition so the .agents bridge can tell whether it has already been mounted
# by hand -- podman refuses a duplicate mount destination outright.
mount_dest() { # mount_dest <spec>
  local m="$1" rest dst=""
  rest="${m#*:}"
  if [[ "$rest" == *:* ]]; then dst="${rest%%:*}"
  elif [[ -n "$rest" && "$rest" != "$m" ]]; then dst="$rest"; fi
  [[ -n "$dst" ]] || dst="/mnt/$(basename -- "${m%%:*}")"
  printf '%s' "$dst"
}

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 the path it has on the host and is the
  working directory, so pwd, prompts and window titles name the project instead of
  every sandbox looking like every other. The mount itself is unchanged by that: same
  directory, same rw, nothing else visible except the one exception below, which is
  read-only and there to keep a harness usable. The rest is opt-in.
  --mount PATH[:DEST][:ro]  Extra bind mount. Repeatable. Default rw, DEST=/mnt/<name>.
  --no-cwd                  Do not mount the current directory.
  --workdir DIR             Mount the cwd there instead of mirroring its host path, and
                            cd there. 'auto' (the default) mirrors, falling back to
                            /project when the host path would bury part of the image or
                            of the sandbox itself (/usr, /etc, /tmp, /home/sandbox, /).
                            A DIR onto one of those is mounted as asked and warned about.
  --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).
  --agents, --no-agents     Share the host ~/.agents (skills, prompts, AGENTS.md) with
                            the harness read-only at /home/sandbox/.agents, which is
                            where a harness looks for user-level skills. On by default
                            whenever that directory exists; --no-agents for a run that
                            should not see it. Nothing is copied: it is one bind mount.
  --agents-dir PATH         Which host directory to share (default ~/.agents).
                            Writable skills would be a way for a sandboxed agent to
                            write instructions you follow later on the host, so the
                            mount is read-only and there is no flag to make it rw.

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.
  --thinking LEVEL          Default thinking level in the sandbox: off, minimal, low,
                            medium, high, xhigh (the default) or host. It is policy, not
                            a preference: it is written over the bridged settings on
                            every run, so an edit inside the sandbox cannot quietly lower
                            it for the next one. 'host' leaves the key to your
                            settings.json. pi only -- codex, claude and your own command
                            read their own config and ai-sandbox does not touch it.

  settings.json is merged per key rather than managed as a whole file: keys you edit
  inside the sandbox stay yours, every key you did not touch follows the host again, keys
  the host dropped go away, and keys the sandbox invented survive.

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.
  --no-interactive          Never attach stdin. For unattended or scripted runs: the
                            command cannot read this terminal, so a stray keystroke
                            cannot type into an agent that is running by itself.
  --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, AISB_THINKING,
  AISB_AGENTS, AISB_AGENTS_DIR, AISB_AGENTS_DEST, ...
  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|thinking|agents|agents_dir|agents_dest) 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"
  # A loop returns the status of the last command its body ran, and here that is the `&&`
  # above: a file whose last line is a comment or blank would return 1. load_config() calls
  # this twice in a non-final position, so under `set -e` every run would die with exit 1
  # and no output at all -- previews included. A blank last line is not an error, so reading a
  # file always succeeds; an allowlist with nothing in it is validate_opts()'s call to
  # refuse, with a message, rather than a silent death here.
  return 0
}

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_AGENTS="" OPT_AGENTS_DIR=""
OPT_PI_CONFIG="" OPT_PI_TOKEN_AUTO="" OPT_PI_TOKEN_ENV="" OPT_THINKING=""
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 agents no-agents agents-dir
  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 thinking
  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 ;;
      --thinking)     need "$1" "${2:-}"; OPT_THINKING="$2"; shift 2 ;;
      --thinking=*)   OPT_THINKING="${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 ;;
      --agents)     OPT_AGENTS=1; shift ;;
      --no-agents)  OPT_AGENTS=0; shift ;;
      --agents-dir) need "$1" "${2:-}"; OPT_AGENTS_DIR="$2"; shift 2 ;;
      --agents-dir=*) OPT_AGENTS_DIR="${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-interactive) OPT_INTERACTIVE=0; 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}"; }

# ---------------------------------------------------------------------------
# the .agents bridge
# ---------------------------------------------------------------------------
# `~/.agents` is where user-level skills, prompt templates and standing instructions
# live (pi reads ~/.agents/skills, and so do the other harnesses). A harness with no
# skills is a harness you have to re-brief on every run, so it is shared by default --
# bind-mounted, never copied, and read-only:
#
#   * a skill is an instruction the model follows *later*, on the host, outside this
#     sandbox. A writable skills directory would be a persistence path out of it, so
#     there is no flag to make it writable and none will be added.
#   * the contents are never read here, only pointed at: whatever a skill file holds
#     (endpoints, placeholder tokens) is visible to the agent exactly as it is on the
#     host, which is what "loaded" means and is stated in docs/threat-model.md.
#   * a project's own .agents/skills needs no bridge at all: it is inside the cwd mount.

# The host directory to share, or nothing when there is nothing to share. A lookup, not
# a copy: it never reads the contents, and an absent directory is not an error.
agents_src_of() { # -> host path or empty
  local d="${POL[agents_dir]}"
  [[ -d "$d" && -r "$d" && -x "$d" ]] || return 0
  printf '%s' "$d"
}

# True when a `mount` entry already lands on the .agents destination: podman refuses a
# duplicate destination, so an explicit --mount wins over the automatic one.
agents_dest_taken() {
  local m
  for m in ${P_mount[@]+"${P_mount[@]}"}; do
    [[ "$(mount_dest "$m")" == "${POL[agents_dest]}" ]] && return 0
  done
  return 1
}

# Would a mirrored copy of $1 land on top of something the image or this sandbox needs?
#
# A bind mount does not only name a place, it hides whatever was already there -- so the
# question is what lives at that path inside the container, not what the project is
# called. /home is the one standard directory people keep their work in, and a per-user
# tree there never meets the container's own home; the rest of the hierarchy either
# belongs to the image or is one of this sandbox's own mounts.
#
# /tmp is measured, not guessed: with `--tmpfs /tmp` and `-v /tmp/x:/tmp/x` in the same
# invocation, podman (5.x, rootless, SELinux enforcing) puts the tmpfs on top, so the
# project comes out as an empty tmpfs -- the right name, writable, and not the project.
# The failure is silent, which is exactly the shape a refusal has to take instead.
#
# Prints the path that blocks mirroring, or nothing when the mirror is safe: the caller
# needs the reason, not just the verdict, because the fallback is a surprise otherwise.
mirror_block_of() { # $1 = absolute host path -> the container path it would bury, or empty
  local p="$1" top
  if [[ "$p" == "/" ]]; then printf '/'; return 0; fi
  top="${p#/}"; top="/${top%%/*}"      # the first component, with its leading slash
  case "$top" in
    # Mirror /home/<user>/..., but never /home itself (it would bury the home mount) and
    # never the container's own home, which holds the home volume and the .agents share.
    /home)
      case "$p" in
        /home)              printf '/home' ;;
        /home/sandbox|/home/sandbox/*) printf '/home/sandbox' ;;
      esac
      return 0 ;;
    # Directories the base image owns, in whole or in part.
    /bin|/boot|/dev|/etc|/lib|/lib32|/lib64|/opt|/proc|/root|/run|/sbin|/sys|/tmp|/usr|/var)
      printf '%s' "$top"; return 0 ;;
  esac
  # Everything else -- /mnt, /srv, /media, or a top-level directory the image does not
  # have at all -- is empty inside the container: mirroring creates a path, shadows
  # nothing, and the run keeps the host path it recognises.
  printf ''
}

# Where the cwd is mounted and worked in -- 'auto' mirrors the host path.
#
# Two reasons for mirroring, one practical and one human:
#
#   * paths the agent prints (an error, a commit message, a todo list) stay readable on
#     the host, where someone has to act on them;
#   * a harness titles its window after the last component of the working directory, so
#     a fixed /project makes every sandbox tab in the session read the same.
#
# The fallback is /project, the one directory baked into the image, so a run always has
# a working directory that exists -- including --no-cwd, which mounts nothing and lands
# there. Destination only: never the mode, the SELinux option or the rw-ness.
resolve_workdir() { # sets POL[workdir] and POL[workdir_note]; needs POL[cwd]
  local w="${OPT_WORKDIR:-${AISB_PROJECT_DEST:-$(cfgv workdir auto)}}"
  if [[ "$w" != auto ]]; then
    [[ "$w" == /* ]] || die "--workdir: must be an absolute path or 'auto' (got '$w')"
    w="${w%/}"
    [[ -n "$w" ]] || die "--workdir: / would mount the project over the whole container"
    POL[workdir]="$w"; POL[workdir_note]=""
    # An explicit path is a choice rather than a fallback, so it is honoured -- but the same
    # hiding rule applies to it, and whoever chose it cannot be expected to know which of
    # these paths the sandbox mounts over. Say so, since the alternative is a pwd that lies.
    local hide; hide="$(mirror_block_of "$w")"
    if [[ -n "$hide" ]]; then
      local msg="--workdir $w: the sandbox owns ${hide}*,"
      msg+=" so the project mount there can be covered by the image or by one of our own"
      msg+=" mounts (/tmp is a tmpfs)"
      warn "$msg"
    fi
    return 0
  fi
  # --no-cwd mounts nothing, so there is nothing to mirror: /project is the image's own
  # directory and a note about the host path would only be noise.
  if [[ "${OPT_NO_CWD:-0}" == 1 ]]; then
    POL[workdir]="/project"; POL[workdir_note]=""; return 0
  fi
  local block; block="$(mirror_block_of "${POL[cwd]}")"
  if [[ -n "$block" ]]; then
    POL[workdir]="/project"
    POL[workdir_note]="cannot mirror ${POL[cwd]}: the sandbox owns ${block}*,"
    POL[workdir_note]+=" so the project is mounted at /project instead (--workdir DIR to choose)"
  else
    POL[workdir]="${POL[cwd]}"; POL[workdir_note]=""
  fi
}

resolve_opts() {
  POL[cwd]="$PWD"
  POL[net]="${OPT_NET:-${AISB_NET:-$(cfgv net allow)}}"
  POL[harness]="${OPT_HARNESS:-${AISB_HARNESS:-$(cfgv harness pi)}}"
  resolve_workdir
  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]="$(expand_home "${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
  # --- thinking level (policy, not preference: see pi_config_bridge) ----------
  # pi's own default is medium, which is not what an agentic sandbox run is for, so
  # ai-sandbox states one. 'host' hands the decision back to settings.json; the entrypoint
  # enforces the value on every run unless it is empty.
  POL[thinking]="${OPT_THINKING:-${AISB_THINKING:-$(cfgv thinking xhigh)}}"
  case "${POL[thinking]}" in
    off|minimal|low|medium|high|xhigh|max) ;;
    host|inherit)   POL[thinking]=host ;;
    *) die "--thinking must be off, minimal, low, medium, high, xhigh, max or host (got '${POL[thinking]}')" ;;
  esac
  POL[thinking_eff]=""     # set by pi_config_bridge: the level actually written, if any

  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)}}"
  # --- the .agents bridge (see agents_src_of) ---------------------------------
  POL[agents_dir]="$(expand_home "${OPT_AGENTS_DIR:-${AISB_AGENTS_DIR:-$(cfgv agents_dir "${HOME%/}/.agents")}}")"
  POL[agents_dest]="${AISB_AGENTS_DEST:-$(cfgv agents_dest /home/sandbox/.agents)}"
  POL[agents]="${OPT_AGENTS:-${AISB_AGENTS:-$(cfgv agents 1)}}"
  case "${POL[agents]}" in 0|false|no) POL[agents]=0 ;; *) POL[agents]=1 ;; esac
  POL[agents_src]=""
  [[ "${POL[agents]}" == 1 ]] && POL[agents_src]="$(agents_src_of)"
  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)}}"

  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"
  # podman splits a `-v` spec on ':', so a project path containing one cannot be handed to
  # it at all (measured: `invalid option type "/project"`). Mirroring makes that string the
  # destination as well as the source, so say it here rather than forward podman's error.
  if [[ "${OPT_NO_CWD:-0}" != 1 && "${POL[cwd]}" == *:* ]]; then
    die "the project path contains a ':', which podman cannot parse in a mount spec.
    rename the directory, or start without it: $PROG --no-cwd"
  fi
  if [[ "${POL[net]}" == full ]]; then
    warn "--net full: the container has unrestricted internet access"
  fi
  # An explicit --agents-dir that resolves to nothing is a typo, so it is an error. The
  # *default* pointing at a directory this host does not have is normal: the bridge
  # switches itself off, it does not fail a run.
  if [[ "${POL[agents]}" == 1 && -z "${POL[agents_src]}" ]]; then
    if [[ -n "${OPT_AGENTS_DIR:-}" ]]; then
      die "--agents-dir is not a readable directory: ${POL[agents_dir]}"
    fi
    [[ "${OPT_AGENTS:-0}" == 1 ]] \
      && warn "--agents: no readable directory at ${POL[agents_dir]} -- the harness starts without your skills"
    POL[agents]=0
  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[*]-}"
    printf 'AISB_PI_THINKING=%s\n'    "${POL[thinking_eff]:-}"
  } > "$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.
#
# The two files are managed differently, on purpose.
#
# models.json is a catalog, so it is managed whole: replaced when it is missing or still
# identical to what we seeded, left alone once the sandbox has edited it.
#
# settings.json is a flat bag of preferences, and managing it whole went badly: pi
# rewrites it itself (a theme change, "I have seen this changelog"), and from that moment
# *every* host preference was frozen out -- provider, model, thinking budgets included,
# with only the changelog line left in the file. So it is merged per key against a copy of
# what we last wrote (.aisb-settings.json.wrote): a key the sandbox changed stays theirs,
# a key it did not touch follows the host again, a key the host dropped disappears unless
# the sandbox had changed it, and a key the sandbox invented survives.
#
# defaultThinkingLevel is the exception inside that merge: it is policy the host set for
# the sandbox, so AISB_PI_THINKING is written over the result every run however the file
# was edited. Empty (from --thinking host) makes it an ordinary preference again.

# The merge itself, as an argument rather than a heredoc: the seeding script below is
# passed to bash -c in single quotes, so nesting a here-document inside it is unreadable.
# Nothing inside that script may contain a single quote either -- one closes the argument
# and the rest of the file silently becomes code in the *outer* shell, where $lvl is not
# set. jq programs there are written unquoted (.defaultThinkingLevel=\$lvl) for that
# reason, and tests/unit/06_entrypoint.sh checks the quoting rather than trusting it.
# T = what is in the sandbox now, B = what we last wrote, N = what the host says now.
SETTINGS_MERGE_JQ='
  ($T[0] // {}) as $t | ($B[0] // {}) as $b | ($N[0] // {}) as $n
  | reduce ($t | keys[]) as $k ($t;
      if ($b | has($k)) and ($n | has($k) | not) and ($t[$k] == $b[$k])
      then del(.[$k]) else . end)
  | . as $kept
  | reduce ($n | keys[]) as $k ($kept;
      if ($b | has($k)) and ($kept | has($k)) and ($kept[$k] != $b[$k])
      then . else .[$k] = $n[$k] end)
'

if [[ -d "$POLICY_DIR/pi" ]]; then
  setpriv --reuid="$UID_TARGET" --regid="$GID_TARGET" --clear-groups -- /bin/bash -c '
    dst="$HOME/.pi/agent"; lvl="$2"; jq_merge="$3"
    mkdir -p "$dst" || { echo "[aisb] pi config: cannot create $dst" >&2; exit 0; }

    # --- models.json: a catalog, managed as a whole file
    f=models.json; src="$1/pi/$f"
    if [ -f "$src" ]; then
      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 [ -e "$tgt" ] && [ ! -r "$tgt" ]; then
        echo "[aisb] pi config: cannot read $f -- its mode or SELinux label blocks this container, and nothing inside can repair it (remove it from the host, or use --ephemeral-home)" >&2
      elif [ -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
    fi

    # --- settings.json: merged per key, then the policy level on top
    f=settings.json; src="$1/pi/$f"
    if [ -f "$src" ]; then
      tgt="$dst/$f"; wrote="$dst/.aisb-$f.wrote"; tmp="$dst/.aisb-$f.tmp"
      rm -f "$dst/.aisb-$f.seeded"        # the whole-file marker is .wrote now
      if [ ! -e "$tgt" ]; then
        install -m 600 "$src" "$tgt" && cp "$tgt" "$wrote"
        echo "[aisb] pi config: seeded $f"
      elif [ ! -r "$tgt" ]; then
        echo "[aisb] pi config: cannot read $f -- its mode or SELinux label blocks this container, and nothing inside can repair it (remove it from the host, or use --ephemeral-home)" >&2
      else
        base=/dev/null; [ -f "$wrote" ] && base="$wrote"
        had=""; [ -n "$lvl" ] && had="$(jq -r ".defaultThinkingLevel // \"\"" "$tgt" 2>/dev/null)"
        if merged="$(jq -n --slurpfile T "$tgt" --slurpfile B "$base" --slurpfile N "$src" \
                          -f <(printf %s "$jq_merge") 2>/dev/null)" && [ -n "$merged" ]; then
          if [ -n "$lvl" ]; then
            merged="$(printf %s "$merged" | jq --arg lvl "$lvl" .defaultThinkingLevel=\$lvl 2>/dev/null)"
            [ -z "$merged" ] && merged="$(printf null | jq --arg lvl "$lvl" .defaultThinkingLevel=\$lvl)"
          fi
        else
          # Unparseable, so pi could not have used it either, and this file is ours to
          # manage: take the host version rather than leave a broken one in place.
          if [ -n "$lvl" ]; then
            merged="$(jq --arg lvl "$lvl" .defaultThinkingLevel=\$lvl "$src" 2>/dev/null)"
          else
            merged="$(jq . "$src" 2>/dev/null)"
          fi
          [ -n "$merged" ] && echo "[aisb] pi config: $f was not usable JSON -- re-seeded from the host config, your in-sandbox changes to it are gone" >&2
        fi
        if [ -n "$merged" ] && printf "%s\n" "$merged" > "$tmp" \
           && install -m 600 "$tmp" "$tgt" && rm -f "$tmp" && cp "$tgt" "$wrote"; then
          if [ -n "$lvl" ] && [ "$had" != "$lvl" ]; then
            echo "[aisb] pi config: thinking -> $lvl (policy: --thinking, or host)"
          fi
        fi
      fi
    fi
  ' aisb-seed "$POLICY_DIR" "${AISB_PI_THINKING:-}" "$SETTINGS_MERGE_JQ" \
    || 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

# Fixed, on purpose: where the project is mounted is a run-time decision (see
# resolve_workdir) and podman is told with --workdir, so two projects -- or one project
# checked out twice -- must never mean two images. /project is also what the runtime
# falls back to, and it is created above, so a workdir always exists.
WORKDIR /project
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 -- and nothing
  # per-project is in it, so the same image serves every directory on the machine
  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
  local lvl="${POL[thinking]}"
  [[ "$lvl" == host ]] && lvl=""
  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"
      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
  # The thinking level is applied to whatever survived, and on its own if the host has no
  # settings.json at all -- a sandbox that silently thinks at pi's default of medium is
  # not what --thinking is for. It is written here *and* carried to the entrypoint, which
  # re-applies it to a settings.json that already exists in the sandbox home.
  if [[ -n "$lvl" ]]; then
    if [[ -f "$dst/settings.json" ]]; then
      jq --arg lvl "$lvl" '.defaultThinkingLevel = $lvl' "$dst/settings.json" \
        > "$dst/settings.json.new" || die "pi config: could not set the thinking level"
      mv "$dst/settings.json.new" "$dst/settings.json"
    else
      jq -n --arg lvl "$lvl" '{defaultThinkingLevel: $lvl}' > "$dst/settings.json" \
        || die "pi config: could not write the thinking level"
    fi
    chmod 644 "$dst/settings.json"
    POL[thinking_eff]="$lvl"
    log "pi config: thinking -> $lvl (policy; --thinking host to let settings.json decide)"
  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)

  # --- the .agents bridge -----------------------------------------------------
  # Inside the home mount (a volume or a tmpfs), which is why it comes after it. ro is
  # not configurable: see agents_src_of. A hand-written --mount on the same destination
  # wins, because podman refuses duplicates and the explicit one is the reviewed one.
  if [[ -n "${POL[agents_src]:-}" ]]; then
    if agents_dest_taken; then
      log "agents: not shared, ${POL[agents_dest]} is already a --mount destination"
    else
      ARGV+=(-v "${POL[agents_src]}:${POL[agents_dest]}:ro${SELOPT_SEP}")
    fi
  fi

  local m src dst opt rest
  for m in ${P_mount[@]+"${P_mount[@]}"}; do
    src="${m%%:*}"; rest="${m#*:}"; opt=""
    if [[ "$rest" == *:* ]]; then opt="${rest##*:}"; fi
    dst="$(mount_dest "$m")"
    [[ -e "$src" ]] || die "--mount: path does not exist: $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[@]}"})"
  # A fallback is worth a line here: the invocation on its own only shows /project, not
  # that the host path was passed over. Same text as workdir_note in --print-policy.
  [[ -z "${POL[workdir_note]:-}" ]] || printf '# workdir: %s\n' "${POL[workdir_note]}"
  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]}"
  [[ -z "${POL[workdir_note]:-}" ]] || printf 'workdir_note=%s\n' "${POL[workdir_note]}"
  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[@]}"})"
  printf 'agents=%s\n'         "${POL[agents_src]:-off}"
  if [[ -n "${POL[agents_src]:-}" ]]; then printf 'agents_dest=%s\n' "${POL[agents_dest]}"; fi
  if [[ -n "${POL[thinking_eff]:-}" ]]; then
    printf 'thinking=%s (enforced on every run)\n' "${POL[thinking]}"
  else
    printf 'thinking=%s (not applied: %s)\n' "${POL[thinking]}" \
      "$([[ "${POL[thinking]}" == host ]] && printf 'the config decides' || printf 'pi config bridging is off')"
  fi
}

# ===========================================================================
# 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
  # A real run is where the fallback would otherwise be invisible: pwd is not the path
  # the user typed, and they should find out from this run rather than from the agent.
  [[ -z "${POL[workdir_note]:-}" ]] || log "${POL[workdir_note]}"

  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
  # The thinking policy: what a pi run in this directory will reason at, and whether
  # anything is actually there to say so.
  if [[ -n "${POL[thinking_eff]:-}" ]]; then
    chk INFO "pi thinking: ${POL[thinking]} (policy, re-applied every run; --thinking host lets settings.json decide)"
  else
    chk INFO "pi thinking: ${POL[thinking]} -- $([[ "${POL[thinking]}" == host ]] && printf 'pi takes it from the bridged settings.json' || printf 'not written, pi config bridging is off')"
  fi

  # The .agents bridge, reported read-only like the pi bridge: doctor never mounts.
  if [[ -z "${POL[agents_src]:-}" ]]; then
    chk INFO "agents: your skills are not shared (agents=0, --no-agents, or no ${POL[agents_dir]})"
  else
    local nsk; nsk="$(find "${POL[agents_src]}" -maxdepth 3 -name SKILL.md 2>/dev/null | wc -l | tr -d ' ')"
    chk INFO "agents: ${POL[agents_src]} -> ${POL[agents_dest]} read-only (${nsk} skill file(s))"
    if [[ -n "${POL[selinux]:-}" && "${POL[agents_src]}" == "$HOME"* ]]; then
      chk INFO "agents: relabelled '${POL[selinux]}' for the container, like the project mount -- read-only either way"
    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 "$@"
