#!/bin/bash
# Launch the dejima container, with a project directory bind-mounted
# at its real host path. Defaults to running bare `claude` inside (with
# --dangerously-skip-permissions); set [core] command in the config file,
# or pass --cmd, to run something else instead (see README).
#
# Usage: dejima [path-to-project] [-- extra args...]
#   dejima                      # sandbox the current directory
#   dejima ~/repos/myproject
set -euo pipefail

# --help, before anything else runs.
#
# Deliberately ahead of the library sourcing and the config parse below: the
# moment someone reaches for --help is often the moment the config is missing
# or wrong, and a help page that needs a working config to print is no help.
# Nothing here but `cat`.
#
# stdout and exit 0, unlike usage(), which stays on stderr with a nonzero exit
# so a mistyped invocation still fails a script. That split is the whole of
# what #64 asked for: bare invocation keeps its terse line, --help explains.
case "${1:-}" in
    --version)
        # Before any config is read, like --help: the moment someone asks
        # which release they are on is often the moment something else is
        # wrong. The version library and nothing else - it is pure functions
        # with no config and no other library behind it, so there is one
        # implementation of this rather than an inline copy that can drift
        # from the one the banner and the update notice use.
        _v_dir="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
        # shellcheck source=dejima-version-lib.sh
        source "$_v_dir/dejima-version-lib.sh"
        _v_root="$(cd "$_v_dir/.." && pwd)"
        _v="$(dejima_version "$_v_root")"
        case "$_v" in
            unknown)    echo "dejima unknown (not a git checkout)" ;;
            unreleased) echo "dejima unreleased (no release tag in this checkout)" ;;
            *+*)        echo "dejima $_v (${_v#*+} commits past ${_v%%+*})" ;;
            *)          echo "dejima $_v" ;;
        esac
        echo ""
        echo "What each release changed: $_v_root/CHANGELOG.md"
        echo "Only the tip of main is supported - see SECURITY.md."
        exit 0 ;;
    -h|--help)
        cat <<'DEJIMA_HELP'
dejima - run an agent harness in a container that cannot reach your machine

Usage: dejima [options] [workspace] [-- args...]

  workspace    project directory to mount read-write (default: current directory)
  -- args...   everything after is passed to the harness command untouched

Options (each overrides the config file for one launch only):
  --harness <name>        which harness to run
  --forge <name>          which forge integration to use
  --workflow <name>       what the agent is told to do with its work
  --cmd <command>         run this instead of the harness's own command
  --identity <name>       which [identity.<name>] supplies the credentials
  -i, --include <tool>    add an optional tool to this launch (repeatable);
                          see docs/tools.md for what dejima ships
  --backend <name>        the selected harness's model API, model and context
  --model <id>            budget, each refused for a harness with no such
  --context-window <n>    setting
  --ro <path>             mount read-only; --rw read-write. Repeatable, long
                          forms --extra-readonly/--extra-read-write, and both
                          refuse a path overlapping dejima's own stores
  --allow-github-push     open GitHub's push path (port 22)
  --strict-audit          refuse to start over unacknowledged CRITICAL findings
  --no-log                ship nothing to the log endpoint this launch
  --no-conversation       keep this session's transcript out of the log
  --no-epitaph            keep nothing if this session compacts
  --no-firewall           disable the egress firewall entirely (debugging only)
  --no-pause              do not wait for a keypress before the image build
  --isolated              no project, no memory, no forge; keeps only ~/shared
  --print-config          print every key as it resolves, and exit
  --print-briefing        print the briefing the agent will get, and exit
  -h, --help              this message; --version prints this release

Every --no-* flag only ever narrows; a config that says off is a decision.

Config lives in ~/.config/dejima/config; [core] harness must be set there.
DEJIMA_HELP
        exit 0 ;;
esac

# readlink -f resolves through the ~/.local/bin/dejima symlink
# install.sh sets up - without it, BASH_SOURCE[0] is the symlink's own path
# when invoked by name via PATH, so dirname finds ~/.local/bin instead of
# this repo's real bin/, and everything below (REPO_ROOT, COMPOSE_FILE)
# resolves against the wrong directory entirely.
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
REPO_ROOT="$SCRIPT_DIR/.."
COMPOSE_FILE="$REPO_ROOT/docker/docker-compose.yml"
# shellcheck source=dejima-project-lib.sh
source "$SCRIPT_DIR/dejima-project-lib.sh"
# shellcheck source=dejima-provider-lib.sh
source "$SCRIPT_DIR/dejima-provider-lib.sh"
# shellcheck source=dejima-color-lib.sh
source "$SCRIPT_DIR/dejima-color-lib.sh"
# shellcheck source=dejima-slug-lib.sh
source "$SCRIPT_DIR/dejima-slug-lib.sh"
# shellcheck source=dejima-harness-lib.sh
source "$SCRIPT_DIR/dejima-harness-lib.sh"
# shellcheck source=dejima-version-lib.sh
source "$SCRIPT_DIR/dejima-version-lib.sh"
# fd 2: everything this script says for itself goes to stderr, so the
# session's own stdout stays exactly what the container wrote.
color_init 2

# Parse the config file here, in THIS shell, before anything reads a key.
#
# The parse is already cached (_CFG_PARSED_PATH), but almost every reader
# reaches it through `$(config_get ...)`, and a cache filled inside a command
# substitution is discarded with the subshell that filled it. Nothing on the
# launch path populated it in the parent, so each of those subshells re-read
# and re-parsed the whole file - dozens of times per launch. Doing it once
# here means every subshell inherits it already parsed.
#
# Failure is deliberately not fatal here: this is a warm-up, not the
# validation, and the callers that already refuse a bad config still do.
#
# But a failed warm must not CONSUME the diagnosis. _config_parse prints the
# first problem it finds as it parses, and the status is cached - so leaving a
# failure cached let the real caller return that status with the message
# already swallowed by the redirect above, and an invalid config was refused
# with no reason given. Dropping the cache entry makes that caller parse again
# and report, which costs one extra parse of a config that is about to be
# refused anyway.
if ! config_validate >/dev/null 2>&1; then
    # Dropping the cache alone left the reporting to whoever parsed next, and
    # that is usually a SUBSHELL - `$(config_get ...)` - whose cache dies with
    # it. So the next parsing context reported it too, and an invalid config
    # printed its reason once per context: twice for an ordinary launch, and
    # ten lines each once a message grew a hint. Same shape as #284.
    #
    # Re-parsed here instead, unsuppressed, in this shell: the reason is
    # printed exactly once and the failed status is cached where every
    # subshell inherits it. Deliberately not an exit - flags are not parsed
    # yet, and `--help` on a broken config should still answer.
    _CFG_PARSED_PATH=""
    config_validate || true
fi

# Flags come first, then the optional workspace path, then "--" and
# everything after that reaches the container command untouched - including a
# token that looks like one of our own flags. The "--" must come AFTER the
# workspace path: a bare "--" here, before any positional argument, is still
# consumed by this loop, so `dejima -- --resume` treats --resume as the
# workspace ("Not a directory: --resume") rather than passing it through.
# Each flag's value is validated against the same schema type its config key
# uses, so `--cmd 'rm -rf /'` fails here for the same reason it would fail in
# the file.
FLAG_OVERRIDES=()
_set_override() {
    local key="$1" value="$2" type entry
    # $3 is the flag to blame, for the flags whose spelling is not their key's:
    # --context-window sets context_window, and reporting the key back would
    # name an option the operator cannot type.
    local flag="${3:---${1##*.}}"
    local -a kept=()
    type="$(config_type "$key")" || { echo "unknown config key: $key" >&2; exit 1; }
    if ! _config_valid_value "$type" "$value"; then
        echo "invalid value for $flag: $value" >&2
        exit 1
    fi
    # A repeated flag takes the last value, same as a repeated key in the
    # config file - drop any earlier override for this key before appending.
    for entry in ${FLAG_OVERRIDES[@]+"${FLAG_OVERRIDES[@]}"}; do
        [ "${entry%%=*}" = "$key" ] || kept+=("$entry")
    done
    FLAG_OVERRIDES=(${kept[@]+"${kept[@]}"} "$key=$value")
}

# Whether a flag has already set $1, for the defaults --isolated applies after
# the loop. Flag order on the command line is arbitrary, so this is asked
# rather than inferred from when a flag ran: `--isolated --forge <name>` and
# `--forge <name> --isolated` have to mean the same thing.
_override_set() {
    local key="$1" entry
    for entry in ${FLAG_OVERRIDES[@]+"${FLAG_OVERRIDES[@]}"}; do
        [ "${entry%%=*}" = "$key" ] || continue
        return 0
    done
    return 1
}
_need_value() {
    [ -n "${2:-}" ] && [ "${2:0:2}" != "--" ] || {
        echo "$1 requires a value" >&2
        exit 1
    }
}

# Per-launch additions to [mounts] readonly and [mounts] read_write, for the
# one-off cases: reading another checkout while migrating code out of it,
# writing to a scratch directory for one session. Repeatable, one path each.
#
# These append to the config's own lists rather than carrying their own mount
# code, which is the whole design. Everything that already applies to a
# configured mount then applies to a flag automatically and with nothing
# duplicated: the existence check, the refusal when a path names the approval
# store or the audit stores, the refusal when the same path appears in both
# lists, and the read-write paths the session briefing and the audit report
# header name. A flag is a way to say where, not a way around any of that.
EXTRA_RO_FLAGS=()
EXTRA_RW_FLAGS=()
FLAG_MOUNTS=""
_add_mount_flag() {
    local flag="$1" value="$2"
    # Both lists are colon-separated, so a path containing one cannot be
    # represented in them at all - it would silently split into two mounts
    # that each fail the existence check. Say so instead.
    case "$value" in
        *:*) echo "$flag path must not contain a colon: $value" >&2; exit 1 ;;
    esac
    # Same leading-~ expansion the config's list:path type does, for the
    # quoted case the shell leaves alone.
    value="${value/#\~/$HOME}"
    # Relative paths are worth accepting here and meaningless in a config
    # file, which has no working directory to be relative to. Made absolute
    # by prefixing rather than by readlink -f: resolving would mount the
    # symlink's target under a path the operator never typed.
    case "$value" in /*) ;; *) value="$PWD/$value" ;; esac
    case "$flag" in
        --extra-readonly)   EXTRA_RO_FLAGS+=("$value") ;;
        --extra-read-write) EXTRA_RW_FLAGS+=("$value") ;;
    esac
    # Recorded here, at parse time, and not where the lists are assembled:
    # that happens inside a command substitution, whose subshell would throw
    # the assignment away and leave every flag reported as a config entry.
    FLAG_MOUNTS="${FLAG_MOUNTS:+$FLAG_MOUNTS:}$value"
}
_append_mounts() {
    local list="$1"; shift
    local p
    for p in "$@"; do
        list="${list:+$list:}$p"
    done
    printf '%s' "$list"
}
# Which of the two the operator actually wrote, for the refusals below.
# Without this a mistyped --ro is reported as a "[mounts] readonly entry",
# sending someone to a config file that does not mention the path at all.

_mount_label() {
    local path="$1" from_config="$2" from_flag="$3"
    case ":$FLAG_MOUNTS:" in
        *":$path:"*) printf '%s' "$from_flag" ;;
        *)           printf '%s' "$from_config" ;;
    esac
}

# A flag naming one of the SELECTED harness's keys, deferred because which
# harness is selected is not known until every flag has been read - --harness
# may come after --model. Recorded here, resolved below the loop.
#
# Deferred rather than resolved eagerly so the error can name the harness:
# "claude-code has no model setting" is actionable where "unknown config key"
# is not.
HARNESS_KEY_FLAGS=()
_defer_harness_key() {
    HARNESS_KEY_FLAGS+=("$1=$2=$3")
}

PRINT_CONFIG=0
PRINT_BRIEFING=0
ALLOW_GITHUB_PUSH_FLAG=0
# Additive, like --ro and --rw and unlike every other flag here: an optional
# tool asked for on the command line is one MORE than the config and the
# project already agreed on, not a replacement for them. Repeatable.
TOOL_FLAGS=()
NO_LOG=0
# Launch-scoped, like NO_LOG: initialised here so an inherited environment
# variable of the same name cannot decide it.
NO_PAUSE=0
# A session with nothing before it and nothing after it: no project mounted,
# no memory, no transcript, and a ~/shared of its own that no other session
# has written to or will read. What survives it is that directory and the
# branches it pushed.
#
# A flag and no config key, deliberately. Every other narrowing here has a
# [section] key behind it because a machine can sensibly prefer it always;
# this one changes what the session IS rather than how much it may do, and a
# file that silently decided it would make the next launch's answer to "where
# is my work" depend on a line nobody reread.
DEJIMA_ISOLATED=0
# Whether the operator wrote `--`, so an isolated launch can tell a project
# path it must refuse from harness arguments it must pass through. Both arrive
# here as positionals once the loop breaks.
DEJIMA_ARGS_ONLY=0
while [ $# -gt 0 ]; do
    case "$1" in
        --) DEJIMA_ARGS_ONLY=1; shift; break ;;
        --print-config)      PRINT_CONFIG=1; shift ;;
        --print-briefing)    PRINT_BRIEFING=1; shift ;;
        --no-firewall)       _set_override core.firewall off; shift ;;
        --allow-github-push) ALLOW_GITHUB_PUSH_FLAG=1; shift ;;
        # Narrowing flags. Each has no counterpart that turns the setting back
        # on: a config saying off is a decision, and a flag that could undo it
        # in passing would make the file's answer the weaker one.
        --strict-audit)      _set_override audit.strict on; shift ;;
        --no-epitaph)        _set_override epitaph.enabled off; shift ;;
        --no-conversation)   _set_override logging.conversation off; shift ;;
        # Not a config override: this is about the terminal in front of THIS
        # launch, not about what dejima should do generally. Someone starting
        # eight sessions from a tmuxinator layout wants every one of them to
        # reach its prompt, and does not want that recorded as a preference
        # for the launch they run by hand tomorrow.
        --no-pause)          NO_PAUSE=1; shift ;;
        --isolated)          DEJIMA_ISOLATED=1; shift ;;
        --no-log)            _set_override logging.loki_url ""
                             _set_override logging.conversation off
                             NO_LOG=1; shift ;;
        --model)          _need_value --model "${2:-}"
                          _defer_harness_key --model model "$2"; shift 2 ;;
        --context-window) _need_value --context-window "${2:-}"
                          _defer_harness_key --context-window context_window "$2"; shift 2 ;;
        # Deferred like the three above, and for the extra reason that this one
        # is per harness by design: which provider a launch uses is the
        # harness's setting, so "claude-code has no provider setting" is the
        # error a harness that declares none should give.
        --backend)        _need_value --backend "${2:-}"
                          _defer_harness_key --backend backend "$2"; shift 2 ;;
        --cmd)      _need_value --cmd "${2:-}";      _set_override core.command "$2";  shift 2 ;;
        --harness)  _need_value --harness "${2:-}";  _set_override core.harness "$2";  shift 2 ;;
        --identity) _need_value --identity "${2:-}"; _set_override core.identity "$2"; shift 2 ;;
        --include|-i)
            _need_value --include "${2:-}"
            TOOL_FLAGS+=("$2"); shift 2 ;;
        --forge)    _need_value --forge "${2:-}";    _set_override core.forge "$2";    shift 2 ;;
        --workflow) _need_value --workflow "${2:-}"; _set_override core.workflow "$2"; shift 2 ;;
        --extra-readonly|--ro)
            _need_value --extra-readonly "${2:-}"
            _add_mount_flag --extra-readonly "$2"; shift 2 ;;
        --extra-read-write|--rw)
            _need_value --extra-read-write "${2:-}"
            _add_mount_flag --extra-read-write "$2"; shift 2 ;;
        --*) echo "unknown option: $1" >&2; exit 1 ;;
        *) break ;;
    esac
done

# An isolated launch reaches no forge and is not told to push, unless this
# command says otherwise.
#
# The flag narrows, like every other one here, and the forge credential is the
# widest thing a session gets: a forge token is commonly account-wide rather
# than per repository, so a session holding one can read and write everything
# the operator can. Carrying that by default into the mode whose purpose is a
# session you are not watching is the wrong way round - the host is narrowed
# to nothing and the forge is left at full reach.
#
# Applied after the loop rather than in it, so `--isolated --forge <name>` and
# `--forge <name> --isolated` agree: naming either on the same command line is
# how an operator asks for a session that clones and opens a pull request,
# which is a real use of this mode and not something to make impossible.
#
# A configured ssh key is untouched, because an isolated launch is not where
# that requirement lives - so a session with no forge still holds a key that
# can reach a host over ssh (#392). The forwarded agent is not in the same
# position: the workflow this sets pushes nothing, and a workflow that never
# pushes already gets no agent socket.
#
# And the two things that are off because nothing would read what they
# produce: an epitaph and a conversation log are both read out of a
# transcript on the host, and this mode mounts none. Set as overrides rather
# than checked at each of the four places that would otherwise ask - the
# banner, --print-config, the shipper's start and the epitaph's save - so
# that every one of them gives the same answer.
if [ "$DEJIMA_ISOLATED" = 1 ]; then
    _override_set core.forge    || _set_override core.forge none
    _override_set core.workflow || _set_override core.workflow local-only
    _override_set epitaph.enabled      || _set_override epitaph.enabled off
    _override_set logging.conversation || _set_override logging.conversation off
fi

# Refused rather than dropped. A configured mount is a standing preference and
# an isolated launch simply does not apply it, with a line saying so; --ro and
# --rw are the operator typing a path in this command, and the only two honest
# answers to "mount this, in a session that mounts nothing" are to refuse or
# to contradict them.
if [ "$DEJIMA_ISOLATED" = 1 ] && [ -n "$FLAG_MOUNTS" ]; then
    echo "--isolated mounts nothing of the host, so it cannot take --ro or --rw:" >&2
    # One per line, read rather than word-split: a path may contain a space,
    # and the list is colon-separated precisely because it may not contain one
    # of those.
    while IFS= read -r _m; do
        [ -n "$_m" ] || continue
        printf '  %s\n' "$_m" >&2
    done < <(printf '%s\n' "$FLAG_MOUNTS" | tr ':' '\n')
    echo "" >&2
    echo "Hand the session what it needs through ~/shared, which is the one" >&2
    echo "directory an isolated session shares with you." >&2
    exit 1
fi

# Wraps config_get so a flag wins over the file for the rest of this run.
# Defined here rather than in the library because an override is a property
# of one invocation of this launcher, not of the config file.
config_get() {
    local key="$1.$2" entry value
    for entry in ${FLAG_OVERRIDES[@]+"${FLAG_OVERRIDES[@]}"}; do
        [ "${entry%%=*}" = "$key" ] || continue
        printf '%s' "${entry#*=}"
        return 0
    done
    # The selected identity, for the handful of keys an identity owns: the
    # credentials and the ssh key a PERSON brings, which is the half a shared
    # project file cannot carry. Below a flag, because a flag is this launch
    # and an identity is this project; above the file, because the file is the
    # machine's default and naming an identity is how you say "not that one".
    #
    # Every consumer goes through config_get, including forge_env_exports in
    # the library, so this one wrapper is the whole insertion point.
    if value="$(identity_get "$1" "$2")"; then
        printf '%s' "$value"
        return 0
    fi
    _config_get_from_file "$1" "$2"
}

# The deferred harness-key flags, now that --harness has been read and
# config_get honours it. A key the selected harness does not declare is
# refused by name rather than ignored: a --model that silently did nothing
# because the harness has no such setting is the failure worth avoiding, and
# it is the whole reason these are not resolved inside the loop above.
if [ "${#HARNESS_KEY_FLAGS[@]}" -gt 0 ]; then
    _flag_harness="$(config_get core harness)"
    for _entry in "${HARNESS_KEY_FLAGS[@]}"; do
        _flag="${_entry%%=*}"
        _rest="${_entry#*=}"
        _key="${_rest%%=*}"
        _value="${_rest#*=}"
        if [ -z "$_flag_harness" ]; then
            echo "$_flag needs a harness: set [core] harness or pass --harness." >&2
            exit 1
        fi
        if ! config_type "harness.$_flag_harness.$_key" >/dev/null 2>&1; then
            echo "the $_flag_harness harness has no '$_key' setting, so $_flag does not apply to it." >&2
            exit 1
        fi
        _set_override "harness.$_flag_harness.$_key" "$_value" "$_flag"
    done
    unset _flag_harness _entry _flag _rest _key _value
fi

# Retired environment variables. Named, never honoured: silently ignoring an
# exported LOKI_URL means logs stop shipping with nothing saying why, and
# silently ignoring SANDBOX_ANTHROPIC_TRANSPORT means a session quietly gets
# a different egress path than the operator configured. Both are the class of
# surprise this repo spends its warnings on.
_warn_legacy_env() {
    local pair name dest shown=0
    for pair in \
        "GITEA_SSH_HOST=[forge.gitea] ssh_host" \
        "GITEA_API=[forge.gitea] api" \
        "GITEA_TOKEN_FILE=[forge.gitea] token_file" \
        "GITEA_TOKEN=[forge.gitea] token_file (set the path, not the token)" \
        "GH_TOKEN_FILE=[github] token_file" \
        "GH_TOKEN=[github] token_file (set the path, not the token)" \
        "SANDBOX_CMD=[core] command" \
        "SANDBOX_SSH_KEY=[core] ssh_key" \
        "SANDBOX_SHARE_DIR=[core] share_dir" \
        "SANDBOX_EXTRA_MOUNTS=[mounts] readonly" \
        "SANDBOX_NVIM_CONFIG=[mounts] nvim_config" \
        "SANDBOX_CREDENTIAL_PROXY=[credentials] proxy" \
        "SANDBOX_ANTHROPIC_TRANSPORT=[credentials] transport" \
        "SANDBOX_LOG_CONVERSATION=[logging] conversation" \
        "LOKI_URL=[logging] loki_url" \
        "ENABLE_FIREWALL=[core] firewall" \
        "SSH_KNOWN_HOSTS=[core] known_hosts" \
        "ALLOW_GITHUB_PUSH=the --allow-github-push flag" \
        "DEJIMA_AUDIT_DIR=[audit] dir" \
        "DEJIMA_AUDIT_BASELINE_DIR=[audit] baselines_dir" \
        "DEJIMA_AUDIT_KEEP=[audit] keep" \
        "DEJIMA_AUDIT_STRICT=[audit] strict" \
        "DEJIMA_AUDIT_DIFFS=[audit] diffs" \
        "DEJIMA_AUDIT_STATUSLINE=[harness.claude-code] statusline_path" \
        "DEJIMA_AUDIT_PROJECTS_DIR=[harness.claude-code] projects_dir" \
        "DEJIMA_DEBOUNCE_SECONDS=[remote-access] debounce" \
    ; do
        name="${pair%%=*}"
        dest="${pair#*=}"
        [ -n "${!name:-}" ] || continue
        if [ "$shown" = 0 ]; then
            echo "" >&2
            echo "These environment variables are no longer read by dejima:" >&2
            shown=1
        fi
        printf '  %s -> %s\n' "$name" "$dest" >&2
    done
    if [ "$shown" = 1 ]; then
        echo "Set them in $(config_path) instead, then unset them in your shell profile." >&2
        DEJIMA_STARTUP_NOTICE=1
    fi

    # GITEA_TOKEN/GITEA_SSH_HOST/GITEA_API are retired (named above) -
    # forge_env_exports computes their replacements fresh from the manifest
    # further down and passes them as -e flags, but a value already exported
    # by the operator's shell is otherwise inherited by the docker subprocess
    # unchanged since nothing else reassigns these three names. Cleared, not
    # just unread, for the same reason the old hardcoded GITEA_TOKEN
    # computation started from "" rather than the inherited value. These
    # three names, specifically, are the only environment variables dejima
    # ever read directly for forge configuration before the manifest schema
    # replaced them - they name no CURRENT forge selection, only that one
    # retired path, so unlike the rest of this file they stay fixed regardless
    # of which forge is configured or added later.
    unset GITEA_TOKEN GITEA_SSH_HOST GITEA_API
}
_warn_legacy_env

if ! config_exists; then
    echo "No dejima config at $(config_path)." >&2
    echo "Run ./install.sh to generate a starter one, then edit it." >&2
    exit 1
fi


# A forge's env_export can name a variable core also fills - today GH_TOKEN,
# which core sets from [github] token_file for every session regardless of
# forge. One variable carries one token, so the forge's wins. Warned rather
# than left silent: an operator who configured both is entitled to know which
# is in effect. Not a refusal, since the forge winning is what selecting it
# asked for.
#
# Driven off env_export rather than the forge's name - the collision belongs
# to the variable, not to any particular forge. Checked here, ahead of
# --print-config's exit, so that reports it too. Both values must actually be
# set: a forge key resolving to nothing leaves core's in effect, and claiming
# the forge won would be false.
_warn_forge_env_collisions() {
    local forge="$1" pair name ref core core_name core_section core_leaf
    local core_file forge_file
    for pair in $(manifest_get forge "$forge" env_export); do
        name="${pair%%=*}"
        ref="${pair#*=}"
        # Only the "@" form (the CONTENTS of the file a config key names) can
        # carry one of core's own credentials.
        [ "${ref:0:1}" = "@" ] || continue
        # NAME=<core section> <core key>, for every variable core fills from
        # the config file itself.
        # A one-row table, not a loop that happens to run once: another
        # core-filled variable is a line here rather than a new branch.
        # shellcheck disable=SC2043
        for core in "GH_TOKEN=github token_file"; do
            core_name="${core%%=*}"
            [ "$name" = "$core_name" ] || continue
            core_section="${core#*=}"
            core_leaf="${core_section##* }"
            core_section="${core_section%% *}"
            core_file="$(config_get "$core_section" "$core_leaf")"
            forge_file="$(config_get "forge.$forge" "${ref:1}")"
            [ -f "$core_file" ] && [ -s "$core_file" ] || continue
            [ -f "$forge_file" ] && [ -s "$forge_file" ] || continue
            echo "Note: [$core_section] $core_leaf is not used while [core] forge = $forge." >&2
            echo "$name comes from [forge.$forge] ${ref:1} for this launch." >&2
        done
    done
}
# Guarded on config_get succeeding: an invalid [core] forge already made
# config_get print the real error (via config_validate) and return empty,
# so feeding that empty value on to _warn_forge_env_collisions would only
# add a second, spurious "invalid forge name:" line on top of it.
if _core_forge="$(config_get core forge)"; then
    _warn_forge_env_collisions "$_core_forge"
fi

# --print-config resolves the whole precedence chain and exits, before any
# workspace argument is required and before anything is built or mounted.
# It exists for two reasons: it answers "one config file now - what is
# actually in effect?", and it is what makes the flag-beats-config chain
# testable without booting Docker.
if [ "$PRINT_CONFIG" = 1 ]; then
    config_validate || exit 1
    # Before printing, not only before launching. Every value below resolves
    # THROUGH the identity, so printing them under a name that resolves to
    # nothing would show this machine's own credentials as though they were
    # the identity's - the exact confusion the refusal exists to prevent, in
    # the one command an operator runs to check what they configured.
    # The forge is read here rather than taken from DEJIMA_FORGE_NAME, which
    # is resolved further down, past this early exit.
    identity_validate "$(config_get core forge)" || exit 1
    # key=value, and deliberately nothing else. config_origin would answer
    # which of the three layers gave each value, and appending it as a second
    # column broke eleven callers in this repo alone - `grep -qx key=value` is
    # the obvious way to read this output, and one every one of them used. A
    # format that has callers is one to extend on purpose, not in passing.
    while IFS= read -r _key; do
        _section="${_key%.*}"
        _leaf="${_key##*.}"
        printf '%s=%s\n' "$_key" "$(config_get "$_section" "$_leaf")"
    done < <(_config_keys)
    exit 0
fi

# Pin this release's defaults if nothing has yet, so a later one cannot move a
# setting the operator never chose. One line, and only on the launch that
# creates the file - see config_defaults_ensure for why a launch does no more
# than that.
#
# After --print-config's exit above, deliberately. That flag is the command an
# operator runs to INSPECT their configuration, and a query that creates a file
# as a side effect is not one. Nothing is lost by waiting: on a machine with no
# pinned file every key resolves through the schema, which is exactly the value
# the first real launch is about to pin.
config_defaults_ensure

# What the pinned file holds that this dejima no longer understands: keys it
# has retired since the file was written. Said once, with the command that
# rebuilds it - the values themselves still resolve, through the schema, so
# this is a thing to tidy rather than a launch to stop.
if [ "$(config_defaults_stale)" -gt 0 ]; then
    printf '\n  %s%s has %s line(s) this dejima no longer reads.%s\n' \
        "${C_WARN:-}" "$(config_defaults_path)" "$(config_defaults_stale)" "${C_RESET:-}" >&2
    printf '  They are keys it has retired. Run %s./install.sh%s to rebuild the file.\n' \
        "${C_KEY:-}" "${C_RESET:-}" >&2
    DEJIMA_STARTUP_NOTICE=1
fi

# The selected forge/workflow/harness combination, resolved and validated
# before anything is built or mounted - see provider_validate_selection's own
# definition for the full set of rules it enforces, all as refusals.
DEJIMA_FORGE_NAME="$(config_get core forge)"
DEJIMA_WORKFLOW_NAME="$(config_get core workflow)"
DEJIMA_HARNESS_NAME="$(config_get core harness)"
# No default: claude-code was one, and a release changing it would move an
# operator who never chose to a different harness on upgrade.
if [ -z "$DEJIMA_HARNESS_NAME" ]; then
    echo "[core] harness is not set in $(config_path)." >&2
    echo "There is no default, so a later release cannot change your choice." >&2
    echo "" >&2
    echo "  [core]" >&2
    echo "  harness = claude-code" >&2
    echo "" >&2
    echo "Available: $(provider_list harness | tr '\n' ' ')" >&2
    exit 1
fi
provider_validate_selection "$DEJIMA_FORGE_NAME" "$DEJIMA_WORKFLOW_NAME" "$DEJIMA_HARNESS_NAME" || exit 1


# The selected harness's plugin: what dejima does for this harness that no
# manifest value could state. Loaded once the selection is validated, and
# before anything that calls a hook. A manifest without a plugin is refused
# rather than treated as a lighter kind of support - see "Adding a new
# harness" in docs/development.md.
harness_plugin_load "$DEJIMA_HARNESS_NAME"

# Whether GitHub is a push destination this session, which the briefing tells
# the agent about. Two ways in: the per-launch flag, or a forge whose hosts
# core allowlists (github) under a workflow that pushes. Derived from the
# manifests directly rather than from DEJIMA_WORKFLOW_PUSH, which is resolved
# further down - this has to be settled before --print-briefing exits, or that
# would render a briefing a real launch does not.
DEJIMA_GITHUB_PUSH=off
if [ "$ALLOW_GITHUB_PUSH_FLAG" = 1 ]; then
    DEJIMA_GITHUB_PUSH=on
elif [ "$(manifest_get forge "$DEJIMA_FORGE_NAME" hosts_allowlisted_by_core)" = "on" ] \
     && [ "$(manifest_get workflow "$DEJIMA_WORKFLOW_NAME" push)" = "on" ]; then
    DEJIMA_GITHUB_PUSH=on
fi
export DEJIMA_GITHUB_PUSH

# Resolved here, ahead of --print-briefing below, because briefing_compose
# needs DEJIMA_RW_MOUNTS (the {{writable_mounts}} placeholder) to render the
# same sentence a real launch renders - the read_write mounts block further
# down only turns this already-validated list into -v flags rather than
# re-deriving it. WORKSPACE_DIR,
# AUDIT_DIR, BASELINE_DIR and SANDBOX_EXTRA_MOUNTS are pure config reads (or,
# for WORKSPACE_DIR, a default-to-pwd with an existence check) - no side
# effects a print-and-exit needs to avoid.
if [ "$DEJIMA_ISOLATED" = 1 ]; then
    # Refused rather than ignored. A path here would be read as the project
    # this session is for, and nothing afterwards would contradict that - the
    # directory simply would not be there. The operator may be anywhere;
    # saying so is the point of the flag.
    if [ "$DEJIMA_ARGS_ONLY" = 0 ] && [ $# -gt 0 ]; then
        echo "--isolated takes no project directory, and would not mount $1." >&2
        echo "Run it from anywhere: the working directory is the container's own." >&2
        exit 1
    fi
    # Container-local, and outside $HOME on purpose - a handful of host-side
    # reads below take this path (an origin URL for the forge sentence, the
    # self-hosting check), and a path an operator might plausibly have would
    # have them read a real directory of theirs. Created in the image.
    WORKSPACE_DIR=/dejima/work
else
    WORKSPACE_DIR="${1:-$(pwd)}"
    if [ ! -d "$WORKSPACE_DIR" ]; then
        echo "Not a directory: $WORKSPACE_DIR" >&2
        exit 1
    fi
    shift || true

    # The flag loop stops at the first positional, so anything after the path
    # is the harness's - including `--isolated`, which was then passed
    # through as a harness argument while the project was mounted read-write.
    # An operator typing `dejima ~/proj --isolated` has asked for the one
    # thing that silently did not happen, so it is refused unless `--` says
    # the rest is deliberately not dejima's.
    if [ "$DEJIMA_ARGS_ONLY" = 0 ]; then
        for _arg in "$@"; do
            [ "$_arg" = "--" ] && break
            [ "$_arg" = "--isolated" ] || continue
            echo "--isolated after the project directory would be passed to the" >&2
            echo "harness, and this launch would mount $WORKSPACE_DIR read-write." >&2
            echo "" >&2
            echo "Put it before the path, or rather: --isolated takes no path at" >&2
            echo "all. If the harness is meant to receive it, separate it with --." >&2
            exit 1
        done
        unset _arg
    fi

    if [ "${1:-}" = "--" ]; then
        shift
    fi

    WORKSPACE_DIR="$(cd "$WORKSPACE_DIR" && pwd)"
fi

# Resolved here, with the workspace, rather than beside the other approval
# reads further down: --print-briefing composes and exits well before those,
# so a briefing printed for inspection would have silently lacked whatever
# they decide. That is not hypothetical - the same ordering already caught
# DEJIMA_RW_MOUNTS, whose sentence went missing from --print-briefing while
# real sessions were told correctly.
#
# Left empty for an isolated launch, and NOT resolved first and discarded:
# resolve_project_store walks upwards from the path it is given, so a launch
# from $HOME on a host whose dotfiles were once approved as a project would
# find that project's store - and with it the domains it may reach and the
# dependencies baked into its image. A directory the operator happens to be
# standing in decides nothing here, which is most of what the flag means.
#
# Every project-scoped read below is already guarded on this being non-empty
# - the corpus paragraph, the approved tools, the allowed domains, the
# dependency layer - so one empty assignment covers all four.
#
# It is not the only thing covering them, and that is deliberate rather than
# redundant. The working directory above is container-local, so even a
# resolver call here would be asking about /dejima/work rather than about
# wherever the operator was standing. Either of the two closes the leak on
# its own, and they answer different questions: that one is about where the
# session runs, this one about what it is entitled to. A test that reverses
# both is in project.bats, because reversing either alone proves nothing.
PROJECT_STORE=""
if [ "$DEJIMA_ISOLATED" = 0 ]; then
    PROJECT_STORE="$(resolve_project_store "$WORKSPACE_DIR")"
fi

# Whether this project has been approved to keep its history as evidence, and
# so gets the commit-granularity paragraph in its briefing.
#
# Read from the APPROVAL STORE, never from the declaration in the workspace.
# The workspace is session-writable, so a session that could turn this on
# would be writing the next session's system prompt - the channel #104 is
# about, and the reason nothing on the launch path reads a project's
# declarations directly. The project declares, the operator approves, and this
# reads what was approved. A static test holds that this file never names that
# directory at all, which is why it is not spelled out here. Global entries
# count too, for an operator who wants this everywhere.
DEJIMA_CORPUS=off
_corpus_approved="$(approved_entries "$(config_root)" corpus | tr '\n' ' ')"
if [ -n "$PROJECT_STORE" ]; then
    _corpus_approved="$_corpus_approved $(approved_entries "$PROJECT_STORE" corpus | tr '\n' ' ')"
fi
case " $_corpus_approved " in
    *" commit-per-step "*) DEJIMA_CORPUS=on ;;
esac
unset _corpus_approved

# The optional tools this launch has: [core] tools, plus whatever the project
# declared AND the operator approved, plus --include. A union rather than a
# precedence chain, because each of the three answers a different question -
# what this machine always wants, what this project needs, what this run is
# for - and none of them is a correction of another.
#
# The project's half comes from the APPROVAL STORE and never from the
# declaration in the workspace, for the reason the corpus block above gives:
# that file is session-writable, and a session that could add a tool would be
# choosing what the next session's image contains. This file may not name that
# directory at all, and a static test holds it to that - which is why the path
# is not spelled out here.
#
# Every name is checked against a manifest that exists before it reaches the
# image build. approved_entries already refuses an entry its validator does
# not accept, so the only unchecked source is the flag - which is the
# operator's own typing, and gets the same answer.
DEJIMA_TOOLS=""
_tools_wanted="$(config_get core tools | tr ':' ' ')"
_tools_wanted="$_tools_wanted $(approved_entries "$(config_root)" tools | tr '\n' ' ')"
if [ -n "$PROJECT_STORE" ]; then
    _tools_wanted="$_tools_wanted $(approved_entries "$PROJECT_STORE" tools | tr '\n' ' ')"
fi
_tools_wanted="$_tools_wanted ${TOOL_FLAGS[*]-}"
for _tool in $_tools_wanted; do
    [ -n "$_tool" ] || continue
    case " $DEJIMA_TOOLS " in *" $_tool "*) continue ;; esac
    if ! valid_tool_name "$_tool"; then
        echo "no optional tool named '$_tool'." >&2
        echo "Available: $(provider_list tool | tr '\n' ' ')" >&2
        echo "See docs/tools.md." >&2
        exit 1
    fi
    tool_validate "$_tool" || exit 1
    DEJIMA_TOOLS="${DEJIMA_TOOLS:+$DEJIMA_TOOLS }$_tool"
done
unset _tools_wanted _tool


AUDIT_DIR="$(config_get audit dir)"
BASELINE_DIR="$(config_get audit baselines_dir)"

# A [mounts] list as this launch should see it: every entry is a host path
# that outlives the session, so an isolated launch gets none of them.
#
# Dropped rather than refused, unlike --ro and --rw. These are a standing
# preference about ordinary work, written once and inherited by every launch;
# refusing them would mean an operator with a [mounts] readonly line could
# never use this flag at all. The notice below is what keeps that from being
# silent, because a directory that is simply absent inside the container is
# the least informative way to find out.
_mounts_list() {
    [ "$DEJIMA_ISOLATED" = 0 ] || return 0
    config_get mounts "$1"
}
if [ "$DEJIMA_ISOLATED" = 1 ]; then
    _dropped=""
    for _key in readonly read_write read_write_per_project; do
        [ -n "$(config_get mounts "$_key")" ] || continue
        _dropped="${_dropped:+$_dropped, }[mounts] $_key"
    done
    [ -z "$_dropped" ] \
        || echo "Isolated: $_dropped not applied - nothing of the host is mounted." >&2
    unset _dropped _key
fi

# Read before either guard runs, because what this list declares becomes
# something the other two lists are refused for naming - see PP_PROJECT_ROOTS.
SANDBOX_RW_PP_MOUNTS="$(_mounts_list read_write_per_project)"

SANDBOX_EXTRA_MOUNTS="$(_mounts_list readonly)"
SANDBOX_EXTRA_MOUNTS="$(_append_mounts "$SANDBOX_EXTRA_MOUNTS" \
    ${EXTRA_RO_FLAGS[@]+"${EXTRA_RO_FLAGS[@]}"})"

# What the selected harness keeps on the host, for the read_write refusal
# below.
#
# Derived from what the plugin declares rather than written out here, because
# a literal list is one nobody extends when a second harness arrives: this
# guard named ~/.claude and nothing of Cline's, so an operator could hand a
# session write access to the hooks and skills their own machine runs next -
# the persistence this whole refusal exists to stop.
#
# All three declaration tiers count, whatever a session may do to the copy
# inside the container. A read-only mount protects the host's file from the
# session that has it; it says nothing about a writable mount an operator
# points at the same file. What matters here is only that the harness reads
# it on the host.
#
# A staged or seeded entry contributes both the host path it is read from and
# $HOME/<rel>, the path it is installed at. Those differ when the operator
# keeps the file somewhere else, and the rel is named whether or not the file
# exists yet - which the source is not, since a plugin skips a source that is
# absent. An absent path is exactly the one worth refusing: nothing stops a
# session creating it.
#
# The loads happen here, before anything is mounted, because a guard that
# runs after the mount it would refuse is not a guard. Their consumers
# further down read these same arrays rather than re-running the hooks.
harness_config_dirs_load
harness_staged_config_load
harness_seed_load

HARNESS_HOST_CONFIG=()
_harness_host_config() {
    local rec src rel spec name type
    for rec in ${DEJIMA_CONFIG_DIRS[@]+"${DEJIMA_CONFIG_DIRS[@]}"}; do
        HARNESS_HOST_CONFIG+=("${rec%%$'\t'*}")
    done
    # Three fields, the third being the ensure flag, which is nothing to this
    # loop - but read into a name of its own so a rel does not silently absorb
    # it and guard a path with a tab in the middle of it.
    for rec in ${DEJIMA_STAGED_CONFIG[@]+"${DEJIMA_STAGED_CONFIG[@]}"}; do
        IFS=$'\t' read -r src rel _ <<< "$rec"
        HARNESS_HOST_CONFIG+=("$src" "$HOME/$rel")
    done
    for rec in ${DEJIMA_SEED[@]+"${DEJIMA_SEED[@]}"}; do
        IFS=$'\t' read -r rel src <<< "$rec"
        HARNESS_HOST_CONFIG+=("$HOME/$rel")
        [ -n "$src" ] && HARNESS_HOST_CONFIG+=("$src")
    done
    # The state and host-executed paths a harness declares as its own config
    # keys - transcripts and an edit-undo tree for one, a whole state
    # directory for another. Selected by TYPE, so nothing here has to know
    # what a given harness calls them, and a `path` key added to a manifest
    # later is guarded without this being touched. Read through
    # harness_config_get so an operator who moved one is guarded where it
    # actually is rather than where the default says.
    for spec in $(_provider_keys_field harness "$DEJIMA_HARNESS_NAME"); do
        IFS=$'\t' read -r name type _ <<< "$(_provider_keyspec_parse "$spec")"
        [ "${type#\?}" = path ] || continue
        src="$(harness_config_get "$name")"
        [ -n "$src" ] && HARNESS_HOST_CONFIG+=("$src")
    done
}
_harness_host_config

# The read-write mounts against the harness's host paths, once more, for a
# list rebuilt after harness_prepare_config. The pass that runs with the rest
# of the mount validation is the guard; this one holds the same rule over
# entries that did not exist when it ran.
#
# Only the read-write lists: a read-only mount over a harness's own config is
# refused on its own path further up, and that list does not change here.
_refuse_rw_over_harness_host_config() {
    local rw rw_real guarded guarded_real
    HARNESS_HOST_CONFIG=()
    _harness_host_config
    for rw in ${RW_MOUNT_PATHS[@]+"${RW_MOUNT_PATHS[@]}"}; do
        rw_real="$(readlink -m "$rw")"
        for guarded in ${HARNESS_HOST_CONFIG[@]+"${HARNESS_HOST_CONFIG[@]}"}; do
            guarded_real="$(readlink -m "$guarded")"
            _paths_overlap "$rw_real" "$guarded_real" || continue
            echo "Refusing writable mount: $rw" >&2
            echo "It overlaps $guarded_real, which the selected harness reads on the" >&2
            echo "host - so a session could rewrite what the next one runs with." >&2
            echo "" >&2
            echo "This path became the harness's only once its per-run configuration" >&2
            echo "was staged, which is why it is refused here rather than with the" >&2
            echo "other mounts." >&2
            exit 1
        done
    done
}

# Read-write host mounts. Distinct from [core] share_dir, which is mounted at
# ~/shared - a well-known handoff point the agent is told about by name -
# while these are mounted at their own absolute path, so a path that works on
# the host works unchanged inside.
#
# A writable mount is a host-persistence surface, so it may not overlap the
# surfaces the rest of this repo exists to protect. Refused, not warned:
# warn_if_inside_workspace below only warns because it reports an
# ENVIRONMENTAL condition an operator may have reached indirectly (an
# XDG_DATA_HOME pointing somewhere surprising). This is a deliberate line in a
# hand-edited config with an obvious alternative, so refusing costs nothing
# and removes the "saw the warning, continued anyway" path.

# The stores that decide what a session may do next, and the record of what
# it did. Read-only is not enough to make mounting them harmless: the
# approval store discloses every project's approved domains and
# dependencies, and the audit baselines say which findings have already been
# waved through. Refusing beats mounting and hoping the mode holds (#15).
#
# The epitaph store is here for the same reason and a sharper one: it holds
# each earlier session's own account of what it worked out, and a session
# that can read those can read what every previous session in this project
# concluded. Never mounting it is the entire design, and a static test
# checks that nothing here mounts it - but that test cannot see a path an
# operator names in [mounts] readonly, which is computed at run time. Listed
# whatever [epitaph] enabled says: turning the feature off stops new
# epitaphs being written, it does not empty the store.
#
# The per-project share root is here because the ~/shared mount is only per
# project while nothing else hands the whole of it over. A session's own share
# arrives at ~/shared without going through this list; naming the root that
# holds every project's would put the pile back, which is the thing the split
# removed.
# Refuse $2 if it reaches a per-project root. Its own check rather than a row
# in GUARDED_STORES, because the generic refusal there is about approval and
# audit stores and says so - an operator who wrote the same path into two
# mount lists would be told their specs directory lets a session approve its
# own domains, which is both untrue and no help.
_refuse_pp_root() {
    local label="$1" path="$2" real root root_real
    _pp_project_roots
    [ ${#PP_PROJECT_ROOTS[@]} -gt 0 ] || return 0
    real="$(readlink -m "$path")"
    for root in "${PP_PROJECT_ROOTS[@]}"; do
        root_real="$(readlink -m "$root")"
        _paths_overlap "$real" "$root_real" || continue
        echo "Refusing $label: $path" >&2
        echo "It reaches $root_real, which holds one directory per project for a" >&2
        echo "[mounts] read_write_per_project entry." >&2
        echo "" >&2
        echo "Handing that over is handing over every project's, which is the pile" >&2
        echo "slicing it removed. If this path is meant to be shared by every" >&2
        echo "project, it belongs in one list or the other and not in both." >&2
        exit 1
    done
}

GUARDED_STORES=()
_guarded_stores() {
    [ ${#GUARDED_STORES[@]} -eq 0 ] || return 0
    GUARDED_STORES=("$(config_root)" "$AUDIT_DIR" "$BASELINE_DIR" \
                    "$(config_get epitaph dir)" \
                    "$(share_projects_root "$(config_get core share_dir)")" \
                    "$(isolated_share_root "$(config_get core share_dir)")")
}

# The `projects` subdirectory of every read_write_per_project root: the
# directory that holds one slice per project, exactly as share_dir's does.
#
# Guarded separately from the stores above because the two lists need
# different answers. For `readonly` and `read_write`, naming one of these is
# the mistake the share guard already names: handing over the root that holds
# every project's directory puts back the pile the split removed. For the
# per-project list itself it is not a mistake at all - it is where that list
# puts things - so that loop consults GUARDED_STORES alone.
PP_PROJECT_ROOTS=()
_pp_project_roots() {
    [ ${#PP_PROJECT_ROOTS[@]} -eq 0 ] || return 0
    local path
    [ -n "$SANDBOX_RW_PP_MOUNTS" ] || return 0
    IFS=':' read -ra _pp_declared <<< "$SANDBOX_RW_PP_MOUNTS"
    for path in "${_pp_declared[@]}"; do
        [ -n "$path" ] || continue
        PP_PROJECT_ROOTS+=("$(projects_root "$path")")
    done
    unset _pp_declared
}

# Refuse $2 if it overlaps any of the paths in $3.., in either direction: an
# ancestor of a store exposes it just as surely as the store itself.
_refuse_guarded_mount() {
    local kind="$1" path="$2"; shift 2
    local real guarded guarded_real
    # readlink -m rather than -f, same reason as the read_write guard below:
    # -f gives nothing for a path that does not exist yet, and an empty
    # result compares equal to nothing and would sail through.
    real="$(readlink -m "$path")"
    for guarded in "$@"; do
        [ -n "$guarded" ] || continue
        guarded_real="$(readlink -m "$guarded")"
        _paths_overlap "$real" "$guarded_real" || continue
        echo "Refusing $kind: $path" >&2
        echo "It overlaps $guarded_real, which dejima needs out of a session's reach." >&2
        echo "Mounted at all, that hands the session every project's approvals, the" >&2
        echo "record of what earlier sessions were caught doing, or the handoff files" >&2
        echo "of every project and every isolated session." >&2
        exit 1
    done
}
SANDBOX_RW_MOUNTS="$(_mounts_list read_write)"
SANDBOX_RW_MOUNTS="$(_append_mounts "$SANDBOX_RW_MOUNTS" \
    ${EXTRA_RW_FLAGS[@]+"${EXTRA_RW_FLAGS[@]}"})"
RW_MOUNT_PATHS=()
DEJIMA_RW_MOUNTS=""
if [ -n "$SANDBOX_RW_MOUNTS" ]; then
    IFS=':' read -ra rw_paths <<< "$SANDBOX_RW_MOUNTS"
    for path in "${rw_paths[@]}"; do
        [ -n "$path" ] || continue
        if [ ! -d "$path" ]; then
            echo "$(_mount_label "$path" "[mounts] read_write entry" "--extra-read-write path") is not an existing directory: $path" >&2
            exit 1
        fi
        # readlink -m, not -f: -f yields nothing unless at most the final
        # component is missing, and a guarded path that does not exist YET is
        # exactly the one worth refusing. An empty result would otherwise be
        # skipped, so an operator whose [audit] dir sits two levels below a
        # directory nothing has created could mount a writable ancestor of the
        # store dejima-audit is about to write findings into. -m also folds
        # away trailing slashes and "."/".." components, so the comparisons
        # below are between canonical paths rather than whatever spelling the
        # config happened to use.
        rw_real="$(readlink -m "$path")"
        # The shared list plus this path's own extras, rather than a second
        # copy of the stores: the two lists having drifted is what let the
        # epitaph store be mountable from here after it was added to one of
        # them. A store added to _guarded_stores is now guarded on both
        # paths without anyone remembering to do it twice.
        _refuse_pp_root "$(_mount_label "$path" "[mounts] read_write entry" "--extra-read-write path")" "$path"
        _guarded_stores
        for guarded in ${GUARDED_STORES[@]+"${GUARDED_STORES[@]}"} \
            ${HARNESS_HOST_CONFIG[@]+"${HARNESS_HOST_CONFIG[@]}"} \
            "$HOME/.local/bin" "$REPO_ROOT" "$WORKSPACE_DIR"; do
            guarded_real="$(readlink -m "$guarded")"
            _paths_overlap "$rw_real" "$guarded_real" || continue
            echo "Refusing $(_mount_label "$path" "[mounts] read_write entry" "--extra-read-write path"): $path" >&2
            echo "It overlaps $guarded_real, which dejima needs out of a session's reach." >&2
            echo "A writable mount there would let a session approve its own domains and" >&2
            echo "dependencies, delete the audit findings it is named in, read and rewrite" >&2
            echo "every project's handoff files, or plant code that runs unsandboxed on the" >&2
            echo "host the next time you start an agent." >&2
            exit 1
        done
        # A path in both lists would be resolved by whichever mount Docker
        # applies last, which is not something to decide by ordering. Compared
        # canonically rather than as config text: "$d" and "$d/" are the same
        # directory, and a raw string match would pass them both through and
        # emit two -v flags for it, leaving the read-only one to win or lose
        # by accident.
        if [ -n "$SANDBOX_EXTRA_MOUNTS" ]; then
            IFS=':' read -ra ro_paths <<< "$SANDBOX_EXTRA_MOUNTS"
            for ro_path in "${ro_paths[@]}"; do
                [ -n "$ro_path" ] || continue
                [ "$(readlink -m "$ro_path")" = "$rw_real" ] || continue
                echo "[mounts] read_write and [mounts] readonly both name the same" >&2
                echo "directory: $path and $ro_path both resolve to $rw_real." >&2
                exit 1
            done
        fi
        RW_MOUNT_PATHS+=("$path")
        DEJIMA_RW_MOUNTS="${DEJIMA_RW_MOUNTS:+$DEJIMA_RW_MOUNTS }$path"
    done
fi

# [mounts] read_write_per_project: the same idea as read_write, sliced the way
# [core] share_dir is. An operator declares ~/specs; the container gets ~/specs,
# backed by ~/specs/projects/<slug> on the host rather than by the whole thing.
#
# Which keeps the property that separates these lists from share_dir - a mount
# arrives at the host path it names, so a project instruction saying "put specs
# in ~/specs" goes on working - while removing the one share_dir was split to
# remove: every session seeing every other project's, across projects. A spec
# is written to be read as background, which makes that threat-model item 4
# with nobody attacking anything.
#
# Its own key rather than a marker on read_write, and that is what makes it
# small: a key that did not previously parse has nothing in it on the day it
# lands. Nothing moves, nothing is stranded, and there is no migration. Some
# writable mounts are meant to be shared - a cache, a bin directory - and
# read_write stays right for those.
RW_PP_MOUNT_SOURCES=()
RW_PP_MOUNT_TARGETS=()
DEJIMA_RW_PP_MOUNTS=""
# Every host path this launch makes writable, which for a plain read_write
# entry is the path itself and for a per-project one is the slice. The audit
# reads this; the briefing reads the two target lists.
DEJIMA_RW_SOURCES="$DEJIMA_RW_MOUNTS"
if [ -n "$SANDBOX_RW_PP_MOUNTS" ]; then
    IFS=':' read -ra rw_pp_paths <<< "$SANDBOX_RW_PP_MOUNTS"
    for path in "${rw_pp_paths[@]}"; do
        [ -n "$path" ] || continue
        # The declared root must exist, like a read_write entry: dejima creates
        # the slice below it, never the root itself, so a typo stays a typo
        # rather than becoming a new empty directory nobody meant.
        if [ ! -d "$path" ]; then
            echo "[mounts] read_write_per_project entry is not an existing directory: $path" >&2
            echo "dejima creates the per-project directory inside it, but not the" >&2
            echo "directory itself - so a path that is not there is a typo, not a" >&2
            echo "layout to be built." >&2
            exit 1
        fi
        pp_real="$(readlink -m "$path")"
        # GUARDED_STORES alone, deliberately: PP_PROJECT_ROOTS holds this
        # list's own `projects` directories, which the other two lists are
        # refused for naming and this one exists to write into.
        _guarded_stores
        for guarded in ${GUARDED_STORES[@]+"${GUARDED_STORES[@]}"} \
            ${HARNESS_HOST_CONFIG[@]+"${HARNESS_HOST_CONFIG[@]}"} \
            "$HOME/.local/bin" "$REPO_ROOT" "$WORKSPACE_DIR"; do
            guarded_real="$(readlink -m "$guarded")"
            _paths_overlap "$pp_real" "$guarded_real" || continue
            echo "Refusing [mounts] read_write_per_project entry: $path" >&2
            echo "It overlaps $guarded_real, which dejima needs out of a session's reach." >&2
            echo "Slicing it per project does not change what the session could reach" >&2
            echo "through it - only how much of it there is." >&2
            exit 1
        done
        # Resolved here and created later, on the launch path. This block runs
        # before --print-briefing so the briefing it composes names the same
        # mounts a launch would, and a diagnostic that prints something should
        # not leave a directory behind - which is why share_dir's own mkdir
        # sits after that exit too.
        pp_source="$(project_dir_for "$path" "$WORKSPACE_DIR")"
        RW_PP_MOUNT_TARGETS+=("$path")
        RW_PP_MOUNT_SOURCES+=("$pp_source")
        # Two lists, because the two readers want different answers and for a
        # per-project mount they differ. The briefing tells the SESSION what it
        # can write, which is the path inside the container. The exit audit
        # tells the OPERATOR what on their host this session could reach, and
        # naming the root there would claim a blast radius this feature exists
        # to have narrowed.
        DEJIMA_RW_PP_MOUNTS="${DEJIMA_RW_PP_MOUNTS:+$DEJIMA_RW_PP_MOUNTS }$path"
        DEJIMA_RW_SOURCES="${DEJIMA_RW_SOURCES:+$DEJIMA_RW_SOURCES }$pp_source"
    done
fi

# --print-briefing composes and exits, the same way --print-config does -
# before anything is built or mounted, and before a workspace argument is
# required. It exists so the composed briefing can be inspected (and, per
# test/providers.bats's parity check, diffed against the pre-composition
# text) without booting a container. Composing after DEJIMA_RW_MOUNTS above,
# rather than before it, is what makes that inspection trustworthy:
# briefing_compose reads DEJIMA_RW_MOUNTS to resolve {{writable_mounts}}, so
# composing any earlier would silently print a briefing missing that
# sentence whenever an operator has a read_write mount configured - exactly
# the gap this ordering exists to close.
if [ "$PRINT_BRIEFING" = 1 ]; then
    briefing_compose "$DEJIMA_FORGE_NAME" "$DEJIMA_WORKFLOW_NAME" "$DEJIMA_HARNESS_NAME" || exit 1
    exit 0
fi

if ! _forge_all_hosts="$(forge_hosts "$DEJIMA_FORGE_NAME")"; then
    exit 1
fi
DEJIMA_WORKFLOW_PUSH="$(manifest_get workflow "$DEJIMA_WORKFLOW_NAME" push)"
# A forge declaring hosts_allowlisted_by_core says its hosts are ones core
# already allowlists in its own right - the GitHub ranges every session can
# read from over 443. Those must NOT reach DEJIMA_FORGE_HOSTS, which
# firewall-up.sh puts in the open class: that path permits a name on every
# port and exempts it from the check that refuses an answer pointing inside a
# private network, both of which are deliberate for an internal, self-hosted
# forge host and both of which are a widening for a public one. It would also
# make the policy's startup self-check ("port 22 was reachable without the
# push grant") fail, killing the container outright.
#
# They still reach DEJIMA_PUSH_HOSTS: git-push-guard.sh's allowlist is a
# separate question from which names the firewall resolves, and a push to a
# host the guard doesn't know is refused inside the container regardless of
# how the packets would have been routed.
ALLOW_CORE_PUSH_GRANT="$ALLOW_GITHUB_PUSH_FLAG"
if [ "$(manifest_get forge "$DEJIMA_FORGE_NAME" hosts_allowlisted_by_core)" = "on" ]; then
    DEJIMA_FORGE_HOSTS=""
    # And pushing to them therefore goes through core's own push grant
    # (ALLOW_GITHUB_PUSH below), which opens port 22 to the same ranges the
    # read-only path already allows on 443 - logged, self-checked, and the
    # documented way that port opens. Only when the workflow actually pushes:
    # a non-pushing workflow has no reason to open it.
    if [ "$DEJIMA_WORKFLOW_PUSH" = "on" ]; then
        ALLOW_CORE_PUSH_GRANT=1
    fi
else
    DEJIMA_FORGE_HOSTS="$_forge_all_hosts"
fi
# Exported as well as passed to the sandbox with `-e NAME=value`. The filter
# container is started with `docker run` and takes this the `-e NAME` way, so
# it has to be in this process's environment - and that form is what keeps a
# value out of a command line, which is the rule the credential beside it in
# that list follows.
export DEJIMA_FORGE_HOSTS
if [ "$DEJIMA_WORKFLOW_PUSH" = "on" ]; then
    DEJIMA_PUSH_HOSTS="$_forge_all_hosts"
else
    DEJIMA_PUSH_HOSTS=""
fi
# --allow-github-push's actual effect: git-push-guard.sh only ever checks
# DEJIMA_PUSH_HOSTS, so granting it a github.com push means adding these
# hosts to that list, not just exporting ALLOW_GITHUB_PUSH - that variable
# reaches only the egress policy's port-22 grant and its startup self-check,
# never the guard. Keyed to the
# flag alone, not to ALLOW_CORE_PUSH_GRANT: a forge whose own hosts these
# are has already contributed them above, and adding them twice would leave
# the guard's list carrying each name two or three times over.
if [ "$ALLOW_GITHUB_PUSH_FLAG" = 1 ]; then
    DEJIMA_PUSH_HOSTS="${DEJIMA_PUSH_HOSTS:+$DEJIMA_PUSH_HOSTS }github.com ssh.github.com"
fi
DEJIMA_BLOCKED_BRANCHES="$(manifest_get workflow "$DEJIMA_WORKFLOW_NAME" blocked_branches)"
if [ "$(manifest_get workflow "$DEJIMA_WORKFLOW_NAME" block_merge)" = "on" ]; then
    DEJIMA_MERGE_BLOCK="$(manifest_get forge "$DEJIMA_FORGE_NAME" merge_block)"
else
    DEJIMA_MERGE_BLOCK=""
fi

# Which of this forge's commands write a body the session authored, as one
# record per line for the shadow to read: "<kind> <command prefix>".
#
# Not conditional on the workflow, unlike the merge block above. A merge block
# is a policy about what a workflow permits; this is a statement about who
# wrote the text, which is true of every session whatever it is allowed to do.
# The comma-separated manifest form is turned into lines here rather than in
# the shadow, so the parsing sits next to the schema that validates it.
DEJIMA_DISCLOSE=""
_disclose_spec="$(manifest_get forge "$DEJIMA_FORGE_NAME" disclose)"
if [ -n "$_disclose_spec" ]; then
    _old_ifs="$IFS"
    IFS=,
    for _entry in $_disclose_spec; do
        _entry="${_entry#"${_entry%%[![:space:]]*}"}"
        _entry="${_entry%"${_entry##*[![:space:]]}"}"
        [ -n "$_entry" ] || continue
        DEJIMA_DISCLOSE="${DEJIMA_DISCLOSE}${DEJIMA_DISCLOSE:+$'\n'}${_entry%%:*} ${_entry#*:}"
    done
    IFS="$_old_ifs"
fi

# How this forge's CLI can be aimed somewhere other than the repository the
# session was launched against, and what that repository is (#187).
#
# A CLI that resolves the repo from origin and refuses another host cannot be
# aimed anywhere and declares nothing here. One that takes the repository as
# an option makes acting on an unrelated repository the same length of command
# as acting on this one. Which is which is the forge's to declare; core only
# resolves the repository the declaration is compared against, and
# docker/forge-guard.sh does the comparing.
#
# The last two path components of origin's URL, which is the same
# "<owner>/<name>" both forges use and covers ssh, scp-style and https
# remotes without parsing any of them as a URL. No origin, or a workspace
# that is not a checkout, leaves this empty - and the guard then refuses any
# --repo or GH_REPO outright rather than waving it through. A workspace with
# no origin has nothing such a flag could legitimately name, so "cannot
# confirm" is not a reason to allow it.
DEJIMA_FORGE_REPO_FLAGS="$(manifest_get forge "$DEJIMA_FORGE_NAME" repo_flags)"
DEJIMA_FORGE_REPO_ENV="$(manifest_get forge "$DEJIMA_FORGE_NAME" repo_env)"
DEJIMA_FORGE_REPO_PATHS="$(manifest_get forge "$DEJIMA_FORGE_NAME" repo_paths)"
DEJIMA_FORGE_REPO=""
if [ -n "$DEJIMA_FORGE_REPO_FLAGS" ] || [ -n "$DEJIMA_FORGE_REPO_ENV" ] ||
   [ -n "$DEJIMA_FORGE_REPO_PATHS" ]; then
    _origin_url="$(git -C "$WORKSPACE_DIR" remote get-url origin 2>/dev/null)" || _origin_url=""
    if [ -n "$_origin_url" ]; then
        _origin_url="${_origin_url%/}"
        _origin_url="${_origin_url%.git}"
        _origin_name="${_origin_url##*/}"
        _origin_rest="${_origin_url%/*}"
        _origin_owner="${_origin_rest##*[:/]}"
        if [ -n "$_origin_owner" ] && [ -n "$_origin_name" ] &&
           [ "$_origin_owner" != "$_origin_url" ]; then
            DEJIMA_FORGE_REPO="$_origin_owner/$_origin_name"
        fi
    fi
fi

# WORKSPACE_DIR is resolved above, ahead of --print-briefing, since
# briefing_compose needs DEJIMA_RW_MOUNTS (which needs WORKSPACE_DIR for its
# own overlap guard) before that point.
#
# Allowed domains come only from the host-side approval store (see
# bin/dejima-project-lib.sh), global then per-project. The first
# assignment below is plain, not ${ALLOWED_DOMAINS:-}-preserving: the store
# is the single host-side source of truth, and an exported ALLOWED_DOMAINS
# in the user's shell profile must not be able to silently widen every
# sandbox's firewall with nothing recording that it did.
#
# Per-project domains are read from the approval store only - deliberately
# NOT from the project's own in-repo declaration directory: that lives on
# the read-write workspace mount, so treating it as a grant would let a
# session widen its own firewall for the next run. The declaration is only
# ever reported (see hooks/sandbox-context.sh) and approved by hand.
ALLOWED_DOMAINS="$(approved_entries "$(config_root)" allowed-domains | tr '\n' ' ')"
if [ -n "$PROJECT_STORE" ]; then
    project_domains="$(approved_entries "$PROJECT_STORE" allowed-domains | tr '\n' ' ')"
    ALLOWED_DOMAINS="${ALLOWED_DOMAINS}${project_domains:+ $project_domains}"
fi


# The approval store, and the exit-audit's own report/baseline stores, are
# only out of the container's reach while none of them are inside the
# mounted workspace - same class of warning as the self-mounted repo TCB
# warning below. AUDIT_DIR/BASELINE_DIR are resolved above, ahead of
# --print-briefing, since the read_write mounts guard needs them too.
# The three stores fail in three different ways once they are reachable, and
# an operator who sees this needs to know which one they are looking at - so
# each states its own consequence rather than sharing one text. Consequence
# lines are hand-wrapped by the caller and printed one per argument.
warn_if_inside_workspace() {
    local what="$1" store="$2"; shift 2
    case "$store/" in
        "$WORKSPACE_DIR"/*) ;;
        *) return 0 ;;
    esac
    echo "Warning: the $what ($store) is inside the mounted" >&2
    echo "workspace this session, writable from inside the sandbox:" >&2
    printf '  %s\n' "$@" >&2
    echo "Review it after this session." >&2
}
warn_if_inside_workspace "approval store" "$(config_root)" \
    "the global approvals and every project's under it can be edited," \
    "so a session can approve its own domains and dependencies - and the" \
    "config file lives here too, so it can flip core.firewall off, add" \
    "mounts.readonly entries of its own choosing, rewrite any token_file," \
    "or turn remote-access.enabled on."
warn_if_inside_workspace "audit report store" "$AUDIT_DIR" \
    "a session can delete the exit-time reports of earlier runs," \
    "including unacknowledged CRITICAL ones it is named in."
warn_if_inside_workspace "audit baseline store" "$BASELINE_DIR" \
    "a session can write its own acknowledgements, suppressing the" \
    "findings it would otherwise produce here from every later report."
# The three above were the whole list, and the guarded-mount refusal further
# down covers six. So a path dejima refuses to mount through [mounts] or --ro
# was mounted without a word when it happened to sit inside the workspace -
# not a judgement about which stores matter, just three that were written and
# three that were not. A static test now holds the two lists in step.
warn_if_inside_workspace "epitaph store" "$(config_get epitaph dir)" \
    "it holds each earlier session's own account of what it worked out, so" \
    "a session can read what every previous one in this project concluded" \
    "- the cross-session channel the never-mounted store exists to close -" \
    "and can delete them."
warn_if_inside_workspace "share directories of every project" \
    "$(share_projects_root "$(config_get core share_dir)")" \
    "every project's handoff files can be read and rewritten, which is the" \
    "pile the per-project split removed."
warn_if_inside_workspace "share directories of every isolated session" \
    "$(isolated_share_root "$(config_get core share_dir)")" \
    "the one thing an isolated session leaves behind is readable here, for" \
    "every isolated session this host has run."

SANDBOX_SSH_KEY="$(config_get core ssh_key)"
if [ -n "$SANDBOX_SSH_KEY" ] && [ ! -f "$SANDBOX_SSH_KEY" ]; then
    echo "[core] ssh_key is set but not a file: $SANDBOX_SSH_KEY" >&2
    exit 1
fi
# An empty file is not a key: it passes the check above and loads nothing in the
# container. Treated as no key at all rather than refused, because the only
# launch it costs anything is one that pushes - and that case is Rule 4's
# below, which already says what to do about having neither.
have_usable_key=1
if [ -z "$SANDBOX_SSH_KEY" ] || [ ! -s "$SANDBOX_SSH_KEY" ]; then
    have_usable_key=0
fi
if [ -n "$SANDBOX_SSH_KEY" ]; then
    key_perms=$(stat -c '%a' "$SANDBOX_SSH_KEY")
    if [ "${key_perms: -2}" != "00" ]; then
        echo "Warning: $SANDBOX_SSH_KEY is readable by group/other (mode $key_perms)." >&2
        echo "Recommended: chmod 600 $SANDBOX_SSH_KEY" >&2
    fi
    # SANDBOX_SSH_KEY is meant to be a dedicated, low-privilege deploy key
    # (see docs/configuration.md "Headless git push"), not your real identity - it stays
    # loaded in an in-container agent for the whole session, unattended.
    # Nothing enforces that convention, so at least catch the common case
    # of pointing it at a default personal keyfile by mistake.
    case "$(basename -- "$SANDBOX_SSH_KEY")" in
        id_rsa|id_ecdsa|id_ed25519|id_dsa|id_ecdsa_sk|id_ed25519_sk)
            echo "Warning: $SANDBOX_SSH_KEY looks like a default personal SSH key," \
                 "not a dedicated one." >&2
            echo "It will be loaded into the sandbox for the whole unattended session -" \
                 "see docs/configuration.md." >&2
            ;;
    esac
fi

have_forwarded_agent=1
if [ -z "${SSH_AUTH_SOCK:-}" ] || [ ! -S "${SSH_AUTH_SOCK}" ]; then
    have_forwarded_agent=0
fi

# Rule 4: a workflow that never pushes has no use for either - skip the
# requirement entirely rather than force an operator doing local-only work
# to set up an agent or a deploy key for nothing.
if [ "$DEJIMA_WORKFLOW_PUSH" = "on" ] && [ "$have_forwarded_agent" = 0 ] && [ "$have_usable_key" = 0 ]; then
    if [ -n "$SANDBOX_SSH_KEY" ]; then
        echo "[core] ssh_key names an empty file, so there is no key to load: $SANDBOX_SSH_KEY" >&2
    fi
    echo "SSH_AUTH_SOCK is not set or not a valid socket, and [core] ssh_key is not set." >&2
    echo "Either start ssh-agent and run ssh-add, or set [core] ssh_key in $(config_path) to a" >&2
    echo "dedicated key for headless use - see docs/configuration.md." >&2
    exit 1
fi

# The same empty file with an agent running is not fatal, and is the case a
# dedicated key is chosen to avoid: no key is loaded, so SSH_AUTH_SOCK is not
# blanked below, so the operator's own agent is mounted and signs for the whole
# unattended session. Said out loud, because every other signal an operator has
# - the key is configured, the launch starts, pushes work - agrees with the
# arrangement they asked for.
if [ -n "$SANDBOX_SSH_KEY" ] && [ "$have_usable_key" = 0 ] \
   && [ "$have_forwarded_agent" = 1 ] && [ "$DEJIMA_WORKFLOW_PUSH" = "on" ]; then
    echo "Warning: [core] ssh_key names an empty file, so there is no key to load:" \
         "$SANDBOX_SSH_KEY" >&2
    echo "Your forwarded ssh-agent stays mounted and will sign for this session," \
         "which is what configuring a dedicated key is meant to prevent." >&2
fi

# A valid forwarded socket with zero identities loaded forwards fine (the
# container sees a live, working agent) but is useless for git-over-ssh -
# warn now rather than have that surface confusingly deep into a session as
# a failed push. Non-fatal: a task might not need git-over-ssh at all.
#
# Silent when [core] ssh_key is set, because then the premise is false: the
# key is loaded into the container's own agent and pushes work without the
# forwarded one carrying anything. It is also the better arrangement of the
# two - a dedicated identity that works unattended, rather than the
# operator's own - so warning about the fallback being empty, while the
# recommended path is configured and working, tells someone who took the
# advice that they got it wrong.
if [ "$have_forwarded_agent" = 1 ] && [ -z "$SANDBOX_SSH_KEY" ] \
   && ! ssh-add -l >/dev/null 2>&1; then
    echo "Warning: ssh-agent has no identities loaded (ssh-add -l shows none)." >&2
    echo "git-over-ssh (e.g. pushing to the configured forge) will fail until you ssh-add a key," >&2
    echo "or set [core] ssh_key in $(config_path) to a dedicated key - see docs/configuration.md." >&2
fi

# Deliberately not `gh auth token` / gh's own hosts.yml: a normal `gh
# auth login` session is write-capable, which would defeat the point.
# This should be a dedicated read-only fine-grained PAT (Contents +
# Metadata: read-only), generated separately for this sandbox.
# Plain assignment: stays empty (never inherited from the shell) when
# GH_TOKEN_FILE doesn't exist, the same "start from empty, not from
# whatever's already exported" reasoning forge_env_exports applies to a
# forge's own token further down.
GH_TOKEN=""
GH_TOKEN_FILE="$(config_get github token_file)"
if [ -f "$GH_TOKEN_FILE" ]; then
    GH_TOKEN="$(<"$GH_TOKEN_FILE")"
fi

# Read-write scratch space, separate from WORKSPACE_DIR, for passing files
# (screenshots, temp scripts) back and forth with a sandboxed session without
# touching the actual project checkout. Persists across sessions (it's a
# real host directory, not a container volume) since the point is sharing
# things with the host, not just scratch space for one run.
#
# One directory per project, not one for the host. `[core] share_dir` was
# mounted whole, so every session saw the handoff files of every session
# before it and of every session running beside it, across projects - the same
# boundary ~/.claude is already sliced per project to hold. What the container
# sees is still ~/shared, so nothing an agent has been told changes; what it
# cannot see any more is another project's.
#
# An isolated launch gets one of its own instead, under a sibling root. Still
# a real host directory, because handing a file back to the operator is the
# one thing that mode keeps - but never the directory any other session had,
# so it carries nothing in and nothing it is given reaches a later session.
# The operator is told the path below, since there is no project name to find
# it under.
SANDBOX_SHARE_ROOT="$(config_get core share_dir)"
if [ "$DEJIMA_ISOLATED" = 1 ]; then
    SANDBOX_SHARE_DIR="$(isolated_share_make "$SANDBOX_SHARE_ROOT")" || {
        echo "Could not make an isolated share directory under $SANDBOX_SHARE_ROOT" >&2
        exit 1
    }
else
    SANDBOX_SHARE_DIR="$(share_dir_for "$SANDBOX_SHARE_ROOT" "$WORKSPACE_DIR")"
fi
mkdir -p "$SANDBOX_SHARE_DIR"

# Files an operator left in share_dir before the split are still there and are
# no longer mounted anywhere. Said every launch while that is true, because
# the alternative is a file that was handed to a session yesterday silently
# not arriving today.
# Both roots dejima writes are excluded, not just the per-project one: the
# question this answers is "did something other than dejima put this here",
# and an isolated session's share is dejima's.
share_loose="$(find "$SANDBOX_SHARE_ROOT" -mindepth 1 -maxdepth 1 \
    ! -name projects ! -name isolated -printf '%f\n' 2>/dev/null | sort)" || true
if [ -n "$share_loose" ]; then
    echo "Note: $SANDBOX_SHARE_ROOT holds entries no session can see any more," >&2
    echo "      because ~/shared is now this project's own subdirectory of it:" >&2
    # A sample, not an inventory. This is a standing condition an operator
    # clears once, and printing ninety filenames on every launch pushes the
    # notices that are about THIS run off the top of the screen.
    _share_n="$(printf '%s\n' "$share_loose" | wc -l)"
    printf '%s\n' "$share_loose" | head -8 | while IFS= read -r _entry; do
        [ -n "$_entry" ] || continue
        printf '        %s\n' "$_entry" >&2
    done
    [ "$_share_n" -le 8 ] || printf '        %s... and %d more%s\n' \
        "${C_DIM:-}" "$(( _share_n - 8 ))" "${C_RESET:-}" >&2
    unset _share_n
    echo "      Move them into a project's share with 'dejima-share adopt <path>'," >&2
    echo "      or delete them. 'dejima-share list' shows what each project holds." >&2
fi
unset share_loose _entry

# ~/.claude is not mounted as a whole (see the mount inventory in
# docker-compose.yml) - every path that crosses into the container is named
# individually, and Docker answers a missing bind source by creating a
# root-owned *directory* at it. That would leave junk on the host and, for
# the read-only config dirs, a writable gap instead of an overlay. Pre-create
# them all: empty is fine, existence is what matters.
# Which directories those are is the harness's (harness_config_dirs); that
# they must exist before the mount is set up is dejima's. Already loaded, up
# where the read_write refusal needed them.
#
# Only the ones that will be mounted, which for an isolated launch is the
# read-only half - see the mount loop further down, which filters the same
# way. Creating the others would have an isolated launch leave four empty
# directories on a host it is not supposed to write to at all.
for _rec in ${DEJIMA_CONFIG_DIRS[@]+"${DEJIMA_CONFIG_DIRS[@]}"}; do
    IFS=$'\t' read -r _cdir _cmode _cscan <<< "$_rec"
    [ "$DEJIMA_ISOLATED" = 0 ] || [ "$_cmode" = ro ] || continue
    mkdir -p "$_cdir"
done

# The override layer the sandbox imposes on the harness's own settings, and
# any config file the harness needs to exist before it is staged.
#
# What that layer says, which files must exist, and what makes one unusable
# are the harness's - a settings schema is exactly the harness-shaped thing a
# manifest cannot carry, since it is a parser's grammar rather than a value.
# What is dejima's: that the layer is a throwaway file the session cannot
# reach, that it is mounted at a container-local path and NEVER over the
# harness's own settings file - a mount at the real path detaches when the
# host renames over the file, silently restoring the operator's settings with
# sandboxing back on (#47) - and that a plugin refusing here stops the launch
# rather than starting a session the harness would misread.
SANDBOX_SETTINGS_FILE="$(mktemp "${TMPDIR:-/tmp}/dejima-settings.XXXXXX.json")"
# And a directory for whatever else that hook has to produce per run. The
# settings override is one file and had one path; a harness whose
# configuration is several files, or whose configuration must not persist
# between launches, needs somewhere to put them that goes away with the run.
# Removed by cleanup(), like every other per-run staging path.
SANDBOX_HARNESS_STAGE_DIR="$(mktemp -d "${TMPDIR:-/tmp}/dejima-harness-stage.XXXXXX")"
harness_hook harness_prepare_config "$SANDBOX_SETTINGS_FILE" "$SANDBOX_HARNESS_STAGE_DIR" || exit 1
# The seed list again, now that the hook above has had its chance to add to
# it: a hook whose job is to make a file has to run before the list that
# mounts it. The load further up is what the mount guard ran against - a
# guard after the mount it would refuse is not a guard - and nothing is
# mounted yet here either, so the same rule is applied again below over
# whatever this pass added.
#
# Re-checked rather than argued about. What a plugin adds here is, today, a
# path dejima made this run and nothing an operator could have pointed a
# mount at - but `harness_seed` runs again in full, so a plugin deriving a
# seed from state prepare_config changed would reach the mount assembly on
# that argument alone. The check is two loops over lists already built.
harness_seed_load
_refuse_rw_over_harness_host_config

# ~/.claude.json mixes live app state with config (mcpServers) that cannot be
# split at the mount level, and read-only is not an option: Claude Code does
# an atomic write-and-rename against it during startup, and a read-only file
# makes that HANG rather than fail cleanly (anthropics/claude-code#29162).
# So it gets a throwaway copy, seeded from the real file and discarded rather
# than merged back - nothing a session writes here reaches the host, and
# concurrent sessions never contend. See docs/configuration.md, "Config
# parity with your host Claude Code setup".
#
# Seeded through jq rather than cp, because this file HOLDS A CREDENTIAL. A
# Claude Code signed in with a console API token stores it as a top-level
# primaryApiKey here - not in .credentials.json, which stays `{}` for that
# login. Copied verbatim, that key entered every container, whatever the
# selected backend and whether or not the credential proxy was running: the
# proxy withholds one file while this copied another.
#
# Stripped rather than the whole file withheld, because everything else in it
# is why the copy exists - mcpServers, project state, onboarding flags.
#
# customApiKeyResponses is left: it holds hashes of keys the operator
# approved, not the keys, and dropping it re-prompts inside the sandbox for a
# decision already made on the host.
#
# A file jq cannot parse is a refusal, not a fallback to cp. Falling back
# would restore the exact leak this exists to close, at the moment least
# likely to be noticed.
#
# The `projects` map is narrowed to the launching project at the same time, for
# the reason #104 narrowed the projects MOUNT: the whole map is every project's.
# It keys per-project state by absolute path, so a copy of all of it tells a
# session the names of every project on the host, a sample of each one's
# filenames (`exampleFiles`), and what each has cost (#380).
#
# The project's own path and anything under it, because a session's cwd is
# routinely a worktree inside the project and Claude Code keys that separately.
# Paths are compared as strings: both sides of this mount agree on them by
# construction - same uid, same home, same project path - which is what makes
# the comparison meaningful rather than a guess about layout.
#
# One function rather than one expression, because there are two call sites:
# this one, and the re-seed further down for a backend whose credential IS
# this file and whose proxy is off. That one differs in a single respect -
# the credential stays - and it reached for `cp`, which put every other
# project's entry back and did it after the credential scan had already read
# the narrowed copy. The difference is a parameter now, so the narrowing
# cannot be lost by taking the other path.
_stage_claude_json() {  # <keep-credential: true|false> <destination>
    local keep="$1" dest="$2"
    if [ ! -f "$HOME/.claude.json" ]; then
        echo '{}' > "$dest"
        return 0
    fi
    # The type is checked rather than the key's presence: `projects` holding
    # something other than an object is not a shape Claude Code writes, and
    # narrowing it would error - turning a parseable file into a refused launch
    # over a key with nothing in it to narrow.
    jq --arg proj "$WORKSPACE_DIR" --argjson keep "$keep" '
            (if $keep then . else del(.primaryApiKey) end)
            | if (.projects | type) == "object" then
                  .projects |= with_entries(
                      select(.key == $proj or (.key | startswith($proj + "/"))))
              else . end
         ' "$HOME/.claude.json" > "$dest"
}
SANDBOX_CLAUDE_JSON_FILE="$(mktemp "${TMPDIR:-/tmp}/dejima-claude-json.XXXXXX.json")"
if ! _stage_claude_json false "$SANDBOX_CLAUDE_JSON_FILE"; then
    echo "$HOME/.claude.json is not readable as JSON, so dejima cannot strip the" >&2
    echo "credential it may hold before copying it into the container." >&2
    exit 1
fi

SANDBOX_AUDIT_STATE="$(mktemp "${TMPDIR:-/tmp}/dejima-audit.XXXXXX")"

# Initialized here, before the trap below is installed, so cleanup() never
# observes a nonempty value it didn't itself set - e.g. an operator's
# environment happening to export a variable of one of these exact names.
# With remote access off (or on an early exit before the remote-access block
# below ever runs), `rm -rf "$DEJIMA_NOTIFY_DIR"` against an inherited value
# would be a data loss bug, not just a no-op - and several `exit 1` paths
# between here and that block would otherwise fire the trap while these
# still held whatever came in from the environment (same class of concern as
# the credential-proxy variables further down, pre-initialized the same way,
# just earlier here since the trap is installed sooner). Every name cleanup()
# removes but does not itself create belongs here, not just the remote-access
# ones: the three below are assigned only inside conditional blocks (an nvim
# config being present, dependencies being approved), so an ordinary run with
# neither would otherwise carry an inherited value all the way to the trap -
# and SANDBOX_DERIVED_CONTEXT is one of the `rm -rf` arguments.
DEJIMA_NOTIFY_DIR=""
DEJIMA_SESSION_FILE=""
# The debounce removal in cleanup() joins this to a run id and removes the
# result, so it belongs here for the same reason as the two above and was
# missing: with remote access off nothing assigns it, and an operator with
# DEJIMA_STATE_DIR exported would have had the trap remove a path of their
# environment's choosing rather than one dejima made.
DEJIMA_STATE_DIR=""
# These three are assigned again further down, where each is read - the
# briefing file when it is composed, the other two among the credential
# proxy's own variables. Both of those sites are BELOW `trap cleanup EXIT`,
# with ten and twelve ordinary `exit` paths between, so on any of those exits
# the trap ran against whatever the environment held. SANDBOX_CREDENTIAL_PROXY_DIR
# is an `rm -rf` argument.
#
# Bound here as well rather than moved, because the later sites are read as
# "what this feature starts from" and are correct as that. This block is a
# different statement: what the trap must never inherit.
SANDBOX_BRIEFING_FILE=""
SANDBOX_CREDENTIAL_PROXY_DIR=""
SANDBOX_CREDENTIAL_PROXY_LOG=""
SANDBOX_FAKE_CREDENTIALS_FILE=""
# The same rule, for two names cleanup() hands to `docker rm` rather than to
# `rm`. An inherited value there removes a container or a network belonging to
# something else on this host, which is a worse thing to get wrong than a file.
SANDBOX_FILTER_CONTAINER=""
SANDBOX_RUN_NETWORK=""
# Same rule again, and `docker volume rm` is the third thing in this trap that
# destroys something named rather than something mktemp'd.
SANDBOX_ISOLATED_VOLUMES=""
# SANDBOX_HARNESS_STAGE_DIR is deliberately absent, like SANDBOX_SETTINGS_FILE
# and SANDBOX_CLAUDE_JSON_FILE: all three are mktemp'd further up, above the
# trap, so there is no window for cleanup() to see an inherited value - and
# resetting them here would run AFTER the assignment and throw the real path
# away. That is what happened to the stage directory, which then leaked once
# per launch while both the comment on it and the changelog said otherwise.
SANDBOX_NVIM_LOCKFILE=""
SANDBOX_DERIVED_DOCKERFILE=""
SANDBOX_DERIVED_CONTEXT=""
SANDBOX_CONVERSATION_SHIP_PID=""
SANDBOX_TTY_SUSPEND_PID=""
SANDBOX_TTY_MODES=""
SANDBOX_CLIENT_PID=""

# This launch's identity, generated for every launch rather than only for a
# remote-enabled one, and here rather than further down for the same reason as
# the block above: cleanup() reads it, so it must be dejima's own value by the
# time the trap is installed and not whatever arrived in the environment.
#
# It names the registry entry and the marker directory when remote access is
# on, and nothing when it is off - but it is the launch's identity either way,
# and deriving it from whether one optional feature is configured made it
# absent exactly where an inherited value could stand in for it.
DEJIMA_RUN_ID="$(od -An -tx1 -N8 /dev/urandom | tr -d ' \n')"

# Names each host-side task in cleanup() as it starts. Between the container
# exiting and the operator's shell prompt returning, dejima audits every
# surface the session could have written to and reads back the transcripts it
# left; on a real project that is seconds of nothing on screen, and a
# terminal that prints nothing reads as one that has hung. The remedy an
# operator reaches for then is Ctrl-C, which is precisely when the audit
# must not be interrupted.
#
# Only once a snapshot exists, which is the same condition `dejima-audit
# compare` uses to decide it has anything to do: an exit before the session
# ever started is instant, and its last line is the error that caused it -
# announcing work that is not happening would push that off the screen.
#
# stderr, like everything else this trap prints, and host-side only: none of
# it is ever routed back into a session's context.
exit_step() {
    # `return 0` rather than letting the test be the last command: a nonzero
    # status from anything in an EXIT trap replaces the script's own.
    [ -s "${SANDBOX_AUDIT_STATE:-}" ] && printf 'dejima: %s...\n' "$1" >&2
    return 0
}

cleanup() {
    # Every rm below is guarded with `|| true`: under set -e, a failing
    # command in an EXIT trap replaces the shell's own exit status, so none
    # of these may be allowed to abort the trap partway through. Most of
    # these paths are host-mktemp'd and not session-writable, so a genuine
    # failure there would be surprising - but SANDBOX_GITCONFIG_DIR is not:
    # it's bind-mounted read-write into the container (see
    # docker-compose.yml), so a session can leave an undeletable entry
    # inside it (e.g. an unwritable subdirectory) and make its `rm -rf`
    # fail, same hazard and same guard as the marker-directory removal
    # further down.
    #
    # SANDBOX_GITCONFIG_DIR additionally carries a `:-` default because it is
    # the one name here first assigned BELOW `trap cleanup EXIT` rather than
    # above it. What makes that safe is not the `:-` but the window: it holds
    # only comments, so there is no exit between the trap and the assignment
    # for the trap to fire on (and a failed `VAR="$(mktemp -d)"` still binds
    # VAR to the empty string before set -e fires).
    #
    # That is a property of this one name and of nothing else here, so the
    # static test asserts it by exception: every other name the trap removes
    # is bound above the trap, and adding a second name to the window fails
    # the test rather than resting on someone re-deriving the argument.
    # SIGTERM, which the shipper traps: it scans once more and flushes before
    # exiting, so the tail of the session - the part worth having - is shipped
    # rather than dying with the batch. Reaped for the same reason the proxy
    # is: an unreaped child reparents to PID 1 and stays a zombie wherever
    # PID 1 does not reap.
    # The terminal first, and its settings before their watcher: the
    # container run restores what it saved when it attached, but a run that
    # was killed rather than exited restores nothing, and what that leaves
    # behind is a terminal with no interrupt character.
    if [ -n "${SANDBOX_TTY_MODES:-}" ]; then
        stty "$SANDBOX_TTY_MODES" < /dev/tty 2>/dev/null || true
        # And the cursor, for the same reason the modes are restored here: a
        # run that was killed rather than exited put neither back.
        printf '\033[?25h' > /dev/tty 2>/dev/null || true
    fi
    if [ -n "${SANDBOX_TTY_SUSPEND_PID:-}" ]; then
        kill "$SANDBOX_TTY_SUSPEND_PID" 2>/dev/null || true
        wait "$SANDBOX_TTY_SUSPEND_PID" 2>/dev/null || true
    fi
    if [ -n "${SANDBOX_CONVERSATION_SHIP_PID:-}" ]; then
        kill "$SANDBOX_CONVERSATION_SHIP_PID" 2>/dev/null || true
        wait "$SANDBOX_CONVERSATION_SHIP_PID" 2>/dev/null || true
    fi
    if [ -n "${SANDBOX_CREDENTIAL_PROXY_PID:-}" ]; then
        kill "$SANDBOX_CREDENTIAL_PROXY_PID" 2>/dev/null || true
        # Reaped, not just signalled. Without this the proxy stays a zombie
        # child until dejima itself exits, at which point it reparents to
        # PID 1 - and where PID 1 does not reap (a CI job container whose
        # PID 1 is `sleep`, rather than the `init: true` dejima gives its
        # own containers) it stays a zombie for the life of that container,
        # holding a PID. One per launch is invisible interactively and
        # accumulates in a test suite that launches hundreds of times.
        wait "$SANDBOX_CREDENTIAL_PROXY_PID" 2>/dev/null || true
    fi
    # What the API said, if it refused anything. The proxy is the only thing
    # that sees both the request the session made and the answer it got, and a
    # rejection reaches the harness as whatever the harness shows - which for
    # a session that "spun for a bit and errored" is nothing to act on.
    #
    # Printed here rather than as it happens: the proxy's stderr is this
    # terminal, and the session owns it while it runs. Read before the rm
    # below, since that is what removes the file.
    if [ -n "${SANDBOX_CREDENTIAL_PROXY_LOG:-}" ] \
        && [ -s "$SANDBOX_CREDENTIAL_PROXY_LOG" ]; then
        # Grouped by status rather than printed line by line. A client that
        # retries turns one condition into several identical lines - three
        # 429s four seconds apart was the first real instance - and a list of
        # them says no more than the count does.
        #
        # match() on the whole line rather than a loop over fields. A loop
        # needs an index, and an awk index dereferenced inside single quotes
        # is indistinguishable to the test that every name cleanup() uses is
        # bound before the EXIT trap can fire - it reads one and reports an
        # unbound name. That test is right to be strict, so this stays out of
        # its way rather than asking it to parse quoting.
        #
        # Anchored to the whole line the proxy writes - its prefix, then
        # `upstream answered N for` or `cannot load|refresh credentials:`,
        # the spellings in bin/dejima-credential-proxy - not to a phrase
        # appearing anywhere. The log also carries text chosen elsewhere: an
        # upstream error-body prefix, an exception echoing the session's
        # request path. Classified on a floating match, a token endpoint
        # whose error body said `answered 503` fabricated a status count
        # here, and a session whose only lines were connection failures was
        # reported as unable to authenticate. Each section prints on its own
        # evidence, and a session can have earned both.
        _audit_counts="$(awk '{ if (match($0, /^[0-9:]* dejima-credential-proxy: upstream answered [0-9]+ for /)) {
                   match($0, / answered [0-9]+ for /)
                   n[substr($0, RSTART + 10, RLENGTH - 15)]++ } }
             END { for (s in n) printf "  %d x HTTP %s\n", n[s], s }' \
            "$SANDBOX_CREDENTIAL_PROXY_LOG")"
        _audit_api_last="$(awk '{ if (match($0, /^[0-9:]* dejima-credential-proxy: upstream answered [0-9]+ for /))
                   line = $0 }
             END { print line }' "$SANDBOX_CREDENTIAL_PROXY_LOG" \
               | sed 's/^[0-9:]* dejima-credential-proxy: //')"
        # The `cannot ...` line names what failed; the line the proxy writes
        # right after it is the remedy, and is the one an operator can act on.
        _audit_cred="$(awk '
            { if (match($0, /^[0-9:]* dejima-credential-proxy: cannot (load|refresh) credentials: /)) {
                  cred = $0; want = NR + 1; remedy = "" }
              else if (NR == want) remedy = $0 }
            END { if (cred != "") { print cred; if (remedy != "") print remedy } }' \
            "$SANDBOX_CREDENTIAL_PROXY_LOG" \
               | sed 's/^[0-9:]* dejima-credential-proxy: //; s/^/  /')"
        _audit_last="$(tail -n 1 "$SANDBOX_CREDENTIAL_PROXY_LOG" \
               | sed 's/^[0-9:]* dejima-credential-proxy: //')"
        if [ -n "$_audit_counts" ]; then
            printf '\n%sThe model API answered with an error this session:%s\n' \
                "${C_WARN:-}" "${C_RESET:-}" >&2
            printf '%s\n' "$_audit_counts" >&2
            printf '  most recent: %s\n' "$_audit_api_last" >&2
            # Said because the count above cannot distinguish them: a harness
            # that retried a 429 or a 5xx may well have got its answer in the
            # end, so this is not on its own a session that failed.
            printf '  A retried status may still have succeeded; this is what the API said,\n' >&2
            printf '  not a verdict on the session.\n' >&2
        fi
        if [ -n "$_audit_cred" ]; then
            # The proxy logged a failure the API never answered - it could not
            # load or refresh the credential, and no request was made. Saying
            # "the model API answered with an error" there sends an operator to
            # check the provider's availability, and the retry caveat above it
            # invites them to hope it succeeded anyway. Neither is true.
            printf '\n%sThe session could not authenticate to the model API:%s\n' \
                "${C_WARN:-}" "${C_RESET:-}" >&2
            printf '%s\n' "$_audit_cred" >&2
        fi
        if [ -z "$_audit_counts" ] && [ -z "$_audit_cred" ]; then
            # Something else: a request that never reached the upstream, a
            # body the proxy refused. Neither an API answer nor a credential
            # failure, so this claims neither and shows the line instead.
            printf '\n%sThe credential proxy reported a problem this session:%s\n' \
                "${C_WARN:-}" "${C_RESET:-}" >&2
            printf '  %s\n' "$_audit_last" >&2
        fi
        unset _audit_counts _audit_last _audit_cred _audit_api_last
    fi
    rm -f "$SANDBOX_SETTINGS_FILE" "$SANDBOX_CLAUDE_JSON_FILE" "${SANDBOX_NVIM_LOCKFILE:-}" "${SANDBOX_FAKE_CREDENTIALS_FILE:-}" "${SANDBOX_DERIVED_DOCKERFILE:-}" "${SANDBOX_BRIEFING_FILE:-}" "${SANDBOX_CREDENTIAL_PROXY_LOG:-}" || true
    rm -rf "${SANDBOX_GITCONFIG_DIR:-}" "${SANDBOX_CREDENTIAL_PROXY_DIR:-}" "${SANDBOX_DERIVED_CONTEXT:-}" "${SANDBOX_HARNESS_STAGE_DIR:-}" || true
    rm -f "${DEJIMA_SESSION_FILE:-}" || true
    # dejima-notify-watch keys its debounce stamps by run id. Removed here,
    # with the registry entry, because this is the only exit both of them are
    # guaranteed to reach: dejima-attach's prune also removes them, but that
    # branch needs a registry entry still present for a container that is
    # gone, which a normal exit has just made impossible. Unguarded, unlike
    # the marker directory below: the debounce directory is host-owned and
    # never mounted into a sandbox.
    if [ -n "${DEJIMA_RUN_ID:-}" ] && [ -n "${DEJIMA_STATE_DIR:-}" ]; then
        rm -f "$DEJIMA_STATE_DIR/debounce/$DEJIMA_RUN_ID-waiting" \
              "$DEJIMA_STATE_DIR/debounce/$DEJIMA_RUN_ID-done" || true
    fi
    # Unlike the silent `|| true` guards above, this removal (and, before
    # the audit below was moved in, the last command of an EXIT trap) warns
    # out loud when it fails: the marker directory is bind-mounted
    # read-write, so a session can leave it undeletable (an unwritable
    # subdirectory is enough), and under set -e a failing command in an EXIT
    # trap REPLACES the shell's exit status - so without this a compromised
    # session could report 1 in place of Claude Code's real exit code. Same
    # guard, for the same reason, as dejima-attach's prune and
    # dejima-notify-watch's marker consumption.
    rm -rf "${DEJIMA_NOTIFY_DIR:-}" \
        || echo "warning: could not remove the marker directory ${DEJIMA_NOTIFY_DIR:-}" >&2
    # This launch's egress control, and the network under it. The sandbox has
    # already exited by the time the trap runs, so nothing is left holding the
    # namespace - Docker permits removing an owner from under a live joiner
    # anyway, which is why the order here is deliberate rather than incidental.
    #
    # `|| true` on both, like every other removal in this trap: a Docker daemon
    # that has gone away must not replace the session's exit status. What that
    # leaves behind is inert - a filter container filters a namespace nothing
    # is in - and a later launch reconciles it.
    if [ -n "${SANDBOX_FILTER_CONTAINER:-}" ]; then
        docker rm -f "$SANDBOX_FILTER_CONTAINER" >/dev/null 2>&1 || true
    fi
    if [ -n "${SANDBOX_RUN_NETWORK:-}" ]; then
        docker network rm "$SANDBOX_RUN_NETWORK" >/dev/null 2>&1 || true
    fi
    # The five volumes an isolated launch named for itself.
    #
    # What keeps them out of every other session is the name rather than this
    # line: it carries this run's id and nothing else computes it, so a daemon
    # that has gone away leaves volumes nobody can ask for. What this line is
    # for is that they do not accumulate - one set per launch, and the
    # working-directory volume among them holds everything the session cloned
    # or wrote, with nothing else on the host to prune it.
    if [ -n "${SANDBOX_ISOLATED_VOLUMES:-}" ]; then
        # Deliberately unquoted - one name per word, all of them dejima's own.
        # shellcheck disable=SC2086
        docker volume rm $SANDBOX_ISOLATED_VOLUMES >/dev/null 2>&1 || true
    fi
    # And this session's share if it is still empty. `rmdir` rather than a
    # test, because only an empty directory can be removed that way and the
    # check is then the removal. It covers both a session that handed nothing
    # back and a launch refused after the directory was made - every refusal
    # that can happen after it, which is most of them, otherwise left one
    # behind for an operator to find and wonder about. An ordinary project's
    # share is not touched: that one is expected to outlive its sessions.
    if [ "$DEJIMA_ISOLATED" = 1 ] && [ -n "${SANDBOX_SHARE_DIR:-}" ]; then
        rmdir "$SANDBOX_SHARE_DIR" 2>/dev/null || true
    fi
    exit_step "session ended - keeping what it compacted away"
    # Keep whatever the session compacted away, into a store nothing mounts.
    # `|| true` for the same reason every other line in this trap has it: a
    # failure here must not replace the container's exit status.
    #
    # Ahead of the audit rather than after it, so the audit keeps the last
    # word (see below): this prints a line of its own whenever it keeps
    # something, and that line was landing under the verdict.
    # Gated here as well as inside the tool, because a flag override lives in
    # this process and the tool re-reads the config for itself - it is also
    # run by hand, where an environment this launcher happened to have would
    # be the wrong authority. Same reason the shipper is gated at its start.
    #
    # DEJIMA_EPITAPH_NO_SHIP carries the one part of --no-log that this
    # process cannot decide for the tool: whether to push what it kept. It
    # only ever suppresses, so a stale export makes epitaph quieter and never
    # louder, which is what makes it safe to honour from the environment at
    # all.
    #
    # An isolated launch reaches this with the key already overridden off, up
    # where the flag is read: an epitaph is taken from a transcript on the
    # host and that mode mounts none, so there is nothing to keep. Decided
    # there rather than here so that the banner and --print-config say the
    # same thing this does.
    if [ "$(config_get_bool epitaph enabled)" = "1" ]; then
        DEJIMA_EPITAPH_NO_SHIP="$NO_LOG" \
            "$SCRIPT_DIR/dejima-epitaph" save "$WORKSPACE_DIR" || true
    fi
    # Last writer, structurally: the operator reads bottom-up from their
    # shell prompt, so anything printed after the verdict pushes it out of
    # view. The marker-directory warning above is the one line a session can
    # deliberately provoke, so the audit runs after it, not before - and gets
    # the same set -e guard for the same reason: a failing audit invocation
    # must not replace Claude Code's real exit status either. SANDBOX_AUDIT_
    # STATE is dropped from the rm -f above and freed only after compare has
    # read it, so this function's own cleanup can't race the audit that
    # needs it - and its removal is guarded too, so it stays truly last.
    exit_step "checking what changed on the host while it ran"
    # DEJIMA_RW_SOURCES, not DEJIMA_RW_MOUNTS: this report names what on the
    # HOST the session could have written, and for a per-project mount that is
    # the slice rather than the root it sits in. Naming the root would claim a
    # blast radius the per-project split exists to have narrowed.
    #
    # Not run for an isolated launch, which has no host-side surface for the
    # audit to be about: its subject is the workspace's own files, and there
    # is no workspace. The one thing such a session can write is ~/shared,
    # and this never hashed that in any mode.
    if [ "$DEJIMA_ISOLATED" = 0 ]; then
        DEJIMA_RW_MOUNTS="$DEJIMA_RW_SOURCES" "$SCRIPT_DIR/dejima-audit" compare "$WORKSPACE_DIR" "$SANDBOX_AUDIT_STATE" || true
    fi
    rm -f "$SANDBOX_AUDIT_STATE" || true
}
trap cleanup EXIT

# Git commits inside the sandbox need a user.name/user.email - carry over a
# copy of the host's global gitconfig, read-write: entrypoint.sh's
# `gh auth setup-git` (what makes GitHub read-only from in here - see
# README) needs to *write* a credential-helper entry into it.
#
# A whole directory, not a single file bind-mounted at ~/.gitconfig: `git
# config` writes via the usual write-temp-then-rename-over-original pattern,
# which fails with "Device or resource busy" on a single-file bind mount -
# Linux won't let that mount point's backing inode be replaced, only a file
# *within* a mounted directory can be freely renamed over. Point git at the
# copy via GIT_CONFIG_GLOBAL instead of relying on it being at ~/.gitconfig.
# Nothing written in here ever reaches the user's real ~/.gitconfig either
# way, since it's a copy in its own directory, not the real file/parent.
SANDBOX_GITCONFIG_DIR="$(mktemp -d "${TMPDIR:-/tmp}/dejima-gitconfig.XXXXXX")"
if [ -f "$HOME/.gitconfig" ]; then
    cp "$HOME/.gitconfig" "$SANDBOX_GITCONFIG_DIR/.gitconfig"
    # A gitconfig can hold credentials, so the copy does not carry them.
    #
    # Three shapes, all ordinary rather than exotic: a `credential.helper`
    # that prints a token (or is a `!` command that fetches one), an
    # `http.<url>.extraHeader` holding an Authorization line, and userinfo
    # embedded in a `url.<...>.insteadOf` key - where the secret is in the key
    # name itself. None of them is part of what the sandbox is granted: the
    # session gets the ssh key and forge token dejima manages, and
    # `gh auth setup-git` writes its own helper inside the container.
    #
    # Dropped by name from the copy rather than by editing the operator's
    # file, which is never touched.
    while IFS= read -r _gc_key; do
        case "${_gc_key,,}" in
            credential.*|*.extraheader) ;;
            url.*insteadof) case "$_gc_key" in *@*) ;; *) continue ;; esac ;;
            *) continue ;;
        esac
        git config -f "$SANDBOX_GITCONFIG_DIR/.gitconfig" --unset-all "$_gc_key" 2>/dev/null || true
        _gc_dropped=1
    done < <(git config -f "$SANDBOX_GITCONFIG_DIR/.gitconfig" --list --name-only 2>/dev/null || true)
    if [ -n "${_gc_dropped:-}" ]; then
        echo "dejima: credential settings in ~/.gitconfig were left out of the copy" >&2
        echo "the session gets. Its git access is the key and token dejima manages." >&2
    fi
    unset _gc_key _gc_dropped
else
    touch "$SANDBOX_GITCONFIG_DIR/.gitconfig"
fi
# The selected identity's git name, appended to the COPY. Appended rather than
# rewritten because git takes the last value for a key, so this needs no
# knowledge of what the operator's own file already says - and because the
# copy is the only thing being edited, the real ~/.gitconfig is untouched
# whatever happens here.
#
# `git config -f` rather than a printf of "[user]\n\tname = ...": the value is
# the operator's own text, and git quotes and escapes its own file format
# correctly where a printf would produce something git reads differently the
# moment a name contains a quote or a backslash.
for _git_field in name email; do
    _git_value="$(identity_git_field "user_$_git_field")" || _git_value=""
    [ -n "$_git_value" ] || continue
    git config -f "$SANDBOX_GITCONFIG_DIR/.gitconfig" "user.$_git_field" "$_git_value" \
        || { echo "could not set user.$_git_field from [identity] - is git installed?" >&2; exit 1; }
done
unset _git_field _git_value

export WORKSPACE_DIR
# The whole mount spec for the working directory, because the compose file
# cannot build it from a source and a target: an isolated launch mounts no
# host path there at all, and that is a spec with no colon in it rather than
# an empty one.
#
# An isolated launch names the compose file's isolated-work volume, which
# gets this run's own name below and is removed with the other four. A bare
# target would have asked for an anonymous volume instead, which is just as
# unreachable by another session but leaves the reaping to whatever `compose
# run --rm` does with one - and an unreaped workspace volume holds everything
# the session cloned, once per launch, with nothing to prune it.
if [ "$DEJIMA_ISOLATED" = 1 ]; then
    export SANDBOX_WORKSPACE_MOUNT="isolated-work:$WORKSPACE_DIR"
else
    export SANDBOX_WORKSPACE_MOUNT="$WORKSPACE_DIR:$WORKSPACE_DIR"
fi
# The image's user is built to match this uid/gid/username exactly (see
# Dockerfile) - this is what makes $HOME-based absolute paths (dotfiles,
# ~/.claude's plugin bookkeeping) resolve identically inside the container.
# Assigned before export, not `export X="$(...)"`: export always succeeds,
# so the combined form returns the export's status and set -e never sees a
# failing substitution. Split, a failure here stops the launch.
SANDBOX_USER="$(id -un)"; export SANDBOX_USER
SANDBOX_UID="$(id -u)"; export SANDBOX_UID
SANDBOX_GID="$(id -g)"; export SANDBOX_GID
export SANDBOX_SHARE_DIR
export SANDBOX_SETTINGS_FILE
export SANDBOX_CLAUDE_JSON_FILE

# Everything dejima copies into a container, checked for credential-shaped
# values it does not already know about.
#
# The two leaks found by hand (#291) were both this: a host file copied
# wholesale, holding a credential nobody had classified as one. The fixes for
# those are denylists, which encode last week's knowledge - so this looks for
# the shape instead, and asks rather than assuming.
#
# The scanner never receives a decision and never sees this function's
# answers; it classifies, and reports redacted paths with opaque ids. Real
# paths stay inside it, which matters because a gitconfig can carry userinfo
# in a section header - a path that IS a credential.
DEJIMA_SCAN_DECISIONS="$(config_root)/credential-scan.decisions"

_scan_record() {  # <decision> <id> <label> <redacted path> <shape>
    mkdir -p "$(dirname "$DEJIMA_SCAN_DECISIONS")" || return 1
    # The id is what matches; the rest is so the file can be read and revised
    # by hand. Keyed on the field rather than the value, so rotating a
    # credential does not re-ask a question already answered.
    printf '%s\t%s\t# %s %s (%s)\n' \
        "$1" "$2" "$3" "$4" "$5" >> "$DEJIMA_SCAN_DECISIONS"
}

# Applies the operator's standing decisions, asks about the rest, and returns
# nonzero to stop the launch.
_scan_staged_file() {  # <path> <format> <label>
    local file="$1" fmt="$2" label="$3"
    local findings ids_allow ids_scrub verdict fid flabel fpath shape answer
    [ -f "$file" ] || return 0
    [ -x "$SCRIPT_DIR/dejima-credential-scan" ] || return 0

    ids_allow="$(mktemp)"; ids_scrub="$(mktemp)"
    # Standing decisions are ids in the first two fields; feed the allow ones
    # in as "already answered" so the scanner does not report them at all.
    if [ -f "$DEJIMA_SCAN_DECISIONS" ]; then
        awk -F'\t' '$1 == "allow" { print $2 }' "$DEJIMA_SCAN_DECISIONS" > "$ids_allow"
        awk -F'\t' '$1 == "scrub" { print $2 }' "$DEJIMA_SCAN_DECISIONS" > "$ids_scrub"
    fi

    findings="$("$SCRIPT_DIR/dejima-credential-scan" --file "$file" --format "$fmt" \
        --label "$label" --known-safe "$SCRIPT_DIR/dejima-credential-scan-known-safe" \
        --decided "$ids_allow" 2>/dev/null)" || {
        # An unparseable file is not a finding, and not a reason to stop: it
        # is copied as-is today and the scanner simply cannot speak for it.
        rm -f "$ids_allow" "$ids_scrub"
        echo "dejima: could not scan $label for credentials; it is copied unchecked." >&2
        return 0
    }

    # Decided BEFORE the loop below, which redirects stdin from a here-string
    # to read the findings - inside it `[ -t 0 ]` is false however the launch
    # was started, so asking there would mean never asking at all.
    local interactive=0
    [ -t 0 ] && interactive=1
    local pending=0
    while IFS=$'\t' read -r verdict fid flabel fpath shape; do
        [ -n "$fid" ] || continue
        # Already told to scrub this one: do it and say nothing.
        if grep -qxF "$fid" "$ids_scrub" 2>/dev/null; then continue; fi
        pending=1
        echo "" >&2
        echo "dejima found something credential-shaped in a file it is about to" >&2
        echo "copy into the container." >&2
        echo "" >&2
        # Padded to the longest label so the values line up. "looks like" is
        # that label, and pushing five short ones out of line to accommodate
        # it reads worse than two extra spaces on each.
        printf '  %-10s  %s\n' file "$flabel" >&2
        printf '  %-10s  %s\n' field "$fpath" >&2
        printf '  %-10s  %s\n' 'looks like' "$shape ($verdict)" >&2
        printf '  %-10s  %s\n' harness "$DEJIMA_HARNESS_NAME" >&2
        printf '  %-10s  %s\n' backend "${DEJIMA_BACKEND_NAME:-(none)}" >&2
        printf '  %-10s  %s\n' id "$fid" >&2
        echo "" >&2
        echo "The value itself is not shown, and dejima has not looked at what it" >&2
        echo "means - only at its shape. Finding it at all is best effort: this" >&2
        echo "recognises common credential shapes and can miss an unusual one." >&2
        echo "" >&2
        echo "If this is a credential dejima should know about, the six lines" >&2
        echo "above are what to send. Never the value." >&2
        echo "" >&2
        if [ "$interactive" = 0 ]; then
            echo "Nothing is attached to answer, so this launch stops rather than" >&2
            echo "carrying it in. Record a decision by running dejima from a" >&2
            echo "terminal once, or add a line to" >&2
            echo "$DEJIMA_SCAN_DECISIONS:" >&2
            echo "" >&2
            # Real tabs: the file is tab-separated, and a line written
            # with spaces silently fails to match.
            printf '    scrub\t%s\n' "$fid" >&2
            printf '    allow\t%s\n' "$fid" >&2
            echo "" >&2
            echo "A standing scrub is not offered at the prompt, because removing" >&2
            echo "something the harness needs breaks every launch after. Written" >&2
            echo "here it is your decision, and the line above is how to undo it." >&2
            rm -f "$ids_allow" "$ids_scrub"
            return 1
        fi
        # Scrubbing is deliberately one launch only. Removing a field the
        # harness turns out to need breaks it on every launch after, and the
        # cause is several layers from the symptom - so the safe answer is
        # not also the sticky one. A standing scrub is still possible by
        # writing the line into the decisions file by hand, which is a
        # deliberate act by someone who has read what it does.
        echo "  s) keep it out of the container this launch, and ask again next time" >&2
        echo "  a) let it into the container, every launch" >&2
        echo "  o) let it into the container this launch, and ask again next time" >&2
        echo "  r) refuse - stop this launch" >&2
        echo "" >&2
        printf 'Which? [s/a/o/r] ' >&2
        read -r answer </dev/tty || answer=r
        case "$answer" in
            s|S) printf '%s\n' "$fid" >> "$ids_scrub" ;;
            a|A) _scan_record allow "$fid" "$flabel" "$fpath" "$shape" ;;
            o|O) echo "Allowed for this launch only; nothing recorded." >&2 ;;
            # Anything else, including an empty line, is the refusal - the
            # answer that changes nothing.
            *)   echo "Refused. Nothing was copied." >&2
                 rm -f "$ids_allow" "$ids_scrub"
                 return 1 ;;
        esac
    done <<< "$findings"

    if [ -s "$ids_scrub" ]; then
        "$SCRIPT_DIR/dejima-credential-scan" --file "$file" --format "$fmt" \
            --label "$label" --scrub "$ids_scrub" \
            || echo "dejima: could not scrub $label; it is copied as it is." >&2
    fi
    [ "$pending" = 0 ] || true
    rm -f "$ids_allow" "$ids_scrub"
    return 0
}

export SANDBOX_GITCONFIG_DIR
# Rule 4's other half: a workflow that never pushes gets no agent socket
# forwarded either - left empty here, docker-compose.yml's
# ${SSH_AUTH_SOCK:-/dev/null} mounts nothing.
[ "$DEJIMA_WORKFLOW_PUSH" = "on" ] || SSH_AUTH_SOCK=""
# And neither does a launch that configured its own key. entrypoint.sh loads
# that key into an agent of its own and points SSH_AUTH_SOCK at it, so the
# forwarded one was already unused - but it stayed mounted at the path
# docker-compose.yml names, where `SSH_AUTH_SOCK=/ssh-agent ssh ...` signs with
# the operator's personal identities. Choosing a dedicated key has to mean the
# personal agent is not in the container, not that it is merely not the default.
[ "$have_usable_key" = 0 ] || SSH_AUTH_SOCK=""
export SSH_AUTH_SOCK
export SANDBOX_SSH_KEY_FILE="$SANDBOX_SSH_KEY"
SSH_KNOWN_HOSTS="$(config_get core known_hosts)"; export SSH_KNOWN_HOSTS
export GH_TOKEN="${GH_TOKEN:-}"
# What the SESSION gets, which is not the same question as what the filter
# container gets. GH_TOKEN above goes to the filter, where it authenticates
# the GitHub ranges fetch - a request dejima makes about its own allowlist,
# not something the session can reach - so that one crosses whatever the
# session is trusted with.
#
# This is core's read-only GitHub credential, filled from [github] token_file
# for every session whatever forge is selected. Read-only across an
# operator's private repositories is still account-wide reach, so an isolated
# launch does not get it: the mode's claim is that it carries no forge
# credential, and that claim has to cover the one core fills in as well as
# the one a forge declares.
#
# Unless this session has a forge to reach, which is the operator saying it is
# meant to. Then nothing here is narrowed and the selection decides, exactly
# as for any other launch.
#
# Read off the RESOLVED forge's capabilities rather than off whether a --forge
# flag was passed. `--forge` naming the no-capability forge is an operator
# asking for no forge, and it was counted as naming one - so the launch that
# said most clearly that it wanted no forge access was the one that got the
# credential. Capabilities rather than the forge's name, because core may not
# name a forge, and because "a forge that can do nothing has no use for a
# credential" is the actual rule.
SANDBOX_GH_TOKEN="$GH_TOKEN"
if [ "$DEJIMA_ISOLATED" = 1 ] \
    && [ -z "$(manifest_get forge "$DEJIMA_FORGE_NAME" capabilities)" ]; then
    SANDBOX_GH_TOKEN=""
fi
export SANDBOX_GH_TOKEN
export ALLOWED_DOMAINS="${ALLOWED_DOMAINS:-}"
# The --allow-github-push flag and a selected forge whose hosts_allowlisted_by_core
# says these are its own push hosts (see ALLOW_CORE_PUSH_GRANT above) are the
# only two ways to grant this - same principle as ALLOWED_DOMAINS above: this
# opens a push path, so an exported value already sitting in the operator's
# shell profile must not be able to silently grant it, with nothing recording
# that it did. Both ways in are recorded: one in the command that used the
# flag, the other in the config file that selected the forge.
if [ "$ALLOW_CORE_PUSH_GRANT" = 1 ]; then
    export ALLOW_GITHUB_PUSH=1
else
    # Plain assignment, not "${ALLOW_GITHUB_PUSH:-0}" - same reason
    # ALLOWED_DOMAINS above is plain. This opens a push path, so an exported
    # value in a shell profile must not be able to grant it silently.
    export ALLOW_GITHUB_PUSH=0
fi
export TZ="${TZ:-$(readlink -f /etc/localtime 2>/dev/null | sed 's#.*/zoneinfo/##')}"

# core.command permits a leading "-" (it is validated as a generic identifier,
# shared with other keys that have no such concern) but entrypoint.sh expands
# it unquoted into `gosu "$SANDBOX_USER" $SANDBOX_CMD "$@"` - a leading dash
# there would be parsed as an option to gosu, not as the command.
# Refuse here, at the point this value is wired in, rather than relax the
# schema type for every other identifier-typed key.
SANDBOX_CMD="$(config_get core command)"
case "$SANDBOX_CMD" in
    -*)
        echo "invalid value for core.command: must not start with '-': $SANDBOX_CMD" >&2
        exit 1
        ;;
esac
export SANDBOX_CMD

# What the wrapper inside the container needs to shadow the harness's binary
# and pin its flags, resolved here because the manifests are host-side data
# the container never sees. Every value is the selected harness's own; none
# of it is written down in the image.
#
# SANDBOX_HARNESS_COMMAND is the name to shadow, and is the manifest's rather
# than [core] command: overriding the latter to `nvim` changes what dejima
# launches, not which harness is wrapped, and an editor-driven session that
# then runs the harness from a terminal must still get the flags.
SANDBOX_HARNESS_COMMAND="$(manifest_get harness "$DEJIMA_HARNESS_NAME" command)"
SANDBOX_HARNESS_REAL="$(manifest_get harness "$DEJIMA_HARNESS_NAME" command_path)"
SANDBOX_HARNESS_ARGS="$(manifest_get harness "$DEJIMA_HARNESS_NAME" no_confirm_args)"
# Named in full rather than templated: the settings layer's container-side
# path is dejima's own fixed contract (docker-compose.yml mounts it there),
# not a value that varies per run, and {{...}} in a manifest belongs to the
# briefing composer's substitution rather than to a second mechanism that
# merely looks like it.
SANDBOX_HARNESS_SETTINGS="$(manifest_get harness "$DEJIMA_HARNESS_NAME" settings_args)"
# Refused here rather than as the wrapper's exit 127 inside a container that
# has already started.
if [ -z "$SANDBOX_HARNESS_REAL" ]; then
    echo "Harness '$DEJIMA_HARNESS_NAME' declares no command_path." >&2
    echo "Without it the wrapper has no binary to shadow." >&2
    exit 1
fi
# Unattended operation is R1 of the capability bar, and a harness whose
# manifest names no way to ask for it would run asking for confirmation on
# every tool call, against a container built on the premise that it does not.
# That fails silently and looks like the harness hanging, so refuse instead.
if [ -z "$SANDBOX_HARNESS_ARGS" ]; then
    echo "Harness '$DEJIMA_HARNESS_NAME' declares no no_confirm_args." >&2
    echo "dejima runs unattended; a harness that stops to ask cannot be run that" >&2
    echo "way. See \"What a harness must be able to do\" in docs/development.md." >&2
    exit 1
fi
# The names this harness's own machinery must reach. Its API endpoint and
# update host are as much a property of the harness as its command is, and
# they were built into the image's fixed list where a second harness would
# have inherited them and still lacked its own.
#
# They go into that fixed list rather than the extra-domains one, which is
# the distinction docker/fixed-domains.sh explains: the extra list is exempt
# from stop-dns-rebind and a failed lookup there is only a warning, both of
# which are wrong for a public SaaS name the session cannot work without.
#
# Manifest data reaching the firewall is the same arrangement a forge's
# fixed_hosts already has, and since #142 the provider directories are in
# the audit's TCB list, so widening this unseen is a CRITICAL finding.
SANDBOX_HARNESS_DOMAINS="$(manifest_get harness "$DEJIMA_HARNESS_NAME" api_domains)"

# Only a skippable answer. Whether the credential is WELL FORMED is the proxy's
# own startup check, which fails the launch loudly rather than falling back -
# a fallback for a malformed key would be a session running with the
# operator's real credential mounted in.
_credential_usable() {  # <kind> <path> [field]
    case "$1" in
        anthropic-oauth) jq -e '.claudeAiOauth.accessToken' "$2" >/dev/null 2>&1 ;;
        static)
            if [ -n "${3:-}" ]; then
                # A named field has to actually be there. Without this a
                # ~/.claude.json with no primaryApiKey passes the gate, the
                # proxy starts, and the first request fails with an
                # authentication error several layers from the cause.
                jq -e --arg f "$3" \
                    'getpath($f | split(".")) | strings | length > 0' \
                    "$2" >/dev/null 2>&1
            else
                [ -s "$2" ]
            fi
            ;;
        *)               return 1 ;;
    esac
}

# Which shipped backends could serve a credential that is actually on this
# host, for the harness this launch selected.
#
# Refusing without this leaves an operator guessing between backends by name,
# which is exactly the report that produced it: an "anthropic" that wanted a
# subscription login, and no sign of which backend served the login they
# actually had. Derived rather than listed, so a backend added later is
# suggested the day it lands.
#
# Only ones the harness can talk to, since naming a backend it has no client
# for trades one refusal for another. And only ones whose credential_file
# resolves to something: a backend whose key has no conventional location
# cannot be confirmed present, and guessing would be the same unhelpful
# advice in the other direction.
_backends_that_fit() {
    local b kind file field styles
    styles="$(manifest_get harness "$DEJIMA_HARNESS_NAME" api_styles)"
    for b in $(provider_list backend); do
        kind="$(manifest_get backend "$b" credential)"
        [ -n "$kind" ] && [ "$kind" != none ] || continue
        if [ -n "$styles" ]; then
            _set_contains "$styles" "$(manifest_get backend "$b" api_style)" || continue
        fi
        config_type "backend.$b.credential_file" >/dev/null 2>&1 || continue
        file="$(config_get "backend.$b" credential_file)" || continue
        [ -n "$file" ] || continue
        field="$(manifest_get backend "$b" credential_field)"
        _credential_usable "$kind" "$file" "$field" && printf '%s\n' "$b"
    done
}

# Claude Code can sign in with no stored credential at all, through workload
# identity federation: ANTHROPIC_FEDERATION_RULE_ID and ANTHROPIC_ORGANIZATION_ID
# name the rule, an OIDC identity token comes from ANTHROPIC_IDENTITY_TOKEN or
# ANTHROPIC_IDENTITY_TOKEN_FILE, and it exchanges that for an access token by
# itself. No backend serves it yet.
#
# Detected only to explain a refusal. On such a host there is no credential
# file for any backend to find, so "no backend fits" is true and useless -
# it reads as dejima not seeing a login that is plainly working.
_federation_configured() {
    [ -n "${ANTHROPIC_FEDERATION_RULE_ID:-}" ] \
        || [ -n "${ANTHROPIC_IDENTITY_TOKEN:-}" ] \
        || [ -n "${ANTHROPIC_IDENTITY_TOKEN_FILE:-}" ]
}

# Prints the "use this one" paragraph, or nothing when nothing fits.
_suggest_backends() {  # <harness-section-name>
    local fits
    fits="$(_backends_that_fit)"
    if [ -z "$fits" ] && _federation_configured; then
        echo "" >&2
        echo "This host is set up for workload identity federation, which signs in" >&2
        echo "without storing a credential - so there is nothing for a backend to" >&2
        echo "read, and no backend serves it yet. See issue #290." >&2
        return 0
    fi
    [ -n "$fits" ] || return 0
    echo "" >&2
    if [ "$(printf '%s\n' "$fits" | wc -l)" = 1 ]; then
        echo "A credential for '$fits' is on this host. To use it:" >&2
    else
        echo "Credentials for these are on this host:" >&2
        printf '%s\n' "$fits" | sed 's/^/    /' >&2
        echo "" >&2
        echo "To use one:" >&2
    fi
    echo "" >&2
    echo "    [harness.$1]" >&2
    echo "    backend = $(printf '%s\n' "$fits" | head -1)" >&2
}

# The selected backend - the model API this session talks to - and the name the
# in-container terminator has to answer for.
#
# Two different things, and it is worth keeping them apart.
#
# WHICH backend a launch uses is the harness's setting, [harness.<name>]
# backend. Per harness because that is how it is actually used: Claude Code
# against Anthropic and Cline against somewhere else, in different launches on
# the same machine, which one global setting cannot express.
#
# WHAT that backend looks like is the backend's manifest, including the name
# the terminator answers for. It belongs there rather than on the harness: a
# harness reaches whichever endpoint it is configured for, so with the name
# declared on the harness the terminator would answer for Anthropic while the
# session talked somewhere else - and the session would reach that API
# directly, with the credential the proxy exists to withhold being the one in
# use.
# A harness that declares no `backend` key at all names no endpoint for the
# proxy to stand in front of, and the launch runs without one. That is the
# self-served case: a model on the operator's own network needs no credential
# withheld, because there is none. Asked with config_type rather than by
# swallowing config_get's error, so a real fault reading a key the harness DOES
# declare still stops the launch.
DEJIMA_BACKEND_NAME=""
SANDBOX_BACKEND_PROXIED_DOMAIN=""
# The host of a backend the operator serves themselves, when the selected one
# has a base_url. Empty for every backend dejima proxies.
DEJIMA_BACKEND_HOST=""
DEJIMA_BACKEND_CREDENTIAL_KIND=""
DEJIMA_BACKEND_CREDENTIAL_FILE=""
DEJIMA_BACKEND_CREDENTIAL_FIELD=""
if config_type "harness.$DEJIMA_HARNESS_NAME.backend" >/dev/null 2>&1; then
    DEJIMA_BACKEND_NAME="$(config_get "harness.$DEJIMA_HARNESS_NAME" backend)"
    # Unset, and the harness says what it used to talk to: write that down.
    #
    # A MIGRATION, NOT A DEFAULT, and the difference is the point. A default is
    # dejima's answer re-decided on every read, so changing it later moves a
    # session whose config never changed. This records the operator's answer
    # once, into their own file; after it runs, nothing dejima decides later
    # can move them. It is only ever stated by a harness for which the value is
    # not a guess - what every existing session of that harness already was -
    # so the launch it runs in behaves identically either way.
    #
    # Written INTO the operator's existing section for this harness when there
    # is one, and appended as a new section only when there is not.
    #
    # Appending unconditionally is simpler and produces a config with the same
    # section header twice, which parses correctly and reads as a mistake
    # dejima made in the operator's own file. Reported as exactly that.
    #
    # Inserting immediately after the header is safe here specifically because
    # the key is UNSET: there is no later assignment for a line near the top
    # to lose to, which is the thing that makes editing a config file
    # dangerous in general.
    if [ -z "$DEJIMA_BACKEND_NAME" ]; then
        _assumed="$(manifest_get harness "$DEJIMA_HARNESS_NAME" assumed_backend)"
        if [ -n "$_assumed" ] && [ -w "$(config_path)" ]; then
            _cfg_tmp="$(mktemp "$(dirname "$(config_path)")/.config.XXXXXX")" || exit 1
            awk -v sec="[harness.$DEJIMA_HARNESS_NAME]" \
                -v line="backend = $_assumed" '
                { print }
                !done {
                    t = $0
                    gsub(/^[ \t]+|[ \t]+$/, "", t)
                    if (t == sec) { print line; done = 1 }
                }
                END { if (!done) printf "\n%s\n%s\n", sec, line }
            ' "$(config_path)" > "$_cfg_tmp" || exit 1
            # The operator's file keeps its own permissions rather than a
            # fresh mktemp's.
            chmod --reference="$(config_path)" "$_cfg_tmp" 2>/dev/null || true
            mv "$_cfg_tmp" "$(config_path)" || exit 1
            unset _cfg_tmp
            DEJIMA_BACKEND_NAME="$_assumed"
            # Its own block, and the line that was written picked out: this
            # is dejima editing the operator's config, which is the one
            # startup notice they may want to go and look at.
            printf '\n  %sdejima wrote a line into %s%s\n' \
                "${C_WARN:-}" "$(config_path)" "${C_RESET:-}" >&2
            printf '      [harness.%s]\n      %sbackend = %s%s\n\n' \
                "$DEJIMA_HARNESS_NAME" "${C_KEY:-}" "$_assumed" "${C_RESET:-}" >&2
            printf '  That is what this harness was already talking to, written down so a\n' >&2
            printf '  later change to dejima cannot move it. Nothing about this launch\n' >&2
            printf '  differs because of it.\n' >&2
            DEJIMA_STARTUP_NOTICE=1
        fi
        unset _assumed
    fi

    # Still unset, and nothing could say what it should be: a refusal, not a
    # default.
    #
    # No harness ships one, for the reason [core] harness has none: a default
    # that could change later is a config file that is not the source of
    # truth, and a session whose config did not change must not start talking
    # to a different endpoint because dejima's idea of a sensible one moved.
    # The risk is concrete rather than theoretical for a harness that supports
    # many backends and has one of its own.
    #
    # Declaring the key is how a harness says it talks to a model API at all,
    # so this needs no harness name here: one pointed only at a model on the
    # operator's own network leaves the key out and never reaches this.
    if [ -z "$DEJIMA_BACKEND_NAME" ]; then
        echo "[harness.$DEJIMA_HARNESS_NAME] backend is not set, so nothing says which" >&2
        echo "model API this session talks to." >&2
        echo "" >&2
        echo "    [harness.$DEJIMA_HARNESS_NAME]" >&2
        echo "    backend = anthropic" >&2
        echo "" >&2
        echo "There is deliberately no default: one dejima could change later" >&2
        echo "would move your session to another endpoint without your config" >&2
        echo "changing. --backend <name> sets it for a single launch." >&2
        echo "" >&2
        printf 'Available:' >&2
        for _p in $(provider_list backend); do printf ' %s' "$_p" >&2; done
        printf '\n' >&2
        _suggest_backends "$DEJIMA_HARNESS_NAME"
        exit 1
    fi
fi
if [ -n "$DEJIMA_BACKEND_NAME" ]; then
    # A manifest that omits a required key is not caught by manifest_validate,
    # which only type-checks the keys a file actually has - true of every axis,
    # not just this one. Left unchecked here, an absent upstream reaches the
    # proxy as an empty --upstream and fails as a connection error with no
    # hint of the cause, so the four a backend cannot work without are named
    # where they are read.
    _p_kind="$(manifest_get backend "$DEJIMA_BACKEND_NAME" credential)"
    _p_required="api_style credential"
    # upstream and proxied_domain describe an endpoint dejima stands in front
    # of, so they are required of a proxied backend and meaningless to a
    # direct one - which reaches a model the operator serves, with nothing to
    # withhold and so no proxy at all.
    [ "$_p_kind" = none ] || _p_required="$_p_required upstream proxied_domain"
    for _need in $_p_required; do
        if [ -z "$(manifest_get backend "$DEJIMA_BACKEND_NAME" "$_need")" ]; then
            echo "backend '$DEJIMA_BACKEND_NAME' declares no $_need, so dejima cannot" >&2
            echo "tell where its API is or how to speak to it." >&2
            echo "See docs/providers.md, \"Model backends\", for what a manifest needs." >&2
            exit 1
        fi
    done

    # A harness speaks one wire format or a few, and a backend speaks one.
    # Pairing them wrongly is knowable here and produces nothing but confusion
    # later: well-formed requests in a shape the endpoint does not parse, or a
    # harness writing a configuration for a client it does not have.
    _p_style="$(manifest_get backend "$DEJIMA_BACKEND_NAME" api_style)"
    _h_styles="$(manifest_get harness "$DEJIMA_HARNESS_NAME" api_styles)"
    if [ -n "$_h_styles" ] && ! _set_contains "$_h_styles" "$_p_style"; then
        echo "harness '$DEJIMA_HARNESS_NAME' cannot talk to backend '$DEJIMA_BACKEND_NAME'." >&2
        echo "The backend speaks the '$_p_style' API; this harness speaks: $_h_styles." >&2
        echo "" >&2
        echo "Pick a backend it can talk to, or a harness that speaks '$_p_style'." >&2
        exit 1
    fi
    # What the backend says it cannot work without. Refused here rather than
    # left to the proxy's own startup check, because an unset credential_file
    # does not fail the proxy - it skips it, and the session then reaches a
    # real endpoint holding a placeholder, which fails as an authentication
    # error several layers from the cause.
    for _need in $(manifest_get backend "$DEJIMA_BACKEND_NAME" requires); do
        if [ -z "$(config_get "backend.$DEJIMA_BACKEND_NAME" "$_need")" ]; then
            echo "backend '$DEJIMA_BACKEND_NAME' needs [backend.$DEJIMA_BACKEND_NAME] $_need, which is not set." >&2
            echo "" >&2
            echo "    [backend.$DEJIMA_BACKEND_NAME]" >&2
            case "$_need" in
                *_file)
                    echo "    $_need = /path/to/your/key" >&2
                    echo "" >&2
                    echo "The file stays on the host and is read per request; it is" >&2
                    echo "never mounted into the container." >&2
                    ;;
                base_url)
                    echo "    $_need = http://127.0.0.1:8080/v1" >&2
                    echo "" >&2
                    echo "Where you serve the model. There is deliberately no default:" >&2
                    echo "whatever is at some other address is not your model." >&2
                    ;;
                *)  echo "    $_need = ..." >&2 ;;
            esac
            # A backend asking to be pointed at a key is the other half of the
            # report: an operator who picked this one by its name, when the
            # login they actually have is served by a different backend whose
            # credential is already where dejima can see it.
            _suggest_backends "$DEJIMA_HARNESS_NAME"
            exit 1
        fi
    done
    unset _p_style _h_styles _need _p_required
    SANDBOX_BACKEND_PROXIED_DOMAIN="$(manifest_get backend "$DEJIMA_BACKEND_NAME" proxied_domain)"
    DEJIMA_BACKEND_CREDENTIAL_KIND="$(manifest_get backend "$DEJIMA_BACKEND_NAME" credential)"
    if config_type "backend.$DEJIMA_BACKEND_NAME.credential_file" >/dev/null 2>&1; then
        DEJIMA_BACKEND_CREDENTIAL_FILE="$(config_get "backend.$DEJIMA_BACKEND_NAME" credential_file)"
    fi
    # The operator's, not the manifest's: one static arrangement serves both
    # a key inside a JSON document and a file that is nothing but the key.
    if config_type "backend.$DEJIMA_BACKEND_NAME.credential_field" >/dev/null 2>&1; then
        DEJIMA_BACKEND_CREDENTIAL_FIELD="$(config_get "backend.$DEJIMA_BACKEND_NAME" credential_field)"
    fi
    # A backend the operator serves rather than one dejima stands in front of:
    # where it is comes from their own config, and the sandbox has to be able
    # to reach it or the session has no model at all.
    #
    # Handed to the firewall path the way the forge's hosts and Loki's are.
    # Without this the endpoint is reachable only if the operator ALSO approves
    # its name as a domain - which works, and then looks like any other
    # approved public name, so a self-hosted endpoint on their own network gets
    # the answer check meant for names nobody reviewed. dejima configured this
    # address; there is nothing for an answer to decide about it.
    #
    # Only for a backend dejima does NOT stand in front of, which today is the
    # same set as "has a base_url" and is guarded rather than assumed. A
    # backend with both would be proxied AND have its host permitted and
    # exempted: that re-opens the DNS bypass leaving a proxied name
    # unresolvable exists to close, and hands a public SaaS name the exemption
    # fixed-domains.sh withholds from it on purpose. No manifest declares both,
    # and this is what keeps that from being a coincidence.
    if [ -z "$SANDBOX_BACKEND_PROXIED_DOMAIN" ] \
        && config_type "backend.$DEJIMA_BACKEND_NAME.base_url" >/dev/null 2>&1; then
        DEJIMA_BACKEND_HOST="$(config_get "backend.$DEJIMA_BACKEND_NAME" base_url \
            | sed -E 's#^[a-zA-Z]+://##; s#[:/].*##')"
    fi

fi
# Everything staged for the container, checked before anything is put back
# into it. Placed here rather than beside the copies so a finding can name
# the harness and backend it belongs to, which is most of a useful report.
# The tildes are deliberate and must not expand: this argument is the name to
# SHOW an operator and to key their decisions on, not a path to open. Keyed on
# the label rather than the temp path so a decision survives the next launch,
# which stages the same file somewhere else entirely.
# shellcheck disable=SC2088
_scan_staged_file "$SANDBOX_CLAUDE_JSON_FILE" json '~/.claude.json' || exit 1
# shellcheck disable=SC2088
_scan_staged_file "$SANDBOX_GITCONFIG_DIR/.gitconfig" ini '~/.gitconfig' || exit 1

# Runs after the scan above, deliberately. The scan reads the safe copy and
# asks about anything credential-shaped in it; this then puts a credential
# back, because the operator asked for exactly that. Re-seeding first would
# have the scanner ask about the thing they just chose.
if [ -n "$DEJIMA_BACKEND_NAME" ]; then
    # The deliberate downgrade, for a backend whose credential lives IN the
    # file dejima stages.
    #
    # ~/.claude.json is copied in with its credential field stripped, which is
    # what keeps a console login's key on the host. With the proxy turned off
    # for this backend the operator has asked for the opposite, and stripping
    # anyway would leave the session with no credential and no way to supply
    # one - the file is a throwaway copy, so there is nothing else to mount.
    #
    # Re-seeded rather than never stripped, so the safe form is what every
    # other path gets and this is one explicit step away from it.
    #
    # Through the same narrowing, with only the credential kept. It was a
    # plain `cp`, which also put back the `projects` map #381 narrowed - every
    # other project's path, filenames and costs - and did it after the
    # credential scan had read the narrowed copy, so nothing ever looked at
    # what came back.
    if [ "$DEJIMA_BACKEND_CREDENTIAL_KIND" != none ] \
        && [ -n "$DEJIMA_BACKEND_CREDENTIAL_FIELD" ] \
        && ! backend_proxy_enabled "$DEJIMA_BACKEND_NAME" \
        && [ -f "$HOME/.claude.json" ] \
        && [ "$(readlink -m "$DEJIMA_BACKEND_CREDENTIAL_FILE")" \
             = "$(readlink -m "$HOME/.claude.json")" ]; then
        _stage_claude_json true "$SANDBOX_CLAUDE_JSON_FILE" \
            || { echo "$HOME/.claude.json stopped being readable as JSON." >&2; exit 1; }
        echo "[backend.$DEJIMA_BACKEND_NAME] proxy is off, so this session gets your real" >&2
        echo "$DEJIMA_BACKEND_CREDENTIAL_FIELD from $HOME/.claude.json." >&2
    fi
fi

# The two ways a briefing reaches a harness that is not handed it by a
# session-start hook. A hook cannot carry a large one: Claude Code persists a
# big payload to a file and injects a short preview in its place - measured in
# a dejima container, 9KB arrived whole and 21KB did not - and a composed
# briefing plus a project's .dejima.md is well past that.
#
# briefing_args names the flag that carries the text on the command line;
# briefing_path names a file the harness reads instead, for one that takes no
# such flag. A homepath in the manifest, so it is already relative and
# traversal-free before it gets here. See docs/providers.md.
SANDBOX_HARNESS_BRIEFING_ARGS="$(manifest_get harness "$DEJIMA_HARNESS_NAME" briefing_args)"
SANDBOX_HARNESS_BRIEFING_PATH="$(manifest_get harness "$DEJIMA_HARNESS_NAME" briefing_path)"
export SANDBOX_HARNESS_COMMAND SANDBOX_HARNESS_REAL SANDBOX_HARNESS_ARGS \
    SANDBOX_HARNESS_SETTINGS SANDBOX_HARNESS_DOMAINS \
    SANDBOX_BACKEND_PROXIED_DOMAIN SANDBOX_HARNESS_BRIEFING_ARGS \
    SANDBOX_HARNESS_BRIEFING_PATH DEJIMA_BACKEND_HOST

# [core] command defaults to the selected harness's own command rather than
# to a literal in core's schema.
[ -n "$SANDBOX_CMD" ] || SANDBOX_CMD="$SANDBOX_HARNESS_COMMAND"
export SANDBOX_CMD
# docker-compose.yml/entrypoint.sh compare these against "1"/"0", not
# "on"/"off" - config_get_bool is what produces that, config_get would pass
# "on"/"off" straight through and (among other things) silently disable the
# firewall by default.
ENABLE_FIREWALL="$(config_get_bool core firewall)"; export ENABLE_FIREWALL
LOKI_URL="$(config_get logging loki_url)"; export LOKI_URL

# Approved names that genuinely resolve inside the operator's own network.
#
# The egress policy refuses an answer for a permitted name that points at a
# private address, because an answer decides what the allowlist contains and an
# answer comes from outside - so one influenced reply for a public name would
# otherwise be egress to an internal host. The configured forge and Loki are
# exempt without being listed here: dejima resolves their addresses itself, so
# there is nothing for an answer to decide. An approved domain is not, because
# a project's declared domains are reviewed with the project and expected to be
# public names.
#
# This is for the case where that expectation is wrong on purpose - an internal
# mirror, a service on the operator's LAN. It exempts and does not grant: a
# name here is reachable only if it is also approved, and firewall-up.sh stops
# the launch when one is not, rather than leaving an exemption attached to
# nothing. Colon-separated in the config file like every other list;
# space-separated from here on, which is what the firewall scripts read.
DEJIMA_INTERNAL_DOMAINS="$(config_get core internal_domains | tr ':' ' ')"
export DEJIMA_INTERNAL_DOMAINS

# Every host that becomes a permitted destination has to name at least two
# labels, checked here because here is where the operator can act on it.
#
# The policy refuses a dotless entry anyway, and correctly: an entry becomes a
# `server=/<entry>/` line, which dnsmasq matches against the name and
# everything under it, so a bare label grants its whole suffix - `org` would
# permit every .org name. There is no exact-name form to use instead. Refusing
# one here is a floor on how much a single entry can grant, not a judgement
# about the name.
#
# But the policy refuses it at container start, which an operator meets as a
# sandbox that will not boot, on a value every check on the way in accepted.
# `valid_domain` cannot simply require a dot: it also gates host-side consumers
# where a bare label is fine. So the requirement lives with the keys that reach
# the firewall, and names the one it found.
#
# The derived hosts are checked, not the values they came from. A URL yields
# its host through a sed, and a slip there - a `base_url` carrying userinfo
# reduces to the user name - produces a bare label nobody typed. That is the
# case this most wants to catch, because it is the one nobody would look for.
_require_fqdn() {  # <config key, for the message> <host>
    [ -n "$2" ] || return 0
    # A host name with at least two labels. Counting dots alone would pass the
    # residue of a derivation that went wrong in a way that happens to leave
    # one: `https://user@host.example/` reduces to `user@host.example`, and
    # `http://loki./` to `loki.`, neither of which is a host name. Those are the
    # slips this exists to catch, so it asks the same question of the value that
    # the policy will ask, rather than a cheaper one.
    if valid_domain "$2"; then
        case "$2" in
            *.*) return 0 ;;
        esac
    fi
    echo "ERROR: $1 gives the host '$2', which this cannot permit." >&2
    echo "" >&2
    echo "A permitted destination has to be a host name of at least two" >&2
    echo "labels. The sandbox's resolver does not forward a dotless query, so" >&2
    echo "a bare label would not resolve; and a permitted name covers" >&2
    echo "everything under it, so a bare label would grant its whole suffix." >&2
    echo "" >&2
    echo "If the value came from a URL, check it parses to the host you meant:" >&2
    echo "userinfo and a trailing dot both survive into the derived name." >&2
    exit 1
}
for _host in ${DEJIMA_FORGE_HOSTS:-}; do
    _require_fqdn "the configured forge's host" "$_host"
done
for _host in ${ALLOWED_DOMAINS:-}; do
    _require_fqdn "an approved domain" "$_host"
done
for _host in ${DEJIMA_INTERNAL_DOMAINS:-}; do
    _require_fqdn "[core] internal_domains" "$_host"
done
_require_fqdn "[backend.$DEJIMA_BACKEND_NAME] base_url" "${DEJIMA_BACKEND_HOST:-}"
if [ -n "${LOKI_URL:-}" ]; then
    _require_fqdn "[logging] loki_url" \
        "$(echo "$LOKI_URL" | sed -E 's#^[a-zA-Z]+://##; s#[:/].*##')"
fi
unset _host

# ~/.claude/hooks may contain symlinks whose real targets live outside
# ~/.claude (this repo's own hooks/, and potentially others - e.g. dotfiles'
# config/claude/hooks/ for deny-dangerous-bash.sh, if that's what a given
# host has installed). docker-compose.yml names no repo's path for this -
# discover what's actually needed here instead, so it's
# correct regardless of what's installed or where this repo itself was
# cloned to.
# Only the directories that are actually mounted in: the top-level files
# (settings.json, CLAUDE.md, statusline.sh) are staged as copies instead, and
# Docker resolves any symlink among them when it sets up that staging mount,
# so their targets don't need to be reachable from inside at all. Scanning
# "." here too would mount a whole config checkout read-only into the
# container for nothing.
extra_mounts=()

# The forge's own env vars, generated from its manifest's env_export list
# rather than hardcoded - see forge_env_exports.
#
# `-e NAME`, never `-e NAME=value`, for the ones that carry a secret - which
# is every `@` reference, since those resolve to a credential FILE'S CONTENTS
# rather than its path. A value in the argument list lands in
# /proc/<pid>/cmdline, which is world-readable: `ps` prints the token to any
# process on the host, and from there into whatever captures ps output. That
# is not a hypothetical - it is how #226 was found, in a paste an operator
# made while debugging something unrelated.
#
# The bare form takes the value from THIS process's environment instead,
# where it sits in /proc/<pid>/environ - mode 0400, owner only.
#
# This repository already made the argument one layer down: a forge's own CLI
# feeds curl its auth header on stdin rather than as an argument, and says why
# in almost these words. The reasoning was right and it had not reached here.
#
# Docker still shows the resolved value to anyone who can `docker inspect`
# the container, which the bare form does not change and cannot. What it
# changes is the audience: daemon access rather than any process at all.
_forge_env_lines=""
if ! _forge_env_lines="$(forge_env_exports "$DEJIMA_FORGE_NAME")"; then
    # forge_env_exports has already explained why on stderr. It can fail
    # partway, having printed some records, so nothing it produced can be
    # trusted - refuse rather than launch with a partial forge environment.
    exit 1
fi
while IFS= read -r _forge_env; do
    [ -n "$_forge_env" ] || continue
    # forge_env_exports emits nothing at all for a value that resolved empty,
    # deliberately - see its own comment on why an empty -e for a variable
    # compose already declares is not harmless. So every name reaching here
    # has a value, and exporting it is what makes the bare -e below carry one.
    _forge_env_name="${_forge_env%%=*}"
    export "${_forge_env_name}=${_forge_env#*=}"
    extra_mounts+=(-e "$_forge_env_name")
done <<< "$_forge_env_lines"
unset _forge_env_name

# The resolved combination, for the briefing and the hooks that read it
# (git-push-guard.sh's host allowlist and no-direct-push branches,
# merge-guard.sh's block) - computed here, unconditionally passed through
# the same way, regardless of which forge and workflow are configured.
extra_mounts+=(-e "DEJIMA_FORGE_NAME=$DEJIMA_FORGE_NAME")
extra_mounts+=(-e "DEJIMA_WORKFLOW_NAME=$DEJIMA_WORKFLOW_NAME")
extra_mounts+=(-e "DEJIMA_HARNESS_NAME=$DEJIMA_HARNESS_NAME")
extra_mounts+=(-e "DEJIMA_FORGE_HOSTS=$DEJIMA_FORGE_HOSTS")
extra_mounts+=(-e "DEJIMA_PUSH_HOSTS=$DEJIMA_PUSH_HOSTS")
extra_mounts+=(-e "DEJIMA_BLOCKED_BRANCHES=$DEJIMA_BLOCKED_BRANCHES")
extra_mounts+=(-e "DEJIMA_MERGE_BLOCK=$DEJIMA_MERGE_BLOCK")
extra_mounts+=(-e "DEJIMA_DISCLOSE=$DEJIMA_DISCLOSE")
extra_mounts+=(-e "DEJIMA_FORGE_REPO=$DEJIMA_FORGE_REPO")
extra_mounts+=(-e "DEJIMA_FORGE_REPO_FLAGS=$DEJIMA_FORGE_REPO_FLAGS")
extra_mounts+=(-e "DEJIMA_FORGE_REPO_PATHS=$DEJIMA_FORGE_REPO_PATHS")
extra_mounts+=(-e "DEJIMA_FORGE_REPO_ENV=$DEJIMA_FORGE_REPO_ENV")

# The harness's own configuration, mounted at the mode it declared, and -
# for the directories it marked scannable - the symlink targets that have to
# come with it.
mounted_dirs=""
for _rec in ${DEJIMA_CONFIG_DIRS[@]+"${DEJIMA_CONFIG_DIRS[@]}"}; do
    IFS=$'\t' read -r dir mounted_mode mounted_scan <<< "$_rec"
    # An isolated launch takes the read-only ones and none of the writable
    # ones. The split is already in the declaration, so this filters on what
    # the harness said rather than on a list of names kept here.
    #
    # The two halves answer differently. A read-only directory is the
    # OPERATOR's - their hooks, skills and commands - and a session cannot
    # write to it, so nothing it holds came from an earlier session and
    # nothing this one does will reach a later one. The writable ones are
    # shared state every project's sessions append to (#380), which is the
    # channel this mode exists to close.
    if [ "$DEJIMA_ISOLATED" = 1 ] && [ "$mounted_mode" != ro ]; then
        continue
    fi
    emit_mount "$dir" "$mounted_mode"
    [ "$mounted_scan" = scan ] || continue
    [ -d "$dir" ] || continue
    for entry in "$dir"/*; do
        [ -L "$entry" ] || continue
        # readlink -f fails (empty stdout) when an intermediate directory of
        # the symlink's target is itself missing, not just the final
        # component - skip rather than let `dirname ""` silently resolve to
        # "." and append a nonsensical (or worse, cwd-mounting) -v .:.:ro.
        target="$(readlink -f "$entry")" || continue
        [ -n "$target" ] || continue
        real_dir="$(dirname "$target")"
        # A target inside the directory being scanned, or beside it, is
        # already mounted by the loop itself.
        case "$real_dir" in
            "$(dirname "$dir")"*) continue ;;
        esac
        case " $mounted_dirs " in
            *" $real_dir "*) continue ;;
        esac
        # The target is discovered, not configured, so nothing has vetted it
        # (#15). A hook symlinked into the approval store would mount it
        # here, read-only and silently. Refusing rather than skipping: a
        # skipped mount leaves the symlink dangling inside the container and
        # the hook failing for a reason nothing states.
        _guarded_stores
        # The tilde is a label an operator reads, not a path to expand.
        # shellcheck disable=SC2088
        _refuse_guarded_mount "~/.claude symlink target" "$real_dir" "${GUARDED_STORES[@]}"
        mounted_dirs="$mounted_dirs $real_dir"
        extra_mounts+=(-v "$real_dir:$real_dir:ro")
    done
done

# Optional: bring in a real nvim config to pair with [core] command = nvim
# (see entrypoint.sh). Not baked into the image - this repo has no opinion on
# anyone's personal editor config.
SANDBOX_NVIM_CONFIG="$(config_get mounts nvim_config)"
if [ -n "$SANDBOX_NVIM_CONFIG" ]; then
    if [ ! -d "$SANDBOX_NVIM_CONFIG" ]; then
        echo "[mounts] nvim_config is set but not a directory: $SANDBOX_NVIM_CONFIG" >&2
        exit 1
    fi
    extra_mounts+=(-v "$SANDBOX_NVIM_CONFIG:$HOME/.config/nvim:ro")
    # lazy.nvim (and most other nvim plugin managers) writes its lockfile
    # back into the config directory on every plugin sync - a throwaway
    # copy, layered read-write on top of the read-only mount above (same
    # "more specific mount wins" trick the ~/.claude overlays use, just
    # inverted), keeps that working without making the rest of a personal
    # config writable from inside the sandbox. Not synced back: losing a
    # sandbox session's plugin-version bumps is low-stakes - the nvim-data
    # volume below already keeps the actual installed plugin files across
    # sessions - next to what a fully writable config directory would risk:
    # arbitrary Lua that runs unsandboxed the next time nvim starts for real.
    SANDBOX_NVIM_LOCKFILE="$(mktemp "${TMPDIR:-/tmp}/dejima-nvim-lock.XXXXXX.json")"
    if [ -f "$SANDBOX_NVIM_CONFIG/lazy-lock.json" ]; then
        cp "$SANDBOX_NVIM_CONFIG/lazy-lock.json" "$SANDBOX_NVIM_LOCKFILE"
    fi
    extra_mounts+=(-v "$SANDBOX_NVIM_LOCKFILE:$HOME/.config/nvim/lazy-lock.json")
fi

# Optional: extra host paths a personal setup references by absolute path
# outside anywhere already mounted above - e.g. a dotfiles bin/ directory
# an editor config shells out to. Colon-separated, like $PATH. Mounted
# read-only at their own path, same as everything else here - this repo
# still has no opinion on what's inside any of them. SANDBOX_EXTRA_MOUNTS
# itself is resolved above, ahead of --print-briefing (the read_write guard
# below needs it too) - only applying it as -v flags happens here.
if [ -n "$SANDBOX_EXTRA_MOUNTS" ]; then
    IFS=':' read -ra extra_paths <<< "$SANDBOX_EXTRA_MOUNTS"
    for path in "${extra_paths[@]}"; do
        [ -n "$path" ] || continue
        if [ ! -e "$path" ]; then
            echo "$(_mount_label "$path" "[mounts] readonly entry" "--extra-readonly path") does not exist: $path" >&2
            exit 1
        fi
        # [mounts] read_write has been guarded since it was added; this one
        # never was, so `readonly = ~/.config/dejima` mounted the approval
        # store into the container and nothing said a word (#15).
        _refuse_pp_root "$(_mount_label "$path" "[mounts] readonly entry" "--extra-readonly path")" "$path"
        _guarded_stores
        _refuse_guarded_mount "$(_mount_label "$path" "[mounts] readonly entry" "--extra-readonly path")" \
            "$path" "${GUARDED_STORES[@]}"
        extra_mounts+=(-v "$path:$path:ro")
    done
fi

# Read-write host mounts: validated and resolved into RW_MOUNT_PATHS and
# DEJIMA_RW_MOUNTS above, ahead of --print-briefing, so the briefing composed
# there and the briefing composed for a real launch read the same value.
# Nothing left to do here but turn the already-validated list into -v flags -
# re-validating would be a second place this could drift from the first.
# DEJIMA_RW_MOUNTS is deliberately not forwarded into the container as an
# environment variable: hooks/sandbox-context.sh does not read it (the
# writable-mounts sentence reaches the session through the composed briefing
# instead), so passing it would just be a value nothing inside consumes. It
# IS forwarded the other direction, host-side, to the exit-time dejima-audit
# invocation in cleanup() below - that report's header names these paths so
# an operator reading any finding knows what else the session could reach.
for path in ${RW_MOUNT_PATHS[@]+"${RW_MOUNT_PATHS[@]}"}; do
    extra_mounts+=(-v "$path:$path")
done
# And the per-project ones, which are the only mounts in dejima whose source
# and target differ on purpose: the slice on the host, the declared path in
# the container. Indices are paired, filled together in one loop above.
for _i in ${RW_PP_MOUNT_TARGETS[@]+"${!RW_PP_MOUNT_TARGETS[@]}"}; do
    # dejima's own layout below a directory the operator made, so the slice is
    # created and the root never is. Here rather than where it was resolved,
    # because this is the launch path and that was also --print-briefing's.
    mkdir -p "${RW_PP_MOUNT_SOURCES[$_i]}"
    extra_mounts+=(-v "${RW_PP_MOUNT_SOURCES[$_i]}:${RW_PP_MOUNT_TARGETS[$_i]}")
done
unset _i

# The session briefing, composed host-side from the core text and the
# selected workflow/harness fragments (see briefing_compose) and mounted
# read-only, so a session can never rewrite its own next briefing. The two
# runtime placeholders it still carries (@@SESSION_ID@@, @@CONTAINER_ID@@)
# are hooks/sandbox-context.sh's job to fill in at SessionStart.
SANDBOX_BRIEFING_FILE="$(mktemp "${TMPDIR:-/tmp}/dejima-briefing.XXXXXX")"
if ! briefing_compose "$DEJIMA_FORGE_NAME" "$DEJIMA_WORKFLOW_NAME" "$DEJIMA_HARNESS_NAME" > "$SANDBOX_BRIEFING_FILE"; then
    exit 1
fi
extra_mounts+=(-v "$SANDBOX_BRIEFING_FILE:/etc/dejima/briefing:ro")
extra_mounts+=(-e "DEJIMA_BRIEFING_FILE=/etc/dejima/briefing")

# Block the workspace's own git hooks from being rewritten to run on the
# host at the next `git` invocation. Only when .git is a real directory - a
# worktree/submodule .git is a pointer file whose hooks live in the common
# git dir outside the mount (container-local, discarded with the session),
# so there's nothing host-persistent to protect there.
if [ -d "$WORKSPACE_DIR/.git/hooks" ]; then
    extra_mounts+=(-v "$WORKSPACE_DIR/.git/hooks:$WORKSPACE_DIR/.git/hooks:ro")
fi

# The state this harness keeps on the host, and which of it this workspace
# is entitled to see. Harness-specific by nature - which directories exist,
# and what decides that one of their entries belongs to this workspace, is a
# fact about the harness rather than about dejima - so it lives in the
# harness plugin, which declares each mount through emit_mount. The guarded-
# store refusal runs there, at the point of declaration, rather than being
# something each plugin has to remember. See bin/dejima-harness-lib.sh.
#
# Not called at all for an isolated launch, which is both halves of what that
# mode needs here: this hook is what mounts the transcripts and the memory
# under ~/.claude/projects, and it CREATES the directory for the workspace's
# own slug before mounting it. Skipping the call is therefore also what stops
# an isolated launch leaving a directory on the host.
if [ "$DEJIMA_ISOLATED" = 0 ]; then
    harness_hook harness_state_mounts "$WORKSPACE_DIR"
fi

# statusline.sh is executed on the host by Claude Code (settings.json's
# statusLine.command), so it is staged for entrypoint.sh to copy rather than
# mounted at its real path - same as the config in docker-compose.yml.
#
# A :ro mount there protected only the plain-file case, confirmed against a
# live container. When statusline.sh is itself a symlink (the common case),
# bind-mount destination resolution follows it, so the mount lands on the
# target and the link stays replaceable. Staging closes that: Docker resolves
# the symlink setting up the staging mount, and the copy lands where the host
# cannot reach it.
#
# Conditional on existence - mounting a missing source makes Docker create a
# root-owned directory at the target. A dangling symlink is neither
# mountable nor "exists", so it refuses rather than skipping the mount
# (reopening the gap) or failing later with an opaque "mkdir: file exists".
SANDBOX_HARNESS_STAGED_EMPTY=""
for _rec in ${DEJIMA_STAGED_CONFIG[@]+"${DEJIMA_STAGED_CONFIG[@]}"}; do
    IFS=$'\t' read -r _ssrc _srel _sensure <<< "$_rec"
    # Asked to exist whatever the host has. Named for the entrypoint to create
    # empty when nothing was staged for it, rather than created here: a file
    # the operator does not have is not one dejima should make on their
    # machine to have something to copy.
    # Carried as "<kind>:<rel>" so the entrypoint knows what to write. A
    # space-separated list because that is how every other list crosses.
    [ -z "$_sensure" ] || \
        SANDBOX_HARNESS_STAGED_EMPTY="${SANDBOX_HARNESS_STAGED_EMPTY:+$SANDBOX_HARNESS_STAGED_EMPTY }$_sensure:$_srel"
    # A dangling symlink is neither mountable nor "exists", so it refuses
    # rather than skipping the mount - which would reopen the gap staging
    # closes - or failing later with an opaque "mkdir: file exists".
    if [ -L "$_ssrc" ] && [ ! -e "$_ssrc" ]; then
        echo "$_ssrc is a symlink to a target that doesn't exist." >&2
        echo "Docker can't bind-mount a dangling symlink, and skipping the mount" >&2
        echo "would leave the session without that file entirely." >&2
        echo "Fix or remove the symlink, then retry." >&2
        exit 1
    fi
    [ -e "$_ssrc" ] || continue
    extra_mounts+=(-v "$_ssrc:/etc/dejima-harness/$_srel:ro")
done
export SANDBOX_HARNESS_STAGED_EMPTY

# The harness's own writable state, seeded from the host. Mounted read-only
# into a staging tree the same way staged config is - entrypoint.sh installs a
# copy owned by the session user, so the harness can rewrite it and nothing it
# writes reaches the operator's.
#
# The destinations travel as one space-separated list rather than being
# rediscovered by walking the staging tree, because some of them have no
# source: a directory whose contents are mounted individually needs to exist
# and be owned by the session, and there is nothing to copy into it.
SANDBOX_HARNESS_SEED=""
for _rec in ${DEJIMA_SEED[@]+"${DEJIMA_SEED[@]}"}; do
    IFS=$'\t' read -r _seedrel _seedsrc <<< "$_rec"
    SANDBOX_HARNESS_SEED="${SANDBOX_HARNESS_SEED:+$SANDBOX_HARNESS_SEED }$_seedrel"
    [ -n "$_seedsrc" ] || continue
    extra_mounts+=(-v "$_seedsrc:/etc/dejima-seed/$_seedrel:ro")
done
export SANDBOX_HARNESS_SEED

# The real Anthropic OAuth credential never enters the container: a host-side
# proxy injects it per request (bin/dejima-credential-proxy, docs/security.md).
# ~/.claude is not mounted whole, so the credential has no path into the
# container and nothing needs masking (#50, docs/security.md).
#
# How the container then reaches the proxy depends on the transport. In the
# default tls one, entrypoint.sh points api.anthropic.com at an in-container
# TLS terminator, so no ANTHROPIC_* value is set at all and the session is
# indistinguishable from a stock install - which means Claude Code reads
# ~/.claude/.credentials.json, so a fake one is generated below and mounted
# there. [credentials] transport = loopback is the documented fallback if
# certificate pinning ever makes that impossible: an explicit base URL and
# placeholder token at a plaintext forwarder, as before, with no credential
# file in the container at all.
#
# Skipped when there's no claudeAiOauth block to protect (API-key setups),
# or via the [credentials] proxy = off kill switch. Both then mount the real
# credential in explicitly, which used to come free with the whole-directory
# mount and now has to be stated.
#
# Two namings here, and the split is the point rather than an unfinished
# rename. SANDBOX_CREDENTIAL_* is the proxy's own machinery, which knows
# nothing about any endpoint: a socket, a transport, a terminator's cert.
# SANDBOX_ANTHROPIC_* carries a value INTO an Anthropic environment variable -
# docker-compose.yml puts each one in the ANTHROPIC_ name beside it, which is
# what Claude Code reads. Renaming those would name them after the thing they
# are not. A second harness wanting a differently-named variable is the point
# at which this becomes a declaration rather than three assignments.
SANDBOX_CREDENTIAL_TRANSPORT="$(config_get credentials transport)"
SANDBOX_ANTHROPIC_BASE_URL=""
SANDBOX_ANTHROPIC_AUTH_TOKEN=""
SANDBOX_ANTHROPIC_API_KEY=""
SANDBOX_CREDENTIAL_PROXY_SOCKET=""
SANDBOX_CREDENTIAL_PROXY_DIR=""
SANDBOX_CREDENTIAL_PROXY_LOG=""
SANDBOX_FAKE_CREDENTIALS_FILE=""
SANDBOX_CREDENTIAL_PROXY_PID=""
# Whether this provider's credential is in a state the proxy can use.
#
# Not "does the file exist", and the answer belongs to the provider's kind
# rather than to one fixed check.
#
# For anthropic-oauth it is whether ~/.claude/.credentials.json holds a
# claudeAiOauth block. A Claude Code set up with a console key rather than a
# login has none, and there is then no OAuth token for the proxy to inject or
# refresh - so selecting this backend for that setup falls through to mounting
# the real credential file, which is the one arrangement where dejima's own
# credential enters the container. The backend for that setup is
# anthropic-api-key, which keeps the key on the host like any other.
#
# For a static key it is whether the file has anything in it.
#

# Asked for the proxy, selected a backend, and the credential is not one that
# backend can serve: refuse.
#
# The fallthrough below mounts the real credential file, which is the right
# answer when the operator asked for it with `proxy = off` and the wrong one
# here - it silently moves a launch from the strongest posture to the weakest,
# and the session still works, so nothing about the result says it happened.
# A credential shape dejima does not recognise is exactly when that is most
# likely: a Claude Code signed in with a console API token has no
# claudeAiOauth block, so the anthropic backend cannot serve it and every
# launch quietly mounted the real key.
#
# Refusing rather than warning, because docs/security.md describes the mounted
# arrangement as a kill switch rather than an option, and a kill switch that
# can engage itself is not one.
#
# Scoped to a credential that EXISTS and cannot be served. An absent one is
# not this: nothing is mounted, nothing is downgraded, and the launch is
# someone who has not logged in on the host yet.
if [ -n "$DEJIMA_BACKEND_NAME" ] \
    && [ "$DEJIMA_BACKEND_CREDENTIAL_KIND" != none ] \
    && backend_proxy_enabled "$DEJIMA_BACKEND_NAME" \
    && [ -s "$DEJIMA_BACKEND_CREDENTIAL_FILE" ] \
    && ! _credential_usable "$DEJIMA_BACKEND_CREDENTIAL_KIND" "$DEJIMA_BACKEND_CREDENTIAL_FILE" "$DEJIMA_BACKEND_CREDENTIAL_FIELD"; then
    echo "backend '$DEJIMA_BACKEND_NAME' cannot serve the credential in" >&2
    echo "$DEJIMA_BACKEND_CREDENTIAL_FILE." >&2
    echo "" >&2
    case "$DEJIMA_BACKEND_CREDENTIAL_KIND" in
        anthropic-oauth)
            echo "It expects a subscription login - a claudeAiOauth block - and that" >&2
            echo "file has none. A Claude Code signed in with a console API token" >&2
            echo "stores it differently." >&2
            ;;
        static)
            echo "It expects a key, and that file is empty or unreadable." >&2
            ;;
    esac
    _suggest_backends "$DEJIMA_HARNESS_NAME"
    echo "" >&2
    echo "Launching as configured would mount that file into the container," >&2
    echo "which is the one arrangement where your real credential leaves the" >&2
    echo "host. If you want that deliberately:" >&2
    echo "" >&2
    echo "    [backend.$DEJIMA_BACKEND_NAME]" >&2
    echo "    proxy = off" >&2
    echo "" >&2
    echo "which turns the proxy off for this backend only. [credentials]" >&2
    echo "proxy = off turns it off for every backend, including ones it" >&2
    echo "serves." >&2
    exit 1
fi

# A direct backend runs no proxy: there is no credential to withhold, so
# there is nothing for one to do. That is the self-served case, and it is the
# arrangement with nothing in the container to steal.
if [ -n "$DEJIMA_BACKEND_NAME" ] \
    && [ "$DEJIMA_BACKEND_CREDENTIAL_KIND" != none ] \
    && backend_proxy_enabled "$DEJIMA_BACKEND_NAME" \
    && _credential_usable "$DEJIMA_BACKEND_CREDENTIAL_KIND" "$DEJIMA_BACKEND_CREDENTIAL_FILE" "$DEJIMA_BACKEND_CREDENTIAL_FIELD"; then
    SANDBOX_CREDENTIAL_PROXY_DIR="$(mktemp -d "${TMPDIR:-/tmp}/dejima-credential-proxy.XXXXXX")"
    SANDBOX_CREDENTIAL_PROXY_SOCKET="$SANDBOX_CREDENTIAL_PROXY_DIR/proxy.sock"
    # Outside SANDBOX_CREDENTIAL_PROXY_DIR, which is bind-mounted into the
    # session below so the socket is reachable. The log holds what the proxy
    # learned from the provider - statuses, error-body prefixes - and
    # docs/security.md says that stays on the host, so it must not share the
    # one proxy path the session can read.
    SANDBOX_CREDENTIAL_PROXY_LOG="$(mktemp "${TMPDIR:-/tmp}/dejima-credential-proxy-log.XXXXXX")"
    # Every value comes from backends/<name>/backend.conf. Nothing about a
    # backend is written here, which is what makes adding one a directory
    # rather than an edit to this file.
    _proxy_args=(
        --socket "$SANDBOX_CREDENTIAL_PROXY_SOCKET"
        --log "$SANDBOX_CREDENTIAL_PROXY_LOG"
        --credentials "$DEJIMA_BACKEND_CREDENTIAL_FILE"
        --upstream "$(manifest_get backend "$DEJIMA_BACKEND_NAME" upstream)"
        --credential-kind "$DEJIMA_BACKEND_CREDENTIAL_KIND"
    )
    [ -z "$DEJIMA_BACKEND_CREDENTIAL_FIELD" ] \
        || _proxy_args+=(--credential-field "$DEJIMA_BACKEND_CREDENTIAL_FIELD")
    _proxy_args+=(
        --auth-header "$(manifest_get backend "$DEJIMA_BACKEND_NAME" auth_header)"
        --auth-format "$(manifest_get backend "$DEJIMA_BACKEND_NAME" auth_format)"
    )
    # Word-split on purpose: a manifest list is space-separated and every
    # element is type-checked, so neither of these can carry a space.
    for _h in $(manifest_get backend "$DEJIMA_BACKEND_NAME" strip_headers); do
        _proxy_args+=(--strip-header "$_h")
    done
    for _h in $(manifest_get backend "$DEJIMA_BACKEND_NAME" ensure_headers); do
        _proxy_args+=(--ensure-header "$_h")
    done
    _oauth_endpoint="$(manifest_get backend "$DEJIMA_BACKEND_NAME" oauth_endpoint)"
    _oauth_client_id="$(manifest_get backend "$DEJIMA_BACKEND_NAME" oauth_client_id)"
    [ -z "$_oauth_endpoint" ] || _proxy_args+=(--oauth-endpoint "$_oauth_endpoint")
    [ -z "$_oauth_client_id" ] || _proxy_args+=(--oauth-client-id "$_oauth_client_id")
    unset _h _oauth_endpoint _oauth_client_id
    "$SCRIPT_DIR/dejima-credential-proxy" "${_proxy_args[@]}" &
    SANDBOX_CREDENTIAL_PROXY_PID=$!
    for _ in $(seq 1 50); do
        [ -S "$SANDBOX_CREDENTIAL_PROXY_SOCKET" ] && break
        kill -0 "$SANDBOX_CREDENTIAL_PROXY_PID" 2>/dev/null || break
        sleep 0.1
    done
    if [ ! -S "$SANDBOX_CREDENTIAL_PROXY_SOCKET" ]; then
        # Falling back to a session with the real credential mounted would
        # be a silent security downgrade - stop loudly instead.
        echo "Credential proxy failed to start - not launching. Fix the error above," >&2
        echo "or set [credentials] proxy = off in $(config_path) to launch without it" >&2
        echo "(the real OAuth credential is then mounted into the container, as before)." >&2
        exit 1
    fi
    extra_mounts+=(-v "$SANDBOX_CREDENTIAL_PROXY_DIR:$SANDBOX_CREDENTIAL_PROXY_DIR:ro")
    if [ "$SANDBOX_CREDENTIAL_TRANSPORT" = "loopback" ]; then
        SANDBOX_ANTHROPIC_BASE_URL="http://127.0.0.1:18300"
        SANDBOX_ANTHROPIC_AUTH_TOKEN="sandbox-credential-proxy-placeholder"
    elif [ "$DEJIMA_BACKEND_CREDENTIAL_KIND" = static ]; then
        # API billing: the session authenticates with a key rather than a
        # login, so there is no credential FILE to fake - Claude Code reads
        # ANTHROPIC_API_KEY, and a placeholder there is the whole of what the
        # container needs. The host-side proxy swaps it for the real key per
        # request, the same arrangement the OAuth path has.
        #
        # Set here rather than by the harness plugin because it reaches the
        # container through compose, which is core's side of the boundary.
        # It is Anthropic-shaped, like the two variables beside it, and a
        # second backend wanting a different variable is the point at which
        # this becomes a declaration rather than a branch.
        SANDBOX_ANTHROPIC_API_KEY="sandbox-credential-proxy-placeholder"
        # Pre-approve it, so the session does not open on a prompt whose
        # recommended answer breaks it.
        #
        # Claude Code asks about any ANTHROPIC_API_KEY it did not put there
        # itself - "Do you want to use this API key?", defaulting to "No
        # (recommended)" - and records the answer as the last 20 characters
        # of the key in customApiKeyResponses.approved. Answering the
        # recommended way leaves an unattended session with no credential,
        # which is a worse failure than the prompt it avoids.
        #
        # Derived from the value rather than written as a literal, so it
        # follows if the placeholder ever changes. If Claude Code changes how
        # it records approvals this stops suppressing the prompt and nothing
        # else - it is a courtesy, not a control.
        if [ -n "$SANDBOX_CLAUDE_JSON_FILE" ] && [ -f "$SANDBOX_CLAUDE_JSON_FILE" ]; then
            _ak_seen="$(mktemp "${TMPDIR:-/tmp}/dejima-claude-json.XXXXXX.json")"
            if jq --arg t "${SANDBOX_ANTHROPIC_API_KEY: -20}" \
                '.customApiKeyResponses.approved =
                    (((.customApiKeyResponses.approved // []) + [$t]) | unique)' \
                "$SANDBOX_CLAUDE_JSON_FILE" > "$_ak_seen"; then
                mv "$_ak_seen" "$SANDBOX_CLAUDE_JSON_FILE"
            else
                rm -f "$_ak_seen"
            fi
            unset _ak_seen
        fi
    else
        # With no ANTHROPIC_AUTH_TOKEN set, Claude Code authenticates from
        # .credentials.json directly, so the container needs a well-formed
        # one where it would otherwise have no credential file at all. Only
        # the two secrets are faked; scopes/subscriptionType/rateLimitTier/
        # clientId are copied so the session's own UI reflects the real
        # account.
        #
        # The expiry deliberately does NOT mirror the real credential. A
        # near-term expiresAt would make Claude Code refresh on its own,
        # against platform.claude.com, with a refresh token that is not real -
        # which fails and logs the session out. A year out means it never
        # tries; the host proxy owns refresh, as it already did.
        SANDBOX_FAKE_CREDENTIALS_FILE="$(mktemp "${TMPDIR:-/tmp}/dejima-fake-credentials.XXXXXX")"
        jq --argjson exp "$(( ($(date +%s) + 31536000) * 1000 ))" '
            {claudeAiOauth: (
                {accessToken: "sandbox-credential-proxy-placeholder",
                 refreshToken: "sandbox-credential-proxy-placeholder",
                 expiresAt: $exp,
                 refreshTokenExpiresAt: $exp}
                + (.claudeAiOauth
                   | {scopes, subscriptionType, rateLimitTier, clientId}
                   | with_entries(select(.value != null)))
            )}' "$HOME/.claude/.credentials.json" > "$SANDBOX_FAKE_CREDENTIALS_FILE"
        chmod 600 "$SANDBOX_FAKE_CREDENTIALS_FILE"
        # Read-write: Claude Code rewrites this file on its own schedule, and
        # a failed write is a startup error. It writes to the host temp file,
        # which the EXIT trap removes.
        extra_mounts+=(-v "$SANDBOX_FAKE_CREDENTIALS_FILE:$HOME/.claude/.credentials.json")
    fi
elif [ -n "$DEJIMA_BACKEND_NAME" ] \
    && [ "$DEJIMA_BACKEND_CREDENTIAL_KIND" != none ] \
    && ! backend_proxy_enabled "$DEJIMA_BACKEND_NAME" \
    && [ -f "$HOME/.claude/.credentials.json" ] \
    && [ "$(readlink -m "$DEJIMA_BACKEND_CREDENTIAL_FILE")" \
         = "$(readlink -m "$HOME/.claude/.credentials.json")" ]; then
    # No proxy: the session authenticates with the real credential, so it has
    # to be mounted in. Read-write because Claude Code refreshes it in place.
    # This is the documented downgrade, not an oversight - see the kill-switch
    # message above and docs/security.md.
    #
    # Each condition names a way this stops being that downgrade and becomes a
    # credential handed to a session with no use for it. The branch above is
    # the proxy engaging; everything else used to fall through to here, which
    # meant a backend needing no credential at all - a model you serve
    # yourself, the arrangement docs/security.md calls the one with nothing in
    # the container to steal - mounted the real subscription login anyway. So
    # did a backend whose own credential file is somewhere else and missing.
    #
    # The file is compared, not just the kind: this mount is for the case
    # where THIS backend's credential IS this file, and the operator turned
    # the proxy off for it.
    extra_mounts+=(-v "$HOME/.claude/.credentials.json:$HOME/.claude/.credentials.json")
fi
export SANDBOX_ANTHROPIC_BASE_URL SANDBOX_ANTHROPIC_AUTH_TOKEN SANDBOX_CREDENTIAL_PROXY_SOCKET
export SANDBOX_ANTHROPIC_API_KEY
export SANDBOX_CREDENTIAL_TRANSPORT

# Optional remote access (see docs/remote-access.md). Entirely skipped
# unless [remote-access] enabled is on in the config file - with it absent
# nothing below runs, no state is created, and nothing is added to the
# compose invocation.
if remote_enabled; then
    DEJIMA_STATE_DIR="$(remote_state_dir)"

    # The state dir's parent (e.g. /tmp, when XDG_RUNTIME_DIR is unset - the
    # common case for exactly the non-systemd ssh sessions this feature
    # targets) is world-writable, and `mkdir -p -m 700` only applies MODE to
    # the leaf it creates - an already-existing path here (a symlink an
    # attacker pre-created, or a directory some other user owns) is left as
    # is. Since the notify dir created under it is bind-mounted read-write
    # into the container and later `rm -rf`'d by the EXIT trap, following a
    # planted symlink would hand a compromised session (or the trap itself)
    # access to an attacker-chosen path. Create it, then verify it's a real
    # directory we own and not a symlink before trusting it - refuse rather
    # than proceed on anything else. Once this check passes, everything
    # created below it (sessions/, notify/, notify/<run-id>) is safe: mode
    # 700 here means no other user can plant anything inside it afterward.
    # -m applying only to the leaf is the reason for the check below, not
    # an oversight: the mode is verified and reset before anything trusts it.
    # shellcheck disable=SC2174
    mkdir -p -m 700 "$DEJIMA_STATE_DIR" 2>/dev/null || true
    if [ -L "$DEJIMA_STATE_DIR" ] || [ ! -d "$DEJIMA_STATE_DIR" ] \
        || [ "$(stat -c %u "$DEJIMA_STATE_DIR")" != "$(id -u)" ]; then
        echo "Refusing remote access: $DEJIMA_STATE_DIR exists and is not a plain" >&2
        echo "directory owned by the current user (or is a symlink). This could be a" >&2
        echo "symlink attack via a shared /tmp - remove it, or set XDG_RUNTIME_DIR to" >&2
        echo "somewhere else, and retry." >&2
        exit 1
    fi
    chmod 700 "$DEJIMA_STATE_DIR"

    # The parent exists and is 700 by the block above, so -p creates only
    # these leaves and -m is the mode each of them gets.
    # shellcheck disable=SC2174
    mkdir -p -m 700 "$DEJIMA_STATE_DIR/sessions" "$DEJIMA_STATE_DIR/notify"
    DEJIMA_SESSION_FILE="$DEJIMA_STATE_DIR/sessions/$DEJIMA_RUN_ID"
    DEJIMA_NOTIFY_DIR="$DEJIMA_STATE_DIR/notify/$DEJIMA_RUN_ID"
    # shellcheck disable=SC2174
    mkdir -p -m 700 "$DEJIMA_NOTIFY_DIR"

    # Verify what was just created, because everything downstream assumes it.
    # A leaf left behind root-owned by an earlier container restart (see
    # entrypoint.sh) is the shape this catches: mkdir -p succeeds against an
    # existing directory regardless of who owns it, so without this the mount
    # goes ahead and the failure surfaces later as EACCES in a hook.
    #
    # A warning rather than a refusal. Remote access is opt-in, and the rest
    # of the session works without it - blocking a launch over undeliverable
    # notifications would cost more than it saves. The neighbouring state-dir
    # check does refuse, but that one is about a symlink handing a session an
    # attacker-chosen path; this one is only about the feature working.
    if [ ! -O "$DEJIMA_NOTIFY_DIR" ] || [ ! -w "$DEJIMA_NOTIFY_DIR" ]; then
        echo "warning: $DEJIMA_NOTIFY_DIR is not a directory this user owns and can write." >&2
        echo "Remote access is enabled, so notifications and dejima-attach will not work for" >&2
        echo "this session. A leftover root-owned directory here is usually Docker's doing," >&2
        echo "from a container that was restarted rather than relaunched; removing it and" >&2
        echo "starting again clears it." >&2
    fi

    # $TMUX_PANE is set by tmux in the launching shell. Empty when the
    # sandbox wasn't started from tmux: the session is still registered, just
    # listed as not-attachable, rather than silently omitted.
    dejima_tmux_label=""
    if [ -n "${TMUX_PANE:-}" ] && command -v tmux >/dev/null 2>&1; then
        dejima_tmux_label="$(tmux display-message -p -t "$TMUX_PANE" \
            '#{session_name}:#{window_index}' 2>/dev/null || true)"
    fi

    # A newline in either value would inject an extra key=value line into the
    # flat registry entry below - e.g. a fabricated `pane=` line inside a
    # crafted label, which the last-match-wins parser (see dejima-attach)
    # would honor over the real one, redirecting an operator to an
    # attacker-chosen pane. Refuse outright rather than sanitize: a value
    # that can't be represented safely here is the same class of problem as
    # a corrupt approval store elsewhere in this repo.
    case "$WORKSPACE_DIR" in
        *$'\n'*)
            echo "Refusing remote access: workspace path contains a newline: $WORKSPACE_DIR" >&2
            exit 1
            ;;
    esac
    case "$dejima_tmux_label" in
        *$'\n'*)
            echo "Refusing remote access: tmux session/window label contains a newline." >&2
            exit 1
            ;;
    esac
    # $TMUX_PANE comes from the launching shell's environment, so it is the
    # one value here that is taken verbatim rather than derived. It is written
    # second of the four lines below, so an injected line lands ahead of
    # label= and started= and would lose the parser's last-match-wins race to
    # the real ones - but it would WIN against the pane= it is part of, which
    # is the field deciding which pane an operator gets attached to. Refusing
    # the newline outright is what makes that independent of printf ordering.
    case "${TMUX_PANE:-}" in
        *$'\n'*)
            echo "Refusing remote access: \$TMUX_PANE contains a newline." >&2
            exit 1
            ;;
    esac

    # Flat key=value, like the rest of the host-side store: no jq dependency
    # here, and nothing that reads it ever sources it.
    #
    # pid is this process, and it is what dejima-attach asks about instead of
    # guessing how long a launch takes: "is the thing that registered this
    # still running" is observable, and a cold build that outruns any guessed
    # window is the case a guess gets wrong. Written before the container
    # exists, so it covers the whole build, and it stops being true at exactly
    # the moment the EXIT trap below stops being able to run.
    {
        # `dejima-attach --list` shows this column as a basename, which for an
        # isolated launch would be "work" - the name of dejima's own scratch
        # path rather than anything about the session. Same substitution the
        # startup banner makes, and for the same reason. What tells two
        # concurrent isolated sessions apart is the run id beside it, which
        # that listing already prints.
        if [ "$DEJIMA_ISOLATED" = 1 ]; then
            printf 'workspace=isolated\n'
        else
            printf 'workspace=%s\n' "$WORKSPACE_DIR"
        fi
        printf 'pane=%s\n' "${TMUX_PANE:-}"
        printf 'label=%s\n' "$dejima_tmux_label"
        printf 'started=%s\n' "$(date +%s)"
        printf 'pid=%s\n' "$$"
    } > "$DEJIMA_SESSION_FILE"

    # This run's OWN marker directory is the only thing mounted in - never
    # its parent - so a compromised session can write markers for itself and
    # for nothing else. The registry is never mounted at all: dejima-attach
    # trusts it to decide which pane to attach an operator to, so a session
    # able to rewrite it could redirect them.
    #
    # -e alongside -v here: this array is passed straight to `docker compose
    # run`, where both are valid flags.
    extra_mounts+=(-v "$DEJIMA_NOTIFY_DIR:/run/dejima-notify")
    extra_mounts+=(-e "DEJIMA_NOTIFY_DIR=/run/dejima-notify")
    # Lets the host correlate this run to its container via `docker inspect`,
    # so the container never needs write access to the registry to report its
    # own ID.
    extra_mounts+=(-e "DEJIMA_RUN_ID=$DEJIMA_RUN_ID")

    # Registered per session, into the override layer from above and nowhere
    # else: putting it in the operator's real settings.json would run it for
    # every sandbox and for the host's own agent, which share that file, when
    # it is only meaningful for a session that has a marker directory.
    # $SANDBOX_SETTINGS_FILE is already a throwaway mktemp file, so this
    # rewrite cannot reach the operator's real settings; it is mounted
    # read-only, so the session cannot unregister itself either.
    #
    # How a harness is told to run something on those two events is the
    # harness's own business - Claude Code merges hook entries across
    # settings layers, another harness may take a config key or an
    # environment variable - so the writing is the plugin's. Whether it
    # happens at all is core's, and is gated on the harness having declared
    # the notify guard.
    if harness_has_guard notify; then
        harness_hook harness_register_notify "$SANDBOX_SETTINGS_FILE"
    fi
fi

# Everything from here to the container's own first output is silent: the
# audit snapshot, an image build that prints nothing when every layer is
# cached, and Docker creating the container with a mount per allowed path.
# Long enough to read as hung, so say what is happening first. A first run,
# or one after a Dockerfile change, builds the image and takes minutes rather
# than seconds - the line names that, since it is the case where someone is
# most likely to give up and interrupt.
# What this launch is, before the long silent part. The startup output is
# otherwise a list of checks with no point at which it says what it is, and
# the facts below are the ones that differ between one launch and the next -
# the ones an operator otherwise has to remember or go and read (#211).
#
# Deliberately not a config dump. The value is seeing at a glance that this
# launch is not configured like the last one, so what is here is the selection
# plus the switches whose state changes what a session can do.
#
# In bin/dejima's own palette rather than the pickers': dejima-color-lib.sh
# and dejima-tui-lib.sh both define C_OK and C_WARN, with different values, so
# sourcing the second here would silently recolour every warning this script
# already prints. Unifying the two palettes is worth doing and is not this.
startup_banner() {
    [ "$(config_get_bool ui banner)" = "1" ] || return 0
    # Nothing at all when stderr is not a terminal: a redirected launch is a
    # log, and this is decoration in it.
    [ -t 2 ] || return 0

    local branch model domains firewall remote epitaph
    branch="$(git -C "$WORKSPACE_DIR" symbolic-ref --quiet --short HEAD 2>/dev/null)" || branch=""
    model="$(harness_config_get model 2>/dev/null)" || model=""
    # The count, not the list: the list is long and the number is what changes.
    domains="$(printf '%s' "${ALLOWED_DOMAINS:-}" | wc -w)"
    if [ "${ENABLE_FIREWALL:-1}" = "1" ]; then
        firewall="${C_OK}on${C_RESET} ($domains domains)"
    else
        # The one switch worth a colour of its own: everything else here is a
        # choice, and this one is a boundary that is not in force.
        firewall="${C_WARN}off${C_RESET}"
    fi
    remote="$(remote_enabled && echo on || echo off)"
    epitaph="$([ "$(config_get_bool epitaph enabled)" = 1 ] && echo on || echo off)"

    # Labels dim, values not. The labels are how you find the value you came
    # for; the values are what differs between one launch and the next.
    # C_BADGE, the same name the pickers set the mark in. Two surfaces
    # spelling the wordmark two different colours is the drift a shared
    # palette exists to stop.
    local name
    # The working directory's basename would be "work" here, which names
    # dejima's own scratch path rather than anything about the launch.
    if [ "$DEJIMA_ISOLATED" = 1 ]; then
        name="isolated"
    else
        name="$(basename -- "$WORKSPACE_DIR")"
    fi
    # The release this checkout is on, beside the wordmark: "which version am
    # I running" is a question an operator should not have to ask, and a
    # launch is where they are already looking. Dim, because it is context
    # rather than a choice this launch made.
    printf '\n  %sdejima%s  %s%s%s  %s%s%s' \
        "$C_BADGE" "$C_RESET" "$C_KEY" "$name" "$C_RESET" \
        "$C_DIM" "$(dejima_version "$REPO_ROOT")" "$C_RESET" >&2
    [ -z "$branch" ] || printf '  %s%s%s  %s' "$C_DIM" "$GLYPH_SEP" "$C_RESET" "$branch" >&2
    printf '\n  %sharness%s %s   %sforge%s %s   %sworkflow%s %s\n' \
        "$C_DIM" "$C_RESET" "$DEJIMA_HARNESS_NAME${model:+ ($model)}" \
        "$C_DIM" "$C_RESET" "$DEJIMA_FORGE_NAME" \
        "$C_DIM" "$C_RESET" "$DEJIMA_WORKFLOW_NAME" >&2
    printf '  %sfirewall%s %s   %sremote%s %s   %sepitaph%s %s\n\n' \
        "$C_DIM" "$C_RESET" "$firewall" \
        "$C_DIM" "$C_RESET" "$remote" \
        "$C_DIM" "$C_RESET" "$epitaph" >&2
}
startup_banner

# Printed whatever [ui] banner says and whether or not stderr is a terminal,
# unlike the banner above: the share path is the only route back to anything
# this session hands over, and the two features it does without are ones an
# operator would otherwise go looking for afterwards and find empty.
if [ "$DEJIMA_ISOLATED" = 1 ]; then
    printf '\n  %sisolated%s  no project mounted, no memory, no transcript;\n' \
        "${C_KEY:-}" "${C_RESET:-}" >&2
    printf '            conversation log and epitaph off - both read one\n' >&2
    printf '  %sforge%s     %s   %sworkflow%s %s\n' \
        "${C_DIM:-}" "${C_RESET:-}" "$DEJIMA_FORGE_NAME" \
        "${C_DIM:-}" "${C_RESET:-}" "$DEJIMA_WORKFLOW_NAME" >&2
    printf '  %sshared%s    %s\n' "${C_DIM:-}" "${C_RESET:-}" "$SANDBOX_SHARE_DIR" >&2
    printf '            the only thing that outlives this session\n' >&2
fi

printf '\n%sdejima%s is starting, please wait...\n' "$C_KEY" "$C_RESET" >&2
printf '%sA first run builds the container image, which takes several minutes.%s\n' \
    "$C_DIM" "$C_RESET" >&2

# Named here, once, at the moment the operator is choosing to proceed. Silent
# when the harness hosts every guard, so this is not a line anyone learns to
# scroll past - it appears only when a protection they have elsewhere is
# genuinely not in force here.
harness_report_guards "$DEJIMA_HARNESS_NAME"

# Declaring a guard is not the same as it being installed. install.sh
# registers them into the operator's own config, gated on [core] harness -
# so setting that key and not re-running install.sh leaves every launch
# after it with no briefing and no guards, and nothing said so. The project's
# own standard is that a silently narrower run is worse than a noisy one.
if ! _unwired="$(harness_guards_wired)"; then
    printf '\n  %snot registered for %s, so not in force this session:%s\n' \
        "$C_WARN" "$DEJIMA_HARNESS_NAME" "$C_RESET" >&2
    printf '%s\n' "$_unwired" | while IFS= read -r _u; do
        [ -n "$_u" ] && printf '    %s%s%s\n' "$C_KEY" "$_u" "$C_RESET" >&2
    done
    printf '  Run %s./install.sh%s to register them.\n' "$C_KEY" "$C_RESET" >&2
    DEJIMA_STARTUP_NOTICE=1
fi

# Everything said so far is about the operator's configuration and this
# session; every line after this is the image build, which on a cold cache is
# thousands of lines of Docker output. A notice that lands here scrolls away
# before it can be read, which is the same reason the exit audit prints its
# findings LAST.
#
# Only when something was actually said, and only with someone there to read
# it: an unattended launch must not block on a keystroke nobody will press.
# The orphaned-share list deliberately does not raise this - it is a standing
# condition rather than something about this run, and pausing every launch
# until it is cleared would teach the operator to press Enter without looking,
# which is the failure this exists to avoid.
if [ "${DEJIMA_STARTUP_NOTICE:-0}" = 1 ] && [ "${NO_PAUSE:-0}" != 1 ] \
    && [ -t 0 ] && [ -t 2 ]; then
    printf '\n  %sThe build output below will scroll that away.%s ' \
        "${C_DIM:-}" "${C_RESET:-}" >&2
    printf '%sPress Enter to continue.%s ' "${C_KEY:-}" "${C_RESET:-}" >&2
    read -r _ </dev/tty || true
    printf '\n' >&2
fi

# Before the image build, which is the long part: an operator who wants the
# newer dejima would rather know now than after waiting for a build of the one
# they have. Advisory and best-effort - see bin/dejima-update-check for why it
# never reaches the remote through this checkout's own git config.
"$SCRIPT_DIR/dejima-update-check" || true

# Snapshot host-persistence surfaces so the post-run audit can report
# anything that changed while this session ran (advisory - see bin/dejima-audit).
# Paired with the compare in cleanup(), which states why an isolated launch
# does neither.
if [ "$DEJIMA_ISOLATED" = 0 ]; then
    "$SCRIPT_DIR/dejima-audit" snapshot "$WORKSPACE_DIR" "$SANDBOX_AUDIT_STATE" || true
fi

# When the sandbox is pointed at its own repo, its TCB (bin/, hooks/,
# docker/, install.sh, test/) is mounted writable - warn, and the audit
# below will report any change to it.
if [ "$(readlink -f "$WORKSPACE_DIR")" = "$(readlink -f "$REPO_ROOT")" ]; then
    echo "Warning: sandbox's own repo is the mounted workspace - its trusted" >&2
    echo "computing base (bin/, hooks/, docker/, test/, install.sh, and the" >&2
    echo "provider and tool manifests) is writable this session; the exit audit" >&2
    echo "will report any changes to review." >&2
fi

# Every image this builds is named under localhost/, which Docker reads as a
# registry host rather than as the first path component of a Docker Hub name
# (#53). A bare `dejima:node` resolves to `docker.io/library/dejima:node` the
# moment anyone pushes it, and none of these images is meant to leave the host
# that built them. Pulling the base still goes to Hub, deliberately: that is
# where `node:22` is.
#
# One prefix, used by every tag below and by the compose file's default, so
# there is no second place for a bare name to reappear.
SANDBOX_IMAGE_REPO="localhost/dejima"

# Compose builds the shared base image under its own tag, explicitly - with
# image: now honoring $SANDBOX_IMAGE, letting the derived tag leak into this
# build would rebuild the base Dockerfile under the derived name.
SANDBOX_IMAGE="${SANDBOX_IMAGE_REPO}:${SANDBOX_USER}" \
    docker compose -f "$COMPOSE_FILE" build sandbox

SANDBOX_IMAGE="${SANDBOX_IMAGE_REPO}:${SANDBOX_USER}"

# Each layer builds FROM whatever the previous one left in $SANDBOX_IMAGE, so
# global packages are shared by every project instead of being rebuilt into
# each one's image.
build_dep_layer() {
    local tag="$1" store="$2"
    generate_derived_dockerfile "$SANDBOX_IMAGE" "$store" > "$SANDBOX_DERIVED_DOCKERFILE"
    echo "Building $tag (approved dependencies)..."
    if ! docker build -t "$tag" -f "$SANDBOX_DERIVED_DOCKERFILE" "$SANDBOX_DERIVED_CONTEXT"; then
        # No fallback to a less-equipped image: that would hand the session an
        # environment missing the dependencies it was told it has.
        echo "Failed to build $tag." >&2
        echo "Fix the approved dependencies (dejima-dep list) and retry." >&2
        exit 1
    fi
    SANDBOX_IMAGE="$tag"
}

# The selected harness, and only it.
#
# The base image is shared by every project on this host, so a harness baked
# into it would be carried by every sandbox whether or not it is the one
# selected - and a second harness could only ever be added alongside the
# first, never chosen between. Here it is a layer keyed on the harness's own
# name, so switching [core] harness switches images rather than accumulating
# into one.
#
# Built before the dependency layers so a project's own packages sit above the
# harness rather than being invalidated whenever it changes.
#
# The spec is manifest data of type `depspec`, so manifest_get has already
# refused anything valid_dep_spec would - the same function an operator's
# approved dependency goes through, and the reason this string is safe to
# interpolate into a Dockerfile RUN line. Re-checking here would be a second
# copy of a rule with no way to disagree with the first, and no way to test.
SANDBOX_HARNESS_INSTALL="$(manifest_get harness "$DEJIMA_HARNESS_NAME" install)"
# What this harness needs its image to contain besides the package: the
# directories it keeps state in, and anything it wants in the environment.
# Both used to be in the base image, where they were one harness's needs
# imposed on every image dejima builds.
SANDBOX_HARNESS_IMAGE_DIRS="$(manifest_get harness "$DEJIMA_HARNESS_NAME" image_dirs)"
SANDBOX_HARNESS_IMAGE_ENV="$(manifest_get harness "$DEJIMA_HARNESS_NAME" image_env)"
SANDBOX_HARNESS_IMAGE_FILES="$(manifest_get harness "$DEJIMA_HARNESS_NAME" image_files)"

if [ -n "$SANDBOX_HARNESS_INSTALL" ] || [ -n "$SANDBOX_HARNESS_IMAGE_DIRS" ] \
    || [ -n "$SANDBOX_HARNESS_IMAGE_ENV" ] || [ -n "$SANDBOX_HARNESS_IMAGE_FILES" ] \
    || [ -n "$DEJIMA_TOOLS" ] \
    || [ -s "$(config_root)/dependencies" ] \
    || { [ -n "$PROJECT_STORE" ] && [ -s "$PROJECT_STORE/dependencies" ]; }; then
    SANDBOX_DERIVED_DOCKERFILE="$(mktemp "${TMPDIR:-/tmp}/dejima-derived.XXXXXX")"
    # An empty context, never $WORKSPACE_DIR: the project directory is
    # session-writable, and a root-context build with it as context would let a
    # compromised session feed files into the image it then runs in.
    SANDBOX_DERIVED_CONTEXT="$(mktemp -d "${TMPDIR:-/tmp}/dejima-derived-ctx.XXXXXX")"
fi

if [ -n "$SANDBOX_HARNESS_INSTALL" ] || [ -n "$SANDBOX_HARNESS_IMAGE_DIRS" ] \
    || [ -n "$SANDBOX_HARNESS_IMAGE_ENV" ] || [ -n "$SANDBOX_HARNESS_IMAGE_FILES" ]; then
    tag="${SANDBOX_IMAGE_REPO}:${SANDBOX_USER}-harness-${DEJIMA_HARNESS_NAME}"
    generate_spec_dockerfile "$SANDBOX_IMAGE" "$SANDBOX_HARNESS_INSTALL" \
        "$SANDBOX_HARNESS_IMAGE_DIRS" "$SANDBOX_HARNESS_IMAGE_ENV" \
        "$SANDBOX_HARNESS_IMAGE_FILES" \
        > "$SANDBOX_DERIVED_DOCKERFILE"
    echo "Building $tag ($DEJIMA_HARNESS_NAME)..."
    if ! docker build -t "$tag" -f "$SANDBOX_DERIVED_DOCKERFILE" "$SANDBOX_DERIVED_CONTEXT"; then
        # No fallback: a session started on an image without its harness gets
        # an exec failure inside a container, which reads as dejima being
        # broken rather than as this build having failed.
        echo "Failed to build $tag." >&2
        echo "The harness installs from: $SANDBOX_HARNESS_INSTALL" >&2
        exit 1
    fi
    SANDBOX_IMAGE="$tag"
fi

# One layer per tool, above the harness and below the dependencies. Above the
# harness because a tool is meant to be on PATH for it; below the operator's
# own dependencies because those are the last word on what the image holds.
#
# Tagged by name and version-of-spec so two projects wanting the same tool
# share a layer, and a manifest that changes what it installs gets a new one
# rather than a stale cached hit.
for _tool in $DEJIMA_TOOLS; do
    _tool_install="$(manifest_get tool "$_tool" install)"
    _tool_env="$(manifest_get tool "$_tool" image_env)"
    _tool_layout="$(manifest_get tool "$_tool" install_layout)"
    tag="${SANDBOX_IMAGE_REPO}:${SANDBOX_USER}-tool-${_tool}-$(printf '%s' "$_tool_install" | sha256sum | cut -c1-12)"
    DEJIMA_TARBALL_STRIP="$_tool_layout" \
        generate_spec_dockerfile "$SANDBOX_IMAGE" "$_tool_install" "" "$_tool_env" "" \
        > "$SANDBOX_DERIVED_DOCKERFILE"
    echo "Building $tag ($_tool: $(manifest_get tool "$_tool" description))..."
    if ! docker build -t "$tag" -f "$SANDBOX_DERIVED_DOCKERFILE" "$SANDBOX_DERIVED_CONTEXT"; then
        # No fallback, for the reason the harness layer gives: a session told
        # it has a tool and given an image without one fails inside the
        # container, where it reads as dejima being broken.
        echo "Failed to build $tag." >&2
        echo "$_tool installs from: $_tool_install" >&2
        exit 1
    fi
    SANDBOX_IMAGE="$tag"
done
unset _tool _tool_install _tool_env _tool_layout

if [ -s "$(config_root)/dependencies" ]; then
    build_dep_layer "${SANDBOX_IMAGE_REPO}:${SANDBOX_USER}-global" "$(config_root)"
fi
if [ -n "$PROJECT_STORE" ] && [ -s "$PROJECT_STORE/dependencies" ]; then
    build_dep_layer "${SANDBOX_IMAGE_REPO}:${SANDBOX_USER}-$(project_slug "$WORKSPACE_DIR")" "$PROJECT_STORE"
fi
export SANDBOX_IMAGE

# nvim's data, state and cache are Docker volumes rather than host paths, so
# nothing a session installs reaches the host's own nvim. They did outlive
# the container under one fixed name each, though, shared by every project
# on the machine: a session that drops a file under `lazy/`, or replaces a
# binary under `mason/bin/`, is writing something a later session in an
# unrelated project executes the next time nvim runs in the sandbox. That is
# the persistence this repository closes everywhere else, so it is scoped
# the same way the derived image tag is - per project.
#
# The prefix is not decoration: project_slug maps a leading `/` to `-`, and
# a Docker volume name may not start with one.
#
# Per RUN rather than per project for an isolated launch, along with the shell
# history - which is shared by every project even when these are not, so it is
# otherwise the one surface where an isolated session reads what was typed in
# any session before it. Every isolated launch has the same working
# directory, so a slug would give them all the same four volumes and put back
# exactly the thing described above.
#
# The run id is what makes them unreachable: no other launch computes that
# name, so no other session can ask for these volumes whether or not the
# removal in cleanup() gets to run.
if [ "$DEJIMA_ISOLATED" = 1 ]; then
    export SANDBOX_NVIM_DATA="dejima_isolated-nvim-data-$DEJIMA_RUN_ID"
    export SANDBOX_NVIM_STATE="dejima_isolated-nvim-state-$DEJIMA_RUN_ID"
    export SANDBOX_NVIM_CACHE="dejima_isolated-nvim-cache-$DEJIMA_RUN_ID"
    export SANDBOX_BASH_HISTORY="dejima_isolated-bashhistory-$DEJIMA_RUN_ID"
    # The working directory, which is a volume rather than a host path here -
    # see SANDBOX_WORKSPACE_MOUNT above. It is the one of the five that holds
    # whatever the session actually did, so it is the one that most needs
    # removing rather than merely being unreachable.
    export SANDBOX_ISOLATED_WORK="dejima_isolated-work-$DEJIMA_RUN_ID"
    SANDBOX_ISOLATED_VOLUMES="$SANDBOX_NVIM_DATA $SANDBOX_NVIM_STATE $SANDBOX_NVIM_CACHE $SANDBOX_BASH_HISTORY $SANDBOX_ISOLATED_WORK"
else
    _nvim_volume_slug="$(project_slug "$WORKSPACE_DIR")"
    export SANDBOX_NVIM_DATA="dejima_nvim-data${_nvim_volume_slug}"
    export SANDBOX_NVIM_STATE="dejima_nvim-state${_nvim_volume_slug}"
    export SANDBOX_NVIM_CACHE="dejima_nvim-cache${_nvim_volume_slug}"
fi

# Not exec'd (the EXIT trap cleans up the generated temp files) and not a
# bare command under set -e (a nonzero/interrupted container run - e.g.
# Ctrl-C returning 130 - would otherwise abort the script here, skipping the
# credential-proxy check below it). Capture the status explicitly, then
# propagate the real container exit code unchanged - the exit-time audit
# itself now runs unconditionally from cleanup(), not because of this
# capture.
# Unacknowledged CRITICAL findings from earlier runs, re-presented here
# because exit-time output can be ignored exactly once, permanently. This is
# host-side and stays host-side: it must never be routed into the session's
# context, which would make it a cross-session persistence channel.
audit_slug="$(ws_slug "$WORKSPACE_DIR")"
audit_reports="$AUDIT_DIR/$audit_slug"
# `|| true`: a workspace with no history yet (the common case - nothing has
# ever run against it, or every past finding is already acknowledged) has no
# audit_reports directory at all, and find on a missing path exits nonzero -
# which, combined with pipefail, would otherwise abort this script here for
# every ordinary launch.
pending_crit="$(find "$audit_reports" -maxdepth 1 -name '*-CRITICAL.txt' 2>/dev/null | sort)" || true
if [ -n "$pending_crit" ]; then
    # Re-read after an inline `ack`, so approving everything here clears the
    # warning instead of asking again about reports that no longer carry a
    # CRITICAL. Looping rather than asking once: `ack` walks ONE report per
    # run (see select_report), so a workspace with several needs several
    # passes, and sending the operator back to their shell between each is
    # the friction this option exists to remove (#65).
    while [ -n "$pending_crit" ]; do
        n="$(printf '%s\n' "$pending_crit" | wc -l)"
        echo "" >&2
        printf '  dejima: %s%s unacknowledged %sCRITICAL%s finding(s) in this workspace\n' \
            "$C_CRIT" "$n" "$C_CRIT" "$C_RESET" >&2
        printf '%s\n' "$pending_crit" \
            | sed "s#.*/##; s#-CRITICAL\.txt\$##; s#^#          from run ${C_INFO}#; s#\$#${C_RESET}#" >&2
        printf '          Review with: %sdejima-audit ack "%s"%s\n' \
            "$C_KEY" "$WORKSPACE_DIR" "$C_RESET" >&2
        if [ -t 0 ]; then
            printf '          %s[a]%s review now  %s[c]%s continue  %s[q]%s quit: ' \
                "$C_KEY" "$C_RESET" "$C_KEY" "$C_RESET" "$C_KEY" "$C_RESET" >&2
            read -r reply || reply=q
            case "$reply" in
                q|Q) echo "aborted." >&2; exit 1 ;;
                a|A)
                    # Run it here rather than telling the operator to run it
                    # in another terminal: this is the moment they are
                    # looking at the finding, and the container has not
                    # started, so nothing is waiting on the answer.
                    "$SCRIPT_DIR/dejima-audit" ack "$WORKSPACE_DIR" || true
                    pending_crit="$(find "$audit_reports" -maxdepth 1 -name '*-CRITICAL.txt' 2>/dev/null | sort)" || true
                    continue
                    ;;
            esac
        fi
        break
    done
fi
if [ -n "$pending_crit" ] && [ ! -t 0 ]; then
    if [ "$(config_get_bool audit strict)" = "1" ]; then
        echo "          audit.strict is on and stdin is not a tty - refusing to start." >&2
        exit 1
    else
        echo "          (not a tty - continuing; set audit.strict = on to refuse)" >&2
    fi
fi

# The egress control runs in a container of its own, which owns this launch's
# network namespace. The sandbox joins that namespace rather than getting one
# of its own, so the filter bounding it is installed somewhere it has no
# capability to reach - capabilities are per process, and the sandbox declares
# none of the network ones.
#
# Ordering is the point, and it is why this is here rather than inside the
# sandbox's own startup. The namespace has its rules before anything is created
# in it, so the window where a session holds an interface and no filter does
# not exist, rather than being bounded by an argument about how fast an
# entrypoint runs.
#
# The base image, never the per-project derived one: this is the privileged
# half of the pair and has no use for a project's declared dependencies.
SANDBOX_RUN_NETWORK="dejima-run-$DEJIMA_RUN_ID"
SANDBOX_FILTER_CONTAINER="dejima-filter-$DEJIMA_RUN_ID"

# A launch removes its own pair on the way out, which covers every exit the
# trap reaches. It does not cover SIGKILL, a host reboot, or a docker daemon
# restart, and each of those leaves a filter container holding a network open.
#
# The leak is inert - a filter container filters a namespace nothing is in - so
# this is hygiene rather than a second control. What makes it worth doing is
# the address pool: Docker carves a bounded number of networks out of its
# default pool, and exhausting it fails a launch with an error about address
# space that says nothing about the cause.
#
# Never fatal. A daemon that cannot answer any of this is one that is about to
# fail the launch a few lines down for a reason worth reporting, and a tidy-up
# pass should not be what reports it.

# How old a filter container or network has to be before an absent session
# counts as abandoned rather than as not started yet.
#
# Between `docker run` for the filter container and `docker compose run` for
# the session there is a window in which a healthy pair has no joiner, and it
# lasts as long as the firewall takes to come up - seconds, or half a minute
# when the GitHub ranges fetch retries. The same window exists for a network
# between its creation and its first endpoint. Minutes against seconds, because
# reaping a concurrent launch out from under itself costs that launch, while
# waiting costs one more pass.
DEJIMA_ORPHAN_GRACE=300

# Seconds since $1, a timestamp as Docker rendered it, or nothing at all if it
# cannot be read as one.
#
# Two renderings arrive here and only one is RFC 3339, because the field is not
# the same type on both of the structs this asks about: a container's `Created`
# is a string, a network's is a `time.Time`. A Go template prints a `time.Time`
# through String(), which appends a timezone NAME - "+0000 UTC" - and date(1)
# refuses that outright.
#
# So the name is stripped rather than the format assumed. Assuming the format
# is what broke this: the comment here used to say every caller read RFC 3339,
# which was true of one of them, and the network half therefore reaped nothing
# at all while looking exactly like the half that worked.
#
# An unparseable value still yields no age rather than a huge one, so the
# failure stays "nothing was reaped" and never becomes "everything was".
_epoch_age() {
    local made stamp="$1"
    # "2026-09-09 18:00:00.1 +0000 UTC" -> "2026-09-09 18:00:00.1 +0000", and
    # an RFC 3339 stamp, which has no spaces, through unchanged.
    case "$stamp" in
        *' '[A-Z]*) stamp="${stamp% *}" ;;
    esac
    made="$(date -d "$stamp" +%s 2>/dev/null)" || return 1
    [ -n "$made" ] || return 1
    printf '%s' "$(( $(date +%s) - made ))"
}

reconcile_orphans() {
    local candidates joined id name created age

    # The candidates first, so a host with no filter containers at all asks one
    # question and stops. The query below is the expensive one - it inspects
    # every running container on the host, dejima's or not - and there is no
    # point paying for it to answer about nothing.
    candidates="$(docker ps -aq --filter 'label=dejima.filter' 2>/dev/null \
        | xargs -r docker inspect -f '{{.Id}} {{.Name}} {{.Created}}' 2>/dev/null)"

    if [ -n "$candidates" ]; then
        # Whose namespace something is still in. Read once: asking per
        # candidate would be a docker call per candidate, on every launch.
        #
        # NetworkMode is `container:<id>` for a joiner - the id, even where the
        # launcher passed a name, so what is compared below are ids.
        joined="$(docker ps -q 2>/dev/null | xargs -r docker inspect \
            -f '{{.HostConfig.NetworkMode}}' 2>/dev/null | sed -n 's/^container://p')"
    fi

    while read -r id name created; do
        [ -n "$id" ] && [ -n "$created" ] || continue
        grep -qxF "$id" <<< "${joined:-}" && continue
        age="$(_epoch_age "$created")" || continue
        [ "$age" -gt "$DEJIMA_ORPHAN_GRACE" ] || continue
        echo "dejima: removing a filter container an earlier session left behind (${name#/})" >&2
        docker rm -f "$id" >/dev/null 2>&1 || true
    done <<< "$candidates"

    # Networks defend themselves twice over: `docker network rm` refuses one
    # that still has an endpoint, and the grace covers the moment between a
    # network being created and its filter container attaching to it.
    while read -r name created; do
        [ -n "$name" ] && [ -n "$created" ] || continue
        age="$(_epoch_age "$created")" || continue
        [ "$age" -gt "$DEJIMA_ORPHAN_GRACE" ] || continue
        docker network rm "$name" >/dev/null 2>&1 || true
    done < <(docker network ls -q --filter 'label=dejima.run' 2>/dev/null \
             | xargs -r docker network inspect -f '{{.Name}} {{.Created}}' 2>/dev/null)
}
reconcile_orphans || true

# One network per launch, for two reasons, and the first is not optional:
# Docker's embedded resolver at 127.0.0.11 exists only on a user-defined
# network, and the restricted resolver forwards to it. The second is that
# concurrent
# sessions are then separated at the link layer as well, rather than only by
# each namespace's own egress default-deny.
docker network create --label "dejima.run=$DEJIMA_RUN_ID" "$SANDBOX_RUN_NETWORK" >/dev/null

# Everything the firewall path reads, passed as `-e NAME` so a value is taken
# from this process's environment rather than written into a command line every
# process on the host can read - GH_TOKEN is in this list.
#
# Derived from nothing: this IS the declaration, because the filter container
# is started with `docker run` and so gets none of the environment
# docker-compose.yml declares for the sandbox. A static test reads the same
# names back out of the firewall scripts, so a script that starts reading a new
# one fails rather than silently allowlisting less than it was told to.
# EGRESS_HOST_ADDR comes from the operator's own environment and travels
# through untouched. Marked exported anyway, so that `-e NAME` carries a value
# rather than an empty string on a shell where it was set without being
# exported.
#
# EGRESS_HOST_CANDIDATES is the other half of the clamp, and it is a list of
# candidates rather than of ports because only the filter can decide.
#
# The clamp holds the host's own address to the ports it was told, ahead of
# every wider rule - so a service dejima pointed at the host needs its port
# there to be reachable at all. The launcher knows which destinations it
# configured; it does not know the host's address, which the policy works out
# from the default route inside the container. EGRESS_HOST_ADDR is the
# operator's override for when that derivation is wrong, so it is normally
# unset: comparing against it here meant nothing was ever derived on an
# ordinary install, and a host-resident Loki at the gateway address stopped
# being reachable the moment the clamp went first.
#
# So the launcher passes what it is sure of - a literal address with an
# explicit port - and the policy keeps the port of each candidate whose
# address is the host's. Nothing resolves a name on either side.
#
# A scheme default is not an explicit port: a remote https URL has none, and
# adding 443 for it opened the host's own 443 - which on a box following
# docs/remote-access.md is the reverse proxy in front of the web terminal.
_egress_host_candidate() {  # <url> -> addr:port, or nothing
    local rest hostport host port
    rest="${1#*://}"
    # Path, query and fragment, in that order: a URL may carry a query with no
    # path at all, and `?x=1` came through as part of the port.
    rest="${rest%%/*}"
    rest="${rest%%\?*}"
    rest="${rest%%#*}"
    # Userinfo. A password beginning with a digit read as the port.
    hostport="${rest##*@}"
    case "$hostport" in
        # Bracketed IPv6 with a port, then without. The unbracketed form has
        # no port by definition - every colon in it is part of the address -
        # which is why `[fe80::1]/` came out as `1]`.
        \[*\]:*) host="${hostport%]:*}]"; port="${hostport##*]:}" ;;
        \[*\])   return 0 ;;
        *:*)      host="${hostport%:*}";  port="${hostport##*:}" ;;
        *)        return 0 ;;
    esac
    case "$port" in ""|*[!0-9]*) return 0 ;; esac
    # A literal IPv4 address and nothing else. A name is a destination the
    # launcher cannot place, and the policy compares against an IPv4 address
    # because the host entry is one.
    case "$host" in
        *[!0-9.]*|"") return 0 ;;
    esac
    printf '%s:%s' "$host" "$port"
}
EGRESS_HOST_CANDIDATES=""
for _hc_url in "$LOKI_URL" \
    "$([ -n "$DEJIMA_BACKEND_NAME" ] \
        && config_type "backend.$DEJIMA_BACKEND_NAME.base_url" >/dev/null 2>&1 \
        && config_get "backend.$DEJIMA_BACKEND_NAME" base_url)"; do
    [ -n "$_hc_url" ] || continue
    _hc="$(_egress_host_candidate "$_hc_url")"
    [ -n "$_hc" ] || continue
    case " $EGRESS_HOST_CANDIDATES " in
        *" $_hc "*) continue ;;
    esac
    EGRESS_HOST_CANDIDATES="${EGRESS_HOST_CANDIDATES:+$EGRESS_HOST_CANDIDATES }$_hc"
done
unset _hc_url _hc
export EGRESS_HOST_CANDIDATES
export EGRESS_HOST_ADDR EGRESS_HOST_PORTS

filter_env=(
    -e ENABLE_FIREWALL       # whether to install rules at all
    -e ALLOWED_DOMAINS       # the operator's approved names
    -e DEJIMA_FORGE_HOSTS    # the configured forge's, resolved host-side
    -e ALLOW_GITHUB_PUSH     # whether port 22 to GitHub opens
    -e GH_TOKEN              # authenticates the GitHub ranges fetch
    -e LOKI_URL              # its host is allowlisted, and the shipper needs it
    -e DEJIMA_INTERNAL_DOMAINS  # approved names that really do resolve inside
    -e DEJIMA_BACKEND_HOST   # a self-served model's host, from its base_url
    # The host entry: which address stands for "this container's host", and
    # which of its TCP ports open. Both default inside egress-policy and
    # neither has a config key, so passing them is what makes them reachable
    # at all - without this the clamp would have no escape hatch an operator
    # could use.
    -e EGRESS_HOST_ADDR
    -e EGRESS_HOST_PORTS
    -e EGRESS_HOST_CANDIDATES  # host-resident destinations this launch configured
    -e SANDBOX_HARNESS_DOMAINS
    -e SANDBOX_BACKEND_PROXIED_DOMAIN
    -e SANDBOX_CREDENTIAL_PROXY_SOCKET
)

# The capabilities the egress control needs, and the sysctl the namespace it
# owns needs. Every one of these used to be in docker-compose.yml, and none of
# them belongs to a container running a session:
#
#   NET_ADMIN, NET_RAW   the packet filter and its named address sets, which
#                        the resolver also writes into as it answers
#   KILL                 the resolver drops to its own user, and dejima-allow
#                        restarts it to add a domain mid-session
#   CHOWN, DAC_OVERRIDE, FOWNER, SETUID, SETGID
#                        the log files, and the daemons that drop privilege
#                        after opening what they need
#   SETPCAP              spent taking the rest away from log-shipper.sh
#
# The sysctl lets the credential proxy's TLS terminator bind :443 as the
# unprivileged sandbox user. It is a property of the network namespace rather
# than of a container, so it is set here, by the container that owns one, and
# it applies to the session container that joins it.
docker run -d --init \
    --name "$SANDBOX_FILTER_CONTAINER" \
    --label "dejima.filter=$DEJIMA_RUN_ID" \
    --network "$SANDBOX_RUN_NETWORK" \
    --cap-drop ALL \
    --cap-add NET_ADMIN --cap-add NET_RAW \
    --cap-add KILL \
    --cap-add CHOWN --cap-add DAC_OVERRIDE --cap-add FOWNER \
    --cap-add SETUID --cap-add SETGID \
    --cap-add SETPCAP \
    --security-opt no-new-privileges:true \
    --sysctl net.ipv4.ip_unprivileged_port_start=0 \
    ${filter_env[@]+"${filter_env[@]}"} \
    --entrypoint /usr/local/bin/filter-entrypoint.sh \
    "${SANDBOX_IMAGE_REPO}:${SANDBOX_USER}" >/dev/null

# Named, not the id `docker run -d` printed. Both work for `container:`, and a
# name we chose is one this launcher already knows without reading anything
# back - which is also what lets the teardown below name it after a failure
# that happened before any output could be captured.
export SANDBOX_NETNS_CONTAINER="container:$SANDBOX_FILTER_CONTAINER"

# Waited for, rather than assumed. The filter container resolves domains and
# fetches GitHub's published ranges, which is seconds on a good day and retries
# for half a minute on a bad one - so "it will be up by the time compose has
# started the sandbox" is a guess, and the thing it guesses about is whether
# the session gets a filter.
#
# Two ways out, and the second is why this is a loop rather than a sleep: the
# marker appears, or the container is gone. A firewall that refused to come up
# exits the container, and without that check this would wait out the whole
# timeout before reporting a failure that had already been decided.
echo "dejima: bringing up this session's egress control..." >&2
_filter_ready=0
for _ in $(seq 1 240); do
    if docker exec "$SANDBOX_FILTER_CONTAINER" \
        test -f /run/dejima-firewall-ready 2>/dev/null; then
        _filter_ready=1
        break
    fi
    if [ "$(docker inspect -f '{{.State.Running}}' "$SANDBOX_FILTER_CONTAINER" 2>/dev/null)" != "true" ]; then
        break
    fi
    sleep 0.5
done
if [ "$_filter_ready" -ne 1 ]; then
    echo "" >&2
    echo "The egress control for this session did not come up, so there is no" >&2
    echo "filter for the sandbox to run behind. Refusing to start it." >&2
    echo "" >&2
    echo "What the filter container said:" >&2
    docker logs "$SANDBOX_FILTER_CONTAINER" 2>&1 | sed 's/^/    /' >&2 || true
    exit 1
fi

compose_status=0
# Conversation shipping, host-side: it reads transcripts that are on the host
# already, so it never needed to be in the container - and being there put it
# in a process the session shared a filesystem and a signal namespace with.
# Started here rather than earlier so it does not run during a launch that
# refuses, and stopped in cleanup() before the audit.
#
# `|| true` and a backgrounded start: shipping is a bonus on top of the
# session, so a shipper that cannot start must not stop the session running.
#
# Gated here as well as inside the shipper, which checks the same two keys for
# itself: --no-conversation and --no-log are overrides held in this process,
# and the shipper re-reads the config rather than inheriting them.
#
# An isolated launch reaches this with the key already overridden off, for
# the reason the epitaph is: the shipper tails a transcript that is on the
# host because the harness's state directory is mounted, and that mode mounts
# none. One override up where the flag is read, rather than a check here that
# the banner would then contradict.
if [ "$(config_get_bool logging conversation)" = "1" ] &&
   [ -n "$(config_get logging loki_url)" ]; then
    "$SCRIPT_DIR/dejima-conversation-ship" "$WORKSPACE_DIR" &
    SANDBOX_CONVERSATION_SHIP_PID=$!
fi

# Ctrl-Z (#179). Two things have to be true at once, and the second is the
# one that took four rounds of measurement to find.
#
# The keystroke has to become a signal at all. Under a container run the
# terminal is raw, ISIG is off, and Ctrl-Z is the byte 0x1a delivered into the
# container - where a harness that implements suspend stops itself, with no
# job-control shell in there to continue it. dejima-tty-suspend takes that one
# character back for the host.
#
# And the container client must never receive SIGTSTP. Measured, holding
# everything else equal: the client survives SIGSTOP and does not survive
# SIGTSTP. A catchable stop signal interrupts the read it is blocked in, its
# input forwarding never recovers, and the session comes back alive but deaf -
# resumed, with nothing typed reaching the harness. That is what shipped once
# and had to be taken out again.
#
# So the client is started with SIGTSTP ignored, which it inherits and keeps
# across exec, and the launcher converts the keystroke into an explicit
# SIGSTOP for it before stopping itself. The job still stops completely, so
# the prompt comes back; the client is stopped by a signal that interrupts
# nothing.
#
# < /dev/tty because a background command is given /dev/null for stdin, and a
# container client with no terminal reads EOF and exits at once.
SANDBOX_TTY_MODES="$(stty -g < /dev/tty 2>/dev/null)" || SANDBOX_TTY_MODES=""
if [ -n "$SANDBOX_TTY_MODES" ]; then
    "$SCRIPT_DIR/dejima-tty-suspend" &
    SANDBOX_TTY_SUSPEND_PID=$!

    trap '' TSTP
    docker compose -f "$COMPOSE_FILE" run --rm ${extra_mounts[@]+"${extra_mounts[@]}"} sandbox "$@" < /dev/tty &
    SANDBOX_CLIENT_PID=$!
    trap - TSTP

    # The pid $! names is the docker CLI; `docker compose run` then runs the
    # compose plugin as a child of it, and the child is what holds the
    # terminal. Stopping only the pid leaves that child in raw mode with the
    # shell also on the terminal - a cursorless prompt that resumes to
    # nothing, which is what the first version of this fix produced.
    #
    # Deepest first, so nothing is left running long enough to fork again;
    # shallowest first on the way back, so a parent is ready before its child
    # resumes.
    _stop_tree() {
        local p="$1" kid
        for kid in $(pgrep -P "$p" 2>/dev/null); do _stop_tree "$kid"; done
        kill -STOP "$p" 2>/dev/null || true
    }
    _cont_tree() {
        local p="$1" kid
        kill -CONT "$p" 2>/dev/null || true
        for kid in $(pgrep -P "$p" 2>/dev/null); do _cont_tree "$kid"; done
    }

    _on_tstp() {
        _stop_tree "$SANDBOX_CLIENT_PID"
        # Show the cursor again. A full-screen harness hides it (DECTCEM,
        # \033[?25l) and puts it back when it exits - but a suspend is not an
        # exit, so the shell prompt inherits an invisible cursor and the
        # terminal looks like a connection that died. The harness hides it
        # again on resume, when the SIGWINCH below makes it repaint.
        printf '\033[?25h' > /dev/tty 2>/dev/null || true
        # Default disposition to actually stop, then put the handler back for
        # the next time. Everything after the re-raise runs on resume.
        trap - TSTP
        kill -TSTP $$
        trap _on_tstp TSTP
        _cont_tree "$SANDBOX_CLIENT_PID"
    }
    trap _on_tstp TSTP

    # Looped because `wait` returns early when a trap runs, which is exactly
    # what happens on every suspend.
    while kill -0 "$SANDBOX_CLIENT_PID" 2>/dev/null; do
        if wait "$SANDBOX_CLIENT_PID"; then
            compose_status=0
        else
            compose_status=$?
        fi
        # 128+n means a signal interrupted the wait rather than the client
        # exiting; anything else is the client's own status.
        case "$compose_status" in
            146|148) continue ;;
        esac
        break
    done
    trap - TSTP
else
    # No terminal - a CI job, or output redirected. Nothing to suspend, and
    # /dev/tty would not open.
    docker compose -f "$COMPOSE_FILE" run --rm ${extra_mounts[@]+"${extra_mounts[@]}"} sandbox "$@" || compose_status=$?
fi
if [ -n "${SANDBOX_CREDENTIAL_PROXY_PID:-}" ] && ! kill -0 "$SANDBOX_CREDENTIAL_PROXY_PID" 2>/dev/null; then
    echo "Warning: the credential proxy exited before the session ended - API calls in" >&2
    echo "the session may have been failing. Check for proxy errors in the output above." >&2
fi
exit "$compose_status"
