#!/usr/bin/env bash
# Shadow agent binary. Wraps a real coding agent in a bwrap sandbox so
# every `claude` / `codex` invocation on $PATH runs sandboxed.
#
# ONE file, several agents: install.sh places this same script at both
# /usr/local/bin/claude and /usr/local/bin/codex, and it decides which
# agent it is wrapping from argv[0]. See "Agent profiles" below.
#
# Self-contained: no @@PLACEHOLDERS@@, no `source $SRC_DIR/...`. The
# bwrap argv builder is inlined as `bwrap_argv_build` below so the
# shadow is a single file you can read top-to-bottom.
#
# Recursion guard: if IS_SANDBOX=1 is already set we are inside the
# sandbox (e.g. a hook or skill spawned `claude`), so fall through to
# the real binary directly. Internal claude-spawns-claude does not
# double-wrap.

set -euo pipefail

GITCONFIG_PATH="/etc/claude-gitconfig"
# Host-global config, placed by install.sh from the clone's
# .devcontainer/claude-sandbox.conf and re-stamped on every rebuild.
# Read from /etc (not the workspace) so a compromised session can't edit
# it to widen the next launch's binds — see parse_config below.
CONFIG_PATH="/etc/claude-sandbox.conf"

# In-netns DNS forwarder address for the egress jail (ADR 0015, issues #60 and #11).
# ALL of Claude's DNS goes here — it is the jail's single resolver. pasta's
# --dns-forward listens on this address INSIDE the netns and relays queries to
# the host's real resolvers from the HOST netns, so the jail itself needs no
# route to any resolver. That buys two things:
#
#   - Stub resolvers work (issue #60). systemd-resolved's 127.0.0.53 and
#     Tailscale MagicDNS's 100.100.100.100 live in the HOST netns and answer
#     nothing inside the jail; pasta can reach them, so forwarding does too.
#   - No resolver-shaped holes in the blackhole (issue #11). Real resolvers are
#     internal hosts; a /32 punch to one is a lateral-movement path open on
#     every port, not just 53.
#
# The address is from RFC5737 TEST-NET-1 (192.0.2.0/24): globally non-routable,
# so it can never shadow a real host Claude needs, and it sits outside every
# blackholed range, so the holder can punch a /32 to it without clashing.
JAIL_DNS_FWD="192.0.2.53"

# Test hook: source-only mode lets tests pull `bwrap_argv_build` into
# scope without running the launch body (nor the recursion guard, which
# would exec the real binary inside an already-sandboxed session).
: "${CLAUDE_SHADOW_SOURCE_ONLY:=0}"
SANDBOX_VERIFY=0

# filter_chrome_args OUTVAR ARGS...: set array OUTVAR to ARGS with any
# `--chrome` removed. Stripping it stops a user-supplied flag from overriding
# our --no-chrome injection (the browser-extension native-messaging RPC channel
# is outside the threat model). TWO call sites strip it — the recursion guard
# below (which execs the real binary) and bwrap_argv_build (the wrapped launch);
# a single helper keeps those two code paths from ever diverging. Uses a nameref
# (not stdout) so bwrap_argv_build stays subprocess-free, and preserves args
# that contain newlines.
filter_chrome_args() {
    local -n _out="$1"; shift
    _out=()
    local _a
    for _a in "$@"; do
        case "$_a" in --chrome) ;; *) _out+=( "$_a" ) ;; esac
    done
}

# Agent profiles select the binary, persistent state and injected flags.
# All agents share the same filesystem, network and terminal isolation.

# Shipped skills: the repo's top-level skills/ tree, placed by install.sh
# under /usr/libexec next to the runtime helpers (root-owned, ro in-session).
# The argv builder binds each one read-only onto the agent's own skills
# directory INSIDE the sandbox, so the user's host ~/.claude (or ~/.codex,
# ~/.pi) is never written to and the skill's scripts cannot be rewritten by
# a compromised session. A plain variable, not an env seam: tests point it
# at a fixture after sourcing; nothing outside the jail's trust boundary
# can choose a bind source.
SHIPPED_SKILLS_DIR="/usr/libexec/claude-sandbox/skills"

# Shared user skills (issue #52, ADR 25): $HOME-relative, bound READ-WRITE
# into EVERY agent's session, unlike the per-agent config dirs. Codex and Pi
# discover skills here natively; Claude reaches one only through a symlink
# the user places in ~/.claude/skills. Each agent keeps its own skills dir as
# well, so sharing a skill is a choice, not the default. Only this one
# directory is shared — never the rest of ~/.agents, whose contents are
# whatever third-party tools decide to write there (Codex already reads
# ~/.agents/plugins). A constant, not a profile knob or an env seam.
SHARED_SKILLS_REL=".agents/skills"

# detect_agent ARGV0 OVERRIDE: name the agent this invocation wraps.
# OVERRIDE (CLAUDE_SANDBOX_AGENT) exists for tests and for reaching a
# shadow through a differently-named symlink. It is matched against a
# CLOSED set and can only ever select a hard-coded profile below — it can
# never name an arbitrary binary for the sandbox to launch.
detect_agent() {
    local argv0="${1:-}" override="${2:-}"
    if [ -n "$override" ]; then
        case "$override" in
            claude|codex|pi) printf '%s\n' "$override"; return 0 ;;
            *)
                echo "claude-sandbox: unknown CLAUDE_SANDBOX_AGENT '$override' (expected: claude, codex, pi)." >&2
                return 1
                ;;
        esac
    fi
    case "${argv0##*/}" in
        codex) printf 'codex\n' ;;
        pi)    printf 'pi\n' ;;
        *)     printf 'claude\n' ;;
    esac
}

# agent_profile NAME: resolve every per-agent knob into globals. Everything
# that differs between the agents lives HERE and nowhere else.
#   AGENT_REAL          real binary on the host, off the user's PATH
#   AGENT_INNER_REL     $HOME-relative conventional path inside the sandbox
#   AGENT_HOME_DIRS     $HOME-relative dirs bound rw (config + login state)
#   AGENT_HOME_FILES    $HOME-relative files bound rw
#   AGENT_SKILLS_REL    $HOME-relative dir the agent discovers skills in
#   AGENT_INJECT        args injected ahead of the user's
#   AGENT_FILTER_CHROME strip a user-supplied --chrome (Claude only)
#   AGENT_LABEL         human name for messages
agent_profile() {
    # Own the name too, so the profile and the label can never disagree —
    # the argv builder stamps $AGENT into IS_SANDBOX_AGENT, and a caller that
    # switched profile without updating the name would mislabel the session.
    AGENT="$1"
    case "$1" in
        claude)
            AGENT_REAL="/usr/libexec/claude-sandbox/claude"
            AGENT_INNER_REL=".local/bin/claude"
            # Bound back to the conventional path inside the sandbox so
            # Claude's installMethod=native self-check sees what it expects
            # (Invariant 1). The bind is rw, so an in-session self-update
            # could rewrite the host binary — mitigated by the managed
            # DISABLE_AUTOUPDATER.
            AGENT_BIND_BACK=1
            AGENT_HOME_DIRS=( .claude )
            AGENT_HOME_FILES=( .claude.json )
            AGENT_HOME_TMPFS=()
            AGENT_SKILLS_REL=".claude/skills"
            AGENT_INJECT=( --no-chrome )
            AGENT_FILTER_CHROME=1
            # Claude's updater-disable is delivered by managed-settings
            # (env.DISABLE_AUTOUPDATER), so there is nothing to add here.
            AGENT_SETENV=()
            AGENT_LABEL="Claude"
            ;;
        codex)
            # Codex ships as a package — the binary needs its siblings
            # (ripgrep at codex-path/rg, its own bwrap/zsh under
            # codex-resources/). So the whole release directory is relocated
            # and we exec IN PLACE from it: /usr/libexec is already visible
            # inside the sandbox via --ro-bind / /. The layout stays intact.
            # This is strictly better
            # than Claude's bind-back — the binary we exec is READ-ONLY in the
            # session, so an in-session self-update cannot rewrite it.
            AGENT_REAL="/usr/libexec/claude-sandbox/codex-dist/bin/codex"
            AGENT_INNER_REL=""
            AGENT_BIND_BACK=0
            # CODEX_HOME defaults to ~/.codex and holds config.toml,
            # auth.json (the OAuth tokens / API key), sessions/ and
            # history. Bound rw for the same reason ~/.claude is
            # (Invariant 2): it is ONE OpenAI login, not a repo-scoped
            # credential like a gh/glab PAT, so carrying it across
            # containers does not widen blast radius the way a forge
            # token would.
            AGENT_HOME_DIRS=( .codex )
            AGENT_HOME_FILES=()
            # The vendor installer unpacks the codex binary itself under
            # $CODEX_HOME/packages/standalone/releases/<version>/ — i.e. INSIDE
            # the directory we just bound read-write. Left visible, that is a
            # writable copy of the agent's own binary in its own session: a
            # compromised codex could rewrite it and be re-executed by the next
            # launch, entirely outside the ro /usr/libexec copy we actually
            # exec. Mask it. Same reasoning as the ~/.local/share/claude tmpfs
            # (Claude's versioned binary cache) below.
            AGENT_HOME_TMPFS=( .codex/packages )
            AGENT_SKILLS_REL=".codex/skills"
            # Codex has no browser-extension RPC channel to disable and
            # no --chrome flag; injecting Claude's would abort the launch.
            AGENT_INJECT=()
            AGENT_FILTER_CHROME=0
            # The in-sandbox half of the updater-disable. The binary we exec is
            # already READ-ONLY in the session (no bind-back — see AGENT_REAL
            # above), so unlike Claude's rw bind-back this is not what stops a
            # self-update rewriting the host binary; it stops codex spending a
            # session's first seconds fetching a release it cannot install, and
            # writing a half-unpacked tree into CODEX_HOME. /etc/codex/
            # managed_config.toml covers the startup check; this covers the
            # explicit `codex update`.
            AGENT_SETENV=( CODEX_UPDATE_DISABLED 1 )
            AGENT_LABEL="Codex"
            ;;
        pi)
            # A fixed launcher checks the sandbox before starting the standalone
            # release. Both live under the read-only /usr/libexec tree.
            AGENT_REAL="/usr/libexec/claude-sandbox/pi-run"
            AGENT_INNER_REL=""
            AGENT_BIND_BACK=0
            AGENT_HOME_DIRS=( .pi )
            AGENT_HOME_FILES=()
            AGENT_HOME_TMPFS=()
            AGENT_SKILLS_REL=".pi/agent/skills"
            AGENT_INJECT=()
            AGENT_FILTER_CHROME=0
            AGENT_SETENV=( PI_SKIP_VERSION_CHECK 1 )
            AGENT_LABEL="Pi"
            ;;
        *)
            echo "claude-sandbox: unknown agent '$1'." >&2
            AGENT=""
            return 1
            ;;
    esac
}

# agent_exec_argv OUT HOME: build the command for fresh and nested launches.
agent_exec_argv() {
    local -n _command_out="$1"
    local agent_home="$2"
    if [ "$SANDBOX_VERIFY" = 1 ]; then
        _command_out=( /bin/bash /usr/libexec/claude-sandbox/verify-sandbox-battery.sh )
        return 0
    fi
    if [ "$AGENT" = codex ]; then
        _command_out=( /usr/libexec/claude-sandbox/codex-launch "$AGENT_REAL" )
    elif [ "$AGENT_BIND_BACK" = 1 ]; then
        _command_out=( "$agent_home/$AGENT_INNER_REL" )
    else
        _command_out=( "$AGENT_REAL" )
    fi
    _command_out+=( "${AGENT_INJECT[@]}" )
}

AGENT="$(detect_agent "${0:-claude}" "${CLAUDE_SANDBOX_AGENT:-}")" || exit 1
agent_profile "$AGENT" || exit 1

# Internal command used by the helper CLI. Runs the battery through the same
# profile and isolation setup without starting a model or asking for input.
if [ "$CLAUDE_SHADOW_SOURCE_ONLY" != 1 ] && [ "${1:-}" = --sandbox-verify ]; then
    shift
    [ "$#" = 0 ] || { echo 'claude-sandbox: --sandbox-verify takes no arguments' >&2; exit 2; }
    SANDBOX_VERIFY=1
fi

# Recursion guard: if IS_SANDBOX=1 is already set we are inside the
# sandbox (e.g. a hook or skill spawned the agent), so use the same agent
# dispatch without nesting bwrap. Inside the sandbox Claude's real binary is
# bind-mounted at $HOME/$AGENT_INNER_REL — exec that path so argv[0]
# matches the conventional install location. Claude's --no-chrome
# injection (via filter_chrome_args) applies here too so a nested spawn
# can't re-enable the browser-extension RPC channel.
if [ "$CLAUDE_SHADOW_SOURCE_ONLY" != "1" ] && [ "${IS_SANDBOX:-}" = "1" ]; then
    if [ "$AGENT_FILTER_CHROME" = "1" ]; then
        filter_chrome_args _filtered "$@"
    else
        _filtered=( "$@" )
    fi
    agent_exec_argv _agent_command "${HOME:-/root}"
    exec "${_agent_command[@]}" "${_filtered[@]}"
fi

# Export so the inlined bwrap_argv_build picks up the gitconfig path.
export CLAUDE_SANDBOX_GITCONFIG_PATH="$GITCONFIG_PATH"

# bwrap_argv_build OUT WORKSPACE REAL ARGS...: fill OUT with the bwrap command.
# Reads configuration and source-path availability without creating files.
# Keep arguments as arrays so newlines and empty strings survive unchanged.
# Host procfs is read-only; PID namespace isolation still scopes kill/ptrace.
bwrap_argv_build() {
    local -n _bwrap_out="$1"; shift
    local workspace="$1"; shift
    local real_agent="$1"; shift

    local home="${HOME:-/root}"
    local gitconfig_path="${CLAUDE_SANDBOX_GITCONFIG_PATH:-/etc/claude-gitconfig}"

    local -a argv=(
        bwrap
        --ro-bind / /
        # Fresh /dev (not --dev-bind) hides the host's /dev/pts so a
        # TIOCSTI inside the sandbox can only inject into the script(1)-
        # allocated pty the shadow wraps us in.
        --dev /dev
        # Unconditional ro-bind of host /proc — host PIDs visible
        # (info-disclosure, accepted) but kernel pidns isolation intact.
        --ro-bind /proc /proc
        --tmpfs /tmp
    )

    # Keep the private /dev and its isolated terminal namespace. Only the
    # explicitly selected device nodes are restored, with --dev-bind so
    # bubblewrap does not apply the nodev flag used for ordinary binds.
    local device resolved
    if [ -n "${CLAUDE_SANDBOX_ALLOW_DEVICES:-}" ]; then
        while IFS= read -r device; do
            [ -n "$device" ] || continue
            resolved="$(realpath -e -- "$device")" || return 1
            if [[ "$device" != /dev/* || "$resolved" != /dev/* ]] ||
                { [ ! -c "$resolved" ] && [ ! -b "$resolved" ]; }; then
                echo "claude-sandbox: allow-device needs a character or block device under /dev: $device" >&2
                return 1
            fi
            argv+=( --dev-bind "$resolved" "$resolved" )
        done <<< "$CLAUDE_SANDBOX_ALLOW_DEVICES"
    fi
    if [ "${CLAUDE_SANDBOX_GPU:-0}" = 1 ]; then
        # The container runtime supplies driver libraries in the read-only
        # root and selects the available GPUs. Never bind all of /dev.
        for device in /dev/nvidia* /dev/nvidia-caps/* /dev/dri/*; do
            [ -c "$device" ] || continue
            argv+=( --dev-bind "$device" "$device" )
        done
    fi

    # /run/{user,secrets} masks are emitted only when the host has the
    # source dir. Bwrap can't mkdir into a read-only /run when the
    # parent has no such subdir (typical of GHA's ubuntu-24.04 runner).
    if [ -d /run/user ]; then
        argv+=( --tmpfs /run/user )
    fi
    if [ -d /run/secrets ]; then
        argv+=( --tmpfs /run/secrets )
    fi

    # Strict-under-/root by inversion: wipe $HOME, then bind back only
    # what Claude legitimately needs. Anything we forgot to enumerate
    # stays masked — the whole point of inverting.
    argv+=( --tmpfs "$home" )

    # Single bind-back list. --bind on a missing source would abort
    # bwrap, so each entry is gated on existence. Directories use
    # `-d`, files use `-f`; both flavours map source→dest identically.
    #
    # Split-by-XDG-category: $HOME/.config stays strict-allowlist
    # (credentials live here — gh/glab tokens, gcloud OAuth, etc.) so
    # forward-compat masking of new credentialed tools still applies.
    # $HOME/.local/share (XDG data) and $HOME/.cache are bulk-bound:
    # plugin/state directories for helm, krew/kubectl, uv-managed
    # Python, etc. just work without per-tool allowlist additions.
    # Bets on XDG discipline — a tool that drops creds under
    # ~/.local/share/<tool>/ instead of ~/.config/<tool>/ would leak.
    # gh/glab token dirs are skipped when CLAUDE_SANDBOX_NO_FORGE=1 — the
    # operator has declared this session should not push to any forge.
    local -a forge_rels=( .config/gh .config/glab-cli )
    if [ "${CLAUDE_SANDBOX_NO_FORGE:-}" = "1" ]; then
        forge_rels=()
    fi
    # $SHARED_SKILLS_REL joins every agent's list: it holds skills, not
    # credentials, so it is the one home path agents deliberately share.
    local rel
    for rel in "${AGENT_HOME_DIRS[@]}" "$SHARED_SKILLS_REL" .cache "${forge_rels[@]+"${forge_rels[@]}"}"; do
        if [ -d "$home/$rel" ]; then
            argv+=( --bind "$home/$rel" "$home/$rel" )
        fi
    done
    # Per-agent tmpfs masks, emitted AFTER the binds above so they cover a
    # sub-path of a directory we just bound rw (bwrap applies argv in order).
    # Unconditional, like the .local/share masks: the mask must exist whether
    # or not the host currently has the directory, or a first in-session write
    # would land on the host.
    local mask_rel
    for mask_rel in "${AGENT_HOME_TMPFS[@]+"${AGENT_HOME_TMPFS[@]}"}"; do
        argv+=( --tmpfs "$home/$mask_rel" )
    done
    # Shipped skills, one --ro-bind PER SKILL onto the agent's skills dir.
    # Per skill, not per tree: every agent discovers skills exactly one level
    # deep (<skills>/<name>/SKILL.md), so binding the whole tree would bury
    # them a level down; and a per-skill bind leaves the user's own skills
    # in the same directory visible alongside. Emitted after the rw binds so
    # it sits inside the bound config dir (bwrap applies argv in order), and
    # read-only so the bundled scripts stay exactly what install placed.
    # The launch body pre-creates the skills dir on the host; on a tmpfs
    # $HOME bwrap creates the mount points itself.
    local skill_dir
    for skill_dir in "$SHIPPED_SKILLS_DIR"/*/; do
        [ -d "$skill_dir" ] || continue
        skill_dir="${skill_dir%/}"
        argv+=( --ro-bind "$skill_dir" "$home/$AGENT_SKILLS_REL/${skill_dir##*/}" )
    done
    # $HOME/.local/share bulk-bound for host XDG data dirs (helm
    # plugins, krew, uv Python, etc.). Two sub-dirs need to stay
    # ephemeral via tmpfs overlays:
    #   applications/  Claude Code writes a .desktop URL handler
    #                  here; binding the host's dir would register
    #                  our in-sandbox claude as a host URL handler.
    #   claude/        Claude Code's versioned binary cache,
    #                  ephemeral by design and would collide with
    #                  the host's claude install.
    if [ -d "$home/.local/share" ]; then
        argv+=( --bind "$home/.local/share" "$home/.local/share" )
        argv+=( --tmpfs "$home/.local/share/applications" )
        argv+=( --tmpfs "$home/.local/share/claude" )
    fi
    for rel in "${AGENT_HOME_FILES[@]+"${AGENT_HOME_FILES[@]}"}" .local/bin/uv .local/bin/uvx; do
        if [ -f "$home/$rel" ]; then
            argv+=( --bind "$home/$rel" "$home/$rel" )
        fi
    done

    # Real binary lives off-PATH on the host; expose it inside the
    # sandbox at the agent's conventional ~/.local/bin/<agent> location
    # so the agent's self-inspection (Claude's installMethod=native
    # check, Codex's own version/update probes) and any internal
    # agent-spawns-agent path lookups see the path they expect.
    # Unconditional — the shadow's loud-fail upstream catches a missing
    # real binary.
    if [ "$AGENT_BIND_BACK" = "1" ]; then
        argv+=( --bind "$real_agent" "$home/$AGENT_INNER_REL" )
    fi

    if [ -n "$workspace" ] && [ -d "$workspace" ]; then
        argv+=( --bind "$workspace" "$workspace" )
    fi
    # -e, not -d/-f: a unix socket is neither a directory nor a regular
    # file, so a -f test silently drops it. Rootless podman/docker expose
    # their engine as a socket under $XDG_RUNTIME_DIR, which is the main
    # thing an operator needs allow-write for beyond plain directories.
    # Dangling symlinks stay skipped (-e is false), which is what we want:
    # bwrap aborts on a --bind whose source does not resolve.
    #
    # Emitted after the /run/{user,secrets} tmpfs masks above, and the
    # order is load-bearing: bwrap applies operations in argv sequence, so
    # a bind listed here re-exposes a single path *through* a mask without
    # lifting it. That is what lets `allow-write = /run/user/1000/podman/
    # podman.sock` reach the engine while the rest of the runtime dir
    # (ssh-agent, gpg-agent, dbus, keyring sockets) stays masked. Keep
    # allow-write after the masks; hoisting it above them would let a mask
    # clobber the operator's bind silently.
    if [ -n "${CLAUDE_SANDBOX_ALLOW_WRITE:-}" ]; then
        while IFS= read -r extra_path; do
            [ -z "$extra_path" ] && continue
            if [ -e "$extra_path" ]; then
                argv+=( --bind "$extra_path" "$extra_path" )
            fi
        done <<< "$CLAUDE_SANDBOX_ALLOW_WRITE"
    fi

    # Defence-in-depth file masks. Strict-under-/root already hides the
    # $HOME dotfiles, but masking them with /dev/null is free and
    # survives if the strict-root bind ever regresses. /etc masks are
    # gated on readability so non-root hosts don't trip EROFS.
    local mask
    for mask in "$home/.netrc" "$home/.Xauthority" "$home/.ICEauthority"; do
        argv+=( --bind-try /dev/null "$mask" )
    done
    for mask in /etc/shadow /etc/gshadow /etc/sudoers; do
        if [ -r "$mask" ]; then
            argv+=( --bind /dev/null "$mask" )
        fi
    done

    # Use the staged pasta-forwarder resolver inside the network jail.
    if [ -n "${CLAUDE_SANDBOX_JAIL_RESOLV:-}" ] && [ -r "${CLAUDE_SANDBOX_JAIL_RESOLV}" ]; then
        argv+=( --ro-bind "$CLAUDE_SANDBOX_JAIL_RESOLV" /etc/resolv.conf )
    fi

    argv+=(
        --cap-drop ALL
        # --unshare-user-try is required when bwrap runs as root inside
        # a nested container that lacks CAP_SYS_ADMIN. When bwrap runs
        # as non-root it implicitly unshares user anyway, so this is a
        # no-op in that path.
        --unshare-user-try
        --unshare-pid
        --unshare-ipc
        --unshare-uts
        --unshare-cgroup-try
        # No --new-session: setsid() severs SIGWINCH delivery. The
        # TIOCSTI defence is delegated to the script(1) wrap around
        # bwrap (the inner pty's input queue is unreachable from the
        # host shell).
        --die-with-parent
    )

    # Scrub the env by default, then re-export only what Claude needs.
    # $HOME/.local/bin is APPENDED so system tools take precedence in
    # PATH resolution.
    argv+=( --clearenv )
    local sandbox_path="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$home/.local/bin"
    # Outer devcontainers (notably python-copier-template) point
    # $VIRTUAL_ENV at a /cache-backed venv; /cache is reachable via
    # --ro-bind / /, so the bin/ is visible. APPENDED so the
    # /usr/local/bin/claude shadow still wins resolution (Invariant 1).
    if [ -n "${VIRTUAL_ENV:-}" ] && [ -d "$VIRTUAL_ENV/bin" ]; then
        sandbox_path="$sandbox_path:$VIRTUAL_ENV/bin"
    fi
    argv+=( --setenv PATH "$sandbox_path" )
    argv+=( --setenv HOME "$home" )
    argv+=( --setenv USER "root" )
    argv+=( --setenv IS_SANDBOX "1" )
    # Which agent's session this is, for the in-sandbox verifier: check 03
    # asserts the EXACT contents of $HOME, so it has to know whose config dir
    # legitimately belongs there. Deliberately NOT named CLAUDE_SANDBOX_AGENT:
    # that variable is detect_agent's override, and a nested `claude` spawned
    # inside a codex session would then re-dispatch to codex.
    argv+=( --setenv IS_SANDBOX_AGENT "$AGENT" )
    # Pi's discovery runs inside the jail, through the relayed model port.
    if [ "${CLAUDE_SANDBOX_LOCAL_MODEL_PORT:-0}" != 0 ]; then
        argv+=( --setenv CLAUDE_SANDBOX_LOCAL_MODEL_PORT "$CLAUDE_SANDBOX_LOCAL_MODEL_PORT" )
    fi
    argv+=( --setenv GIT_CONFIG_GLOBAL "$gitconfig_path" )
    argv+=( --setenv GIT_CONFIG_SYSTEM "/dev/null" )
    # Per-agent env the sandbox sets for itself (agent_profile's AGENT_SETENV,
    # a flat NAME VALUE list). Empty for claude, so its argv is unchanged.
    local _sv
    for (( _sv=0; _sv < ${#AGENT_SETENV[@]}; _sv+=2 )); do
        argv+=( --setenv "${AGENT_SETENV[_sv]}" "${AGENT_SETENV[_sv+1]}" )
    done
    local pass_through_var
    for pass_through_var in \
        TERM LANG LC_ALL LC_CTYPE LC_MESSAGES LC_TIME LC_COLLATE LC_NUMERIC LC_MONETARY \
        VIRTUAL_ENV UV_PROJECT_ENVIRONMENT UV_CACHE_DIR UV_PYTHON_CACHE_DIR \
        UV_PYTHON_INSTALL_DIR UV_TOOL_DIR PRE_COMMIT_HOME \
        CLAUDE_SANDBOX_WORKSPACE_ROOT; do
        if [ -n "${!pass_through_var:-}" ]; then
            argv+=( --setenv "$pass_through_var" "${!pass_through_var}" )
        fi
    done

    # Operator-configured passthrough (pass-env in claude-sandbox.conf).
    # The list above is what Claude itself needs; this is for what the
    # *project* needs — DOCKER_HOST for a container-based test suite, and
    # the like. Opt-in by name, so --clearenv stays the default and the
    # sandbox never inherits the surrounding environment wholesale.
    #
    # Names only: values are read from the launching environment here, not
    # from the conf, so pass-env can forward a variable the operator's
    # shell already has but cannot invent a value of its own.
    if [ -n "${CLAUDE_SANDBOX_PASS_ENV:-}" ]; then
        # Split on comma/space/newline so `pass-env = A, B` and repeated
        # pass-env lines both work. Word splitting is the point here, and
        # a local IFS keeps it from leaking into the caller.
        local old_ifs="$IFS"
        IFS=$', \t\n'
        # shellcheck disable=SC2206  # deliberate split on IFS
        local -a pass_names=( $CLAUDE_SANDBOX_PASS_ENV )
        IFS="$old_ifs"
        local pass_name
        for pass_name in "${pass_names[@]+"${pass_names[@]}"}"; do
            # Not a shell identifier — skip rather than emit a --setenv
            # bwrap would choke on.
            case "$pass_name" in
                ""|[0-9]*|*[!A-Za-z0-9_]*) continue ;;
            esac
            # Refuse the variables the sandbox sets itself, plus the
            # loader/shell hooks. Passing PATH would undo the shadow's
            # PATH discipline (Invariant 1: plain `claude` must resolve to
            # /usr/local/bin/claude); IS_SANDBOX would trip the recursion
            # guard into skipping the jail; LD_PRELOAD and friends execute
            # attacker-chosen code in every process the session spawns.
            # An operator asking for these has misunderstood the flag, so
            # the sandbox's own value wins.
            case "$pass_name" in
                PATH|HOME|USER|IS_SANDBOX|GIT_CONFIG_GLOBAL|GIT_CONFIG_SYSTEM) continue ;;
                # CODEX_HOME would move Codex's config + auth.json off the
                # bound ~/.codex to an unbound tmpfs path, silently losing
                # the login on exit. The profile's bind is the supported
                # location, so the sandbox's own value wins here too.
                CODEX_HOME|CLAUDE_CONFIG_DIR|CLAUDE_SANDBOX_AGENT) continue ;;
                # IS_SANDBOX_AGENT is emitted ABOVE this loop, and a later
                # --setenv of the same name wins — so forwarding it here would
                # let the conf overwrite the sandbox's own value. It is what
                # battery check 03 uses to decide WHICH agent's config dir may
                # legitimately appear under $HOME, so a forged value downgrades
                # that check into ignoring the other agent's credentials.
                # Blocked for the same reason as IS_SANDBOX.
                IS_SANDBOX_AGENT) continue ;;
                LD_*|BASH_ENV|ENV|SHELLOPTS|BASHOPTS|IFS) continue ;;
            esac
            if [ -n "${!pass_name:-}" ]; then
                argv+=( --setenv "$pass_name" "${!pass_name}" )
            fi
        done
    fi

    # Disable the Chrome browser-extension RPC channel: strip any
    # user-supplied --chrome so it can't override our --no-chrome
    # injection (see filter_chrome_args). The browser extension's
    # native-messaging-host bridge would let any installed Chrome
    # extension on the host invoke tools inside this in-sandbox Claude —
    # outside the threat model. Manifests Claude would otherwise write
    # into ~/.config/<browser>/NativeMessagingHosts/ are gated on this
    # flag, so check 03 (strict-under-/root) catches any regression.
    local -a user_args=()
    if [ "${AGENT_FILTER_CHROME:-0}" = "1" ]; then
        filter_chrome_args user_args "$@"
    else
        user_args=( "$@" )
    fi

    # Exec via the in-sandbox conventional path so the agent's argv[0]
    # matches what its official installer would have placed.
    local -a agent_command=()
    agent_exec_argv agent_command "$home"
    argv+=( -- "${agent_command[@]}" "${user_args[@]}" )
    _bwrap_out=( "${argv[@]}" )
}

# resolve_workspace_root: pure function picking the rw bind-mount root.
# Priority:
#   1. CLAUDE_SANDBOX_WORKSPACE_ROOT — explicit override. Set to /workspaces
#      in your devcontainer remoteEnv to restore the old broad bind and make
#      sibling projects writable. Set to any absolute path for a custom root.
#   2. Default: $PWD. Only the current project directory is writable.
# Stdout: resolved path. No I/O, no subprocesses — sourced by tests.
resolve_workspace_root() {
    local pwd_in="$1"
    if [ -n "${CLAUDE_SANDBOX_WORKSPACE_ROOT:-}" ]; then
        printf '%s\n' "$CLAUDE_SANDBOX_WORKSPACE_ROOT"
    else
        printf '%s\n' "$pwd_in"
    fi
}

# parse_config: read the host-global /etc/claude-sandbox.conf and apply
# recognised keys. Format: "key = value" or bare "key" for boolean
# flags; # comments and blank lines are ignored.
# Env vars already set take precedence — the config supplies defaults.
# The conf lives at /etc (placed by install.sh), NOT inside the rw-bound
# workspace, so a compromised session cannot rewrite it to widen the
# next launch's binds. Takes the path as $1 so tests can point it at a
# fixture.
parse_config() {
    local conf="$1"
    [ -f "$conf" ] || return 0
    local line key value
    while IFS= read -r line || [ -n "$line" ]; do
        line="${line%%#*}"
        line="${line#"${line%%[![:space:]]*}"}"
        line="${line%"${line##*[![:space:]]}"}"
        [ -z "$line" ] && continue
        if [[ "$line" == *=* ]]; then
            key="${line%%=*}"
            key="${key%"${key##*[![:space:]]}"}"
            value="${line#*=}"
            value="${value#"${value%%[![:space:]]*}"}"
            value="${value%"${value##*[![:space:]]}"}"
        else
            key="$line"
            value=""
        fi
        case "$key" in
            workspace-root)
                [ -n "$value" ] && : "${CLAUDE_SANDBOX_WORKSPACE_ROOT:=$value}"
                export CLAUDE_SANDBOX_WORKSPACE_ROOT
                ;;
            no-forge)
                : "${CLAUDE_SANDBOX_NO_FORGE:=1}"
                export CLAUDE_SANDBOX_NO_FORGE
                ;;
            egress-jail)
                # Per-process network egress jail (ADR 0015, Design D). ON by
                # default — see egress_jail_enabled(); this conf key only needs
                # to appear to TURN IT OFF on a host: `egress-jail = 0`. A bare
                # `egress-jail` (no value) just reaffirms on. := no-ops when the
                # var is already set, so an env CLAUDE_SANDBOX_EGRESS_JAIL=0 wins
                # over the conf.
                : "${CLAUDE_SANDBOX_EGRESS_JAIL:=${value:-1}}"
                export CLAUDE_SANDBOX_EGRESS_JAIL
                ;;
            local-model-port)
                # The port Pi discovers a model on; always part of the relay set
                # (ADR 0020). 0 drops it and Pi's discovery. Env wins over conf.
                : "${CLAUDE_SANDBOX_LOCAL_MODEL_PORT:=${value:-1920}}"
                export CLAUDE_SANDBOX_LOCAL_MODEL_PORT
                ;;
            local-port)
                # Extra outer-loopback TCP ports relayed into the jail (ADR 0020).
                # Repeatable; accumulated like allow-ip, and merged with any
                # CLAUDE_SANDBOX_LOCAL_PORTS already in the environment.
                [ -z "$value" ] && continue
                CLAUDE_SANDBOX_LOCAL_PORTS="${CLAUDE_SANDBOX_LOCAL_PORTS:+${CLAUDE_SANDBOX_LOCAL_PORTS}
}${value}"
                export CLAUDE_SANDBOX_LOCAL_PORTS
                ;;
            callback-port)
                # Outer-loopback TCP ports relayed INTO the jail, the reverse of
                # local-port (ADR 0021): a browser on the host reaches an OAuth
                # callback server the agent opens on its own loopback. Repeatable;
                # merged with CLAUDE_SANDBOX_CALLBACK_PORTS from the environment.
                [ -z "$value" ] && continue
                CLAUDE_SANDBOX_CALLBACK_PORTS="${CLAUDE_SANDBOX_CALLBACK_PORTS:+${CLAUDE_SANDBOX_CALLBACK_PORTS}
}${value}"
                export CLAUDE_SANDBOX_CALLBACK_PORTS
                ;;
            gpu)
                : "${CLAUDE_SANDBOX_GPU:=${value:-1}}"
                export CLAUDE_SANDBOX_GPU
                ;;
            allow-device)
                [ -n "$value" ] || continue
                CLAUDE_SANDBOX_ALLOW_DEVICES="${CLAUDE_SANDBOX_ALLOW_DEVICES:+${CLAUDE_SANDBOX_ALLOW_DEVICES}
}$value"
                export CLAUDE_SANDBOX_ALLOW_DEVICES
                ;;
            allow-write)
                [ -z "$value" ] && continue
                CLAUDE_SANDBOX_ALLOW_WRITE="${CLAUDE_SANDBOX_ALLOW_WRITE:+${CLAUDE_SANDBOX_ALLOW_WRITE}
}${value}"
                export CLAUDE_SANDBOX_ALLOW_WRITE
                ;;
            allow-ip)
                # Device IPs the jail keeps reachable past the RFC1918 blackhole
                # (EPICS IOC / PMAC etc.). Repeatable; accumulated newline-
                # separated like allow-write. Read from /etc only (never the
                # workspace), so a compromised session can't widen its own reach.
                [ -z "$value" ] && continue
                CLAUDE_SANDBOX_ALLOW_IP="${CLAUDE_SANDBOX_ALLOW_IP:+${CLAUDE_SANDBOX_ALLOW_IP}
}${value}"
                export CLAUDE_SANDBOX_ALLOW_IP
                ;;
            pass-env)
                [ -z "$value" ] && continue
                CLAUDE_SANDBOX_PASS_ENV="${CLAUDE_SANDBOX_PASS_ENV:+${CLAUDE_SANDBOX_PASS_ENV}
}${value}"
                export CLAUDE_SANDBOX_PASS_ENV
                ;;
        esac
    done < "$conf"
}

# jail_fail MSG [PID]: print a uniform fail-closed egress-jail error to stderr
# and exit 1, optionally killing the holder PID first. Every fail-closed point
# in netns_holder/netns_launch routes through here so the jail's refuse-to-
# launch behaviour reads identically everywhere. MSG already carries its own
# leading "— " or "needs " so both wordings render naturally after "egress jail".
jail_fail() {
    echo "claude-sandbox: egress jail $1" >&2
    if [ -n "${2:-}" ]; then kill "$2" 2>/dev/null || true; fi
    exit 1
}

# wait_for CMD...: poll CMD (run as a command, e.g. `wait_for test -f X`) up to
# ~10s (200 × 50ms). Returns 0 the instant it succeeds, 1 if it never does.
# Used for the netns/pasta handshake where one process waits on state another
# is about to create.
wait_for() {
    local _i
    for _i in $(seq 1 200); do
        if "$@"; then return 0; fi
        sleep 0.05
    done
    return 1
}

# route_field KEY <route-line: echo the token following KEY in an `ip route`
# line (KEY=via → gateway, KEY=dev → NIC). Empty when KEY is absent.
route_field() {
    awk -v k="$1" '{for(i=1;i<NF;i++)if($i==k)print $(i+1)}'
}

# netns_holder: runs inside `unshare -rn` (a user+net namespace it owns).
# Brings up loopback, waits for claude-shadow to attach pasta and signal
# readiness, locks the routing allowlist, then execs the real launch ("$@").
#
# Security model (ADR 0015, Design D): this process holds CAP_NET_ADMIN over its
# OWN netns, so it can program the routes. The bwrap it then execs nests its own
# userns and is therefore NOT capless (full CapBnd) — but that netns is owned by
# THIS (ancestor) userns, so Claude's caps confer no authority over it: route
# edits / device adds fail EPERM from inside. Caplessness is not the boundary;
# ancestor-userns ownership is.
#
# Fail-closed: the function runs in a fresh `bash -c` (see netns_launch), so the
# file-level `set -euo pipefail` does NOT reach it — the FIRST statement below
# re-establishes it so an unhandled failure aborts the holder. On top of that,
# every load-bearing blackhole/route step routes through jail_fail on failure, so
# a failed `ip route` add can never let Claude start un-allowlisted (fail-OPEN).
# IPv4-only: pasta attaches with --ipv4-only (netns_launch), so the netns has no
# IPv6 at all — no v6/ULA/link-local address family to blackhole or punch.
netns_holder() {
    set -euo pipefail
    ip link set lo up
    wait_for test -f "$CLAUDE_JAIL_READY" \
        || jail_fail "— pasta never signalled ready"
    ip route show default | grep -q . \
        || jail_fail "— no default route after pasta attach"
    # pasta --config-net mirrors the host L3 config into the netns (address,
    # connected subnet, default gateway). Capture it, then apply the SURGICAL
    # allowlist: blackhole RFC1918 AND the connected subnet, punch back only the
    # gateway, the DNS resolvers, and the allow-ip devices. A blanket RFC1918
    # blackhole would kill DNS on sites where the resolvers/gateway are
    # themselves RFC1918, and the mirrored connected-subnet route (more specific
    # than the blackhole) would otherwise leave the whole local subnet reachable.
    # See ADR 0015 (validated by probe-network-jail.sh).
    local def gw nic
    def="$(ip route show default 2>/dev/null | head -n1)"
    gw="$(route_field via <<< "$def")"
    nic="$(route_field dev <<< "$def")"
    if [ -z "$gw" ] || [ -z "$nic" ]; then
        jail_fail "— no default route via/dev after pasta attach"
    fi
    # ALL connected (scope-link) subnets on the egress NIC, not just the first:
    # pasta --config-net mirrors a connected route for EVERY on-link subnet, and
    # each is more-specific than the RFC1918/CGNAT blackholes, so any subnet left
    # un-blackholed stays fully reachable (longest-prefix-match wins). Enumerate
    # them all and blackhole each below. (`ip -o` keeps one route per line.)
    local -a subnets=()
    mapfile -t subnets < <(ip -o route show dev "$nic" scope link 2>/dev/null \
        | awk '{print $1}')

    # Blackhole everything internal first, then re-punch the gateway and other
    # allowed routes. A mirrored scope-link route can itself be the gateway's
    # /32 (e.g. DHCP on Azure). Pinning it before this loop would overwrite that
    # exception with a blackhole and leave the gateway unreachable.
    # Fail-closed: each load-bearing step (the
    # gateway on-link route, every connected-subnet and RFC1918/CGNAT blackhole,
    # the link-local unreachable, and the restored default) routes through
    # jail_fail on failure — combined with the `set -euo pipefail` at the top of
    # this function, a failed add aborts the holder so Claude never starts with an
    # internal range reachable. CGNAT (100.64/10) is blackholed so a host on a
    # Tailscale / CGNAT internal address cannot be pivoted to.
    local subnet
    for subnet in "${subnets[@]}"; do
        [ -n "$subnet" ] || continue
        ip route replace blackhole "$subnet" \
            || jail_fail "— failed to blackhole connected subnet $subnet (fail-closed)"
    done
    ip route replace blackhole   10.0.0.0/8       || jail_fail "— failed to blackhole 10.0.0.0/8 (fail-closed)"
    ip route replace blackhole   172.16.0.0/12    || jail_fail "— failed to blackhole 172.16.0.0/12 (fail-closed)"
    ip route replace blackhole   192.168.0.0/16   || jail_fail "— failed to blackhole 192.168.0.0/16 (fail-closed)"
    ip route replace blackhole   100.64.0.0/10    || jail_fail "— failed to blackhole 100.64.0.0/10 CGNAT (fail-closed)"
    ip route replace unreachable 169.254.0.0/16   || jail_fail "— failed to mark 169.254.0.0/16 unreachable (fail-closed)"
    ip route replace "$gw/32" dev "$nic" \
        || jail_fail "— failed to pin gateway $gw on-link (fail-closed)"
    ip route replace default via "$gw" dev "$nic" || jail_fail "— failed to restore default via $gw (fail-closed)"

    # Route DNS only to the pasta forwarder, which queries the host resolver
    # outside the jail. A direct route to an internal resolver would expose
    # every port on that host. Failure here loses DNS, not containment.
    ip route replace "${JAIL_DNS_FWD}/32" via "$gw" 2>/dev/null \
        || echo "claude-sandbox: egress jail — could not route DNS forwarder $JAIL_DNS_FWD" >&2

    # allow-ip devices (EPICS IOC / PMAC). Fail-soft: a missing device route is a
    # reachability loss, not a security hole (the blackhole still holds).
    if [ -n "${CLAUDE_SANDBOX_ALLOW_IP:-}" ]; then
        local aip
        while IFS= read -r aip; do
            [ -n "$aip" ] || continue
            ip route replace "${aip%/*}/32" via "$gw" 2>/dev/null \
                || echo "claude-sandbox: egress jail — could not route allow-ip $aip" >&2
        done <<< "$CLAUDE_SANDBOX_ALLOW_IP"
    fi
    if [ -n "${CLAUDE_JAIL_LOCAL_PORTS:-}${CLAUDE_JAIL_CALLBACK_PORTS:-}" ]; then
        local_model_inner "$@"
    else
        exec "$@"
    fi
}

# The relay set (ADR 0020): local-model-port (Pi's discovery port, shipped 1920)
# plus every local-port entry, deduplicated, one line per port. Entries may be
# separated by newlines (conf accumulation), spaces or commas (the env var).
# Every agent gets the relay when the set is non-empty (ADR 0019 had it
# Pi-only). No destination strings reach socat: the host target is always
# IPv4 loopback and every port is validated before any launch.
# local-port entries as words: newline (conf), space or comma (env) separated.
local_port_words() {
    printf '%s' "${CLAUDE_SANDBOX_LOCAL_PORTS:-}" | tr ',\n' '  '
}

local_ports() {
    local p seen=" "
    for p in "${CLAUDE_SANDBOX_LOCAL_MODEL_PORT:-0}" $(local_port_words); do
        [ "$p" = 0 ] && continue
        case "$seen" in *" $p "*) continue ;; esac
        seen="$seen$p "
        printf '%s\n' "$p"
    done
}

local_model_enabled() {
    [ -n "$(local_ports)" ]
}

# Validates the raw configuration, not the deduplicated set, so a bad entry is
# named by its key. Only local-model-port may be 0.
validate_local_model_port() {
    local port="${CLAUDE_SANDBOX_LOCAL_MODEL_PORT:-0}" ok=0
    if [ "$port" != 0 ] && ! valid_tcp_port "$port"; then
        echo "claude-sandbox: local-model-port must be 1–65535 (or 0 to disable)." >&2
        ok=1
    fi
    for port in $(local_port_words); do
        if ! valid_tcp_port "$port"; then
            echo "claude-sandbox: local-port entries must be 1–65535, got '$port'." >&2
            ok=1
        fi
    done
    return "$ok"
}

valid_tcp_port() {
    [[ "$1" =~ ^[1-9][0-9]{0,4}$ ]] && (( $1 <= 65535 ))
}

# The callback set (ADR 0021): ports relayed the OTHER way, from the outer
# container's loopback into the jail, so a host browser can complete an OAuth
# login against the callback server an agent opens on its own 127.0.0.1 (Pi's
# Claude Pro/Max login on 53692; Codex CLI and Pi's Codex login on 1455).
# Same word forms as local-port; deduplicated; one line per port.
callback_port_words() {
    printf '%s' "${CLAUDE_SANDBOX_CALLBACK_PORTS:-}" | tr ',\n' '  '
}

callback_ports() {
    local p seen=" "
    for p in $(callback_port_words); do
        case "$seen" in *" $p "*) continue ;; esac
        seen="$seen$p "
        printf '%s\n' "$p"
    done
}

callback_enabled() {
    [ -n "$(callback_ports)" ]
}

# A port cannot be relayed both ways: the outbound relay's in-jail listener
# would sit on the port the agent needs for its own server, and the inbound
# listener outside would sit on the host service's port.
validate_callback_ports() {
    local port ok=0
    for port in $(callback_port_words); do
        if ! valid_tcp_port "$port"; then
            echo "claude-sandbox: callback-port entries must be 1–65535, got '$port'." >&2
            ok=1
        elif local_ports | grep -qx "$port"; then
            echo "claude-sandbox: port $port is listed as both callback-port and local-port/local-model-port." >&2
            ok=1
        fi
    done
    return "$ok"
}

port_in_use() {
    [ -n "$(ss -H -ltn "sport = :$1")" ]
}

# Each socat owns a new process group, including forked connections. Kill every
# group on exit so neither streaming requests nor listeners outlive the agent.
# Their stderr is dropped: startup is verified by wait_for below, and the only
# thing socat says otherwise is "W exiting on signal 15" at this teardown —
# noise that lands after the agent's own error and reads as a second failure.
stop_relay() {
    local pid
    for pid in "$@"; do
        [ -n "$pid" ] || continue
        kill -TERM -- "-$pid" 2>/dev/null || true
        wait "$pid" 2>/dev/null || true
    done
}

# Run OUTSIDE the jail. One private pathname socket per port bridges the network
# namespaces without adding any IP route or mapping the host's entire loopback
# interface. Sockets live in CLAUDE_JAIL_RELAY_DIR under /tmp, which bwrap masks.
relay_dir_init() {
    command -v socat >/dev/null 2>&1 || jail_fail "needs socat for loopback relays"
    command -v setsid >/dev/null 2>&1 || jail_fail "needs setsid for loopback relays"
    CLAUDE_JAIL_RELAY_DIR="$CLAUDE_JAIL_DIR/relay"
    mkdir -m 0700 "$CLAUDE_JAIL_RELAY_DIR"
    export CLAUDE_JAIL_RELAY_DIR
}

local_model_outer() {
    local port
    CLAUDE_JAIL_LOCAL_PORTS="$(local_ports | tr '\n' ' ')"
    export CLAUDE_JAIL_LOCAL_PORTS
    for port in $CLAUDE_JAIL_LOCAL_PORTS; do
        setsid socat "UNIX-LISTEN:$CLAUDE_JAIL_RELAY_DIR/$port.sock,mode=0600,fork" \
            "TCP4:127.0.0.1:$port" 2>/dev/null &
        model_relay="$model_relay $!"
        wait_for test -S "$CLAUDE_JAIL_RELAY_DIR/$port.sock" \
            || jail_fail "— loopback relay for port $port failed to start"
    done
}

# Run OUTSIDE the jail: the inbound half of a callback-port relay (ADR 0021).
# This end LISTENS on the outer loopback, so unlike local_model_outer it can
# collide with whatever already owns the port (a second agent session, an
# unwrapped agent on the host). Fail soft: the session launches without that
# relay and the browser fails fast instead of hanging. Only the ports that did
# start are handed to the holder, so the inner side never waits on a socket
# that has no listener behind it.
callback_outer() {
    local port started=""
    for port in $(callback_ports); do
        if port_in_use "$port"; then
            echo "claude-sandbox: callback-port $port is already in use on this host; browser logins on that port will not reach this session." >&2
            continue
        fi
        setsid socat "TCP4-LISTEN:$port,bind=127.0.0.1,reuseaddr,fork" \
            "UNIX-CONNECT:$CLAUDE_JAIL_RELAY_DIR/in-$port.sock" 2>/dev/null &
        model_relay="$model_relay $!"
        if wait_for port_in_use "$port"; then
            started="$started$port "
        else
            echo "claude-sandbox: callback-port $port relay failed to start; browser logins on that port will not reach this session." >&2
        fi
    done
    CLAUDE_JAIL_CALLBACK_PORTS="$started"
    export CLAUDE_JAIL_CALLBACK_PORTS
}

# Run in the HOLDER's netns, before bwrap masks /tmp and enters its PID namespace.
# The agent sees only these loopback listeners, never the host-side Unix sockets.
# Callback ports run the other way: this end listens on the private socket and
# connects to the agent's loopback listener only when a browser arrives, so an
# agent that is not mid-login costs nothing and the browser is refused, not hung.
local_model_inner() {
    local relay="" child="" rc=0 port
    trap 'stop_relay $relay; if [ -n "$child" ]; then kill "$child" 2>/dev/null || true; wait "$child" 2>/dev/null || true; fi' EXIT
    trap 'exit 130' INT
    trap 'exit 143' TERM HUP
    for port in ${CLAUDE_JAIL_LOCAL_PORTS:-}; do
        setsid socat "TCP4-LISTEN:$port,bind=127.0.0.1,reuseaddr,fork" \
            "UNIX-CONNECT:$CLAUDE_JAIL_RELAY_DIR/$port.sock" 2>/dev/null &
        relay="$relay $!"
        # ss reads the kernel listener table; do not probe by connecting to the
        # service or require it to be running just to use a cloud provider.
        wait_for local_model_listening "$port" \
            || jail_fail "— loopback listener for port $port failed to start"
    done
    for port in ${CLAUDE_JAIL_CALLBACK_PORTS:-}; do
        setsid socat "UNIX-LISTEN:$CLAUDE_JAIL_RELAY_DIR/in-$port.sock,mode=0600,fork" \
            "TCP4:127.0.0.1:$port" 2>/dev/null &
        relay="$relay $!"
        wait_for test -S "$CLAUDE_JAIL_RELAY_DIR/in-$port.sock" \
            || jail_fail "— callback relay for port $port failed to start"
    done
    "$@" <&0 &
    child=$!
    wait "$child" || rc=$?
    child=""
    exit "$rc"
}

local_model_listening() {
    ss -H -ltn "sport = :$1" | grep -q '127.0.0.1:'
}

# egress_jail_enabled: ADR 0015 — the jail is ON by default. Only an explicit
# CLAUDE_SANDBOX_EGRESS_JAIL=0 (env var, or `egress-jail = 0` in the /etc conf)
# turns it off; any other value (unset, 1, …) means on. Kept a one-line
# predicate so the launch gate reads clearly and the default is unit-testable.
egress_jail_enabled() {
    [ "${CLAUDE_SANDBOX_EGRESS_JAIL:-1}" != "0" ]
}

# Stage the resolver before building the bwrap arguments. All jailed DNS
# uses the pasta forwarder; preserve search domains and resolver options.
jail_stage_dns() {
    local f
    if ! f="$(mktemp "${TMPDIR:-/tmp}/claude-jail-resolv.XXXXXX" 2>/dev/null)"; then
        launch_warn "egress jail — could not stage a DNS override (mktemp failed); name resolution may fail."
        return 0
    fi
    printf 'nameserver %s\n' "$JAIL_DNS_FWD" > "$f"
    awk '/^[[:space:]]*(search|domain|options)[[:space:]]/' /etc/resolv.conf >> "$f" 2>/dev/null
    export CLAUDE_SANDBOX_JAIL_RESOLV="$f"

    # pasta forwards to the host's own resolvers; with none listed there is
    # nothing to forward to. Warn rather than fail: pasta attach is the
    # fail-closed gate, and a host with no resolver has bigger problems.
    if ! grep -qE '^[[:space:]]*nameserver' /etc/resolv.conf 2>/dev/null; then
        launch_warn "egress jail — /etc/resolv.conf lists no resolvers; forwarding Claude DNS to the host's resolvers via pasta. If resolution still fails the host has none to forward to."
    fi
    return 0
}

# netns_launch: Design D orchestration (ADR 0015). Runs the launch ("$@") inside
# a user+net namespace bridged to the internet by pasta, with the routing
# allowlist above. The holder must create the netns (not bwrap, not pasta): the
# container has no CAP_NET_ADMIN to make a netns without a userns, and a pasta-
# created namespace set leaves bwrap a /proc it can't use. Fail-closed: the jail
# is on by default, so if its prerequisites are missing Claude does NOT launch
# (a silent unjailed fallback would quietly drop the default security control).
# Each error names the fix. The messages deliberately do NOT name the
# CLAUDE_SANDBOX_EGRESS_JAIL=0 escape hatch (it still works, for operators
# who know the risks) — advertising a sandbox-weakening switch in an error
# everyone hits would invite exactly the quiet opt-out fail-closed exists
# to prevent.
netns_launch() {
    command -v unshare >/dev/null 2>&1 || jail_fail "needs unshare (util-linux)"
    command -v pasta   >/dev/null 2>&1 || jail_fail "needs pasta (apt-get install passt)"
    [ -e /dev/net/tun ] \
        || jail_fail "needs /dev/net/tun — add --device=/dev/net/tun to the container"

    # Always under /tmp, which bwrap masks. Never honour a TMPDIR inside the
    # writable workspace for the socket or namespace readiness handshake.
    CLAUDE_JAIL_DIR="$(mktemp -d /tmp/claude-jail.XXXXXX)"
    local model_relay="" holder=""
    trap 'stop_relay $model_relay; if [ -n "$holder" ]; then kill "$holder" 2>/dev/null || true; wait "$holder" 2>/dev/null || true; fi; rm -rf "$CLAUDE_JAIL_DIR"; rm -f "${CLAUDE_SANDBOX_JAIL_RESOLV:-}"' EXIT
    trap 'exit 130' INT
    trap 'exit 143' TERM HUP
    CLAUDE_JAIL_READY="$CLAUDE_JAIL_DIR/ready"
    export CLAUDE_JAIL_READY
    unset CLAUDE_JAIL_LOCAL_PORTS CLAUDE_JAIL_CALLBACK_PORTS CLAUDE_JAIL_RELAY_DIR
    validate_local_model_port || exit 1
    validate_callback_ports || exit 1
    if local_model_enabled || callback_enabled; then
        relay_dir_init
    fi
    if local_model_enabled; then
        local_model_outer
    fi
    if callback_enabled; then
        callback_outer
    fi

    # The holder runs in a fresh `bash -c`, so every function it calls must be
    # exported too — not just netns_holder, but the helpers it reaches:
    # jail_fail, wait_for, route_field.
    export -f netns_holder jail_fail wait_for route_field local_model_inner local_model_listening stop_relay
    export JAIL_DNS_FWD
    # Explicit <&0 keeps the holder's stdin on the terminal: bash redirects a
    # background job's stdin to /dev/null otherwise, which would break Claude's
    # interactive TUI. Job control is off in this non-interactive shell, so the
    # holder shares the shell's process group (the terminal's foreground group)
    # — no SIGTTIN/SIGTTOU when it (and the nested script(1)/bwrap) use the tty.
    unshare -rn bash -c 'netns_holder "$@"' _ "$@" <&0 &
    holder=$!

    wait_for test -e "/proc/$holder/ns/net" \
        || jail_fail "— holder netns never appeared" "$holder"

    # Attach pasta from the outer namespace. IPv4-only keeps all traffic
    # within the routing policy. Disable automatic port forwarding and
    # gateway-to-loopback mapping; loopback access uses explicit relays.
    # Capture startup diagnostics as well as pasta's regular log.
    if ! pasta --config-net --ipv4-only --no-map-gw -t none -u none -T none -U none \
        --dns-forward "$JAIL_DNS_FWD" --quiet --log-file /tmp/claude-pasta.log "$holder" 2>>/tmp/claude-pasta.log; then
        jail_fail "— pasta failed to attach to the netns (see /tmp/claude-pasta.log)" "$holder"
    fi
    : > "$CLAUDE_JAIL_READY"

    local rc=0
    wait "$holder" || rc=$?
    holder=""
    exit "$rc"
}

# LAUNCH_WARNED: set when a warning printed before launch.
# launch_warn MESSAGE: print a warning that does not stop the launch, and set
# LAUNCH_WARNED so that pause_after_warnings holds the terminal.
LAUNCH_WARNED=0
launch_warn() {
    echo "claude-sandbox: $1" >&2
    LAUNCH_WARNED=1
}

# pause_after_warnings: an agent redraws the whole terminal as it starts, so a
# warning printed before launch vanishes at once. After any warning, wait for
# a key press. Skip the wait when no person is at the terminal (stdin or
# stderr is not a tty) and for --sandbox-verify, which does not redraw.
pause_after_warnings() {
    local _key
    [ "$LAUNCH_WARNED" = 1 ] || return 0
    [ "$SANDBOX_VERIFY" != 1 ] || return 0
    [ -t 0 ] && [ -t 2 ] || return 0
    printf 'Press any key to continue, Ctrl-C to cancel.' >&2
    read -rsn 1 _key
    printf '\n' >&2
}

# check_config_persistence: warn (and set LAUNCH_WARNED) when the agent's
# config dir is neither a symlink (link_terminal_config path) nor a direct
# bind mount. A plain container directory means memory, settings, and OAuth
# state will be lost on the next devcontainer rebuild.
check_config_persistence() {
    local cfg_dir="${HOME:-/root}/${AGENT_HOME_DIRS[0]}"
    # symlink → link_terminal_config wired it to a host-mounted path
    [ -L "$cfg_dir" ] && return 0
    # direct bind mount the user set up themselves
    mountpoint -q "$cfg_dir" 2>/dev/null && return 0
    printf '\n\033[33mWARNING\033[0m: ~/%s is not host-mounted.\n' "${AGENT_HOME_DIRS[0]}" >&2
    printf '%s memory, settings, and login state will be lost on devcontainer rebuild.\n' "$AGENT_LABEL" >&2
    printf 'See https://diamondlightsource.github.io/claude-sandbox/how-to/use-the-container-image.html#authentication-and-persistence\n\n' >&2
    LAUNCH_WARNED=1
}

# prepare_shipped_skills: create the agent's host skills dir when anything
# ships, and warn about each host skill a shipped one masks. The shipped-skills
# binds land inside that dir, one per skill. Creating the dir here, the one
# deliberate host write, stops bwrap from making it on the host bind. bwrap
# still leaves an empty mount point per shipped skill on the host. An empty
# dir masks nothing, so only a non-empty one, or a non-directory, warns.
prepare_shipped_skills() {
    local skills_dir="${HOME:-/root}/$AGENT_SKILLS_REL" skill_dir host_skill
    for skill_dir in "$SHIPPED_SKILLS_DIR"/*/; do
        [ -d "$skill_dir" ] || continue
        mkdir -p "$skills_dir"
        skill_dir="${skill_dir%/}"
        host_skill="$skills_dir/${skill_dir##*/}"
        if [ -L "$host_skill" ] \
                || { [ -e "$host_skill" ] && ! [ -d "$host_skill" ]; } \
                || [ -n "$(find "$host_skill" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null)" ]; then
            launch_warn "~/$AGENT_SKILLS_REL/${skill_dir##*/} is shadowed by the shipped skill for this session."
        fi
    done
}

# render_gitconfig: (re)write $CLAUDE_SANDBOX_GITCONFIG_PATH from the host's
# current git identity. Called on every launch because VS Code's
# dev.containers.copyGitConfig fires AFTER postCreate, so an install-time render
# can have an empty user.name; by the time the user types `claude`, copyGitConfig
# has run, and this re-render makes the [user] block reflect the real identity.
render_gitconfig() (
    local git_name git_email tmp
    git_name=$(git config --get user.name 2>/dev/null || true)
    git_email=$(git config --get user.email 2>/dev/null || true)
    tmp="$(mktemp "$CLAUDE_SANDBOX_GITCONFIG_PATH.XXXXXX")"
    trap 'rm -f "$tmp"' EXIT
    {
        if [ "${CLAUDE_SANDBOX_NO_FORGE:-}" != "1" ]; then
            cat <<'EOF'
[credential "https://github.com"]
    helper = !gh auth git-credential
[credential "https://gitlab.diamond.ac.uk"]
    helper = !glab auth git-credential
EOF
        fi
        cat <<'EOF'
[url "https://github.com/"]
    insteadOf = git@github.com:
    insteadOf = ssh://git@github.com/
[url "https://gitlab.diamond.ac.uk/"]
    insteadOf = git@gitlab.diamond.ac.uk:
    insteadOf = ssh://git@gitlab.diamond.ac.uk/
[init]
    defaultBranch = main
[safe]
    directory = *
EOF
    } > "$tmp"
    git config --file "$tmp" user.name "$git_name"
    git config --file "$tmp" user.email "$git_email"
    chmod 0644 "$tmp"
    mv "$tmp" "$CLAUDE_SANDBOX_GITCONFIG_PATH"
)

# Resolve configuration before creating credential dirs or building mounts.
configure_launch() {
    parse_config "$CONFIG_PATH"
    render_gitconfig
}

# Run in a fresh terminal, preserving the wrapped command's exit status.
sandbox_launch() {
    local command
    printf -v command '%q ' "$@"
    # printf %q uses Bash quoting, including $'...' for embedded newlines.
    local -x SHELL=/bin/bash
    local -a terminal=( script --return -q -E never -c "$command" /dev/null )
    if egress_jail_enabled; then
        netns_launch "${terminal[@]}"
    else
        exec "${terminal[@]}"
    fi
}

# Guard: tests source this file with CLAUDE_SHADOW_SOURCE_ONLY=1 so
# only the function definitions are pulled into scope.
if [ "$CLAUDE_SHADOW_SOURCE_ONLY" = "1" ]; then
    return 0 2>/dev/null || exit 0
fi

# Say so when argv[0] named no agent we know. detect_agent falls back to the
# claude profile so that sourcing the file (tests, $0 = the test script) and
# any odd exec still work — but silently treating a renamed symlink as Claude
# means injecting --no-chrome into something that is not Claude, which surfaces
# as an unexplained abort from the vendor binary. Warn on the launch path only,
# where a real user is watching; CLAUDE_SANDBOX_AGENT is the supported way to
# reach a profile through a name we do not recognise.
if [ -z "${CLAUDE_SANDBOX_AGENT:-}" ]; then
    case "${0##*/}" in
        claude|codex|pi) ;;
        *) launch_warn "invoked as '${0##*/}', which names no known agent — assuming $AGENT_LABEL. Set CLAUDE_SANDBOX_AGENT to choose explicitly." ;;
    esac
fi

# Loud-fail when the real binary is missing — clearer than the
# downstream errno from bwrap exec'ing a non-existent path. The shadow is
# installed for every supported agent whether or not that agent's binary
# was fetched, so this is the message a user sees when they run `codex`
# on a host where only Claude was installed. That is deliberate: the
# shadow must own the name on $PATH before the vendor's installer can
# claim it (Invariant 1), so it has to exist first and explain itself.
if [ ! -x "$AGENT_REAL" ]; then
    echo "claude-sandbox: real $AGENT_LABEL binary missing at $AGENT_REAL." >&2
    echo "  Re-run \`./install\` from a fresh clone of claude-sandbox" >&2
    echo "  (it fetches and relocates every supported agent it can reach)." >&2
    exit 1
fi

# Refuse to exec ourselves. If a broken install ever relocates this shadow as
# the "real" binary, the recursion guard below would exec it, it would exec
# the same path again, and the session would spin forever — a hang gives the
# user nothing to go on, so turn it into a one-line error. Cheap: one cmp
# against a file already in the page cache, only on the launch path.
if cmp -s "$AGENT_REAL" "$0"; then
    echo "claude-sandbox: $AGENT_REAL is a copy of this shadow, not the real $AGENT_LABEL binary." >&2
    echo "  Launching it would loop forever. Re-run \`./install\` to relocate a real one;" >&2
    echo "  if the download is failing, the sandbox says so at the end of install." >&2
    exit 1
fi

configure_launch
if [ "$SANDBOX_VERIFY" != 1 ]; then
    check_config_persistence
fi

# Pre-create forge credential dirs so the argv builder's --bind succeeds
# on first run. Skipped when CLAUDE_SANDBOX_NO_FORGE=1 since those binds
# are omitted anyway.
if [ "${CLAUDE_SANDBOX_NO_FORGE:-}" != "1" ]; then
    mkdir -p "${HOME:-/root}/.config/gh" "${HOME:-/root}/.config/glab-cli"
fi

# Ensure the agent's config dir and files exist for the bind-back. Without
# this the agent's OAuth token (~/.claude.json, ~/.codex/auth.json) writes
# into the in-sandbox tmpfs and vanishes on exit — and a --bind of a
# missing source aborts bwrap outright. mkdir/touch are no-ops on existing
# paths apart from mtime.
for _agent_dir in "${AGENT_HOME_DIRS[@]}"; do
    mkdir -p "${HOME:-/root}/$_agent_dir"
done
for _agent_file in "${AGENT_HOME_FILES[@]+"${AGENT_HOME_FILES[@]}"}"; do
    touch "${HOME:-/root}/$_agent_file"
done
prepare_shipped_skills
# The shared skills dir must exist for its bind (the builder skips a missing
# source). A dangling symlink — a shared store that has gone away — makes
# mkdir fail; warn and launch without the share rather than abort.
if ! mkdir -p "${HOME:-/root}/$SHARED_SKILLS_REL" 2>/dev/null; then
    launch_warn "cannot create ~/$SHARED_SKILLS_REL; shared skills are unavailable this session."
fi

# Stage the DNS forwarder configuration before building the arguments.
if egress_jail_enabled; then
    jail_stage_dns
fi
WORKSPACE_ROOT="$(resolve_workspace_root "$PWD")"
bwrap_argv_build ARGV "$WORKSPACE_ROOT" "$AGENT_REAL" "$@"

# Hold any warning on screen before the agent redraws the terminal.
pause_after_warnings

# Wrap bwrap in script(1) so the sandbox runs inside a freshly-allocated
# pseudo-terminal. SIGWINCH propagates, job control works, and TIOCSTI
# is defanged (an ioctl inside the sandbox lands in script's pty, which
# script reads and writes back as bytes — not keystrokes — to the host
# terminal). Build the script(1) command once for both launch paths.
sandbox_launch "${ARGV[@]}"
