#!/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}"

# 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 ────────────────────────────────────────────────────
# The sandbox wraps more than one coding agent: Claude Code, and OpenAI's
# Codex CLI (the client for GPT-6 Astra). They differ only in which binary
# to exec, which $HOME paths carry their login, and which flags to inject.
# The security-critical parts — the bwrap argv, the egress jail, the
# script(1) pty wrap — are identical, and must stay that way.
#
# So: ONE file, installed under both names, dispatching on argv[0]. A
# second 800-line shadow to keep in sync would rebuild exactly the
# "surface too big to audit in one read" that the bash-only rewrite
# walked back from.
#
# The real binaries live off the user's PATH (/usr/libexec/claude-sandbox/)
# so plain `claude` / `codex` always resolve to the shadow in
# /usr/local/bin. Both vendors' installers drop their binary in
# ~/.local/bin AND prepend that dir to the user's shell rc; install.sh
# relocates both so the rc-mutation is inert (Invariant 1).

# Shipped skills: the repo's top-level skills/ tree, placed by install.sh
# under /usr/libexec next to the guard scripts (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"

# 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_inner_path HOME: where the agent is exec'd from INSIDE the sandbox.
# Bind-back agents (claude) run from the conventional $HOME/.local/bin/<agent>
# that the argv builder binds the real binary onto; the rest run in place from
# their root-owned, read-only /usr/libexec path, which is already visible via
# --ro-bind / /.
agent_inner_path() {
    if [ "$AGENT_BIND_BACK" = "1" ]; then
        printf '%s/%s\n' "$1" "$AGENT_INNER_REL"
    else
        printf '%s\n' "$AGENT_REAL"
    fi
}

# One dispatch path for both fresh sandboxes and already-sandboxed spawns.
# Pass the real executable explicitly so the launcher has no relocation path.
agent_exec_argv() {
    if [ "$AGENT" = codex ]; then
        printf '%s\n' /usr/libexec/claude-sandbox/codex-launch "$AGENT_REAL"
    else
        agent_inner_path "$1"
    fi
}

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

# 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
    mapfile -t _agent_command < <(agent_exec_argv "${HOME:-/root}")
    exec "${_agent_command[@]}" \
        "${AGENT_INJECT[@]+"${AGENT_INJECT[@]}"}" "${_filtered[@]+"${_filtered[@]}"}"
fi

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

# BwrapArgvBuilder: pure-function emitting the bwrap argv. Deterministic
# given (workspace, real_claude, "$@") and ($HOME,
# $CLAUDE_SANDBOX_GITCONFIG_PATH, $CLAUDE_SANDBOX_NO_FORGE,
# $CLAUDE_SANDBOX_ALLOW_WRITE, $CLAUDE_SANDBOX_PASS_ENV — plus whatever
# variables that last one names). No I/O, no subprocesses — sourced by
# tests/bwrap_argv.sh and called in isolation.
#
# Procfs is always `--ro-bind /proc /proc` (no per-launch probe). Trade-
# off: host PIDs are enumerable from inside the sandbox (info-disclosure),
# but credential-bearing procfs entries stay gated by YAMA
# ptrace_scope=1. Kernel-level pidns isolation (kill/ptrace scoping)
# remains intact via --unshare-pid.
bwrap_argv_build() {
    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
    )

    # /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
    local rel
    for rel in "${AGENT_HOME_DIRS[@]}" .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.
    local inner; inner="$(agent_inner_path "$home")"
    if [ "$AGENT_BIND_BACK" = "1" ]; then
        argv+=( --bind "$real_agent" "$inner" )
    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

    # Egress-jail DNS override (ADR 0015, issue #60). On a stub-resolver host
    # the launch path detects that /etc/resolv.conf points only at loopback
    # resolvers (unreachable from the jail's netns) and stages a one-line
    # resolv.conf aimed at the in-netns pasta forwarder; bind it over the
    # host's so Claude queries the forwarder instead of the dead stub. Gated on
    # the env var (set only on that path) so the non-jail / routable-resolver
    # argv — and the bwrap_argv unit test — are byte-for-byte unchanged.
    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=()
    mapfile -t agent_command < <(agent_exec_argv "$home")
    argv+=( -- "${agent_command[@]}" \
        "${AGENT_INJECT[@]+"${AGENT_INJECT[@]}"}" "${user_args[@]+"${user_args[@]}"}" )
    printf '%s\n' "${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
                ;;
            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)"

    # DNS — the ONLY punch is the pasta forwarder. Claude's resolv.conf always
    # points at JAIL_DNS_FWD (jail_stage_dns), pasta answers there from inside
    # the netns and relays to the host's real resolvers from the HOST netns, so
    # the jail never needs a route to a real resolver.
    #
    # We deliberately do NOT punch /32s for the resolvers in /etc/resolv.conf
    # any more. Those were holes through the RFC1918 blackhole aimed at real
    # internal hosts, and a compromised agent can talk to whatever is on the
    # far side — a DNS server is a host like any other, reachable on every port
    # the punch allows, not just 53. Routing DNS through pasta removes that
    # surface entirely: JAIL_DNS_FWD is RFC5737 TEST-NET, non-routable, and
    # terminates in pasta rather than on the LAN. Punching it is always safe;
    # fail-soft, since a failure costs resolution, 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" ]
}

# jail_stage_dns: point Claude's DNS at the pasta forwarder (ADR 0015, issues
# #60 and #11). ALWAYS — there is one DNS path in the jail, not two. Stage a
# resolv.conf naming JAIL_DNS_FWD and export its path so bwrap_argv_build binds
# it over Claude's /etc/resolv.conf. Runs in the main flow (not the holder)
# because the bind must be staged before the argv is built.
#
# Unconditional for two reasons. Security: it lets netns_holder drop the
# per-resolver /32 punches, so no hole through the blackhole points at a real
# internal host (see the DNS block there). Correctness: pasta's --dns-forward
# implies --dns-host = the FIRST resolver in the host's resolv.conf, and pasta
# reverse-translates that host's replies to come from JAIL_DNS_FWD. Anything
# querying resolver #1 directly therefore gets a reply from an unexpected
# source, which a connected UDP socket discards — resolver #1 reads as dead
# from inside the jail. That silently broke the first resolver for every
# routable-resolver host (issue #11); routing all DNS through the forwarder
# makes the translation the intended path instead of a trap.
#
# search / domain / options are copied from the host resolv.conf: dropping them
# would break short-name resolution on sites that rely on a search list.
jail_stage_dns() {
    local f
    if ! f="$(mktemp "${TMPDIR:-/tmp}/claude-jail-resolv.XXXXXX" 2>/dev/null)"; then
        echo "claude-sandbox: egress jail — could not stage a DNS override (mktemp failed); name resolution may fail." >&2
        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
        echo "claude-sandbox: 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." >&2
    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"

    # pasta attaches from OUTSIDE the netns (it needs host connectivity to
    # proxy), configures the default route, and backgrounds itself.
    # pasta prints an early IPv6 notice to stderr *before* it opens --log-file,
    # so flags can't suppress it; redirect stderr to the same log so the terminal
    # stays clean while early-startup failures are still captured.
    # --ipv4-only (ADR 0015, H1): give the netns NO IPv6 at all. Without it pasta
    # mirrors the host's v6 connectivity (GUA/ULA/link-local) into the netns, an
    # entire address family the IPv4 routing allowlist does not cover — a lateral-
    # movement path around the v4 blackholes. The DNS forwarder JAIL_DNS_FWD is
    # IPv4 (192.0.2.53), so --dns-forward is unaffected.
    # --dns-forward makes pasta listen on JAIL_DNS_FWD inside the netns and
    # relay DNS to the host's real resolvers (issue #60). It is the ONLY way
    # Claude resolves in the jail: jail_stage_dns always points resolv.conf
    # here, and netns_holder punches no route to any real resolver.
    #
    # NOT side-effect-free, which is why nothing may query a real resolver
    # directly: --dns-forward implies --dns-host = the first resolver in the
    # host's resolv.conf, and pasta reverse-translates that host's port-53
    # replies to come from JAIL_DNS_FWD. A direct query to resolver #1 gets a
    # reply from an unexpected source and the kernel drops it on a connected
    # socket (issue #11).
    # pasta defaults to automatic port forwarding in BOTH directions and maps
    # the gateway to host loopback. Neither is part of the route allowlist:
    # -T auto would expose unrelated localhost services (and occupy our relay's
    # port), while gateway mapping reaches host services through the pinned gw.
    # Disable those shortcuts for every agent; the optional relay is explicit.
    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"
}

# check_config_persistence: warn (with keypress gate) 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/persist-login-and-memory.html\n\n' >&2
    read -rsp "Press any key to continue..." -n 1 _key </dev/tty
    printf '\n\n' >&2
}

# 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
    git_name=$(git config --get user.name 2>/dev/null || true)
    git_email=$(git config --get user.email 2>/dev/null || true)
    {
        cat <<EOF
[user]
    name = $git_name
    email = $git_email
EOF
        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
    } > "$CLAUDE_SANDBOX_GITCONFIG_PATH"
}

# 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) ;;
        *) echo "claude-sandbox: invoked as '${0##*/}', which names no known agent — assuming $AGENT_LABEL. Set CLAUDE_SANDBOX_AGENT to choose explicitly." >&2 ;;
    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

check_config_persistence

# 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
# The shipped-skills binds land inside that config dir, one per skill. Create
# the skills dir explicitly (the one deliberate host write, and only when
# something ships) rather than let bwrap mkdir its mount points on the host
# bind. A host skill of the same name would be masked by the ro bind for the
# session, so say so — the shipped copy wins by design, but not silently.
for _skill_dir in "$SHIPPED_SKILLS_DIR"/*/; do
    [ -d "$_skill_dir" ] || continue
    mkdir -p "${HOME:-/root}/$AGENT_SKILLS_REL"
    _skill_dir="${_skill_dir%/}"
    if [ -e "${HOME:-/root}/$AGENT_SKILLS_REL/${_skill_dir##*/}" ]; then
        echo "claude-sandbox: ~/$AGENT_SKILLS_REL/${_skill_dir##*/} is shadowed by the shipped skill for this session." >&2
    fi
done

render_gitconfig

parse_config "$CONFIG_PATH"
# Stage the jail's DNS override (issue #60) before the argv is built — on a
# stub-resolver host this exports CLAUDE_SANDBOX_JAIL_RESOLV so bwrap_argv_build
# binds a forwarder-pointed resolv.conf. No-op when the jail is off or routable
# resolvers exist (keeps the office path byte-for-byte unchanged).
if egress_jail_enabled; then
    jail_stage_dns
fi
WORKSPACE_ROOT="$(resolve_workspace_root "$PWD")"
mapfile -t ARGV < <(bwrap_argv_build "$WORKSPACE_ROOT" "$AGENT_REAL" "$@")

# 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_CMD="$(printf '%q ' "${ARGV[@]}")"
if egress_jail_enabled; then
    # ADR 0015 (on by default): run the sandbox inside a pasta-bridged netns
    # with a routing allowlist. netns_launch cannot `exec` — it must stay
    # foreground to drive the holder/pasta handshake — so it exits with the
    # sandbox's status. Disable per host with CLAUDE_SANDBOX_EGRESS_JAIL=0.
    netns_launch script -q -E never -c "$SANDBOX_CMD" /dev/null
else
    exec script -q -E never -c "$SANDBOX_CMD" /dev/null
fi
