#!/usr/bin/env bash
# Shadow `claude` binary. Wraps the real Claude in a bwrap sandbox so
# every `claude` invocation on $PATH runs sandboxed.
#
# 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

# Real Claude lives off the user's PATH so plain `claude` always
# resolves to /usr/local/bin/claude (this shadow). The official
# claude.ai installer drops the binary at ~/.local/bin/claude AND
# prepends ~/.local/bin to the user's shell rc; the installer
# relocates it here so that rc-mutation becomes inert.
REAL_CLAUDE="/usr/libexec/claude-sandbox/claude"
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"

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

# 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. Inside the sandbox the real binary is
# bind-mounted at ~/.local/bin/claude — exec that path so argv[0]
# matches the conventional install location. --no-chrome injection
# (see filter_chrome_args below) 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
    _filtered=()
    for _a in "$@"; do
        case "$_a" in --chrome) ;; *) _filtered+=( "$_a" ) ;; esac
    done
    exec "${HOME:-/root}/.local/bin/claude" --no-chrome "${_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). 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_claude="$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 .claude .cache "${forge_rels[@]+"${forge_rels[@]}"}"; do
        if [ -d "$home/$rel" ]; then
            argv+=( --bind "$home/$rel" "$home/$rel" )
        fi
    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 .claude.json .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 conventional ~/.local/bin/claude location so
    # Claude's self-inspection and any internal claude-spawns-claude
    # path lookups see the path they expect. Unconditional — the
    # shadow's loud-fail upstream catches a missing real binary.
    argv+=( --bind "$real_claude" "$home/.local/bin/claude" )

    if [ -n "$workspace" ] && [ -d "$workspace" ]; then
        argv+=( --bind "$workspace" "$workspace" )
    fi
    if [ -n "${CLAUDE_SANDBOX_ALLOW_WRITE:-}" ]; then
        while IFS= read -r extra_path; do
            [ -z "$extra_path" ] && continue
            if [ -d "$extra_path" ] || [ -f "$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

    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" )
    argv+=( --setenv GIT_CONFIG_GLOBAL "$gitconfig_path" )
    argv+=( --setenv GIT_CONFIG_SYSTEM "/dev/null" )
    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 PRE_COMMIT_HOME \
        CLAUDE_SANDBOX_WORKSPACE_ROOT; do
        if [ -n "${!pass_through_var:-}" ]; then
            argv+=( --setenv "$pass_through_var" "${!pass_through_var}" )
        fi
    done

    # Disable the Chrome browser-extension RPC channel: strip any
    # user-supplied --chrome so it can't override our --no-chrome
    # injection. 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=()
    local user_arg
    for user_arg in "$@"; do
        case "$user_arg" in
            --chrome) ;;
            *) user_args+=( "$user_arg" ) ;;
        esac
    done

    # Exec via the in-sandbox conventional path so Claude's argv[0]
    # matches what the official installer would have placed.
    argv+=( -- "$home/.local/bin/claude" --no-chrome "${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
                ;;
            allow-write)
                [ -z "$value" ] && continue
                CLAUDE_SANDBOX_ALLOW_WRITE="${CLAUDE_SANDBOX_ALLOW_WRITE:+${CLAUDE_SANDBOX_ALLOW_WRITE}
}${value}"
                export CLAUDE_SANDBOX_ALLOW_WRITE
                ;;
        esac
    done < "$conf"
}

# 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

# Loud-fail when the real binary is missing — clearer than the
# downstream errno from bwrap exec'ing a non-existent path.
if [ ! -x "$REAL_CLAUDE" ]; then
    echo "claude-sandbox: real Claude binary missing at $REAL_CLAUDE." >&2
    echo "  Re-run \`./install\` from a fresh clone of claude-sandbox." >&2
    exit 1
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 ~/.claude.json exists for the bind-back. Without this, Claude
# Code's OAuth token writes into the in-sandbox tmpfs and vanishes on
# exit. touch is a no-op on an existing file apart from mtime.
touch "${HOME:-/root}/.claude.json"

# Refresh /etc/claude-gitconfig from the host's current git identity on
# every launch. VS Code's dev.containers.copyGitConfig fires AFTER
# postCreate so the install-time render can have empty user.name. By
# the time the user types `claude`, copyGitConfig has run — re-render
# here so the [user] block always reflects the current host identity.
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"

parse_config "$CONFIG_PATH"
WORKSPACE_ROOT="$(resolve_workspace_root "$PWD")"
mapfile -t ARGV < <(bwrap_argv_build "$WORKSPACE_ROOT" "$REAL_CLAUDE" "$@")

# 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).
exec script -q -E never -c "$(printf '%q ' "${ARGV[@]}")" /dev/null
