#!/usr/bin/bash
# SPDX-License-Identifier: AGPL-3.0-only
# /usr/local/bin/ai-tools
# Project-lifecycle CLI for the ai-tools Claude Code sandbox. Runs AS the invoking operator (not
# as root, not as the sandbox account). It writes the operator-owned allowlist
# (~/.config/ai-tools/allowed-projects) directly, and reaches the root-owned bits
# -- the git safe.directory list in /opt/ai-tools/.gitconfig, the SELinux label, the ACL, and
# secret lockdown -- through the sudo root helpers (no NOPASSWD: the operator is prompted for a
# password; the sandbox account holds no grant).
#
# Two preflight gates run before dispatch: require_bootstrap (provisioned install) and, for the
# operator-acting commands (--project-*/--sandbox-*/--lockdown/--reclaim/--relabel),
# require_operator -- the invoking user must be in OPERATORS in operator.conf, since the root
# helpers resolve the caller's identity from that list. --help/--version/--list/--providers stay
# open to any user.
#
# Commands (each confirms before applying and reports the result):
#   --project-claim   [path]  claim a project in place -- grant the agent access (idempotent;
#                             default: cwd); -y/--yes pre-answers its proceed prompt (delegated)
#   --project-create  [path]  alias for --project-claim (kept for back-compat)
#   --project-unclaim [path]  release a project -- revoke the agent's access and hand the tree
#                             back to your own group (or a named user's), the agent's write
#                             removed; the directory is left on disk
#   --project-remove  [path]  alias for --project-unclaim (kept for back-compat)
#   --sandbox-create [path]   shallow-clone a repo into the sandbox area (private,
#                             umask 077), lock down tip-commit secrets, then grant
#                             the agent access and register -- fail-closed: an
#                             unsecured clone stays private and unregistered; run
#                             again on the clone path to resume securing it
#   --sandbox-push   [path]   push the sandbox clone's commits to its branch
#   --sandbox-remove [path]   remove a sandbox clone and unregister it
#   --lockdown [path]         lock down secret-named files under the project (sudo)
#   --reclaim [--full] [path] take back ownership of agent-written files -- the project stays
#                             claimed and the agent keeps access; the on-demand ownership
#                             handback, e.g. before an ACL-unaware backup (sudo; default: cwd)
#   --relabel                 relabel the enabled agents' entrypoints after a Node upgrade (sudo)
#   --providers               report the installed agents/integrations, which are enabled,
#                             and why (read-only; resolved through providers.lib.sh)
#   --status                  report ai-tools service health (read-only; services.lib.sh)
#   --list                    list registered projects (real vs sandbox)
#   --version                 print the installed ai-tools version
#   --help
#
# Sandbox model: the agent works in a shallow clone under SANDBOX_ROOT so it never
# reads the original repo's full git history. Work is pushed to a per-repo branch
# ai-tools/sandbox-<user>/<leaf> (default leaf: main). Only the projects user can
# push -- the sandbox account has no git credentials. Anyone with repo access then
# merges that branch back, preserving the agent's commits granularly. See
# /var/opt/ai-tools/README.md.
#
# Deploy: install -o root -g root -m 755 src/usr/local/bin/ai-tools.sh \
#         /usr/local/bin/ai-tools

set -euo pipefail
IFS=$'\n\t'

readonly SANDBOX_USER="ai-tools"
readonly SANDBOX_GROUP="ai-tools"
# Substituted at deploy time (install.sh from packaging/VERSION; the RPM from %{version});
# a raw source-tree run reports "dev".
AI_TOOLS_VERSION="0.11.1-1.el9"
[[ "${AI_TOOLS_VERSION}" == @*@ ]] && AI_TOOLS_VERSION="dev"
readonly AI_TOOLS_VERSION
# AI_TOOLS_GITCONFIG / AI_TOOLS_ALLOWLIST (below): root-only test hooks, the same family the
# root helpers carry (see tests.rule.md). The CLI runs as the operator, who owns both files
# anyway, so an override widens nothing it could not already do by editing them directly; sudo
# strips both (env_reset, not env_keep) before any root helper, which re-resolves the real paths
# itself, and the sandbox account is refused by the principal guard below before either is read.
readonly GITCONFIG="${AI_TOOLS_GITCONFIG:-/opt/ai-tools/.gitconfig}"
readonly SANDBOX_ROOT="/var/opt/ai-tools/sandbox-projects"
# Bootstrap's last load-bearing artifact -- the require_bootstrap gate keys on it (below).
# Same symlink the launch wrapper resolves; kept identical to claude.sh's CLAUDE_LINK.
readonly CLAUDE_LINK="/opt/ai-tools/bin/claude"
# Root-only secret lockdown helper. Invoked via sudo (NO NOPASSWD grant exists for
# it -- by design), so sudo prompts for the projects user's password.
readonly LOCKDOWN_BIN="/usr/local/libexec/ai-tools/ai-tools-lockdown"
# Root-only SELinux project-label helper, same sudo (no NOPASSWD) model as lockdown.
# Applies/reverts ai_tools_project_t so the confined agent can access a claimed,
# in-place tree; the per-project semanage fcontext rule it adds needs root, which
# this unprivileged CLI lacks. Sandbox clones do NOT use it (static rule + plain
# restorecon -- see relabel_clone).
readonly RELABEL_BIN="/usr/local/libexec/ai-tools/ai-tools-relabel"
# Root-only ACL helper, same sudo (no NOPASSWD) model as lockdown/relabel. Applies the
# project's group-permission ACL (default + access group:SANDBOX_GROUP:rwX, other denied)
# so files the projects user's git checkout/merge writes under a restrictive umask stay
# group-accessible to the agent. Needs root (CAP_FOWNER) to ACL files the projects user
# does not own; this unprivileged CLI lacks that.
readonly SETFACL_BIN="/usr/local/libexec/ai-tools/ai-tools-setfacl"
# Root-only setgid helper, same sudo (no NOPASSWD) model. Sets group SANDBOX_GROUP + the setgid
# bit on a claimed project's directories. The operator is not a SANDBOX_GROUP member
# (multi-operator), so the group change needs root; the helper carries its own allowlist + owner
# guard. Also invoked by the handback daemon for the SessionStart normalization pass.
readonly SETGID_BIN="/usr/local/libexec/ai-tools/ai-tools-setgid"
# Root-only unclaim helper, same sudo (no NOPASSWD) model. Reverses the filesystem side
# of a claim: clears the agent ACL + default ACL, regroups the tree to a target group, and
# removes group write. Needs root to chgrp to an arbitrary group and to act on files the
# projects user does not own.
readonly UNCLAIM_BIN="/usr/local/libexec/ai-tools/ai-tools-unclaim"
# Root-only entrypoint-relabel helper, same sudo (no NOPASSWD) model. Restores
# ai_tools_exec_t on the claude.exe entrypoint(s) after a Node auto-upgrade leaves them
# mislabelled; needs root (the projects user runs as unconfined_t, which can relabel, but
# only via sudo as the helper is 750 root:root). Invoked by --relabel and --postupgrade.
readonly RELABEL_ENTRYPOINT_BIN="/usr/local/libexec/ai-tools/ai-tools-relabel-agent"
# Root-only git safe.directory helper, same sudo (no NOPASSWD) model as lockdown/relabel/
# setfacl/unclaim. /opt/ai-tools/.gitconfig is root-owned 644: world-readable (the agent reads
# safe.directory on startup) but root-write-only, so neither the operator nor the agent writes it
# directly -- the operator reaches the validated add/--remove through this helper.
readonly SAFEDIR_BIN="/usr/local/libexec/ai-tools/ai-tools-safedir"
# Root-only ownership-reclaim helper, same sudo (no NOPASSWD) model. Hands agent-written files
# under a project back to the operator via ai-tools-chown (the per-path trust boundary), needed for
# the .git tree the per-session sweeps skip; useful before an ACL-unaware backup.
readonly RECLAIM_BIN="/usr/local/libexec/ai-tools/ai-tools-reclaim"
# Sentinel in a guard CLAUDE.md (see drop_lockdown_guard) so the lockdown step can
# recognise and remove its own placeholder once secrets are secured.
readonly GUARD_MARKER="ai-tools-lockdown-guard"

# ── Invoker guards ───────────────────────────────────────────────────────────────
# This is a user tool. It must run as the projects user: never as root (it would
# write the registries with the wrong owner) and never as the sandbox account
# (the agent must not manage its own allowlist).
ME="$(id -un)"
# The invoking operator's own primary group, for the one message that must name it: the lockdown
# preamble, which states the owner a locked secret ends up with (<you>:<you>). Not a decision
# input anywhere -- what a walk treats as "the operator's group" is resolved per path from the
# path's owner, never from who happens to be running the CLI.
MY_GROUP="$(id -gn)"
[[ "${ME}" == "root" ]] \
    && { echo "ai-tools: do not run as root -- run as the projects user, without sudo" >&2
         echo "          (the CLI invokes sudo itself for the steps that need it)" >&2; exit 1; }
[[ "${ME}" == "${SANDBOX_USER}" ]] \
    && { echo "ai-tools: refusing to run as the sandbox account ${SANDBOX_USER}" >&2; exit 1; }

HOME_DIR="$(getent passwd "${ME}" | cut -d: -f6)"
[[ -d "${HOME_DIR}" ]] || { echo "ai-tools: cannot resolve home for ${ME}" >&2; exit 1; }
readonly ME HOME_DIR
# One resolution point for readers AND writers (reg_allow/unreg_allow), so a fixture test that
# sets AI_TOOLS_ALLOWLIST never mutates the operator's real registry. Root-only test hook -- see
# the GITCONFIG note above for why the override grants the CLI's operator caller nothing new.
readonly ALLOWLIST="${AI_TOOLS_ALLOWLIST:-${HOME_DIR}/.config/ai-tools/allowed-projects}"

# ── Output / prompt helpers ──────────────────────────────────────────────────────
if [[ -t 1 ]]; then
    readonly C_BOLD=$'\033[1m' C_DIM=$'\033[2m' C_GRN=$'\033[32m' C_YEL=$'\033[33m' C_RED=$'\033[31m' C_RST=$'\033[0m'
else
    readonly C_BOLD='' C_DIM='' C_GRN='' C_YEL='' C_RED='' C_RST=''
fi

say()     { printf '%s\n' "$*"; }
section() { printf '\n%s%s%s\n' "${C_BOLD}" "$*" "${C_RST}"; }
ok()      { printf '  %s✓%s %s\n' "${C_GRN}" "${C_RST}" "$*"; }
warn()    { ai_tools_msg_warn "$@"; }
die()     { ai_tools_log_error "$*"; ai_tools_msg_error "ai-tools: $*"; exit 1; }
# The claim/sandbox flows are sequences of SELF-CONTAINED blocks, each opened by a wide
# headline box (title + summary prose), with details, prompts, and results printed plain
# below it and a closing ✓ (or a fail-closed error) ending the block -- see
# messaging.rule.md. headline() narrates to stdout; headline_warn() carries a
# "WARNING: ..."-titled block on stderr.
headline()      { ai_tools_msg_headline "$1" 1 "${@:2}"; }
headline_warn() { ai_tools_msg_headline "$1" 2 "${@:2}"; }

# Shared leveled logger -- journald only (this CLI runs as the projects user, not root,
# so it cannot write the root-only /var/log/ai-tools files). Records workflow
# milestones (project/sandbox created, pushed, removed, locked down) at INFO under the
# tag "ai-tools". Best-effort no-op fallback if the lib is missing.
AI_TOOLS_LOG_TAG="ai-tools"
readonly LOG_LIB="/usr/local/lib/ai-tools/log.lib.sh"
# shellcheck source=SCRIPTDIR/../lib/ai-tools/log.lib.sh
if ! source "${LOG_LIB}" 2>/dev/null; then
    ai_tools_log() { :; }; ai_tools_log_debug() { :; }; ai_tools_log_info() { :; }
    ai_tools_log_warn() { :; }; ai_tools_log_error() { :; }
fi

# Shared message formatter -- die()/warn() above frame their text in the paste-safe
# '#' alert box (50 columns) and headline()/headline_warn() open the wide (80-column)
# flow blocks on a terminal, plain text otherwise, and
# ai_tools_msg_confirm carries every yes/no prompt. REQUIRED, like safe-paths.lib.sh
# below: the confirms gate real decisions, so a missing lib fails closed instead of
# running through a private fallback (see messaging.rule.md).
readonly MSG_LIB="/usr/local/lib/ai-tools/msg.lib.sh"
# shellcheck source=SCRIPTDIR/../lib/ai-tools/msg.lib.sh
if ! source "${MSG_LIB}" 2>/dev/null; then
    command -v logger >/dev/null 2>&1 \
        && logger -t ai-tools -p user.err \
            "required library ${MSG_LIB} unavailable -- ai-tools refused (fail closed)"
    printf 'ai-tools: cannot load required library %s\n' "${MSG_LIB}" >&2
    printf '  the install is incomplete or /usr/local/lib/ai-tools is not traversable;\n' >&2
    printf '  refusing (fail closed) -- reinstall the ai-tools package, then retry.\n' >&2
    exit 3
fi
# One fixed 80-column frame for every box this CLI shows: a claim/reclaim run emits a
# SEQUENCE of boxes, which aligns instead of each sizing to its own text.
export AI_TOOLS_MSG_FULLWIDTH=1

# Protected-paths backstop (safe-paths.lib.sh): refuse to claim a system directory, and vet
# ancestors for the reachability grant (reg_reach -> grantable_ancestor). It is REQUIRED:
# FAIL CLOSED if it cannot be sourced (missing, unreadable, or the lib dir is not traversable)
# or does not define its guard. A broken install is not a state to run through with the guard
# disabled -- a stubbed no-op would skip the system-dir refusal AND silently never grant
# ancestor traversal (a claimed project the agent cannot reach). Log to journald (via logger,
# independent of log.lib which may share the broken dir) and warn the user, then exit.
readonly SAFE_PATHS_LIB="/usr/local/lib/ai-tools/safe-paths.lib.sh"
# shellcheck source=SCRIPTDIR/../lib/ai-tools/safe-paths.lib.sh
if ! source "${SAFE_PATHS_LIB}" 2>/dev/null \
        || ! declare -F ai_tools_assert_safe_target  >/dev/null 2>&1 \
        || ! declare -F ai_tools_protected_path_match >/dev/null 2>&1; then
    command -v logger >/dev/null 2>&1 \
        && logger -t ai-tools -p user.err \
            "required safety library ${SAFE_PATHS_LIB} unavailable -- ai-tools refused (fail closed)"
    ai_tools_msg_error "ai-tools: cannot load required safety library ${SAFE_PATHS_LIB}" \
        "the install is incomplete or /usr/local/lib/ai-tools is not traversable (expected 0751);" \
        "refusing (fail closed) -- reinstall the ai-tools package, then retry."
    exit 3
fi

# The shared config grammar, which this CLI reads allowed-projects with (ai_tools_conf_path_entry)
# so its project listing and the launch wrapper's gate agree on what every line denotes. REQUIRED:
# a private fallback parser is exactly the drift the shared grammar exists to prevent, and a CLI
# that lists a different set of projects than the wrapper will launch in is worse than one that
# refuses.
readonly CONF_LIB="/usr/local/lib/ai-tools/conf.lib.sh"
# shellcheck source=SCRIPTDIR/../lib/ai-tools/conf.lib.sh
if ! source "${CONF_LIB}" 2>/dev/null \
        || ! declare -F ai_tools_conf_path_entry >/dev/null 2>&1; then
    ai_tools_msg_error "ai-tools: cannot load required config library ${CONF_LIB}" \
        "the install is incomplete or /usr/local/lib/ai-tools is not traversable (expected 0751);" \
        "refusing (fail closed) -- reinstall the ai-tools package, then retry."
    exit 3
fi

# Skip-dir selector (the single skip source shared with the sweeps and the claim helpers).
# The claim drift scan uses it to tell repairable hits from skip-listed ones. Fail-soft: a
# missing lib classifies nothing as skip-listed -- a noisier report, never a wrong repair
# (the root helpers load their own copy for the walks).
readonly SKIP_DIRS_LIB="/usr/local/lib/ai-tools/skip-dirs.lib.sh"
# shellcheck source=SCRIPTDIR/../lib/ai-tools/skip-dirs.lib.sh
source "${SKIP_DIRS_LIB}" 2>/dev/null \
    || ai_tools_skip_find_expr() { AI_TOOLS_SKIP_NAMES=(); AI_TOOLS_SKIP_FIND_EXPR=(); return 0; }

# Service-health registry (services.lib.sh): the single source `ai-tools --status` and the launch
# wrapper's pre-launch health warning share, so the two never disagree on which units matter or how
# to fix one. Best-effort -- only --status reads it, and it degrades to a "registry unavailable"
# notice rather than failing any command.
readonly SERVICES_LIB="/usr/local/lib/ai-tools/services.lib.sh"
# shellcheck source=SCRIPTDIR/../lib/ai-tools/services.lib.sh
source "${SERVICES_LIB}" 2>/dev/null || true

# confirm <prompt> <y|n>  -- the shared yes/no prompt (ai_tools_msg_confirm; see
# msg.lib.sh): the explicit default decides the Enter answer and the no-tty answer, so
# each caller states the default whose unattended answer is the safe outcome for its
# question. AI_TOOLS_ASSUME_YES=1 fast-tracks only default-YES prompts (the lib's rule);
# a default-NO prompt is answered ahead of time only by the CLI's own --yes flag -- the
# launch wrapper passes it for a delegated --project-claim after taking its own
# confirmation, so the claim's proceed prompt does not ask a second time.
# have_tty: true only when a controlling terminal can actually be opened. `[[ -r /dev/tty ]]`
# tests the node's permission bits (crw-rw-rw-), not openability, so it reads true even with no
# controlling terminal (e.g. a systemd unit or under setsid); opening /dev/tty is the only honest
# probe -- with no controlling tty the open fails ENXIO, so the prompt guards skip cleanly instead
# of writing to /dev/tty and aborting. Mirrors claude.sh's have_tty.
have_tty() { { : > /dev/tty; } 2>/dev/null; }

confirm() { ai_tools_msg_confirm "$@"; }

# ask <prompt> <default>  -- echo the chosen value on stdout; prompt to the tty.
ask() {
    local prompt="$1" def="$2" resp
    if have_tty; then
        printf '%s %s[%s]%s: ' "${prompt}" "${C_DIM}" "${def}" "${C_RST}" > /dev/tty
        read -r resp < /dev/tty || resp=""
    else
        resp=""
    fi
    printf '%s' "${resp:-$def}"
}

# ── Path helpers ─────────────────────────────────────────────────────────────────

# resolve_dir <path>  -- canonicalize <path> (realpath -e) to stdout; die if absent.
resolve_dir() {
    local p
    p="$(realpath -e "$1" 2>/dev/null)" || die "path not found: $1"
    printf '%s' "${p}"
}

# require_sandbox_clone <path>  -- die unless <path> is a real sandbox CLONE: it passes the
# protected-paths backstop, is a DIRECT child of SANDBOX_ROOT (exactly one component under it --
# never SANDBOX_ROOT itself, never a nested or system path), and is a git worktree. This scopes the
# destructive --sandbox-remove (rm -rf) and --sandbox-push to an actual clone, so neither the shared
# clone area root nor an unrelated path can ever be the target.
require_sandbox_clone() {
    local d="$1" rel
    ai_tools_assert_safe_target "${d}" "sandbox" || exit 3
    [[ "${d}" == "${SANDBOX_ROOT}/"* ]] \
        || die "not a sandbox clone (must be a clone under ${SANDBOX_ROOT}): ${d}"
    rel="${d#"${SANDBOX_ROOT}/"}"
    [[ -n "${rel}" && "${rel}" != */* ]] \
        || die "not a sandbox clone (expected ${SANDBOX_ROOT}/<clone>, one level deep): ${d}"
    git -C "${d}" rev-parse --is-inside-work-tree >/dev/null 2>&1 \
        || die "not a git clone: ${d} -- if it is a stray directory, remove it by hand"
}

# ── Registry helpers (the only mutating filesystem writes besides clones) ─────────
# allowed-projects: one absolute path per line; '!'-prefixed lines are exclusions.
# safe.directory: git refuses to operate in a dir it does not own, and the clone is
# owned by the projects user, so the sandbox account (which runs git as the agent)
# needs an explicit entry per registered path.

reg_allow() {
    local dir="$1"
    [[ -f "${ALLOWLIST}" ]] || die "allowlist not found at ${ALLOWLIST} -- run install first"
    # Match through the shared grammar, not a raw line: a hand-added entry with a comment or
    # quotes is already listed, and appending would duplicate it (conf.lib.sh).
    if ai_tools_conf_allowlist_has_entry "${ALLOWLIST}" "${dir}"; then
        say "    allowed-projects: already listed"
    else
        printf '%s\n' "${dir}" >> "${ALLOWLIST}"
        say "    allowed-projects: added"
    fi
}

# allow_escape <text>  -- escape <text> so it matches literally inside a sed `\|^...$|` address:
# the '|' delimiter, backslash, and the BRE metacharacters (`.[]*^$`). Shared by unreg_allow,
# which runs the anchored-exact line deletion, and cmd_list, which prints the same deletion as a
# copy-paste remediation command. Both delete a whole RAW allowlist line, which may carry a
# comment or a dot in a path, so an under-escaped pattern would match a sibling line or none.
allow_escape() { printf '%s' "$1" | sed 's/[]\.*^$|[]/\\&/g'; }

unreg_allow() {
    local dir="$1"
    [[ -f "${ALLOWLIST}" ]] || return 0
    # Delete the RAW line(s) whose grammar entry matches ${dir}, not a line rebuilt from ${dir}:
    # a hand-added entry may carry a comment or quotes (conf.lib.sh), and anchoring on ${dir}
    # alone would miss it -- the same blind spot that used to leave the entry (and the agent's
    # access) behind on unclaim.
    local -a lines=() raw
    if ai_tools_conf_allowlist_matching_lines lines "${ALLOWLIST}" "${dir}"; then
        for raw in "${lines[@]}"; do
            sed -i "\|^$(allow_escape "${raw}")$|d" "${ALLOWLIST}"
        done
        say "    allowed-projects: removed"
    else
        say "    allowed-projects: not listed"
    fi
}

# reg_safedir <dir>  -- register <dir> in the agent's git safe.directory list: read unprivileged
# for idempotency, then write via the SAFEDIR_BIN root helper (see its declaration for the
# sudo/644 rationale). The entry lets the agent's git trust this tree, so the step is
# best-effort: when sudo is absent or the helper does not complete, it prints the manual command
# as a hint and lets the claim carry on.
reg_safedir() {
    local dir="$1"
    if git config --file "${GITCONFIG}" --get-all safe.directory 2>/dev/null \
            | grep -qxF "${dir}"; then
        say "    git safe.directory: already listed"
        return 0
    fi
    if ! command -v sudo >/dev/null 2>&1; then
        warn "sudo not found -- cannot register git safe.directory automatically"
        say  "      ${C_BOLD}sudo ${SAFEDIR_BIN} ${dir}${C_RST}"
        return 0
    fi
    if sudo "${SAFEDIR_BIN}" "${dir}"; then
        say "    git safe.directory: added"
    else
        warn "could not register git safe.directory -- run it by hand:"
        say  "      ${C_BOLD}sudo ${SAFEDIR_BIN} ${dir}${C_RST}"
    fi
}

# unreg_safedir <dir>  -- the unclaim counterpart to reg_safedir: drop <dir> via SAFEDIR_BIN
# --remove. Called after unreg_allow, so the helper's --remove is lenient about allowlist
# membership. Best-effort like reg_safedir: warns with the manual command and lets the unclaim
# carry on.
unreg_safedir() {
    local dir="$1"
    if ! git config --file "${GITCONFIG}" --get-all safe.directory 2>/dev/null \
            | grep -qxF "${dir}"; then
        say "    git safe.directory: not listed"
        return 0
    fi
    if ! command -v sudo >/dev/null 2>&1; then
        warn "sudo not found -- cannot remove git safe.directory automatically"
        say  "      ${C_BOLD}sudo ${SAFEDIR_BIN} --remove ${dir}${C_RST}"
        return 0
    fi
    if sudo "${SAFEDIR_BIN}" --remove "${dir}"; then
        say "    git safe.directory: removed"
    else
        warn "could not remove git safe.directory -- run it by hand:"
        say  "      ${C_BOLD}sudo ${SAFEDIR_BIN} --remove ${dir}${C_RST}"
    fi
}

# reg_filemode <dir>  -- pin core.filemode=true in the project's own .git/config so
# git tracks the executable bit deterministically for BOTH co-writers, regardless of
# either user's global git config. Repo-LOCAL (not the shared /opt/ai-tools/.gitconfig,
# which is the agent's global): the setting must be shared by the projects user and the
# agent, and .git is reclaimed to the projects user, who can write it. Idempotent and
# quiet when already set; a no-op (with a note) when <dir> is not a git work tree.
# Orthogonal to the ACL hardening -- filemode governs only the exec bit, never group/
# other permission bits -- but claimed in the same git-config step as safe.directory.
reg_filemode() {
    local dir="$1"
    if ! git -C "${dir}" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
        say "    git core.filemode: not a git work tree -- skipped"
        return 0
    fi
    if [[ "$(git -C "${dir}" config --local --get core.filemode 2>/dev/null)" == "true" ]]; then
        say "    git core.filemode: already true"
    else
        if git -C "${dir}" config --local core.filemode true; then
            say "    git core.filemode: set true"
        else
            warn "git core.filemode: could not set (continuing)"
        fi
    fi
}

# acl_gap <dir>  -- true (0) when the project's group-permission ACL is NOT yet in
# place: <dir>'s root carries no `default:group:SANDBOX_GROUP:` entry. Read-only and
# unprivileged. Returns false (1) when the ACL is present, and ALSO when ACLs cannot be
# inspected at all (getfacl missing) -- there is then no gap we can act on, so claim
# does not perpetually re-prompt for a step that cannot run. Mirrors the dir_owngap /
# project_state "na when unavailable" convention.
acl_gap() {
    local dir="$1"
    command -v getfacl >/dev/null 2>&1 || return 1
    getfacl -p "${dir}" 2>/dev/null \
        | grep -qE "^default:group:${SANDBOX_GROUP}:" && return 1
    return 0
}

# git_gap <dir>  -- true (0) when <dir> has a .git tree NOT yet normalized for agent
# git-history access: its .git root lacks group SANDBOX_GROUP, the setgid bit, or the
# default group ACL. Read-only and unprivileged. Returns false (1) when there is no .git
# tree (none, or a submodule/worktree .git FILE), when .git is already normalized, and when
# ACLs cannot be inspected (getfacl missing) -- there is then no gap we can act on, so claim
# does not perpetually re-offer a step that cannot run. Mirrors the acl_gap / dir_owngap
# "na when unavailable" convention. Unlike the other gaps, normalizing .git is opt-in (the
# operator is asked, default yes), so this only DETECTS the gap; cmd_project_claim decides.
git_gap() {
    local dir="$1" grp mode
    [[ -d "${dir}/.git" ]] || return 1
    command -v getfacl >/dev/null 2>&1 || return 1
    # IFS pinned to a space: the script's global IFS ($'\n\t') would land the whole
    # stat line in grp and leave mode empty -- the same pitfall project_state's reader
    # documents.
    IFS=' ' read -r grp mode < <(stat -c '%G %a' "${dir}/.git" 2>/dev/null) || return 1
    [[ "${grp}" == "${SANDBOX_GROUP}" ]] \
        && (( (0${mode} & 02000) != 0 )) \
        && getfacl -p "${dir}/.git" 2>/dev/null | grep -qE "^default:group:${SANDBOX_GROUP}:" \
        && return 1
    return 0
}

# dir_owngap <dir>  -- true (0) when <dir> is NOT group-accessible to the sandbox
# account: group is not SANDBOX_GROUP, or the group-execute bit is clear. The
# sandbox user runs with the project as its cwd, and Node's posix_spawn needs
# group-execute there to launch ANY child (hooks, the Bash tool). This is the exact
# gap the launch wrapper refuses to start on, factored here so both agree.
dir_owngap() {
    local dir="$1" grp mode
    grp="$(stat -c '%G' "${dir}" 2>/dev/null)" || return 0
    mode="$(stat -c '%a' "${dir}" 2>/dev/null)" || return 0
    [[ "${grp}" == "${SANDBOX_GROUP}" ]] && (( (0${mode} & 010) != 0 )) && return 1
    return 0
}

# acl_drift_scan <dir>  -- list paths inside a claimed tree that look shared but carry the
# wrong group: owned by the operator or the sandbox account, group not SANDBOX_GROUP, yet
# with group/other permission bits set. Creation under a claimed tree inherits the group
# (setgid) and the ACLs (default entries); a path lacking both arrived by rename(2) -- mv
# from outside the tree preserves the old group and inherits nothing -- and the agent gets
# EACCES on it deep inside an allowlisted project. Owner-only paths (600/700: locked-down
# secrets, deliberately private files) and '!'-excluded subtrees are not reported -- out of
# the agent's reach by intent. Read-only and unprivileged, detection only: the repair runs
# behind the claim confirm + secret gate, and the helper walks keep their own secret-name/
# exclusion/foreign-owner skips, so reporting a path here never by itself widens access.
acl_drift_scan() {
    local dir="$1" excl
    local -a skip=( -name .git -prune )
    # Leave this project's '!'-excluded subtrees out of the walk: an intentional
    # carve-out stays unreported.
    while IFS= read -r excl; do
        excl="${excl#!}"
        [[ "${excl}" == "${dir}"/* ]] && skip+=( -o -path "${excl}" -prune )
    done < <(grep '^!' "${ALLOWLIST}" 2>/dev/null || true)
    find "${dir}" -xdev \( "${skip[@]}" \) -o \
        \( -user "${ME}" -o -user "${SANDBOX_USER}" \) \
        ! -group "${SANDBOX_GROUP}" -perm /077 -print 2>/dev/null
}

# sealed_setgid_scan <dir>  -- list owner-only directories inside a claimed tree whose setgid bit
# carries a THIRD-party group: neither SANDBOX_GROUP nor the group of the directory's own owner.
# When the claim walks seal a path they clear a setgid bit belonging to one of those two, since a
# claimed tree carries no other legitimately; any further group reads as a deliberate operator
# choice and is kept (owner-only.lib.sh). That leaves the operator the one who decides, so the
# claim has to say so rather than act. Read-only and unprivileged, detection only -- a path
# reported here is one the claim did NOT touch, so reporting it never widens access.
#
# "Third party" is decided per path, against the OWNER's primary group -- not against the invoking
# user's. The two differ on a multi-operator host, where the group the claim walks treat as
# legitimate is the resolved project owner's (they act only on paths that owner or the sandbox
# account holds, so the owner's group is exactly what their check comes to), and reporting against
# the invoker's would flag a bit the claim goes on to strip, or stay silent about one it keeps.
sealed_setgid_scan() {
    local dir="$1" excl
    local -a skip=( -name .git -prune )
    while IFS= read -r excl; do
        excl="${excl#!}"
        [[ "${excl}" == "${dir}"/* ]] && skip+=( -o -path "${excl}" -prune )
    done < <(grep '^!' "${ALLOWLIST}" 2>/dev/null || true)
    # find cannot compare a path's group to its own owner's, so it narrows to the candidates
    # (owner-only, setgid, not the sandbox group) and the owner comparison is made per path here.
    # An owner with no passwd entry resolves to no group and is therefore reported, which is the
    # right way round: a setgid whose group cannot be tied to the owner is one to look at.
    find "${dir}" -xdev \( "${skip[@]}" \) -o \
        -type d ! -perm /077 -perm -2000 ! -group "${SANDBOX_GROUP}" \
        -printf '%U\t%G\t%p\n' 2>/dev/null \
    | while IFS=$'\t' read -r _uid _grp _path; do
          [[ "${_grp}" == "$(id -gn "${_uid}" 2>/dev/null || true)" ]] && continue
          printf '%s\n' "${_path}"
      done
}

# reg_ownership <dir>  -- make <dir> usable by the sandbox account: group SANDBOX_GROUP + the
# setgid bit on the project's directories, via the root ai-tools-setgid helper, so the agent can
# enter the tree and files born there inherit the group. Without it a path can be allowlisted yet
# fail every posix_spawn -- the session starts but cannot enter the tree or run a child. The
# operator is not a SANDBOX_GROUP member (multi-operator), so it cannot chgrp to that group
# unprivileged; the helper does it as root and carries its own allowlist + owner guard (a dir owned
# by a third party is left untouched). Pre-existing FILES become agent-accessible through the group
# ACL claim_setfacl applies next -- not a recursive chgrp: only a DRIFTED file (group-accessible
# yet foreign group, per acl_drift_scan) gets its primary group normalized there, which is what
# settles the drift report instead of re-flagging the same paths on every claim.
#
# CALLER MUST run secret_gate "${dir}" first: claim_setfacl then grants the agent group access to
# existing files, so a group-readable secret left un-locked (e.g. appsettings.json 640) would
# become readable by the agent. secret_gate locks secrets to 600/700 first.
reg_ownership() {
    local dir="$1" force="${2:-}"
    # 'force' runs the helper walk even when the project root already matches -- the
    # interior-drift repair, where the gap sits below the root.
    if [[ "${force}" != force ]] && ! dir_owngap "${dir}"; then
        say "    ownership: already group ${SANDBOX_GROUP}, setgid"
        return 0
    fi
    if sudo "${SETGID_BIN}" "${dir}"; then
        say "    ownership: set group ${SANDBOX_GROUP} + setgid on the project directories"
    else
        warn "ownership: could not set group/setgid on ${dir} -- run: sudo ${SETGID_BIN} ${dir}"
    fi
}

# agent_can_traverse <dir>  -- 0 if the sandbox account (SANDBOX_USER, a SANDBOX_GROUP member) can
# ENTER <dir>: world-execute, or group-execute with the directory in group SANDBOX_GROUP, or an
# explicit user:SANDBOX_USER ACL carrying execute.
agent_can_traverse() {
    local d="$1" m grp
    m="$(stat -c '%a' "${d}" 2>/dev/null)" || return 1
    if (( 8#${m} & 0001 )); then return 0; fi
    grp="$(stat -c '%G' "${d}" 2>/dev/null || true)"
    if [[ "${grp}" == "${SANDBOX_GROUP}" ]] && (( 8#${m} & 0010 )); then return 0; fi
    if command -v getfacl >/dev/null 2>&1 \
            && getfacl -p "${d}" 2>/dev/null | grep -qE "^user:${SANDBOX_USER}:..x"; then
        return 0
    fi
    return 1
}

# grantable_ancestor <dir>  -- 0 if reg_reach may grant traverse on <dir>: the operator OWNS it and
# it is not a protected system directory (the safe-paths backstop). Fail-closed when the predicate
# is unavailable, so a broken install never widens a directory it cannot vet.
grantable_ancestor() {
    local p="$1"
    declare -F ai_tools_protected_path_match >/dev/null 2>&1 || return 1
    if ai_tools_protected_path_match "${p}" >/dev/null 2>&1; then return 1; fi
    [[ "$(stat -c '%U' "${p}" 2>/dev/null || true)" == "${ME}" ]]
}

# reach_scan <dir>  -- detect the traverse gap between the sandbox account and <dir>:
# fills REACH_GRANT (each blocking ancestor a grant may cover: operator-owned, not a
# protected system directory) and REACH_BLOCKED (the first blocking ancestor no grant may
# cover, empty when none). Read-only and unprivileged; reg_reach acts on the result, and
# the claim's pending overview reads it so the traverse opt-in is announced up front.
reach_scan() {
    local dir="$1" anc
    REACH_GRANT=(); REACH_BLOCKED=""
    anc="$(dirname "${dir}")"
    while [[ "${anc}" != / && "${anc}" != . ]]; do
        if agent_can_traverse "${anc}"; then break; fi
        if grantable_ancestor "${anc}"; then
            REACH_GRANT+=("${anc}")
        else
            REACH_BLOCKED="${anc}"; break
        fi
        anc="$(dirname "${anc}")"
    done
}

# reg_reach <dir>  -- the reachability block: ensure the sandbox account can TRAVERSE the
# path to <dir>, acting on reach_scan's result (the CALLER runs reach_scan first). The
# confined session runs as the sandbox account; a project nested under a directory it
# cannot enter (a private home, 700) is unreachable, so ai-tools-run reports it missing even
# after a clean claim. Grant traverse-only (execute, no read -- u:SANDBOX_USER:--x) on
# each blocking ancestor the operator owns and that is not a protected system directory:
# enough to enter and reach the project, never to list or read it, and unprivileged
# because the operator owns those directories. A blocking ancestor that is a system
# directory or someone else's is left untouched -- there an isolated sandbox clone (under
# /var/opt/ai-tools, already agent-traversable) is the way in. Default-NO: it widens
# access ABOVE the project, so it is a separate, explicit opt-in.
reg_reach() {
    local dir="$1" a
    if [[ -n "${REACH_BLOCKED}" ]]; then
        local why
        if ! declare -F ai_tools_protected_path_match >/dev/null 2>&1; then
            why="the safe-paths backstop is not loaded, so ancestors cannot be vetted"
        elif ai_tools_protected_path_match "${REACH_BLOCKED}" >/dev/null 2>&1; then
            why="a protected system directory"
        else
            why="owned by $(stat -c '%U' "${REACH_BLOCKED}" 2>/dev/null || echo '?'), not by ${ME}"
        fi
        headline_warn "WARNING: project unreachable for the sandbox account" \
            "the sandbox account cannot traverse ${REACH_BLOCKED} (${why}), so it cannot reach ${dir}; an isolated clone under the sandbox area is the way in:"
        say "      ${C_BOLD}ai-tools --sandbox-create ${dir}${C_RST}"
        return 0
    fi
    if (( ${#REACH_GRANT[@]} == 0 )); then return 0; fi
    headline_warn "WARNING: parent directories block the agent" \
        "the sandbox account must be able to traverse every parent directory to reach the project; the grant below is traverse-only (enter, never list or read): u:${SANDBOX_USER}:--x"
    for a in "${REACH_GRANT[@]}"; do say "      ${a}"; done
    if confirm "Grant the sandbox account traverse-only access on them?" n; then
        local failed=false
        for a in "${REACH_GRANT[@]}"; do
            if setfacl -m "u:${SANDBOX_USER}:--x" "${a}" 2>/dev/null; then
                say "    reach: u:${SANDBOX_USER}:--x ${a}"
            else
                failed=true
                warn "reach: could not grant on ${a} -- run: setfacl -m u:${SANDBOX_USER}:--x ${a}"
            fi
        done
        ${failed} || ok "parent directories traversable by the sandbox account"
    else
        say "    reach: left as-is -- the agent may be unable to enter ${dir}"
    fi
}

# normalize_clone <dir> [locked-path...]  -- make a freshly created clone
# agent-accessible. The clone is born in group SANDBOX_GROUP via the setgid SANDBOX_ROOT
# but cloned under umask 077 (see cmd_sandbox_create), so nothing in it is
# group-readable until this step. Add group rwX and the setgid bit on every directory
# (owner stays the projects user); the SessionStart ai-tools-setgid pass keeps it
# normalized thereafter. Every <locked-path> (the secret gate's finds, locked to
# owner-only by ai-tools-lockdown) is PRUNED from both walks -- re-opening one here
# would undo the lockdown this step is sequenced after.
normalize_clone() {
    local d="$1"; shift
    local -a prune=() p
    for p in "$@"; do prune+=( -path "${p}" -prune -o ); done
    find "${d}" "${prune[@]}" -exec chmod g+rwX {} +
    find "${d}" "${prune[@]}" -type d -exec chmod g+s {} +
}

# relabel_clone <dir>  -- apply the SELinux project label so the agent (ai_tools_t)
# can read/write the clone. A static fcontext rule in selinux/policy/ai_tools.fc maps every
# directory under sandbox-projects/ to ai_tools_project_t, so a plain restorecon
# labels it -- no per-project semanage and no root: the projects user runs as
# unconfined_t, which the policy grants relabel to ai_tools_project_t. No-op when
# SELinux is disabled (or the module is not loaded, in which case the label stays
# the default and the operator must run selinux/install-selinux.sh install).
relabel_clone() {
    local d="$1"
    command -v restorecon >/dev/null 2>&1 || return 0
    [[ "$(getenforce 2>/dev/null)" == "Disabled" ]] && return 0
    if restorecon -FR "${d}" 2>/dev/null; then
        ok "labelled clone ai_tools_project_t (SELinux)"
    else
        warn "could not relabel ${d} for SELinux; if enforcing, run: sudo restorecon -FR ${d}"
    fi
}

# ── Lockdown helpers ───────────────────────────────────────────────────────────
# ai-tools-lockdown revokes ai-tools' read access to secret-named files under a
# project. It is root-only and reads its target from the working directory, so we
# cd there and sudo it; there is no NOPASSWD grant, so sudo prompts for a password.

# run_lockdown <dir> [extra-args...]  -- run the helper on <dir>; returns its status.
run_lockdown() {
    local d="$1"; shift
    ( cd "${d}" && sudo "${LOCKDOWN_BIN}" "$@" )
}

# run_relabel <dir> [--remove]  -- apply (or revert) the SELinux project label on
# <dir> via the root helper (sudo, password); returns its status. The helper parses
# the path and the optional flag in any order.
run_relabel() {
    local d="$1"; shift
    sudo "${RELABEL_BIN}" "$@" "${d}"
}

# run_reclaim <dir> [--full]  -- hand agent-written files under <dir> back to the operator via
# the root helper (sudo, password); returns its status. The helper parses the path and --full in
# any order.
run_reclaim() {
    local d="$1"; shift
    sudo "${RECLAIM_BIN}" "$@" "${d}"
}

# run_setfacl <dir> <with_git>  -- apply the project's group-permission ACL on <dir> via
# the root helper (sudo, password); when <with_git> is true, also pass --with-git so the
# helper normalizes the .git tree too. Returns its status.
run_setfacl() {
    local d="$1" with_git="${2:-false}"
    if ${with_git}; then
        sudo "${SETFACL_BIN}" --with-git "${d}"
    else
        sudo "${SETFACL_BIN}" "${d}"
    fi
}

# run_unclaim <dir> <target-group> [helper-flag...]  -- clear the agent ACL, regroup <dir> to
# <target-group>, and remove group write, via the root helper (sudo, password); returns
# its status. Trailing flags (--unlisted, --full) pass straight through: the helper re-derives
# every gate from them itself rather than trusting this caller's classification.
run_unclaim() {
    local d="$1" g="$2"; shift 2
    sudo "${UNCLAIM_BIN}" "${d}" "${g}" "$@"
}

# secret_gate <dir>  -- the secret-lockdown block: before ANY step grants the agent
# access to <dir> (the group ACL, the setgid group change, .git normalization, the
# clone normalize), make sure no group-readable secret would be exposed. The CLI cannot
# read the root-only secret-pattern library, so detection is delegated to
# ai-tools-lockdown --dry-run (sudo, password -- the first sudo prompt of a claim, so it
# lands right under this block's headline). Found secrets are listed and the user is
# asked to lock them down (--yes apply); the helper's own interactive mode is NOT used
# for this because it exits 0 whether the user applies or aborts, which would let an
# un-locked tree through. Fills SECRET_GATE_LOCKED with the found paths so
# normalize_clone can prune them. Returns 0 only when the tree is safe to expose (no
# secrets found, or all locked down); non-zero means the caller must fail closed.
secret_gate() {
    local dir="$1" out
    SECRET_GATE_LOCKED=()
    headline "Secret lockdown" \
        "scanning ${dir} for secret-named files before the agent is granted access"
    if ! out="$(run_lockdown "${dir}" --dry-run 2>&1)"; then
        warn "secret scan failed -- not granting access:"
        printf '%s\n' "${out}" >&2
        ai_tools_log_error "secret pre-check: scan failed for ${dir}, access not granted"
        return 1
    fi
    # "N secret-matching path(s)" when any are found vs "no secret-matching paths"
    # when clean -- match the count form to tell them apart.
    if ! grep -qE 'ai-tools-lockdown: [0-9]+ secret-matching' <<<"${out}"; then
        ok "no secret-matching paths found"
        ai_tools_log_info "secret pre-check: clean, no secret-matching paths under ${dir}"
        return 0                                   # clean tree: safe to expose
    fi

    # The helper has already logged the count and each path (journald + lockdown.log);
    # record the operator-side decision here too.
    mapfile -t SECRET_GATE_LOCKED < <(printf '%s\n' "${out}" \
        | sed -n 's/^[[:space:]]*\[\(file\|dir\)\][[:space:]]*//p')
    say ""
    say "  found ${#SECRET_GATE_LOCKED[@]} secret-matching path(s):"
    printf '%s\n' "${out}" | grep -E '\[(file|dir)\]' >&2 || true
    warn "lockdown is best effort, matching only known secret patterns -- handle any secret it misses yourself first"
    ai_tools_log_warn "secret pre-check: secrets present under ${dir} (see lockdown.log for paths)"
    # Default YES: locking down is the safe direction and the list above may be long,
    # so Enter -- and an unattended run -- proceeds to lock down.
    if ! confirm "Lock down these secrets now?" y; then
        warn "declined -- access will not be granted while secrets are exposed"
        ai_tools_log_warn "secret pre-check: lockdown declined for ${dir}, access not granted"
        return 1
    fi
    if run_lockdown "${dir}" --yes; then
        say ""
        ok "secrets locked down"
        ai_tools_log_info "secret pre-check: secrets locked down under ${dir}"
        return 0
    fi
    warn "lockdown did not complete -- not granting access"
    ai_tools_log_error "secret pre-check: lockdown failed under ${dir}, access not granted"
    return 1
}

# drop_lockdown_guard <dir>  -- write a placeholder CLAUDE.md telling the agent to
# do nothing until lockdown runs, used when a fresh sandbox clone's tip-commit
# secrets are still readable. An existing CLAUDE.md is preserved as CLAUDE.md.bak
# (via git mv, falling back to a plain mv) and restored by clear_lockdown_guard.
drop_lockdown_guard() {
    local d="$1"; local md="${d}/CLAUDE.md"
    if [[ -f "${md}" ]] && grep -q "${GUARD_MARKER}" "${md}" 2>/dev/null; then
        return 0                                   # already guarded (re-run)
    fi
    if [[ -e "${md}" ]]; then
        if [[ -e "${d}/CLAUDE.md.bak" ]]; then
            warn "CLAUDE.md.bak already exists in ${d}; not overwriting -- guard skipped"
            return 0
        fi
        git -C "${d}" mv CLAUDE.md CLAUDE.md.bak 2>/dev/null \
            || mv "${md}" "${d}/CLAUDE.md.bak"
        say "    preserved existing CLAUDE.md as CLAUDE.md.bak"
    fi
    cat > "${md}" <<EOF
<!-- ${GUARD_MARKER} -->
# STOP — this sandbox is not secured yet

\`ai-tools-lockdown\` has **not** been run on this shallow clone, so credential
files in its tip commit (\`.env\`, \`appsettings.*.json\`, \`*.key\`, …) may still be
readable by the agent.

Until lockdown is performed:

- **Do not read, open, copy, or transmit any file in this project.**
- **Do not run any command.**
- Ask the operator to secure it first by running, as the projects user:

      ai-tools --lockdown ${d}

Only paths approved in the operator's \`allowed-projects\` allowlist are ever in
scope, and only after lockdown has revoked the agent's read access to secrets.

This file is a temporary guard. It is removed automatically once lockdown runs,
and any original CLAUDE.md is restored from CLAUDE.md.bak.
EOF
    ok "wrote a guard CLAUDE.md (agent told to wait for lockdown)"
}

# clear_lockdown_guard <dir>  -- remove a guard CLAUDE.md and restore any
# CLAUDE.md.bak it set aside. No-op unless the guard sentinel is present. Called
# after a successful (non-dry-run) lockdown.
clear_lockdown_guard() {
    local d="$1"; local md="${d}/CLAUDE.md"
    [[ -f "${md}" ]] || return 0
    grep -q "${GUARD_MARKER}" "${md}" 2>/dev/null || return 0
    rm -f "${md}"
    if [[ -e "${d}/CLAUDE.md.bak" ]]; then
        git -C "${d}" mv CLAUDE.md.bak CLAUDE.md 2>/dev/null \
            || mv "${d}/CLAUDE.md.bak" "${md}"
        say "    restored original CLAUDE.md from CLAUDE.md.bak"
    fi
    ok "removed the lockdown guard from ${d}"
}

# ── Commands ─────────────────────────────────────────────────────────────────────

# project_state <dir>  -- print the claim state of <dir> as seven space-separated
# tokens: "<listed> <safedir> <filemode> <owngap> <acl> <labelled> <git>". listed/safedir
# reflect the two registries; filemode is true when repo-local core.filemode is already
# true ("na" when <dir> is not a git work tree); owngap is true when the agent still
# lacks group access (see dir_owngap); acl is true when the group-permission ACL still
# needs applying (see acl_gap); labelled is the live SELinux type check -- true/false
# when SELinux is active, "na" when it is disabled (no label needed); git is true when a
# .git tree is present but not yet normalized for agent history sharing (see git_gap),
# false otherwise -- it gates the opt-in .git prompt, not a mandatory claim step. Read-only,
# no privilege. The ai_tools_project_t string is the single fact mirrored from the root
# labelling lib; the authoritative semanage/restorecon logic is NOT duplicated here.
project_state() {
    local dir="$1" listed=false safedir=false filemode=na owngap=true acl=false labelled=na git=false
    ai_tools_conf_allowlist_has_entry "${ALLOWLIST}" "${dir}" 2>/dev/null && listed=true
    git config --file "${GITCONFIG}" --get-all safe.directory 2>/dev/null \
        | grep -qxF "${dir}" && safedir=true
    if git -C "${dir}" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
        [[ "$(git -C "${dir}" config --local --get core.filemode 2>/dev/null)" == "true" ]] \
            && filemode=true || filemode=false
    fi
    dir_owngap "${dir}" || owngap=false
    acl_gap "${dir}" && acl=true
    if command -v getenforce >/dev/null 2>&1 && [[ "$(getenforce 2>/dev/null)" != "Disabled" ]]; then
        if ls -Zd "${dir}" 2>/dev/null | grep -q ':ai_tools_project_t:'; then
            labelled=true
        else
            labelled=false
        fi
    fi
    git_gap "${dir}" && git=true
    printf '%s %s %s %s %s %s %s\n' \
        "${listed}" "${safedir}" "${filemode}" "${owngap}" "${acl}" "${labelled}" "${git}"
}

# claim_relabel <dir>  -- apply the SELinux project label via the root helper so the
# confined agent can access the tree. Best-effort, mirroring lockdown: warns with the
# manual command (never dies) when sudo is missing or the helper fails.
claim_relabel() {
    local d="$1"
    if ! command -v sudo >/dev/null 2>&1; then
        warn "sudo not found -- cannot apply the SELinux label automatically"
        say  "      ${C_BOLD}sudo ${RELABEL_BIN} ${d}${C_RST}"
        return 0
    fi
    if run_relabel "${d}"; then
        say "    SELinux label: ai_tools_project_t applied"
    else
        warn "could not apply the SELinux label -- run it by hand:"
        say  "      ${C_BOLD}sudo ${RELABEL_BIN} ${d}${C_RST}"
    fi
}

# claim_setfacl <dir> <with_git>  -- apply the group-permission ACL via the root helper so
# files the projects user's git checkout/merge writes under a restrictive umask stay group-
# accessible; when <with_git> is true the helper also normalizes the .git tree (group +
# setgid + ACL) so the operator's commits stay agent-accessible. Best-effort, mirroring
# claim_relabel: warns with the manual command (never dies) when sudo is missing or fails.
claim_setfacl() {
    local d="$1" with_git="${2:-false}" flag="" note=""
    ${with_git} && { flag=" --with-git"; note=" (incl. .git)"; }
    if ! command -v sudo >/dev/null 2>&1; then
        warn "sudo not found -- cannot apply the project ACL automatically"
        say  "      ${C_BOLD}sudo ${SETFACL_BIN}${flag} ${d}${C_RST}"
        return 0
    fi
    if run_setfacl "${d}" "${with_git}"; then
        say "    group-permission ACL: applied${note}"
    else
        warn "could not apply the project ACL -- run it by hand:"
        say  "      ${C_BOLD}sudo ${SETFACL_BIN}${flag} ${d}${C_RST}"
    fi
}

# cmd_project_claim [path]  -- idempotently bring a real, IN-PLACE project (default:
# cwd) to a fully claimed state: allowlist + git safe.directory + git core.filemode +
# secret lockdown + recursive ownership + group-permission ACL + SELinux
# ai_tools_project_t label, so the agent can work the REAL tree. Inspects current state
# first and performs ONLY the missing steps, so a re-run is quiet and a fully-claimed
# project is a clean no-op (no prompt, no sudo).
#
# The flow is a sequence of SELF-CONTAINED blocks, each opened by a headline box and
# closed by its own confirm/result, in this order:
#   1. Review    -- the pending-step overview (every later block announced), the drift
#                   reports, and -- when a heavy step (chgrp, ACL, relabel, drift repair)
#                   is pending -- the default-NO proceed confirm that covers exactly the
#                   steps listed.
#   2. Secret lockdown -- BEFORE any access-granting step, whenever one is pending or
#                   this is a first claim (see secret_gate); fails the claim closed.
#   3. .git history  -- separate default-YES opt-in (ai-tools-setfacl --with-git).
#   4. Reachability  -- separate default-NO opt-in for traverse-only ancestor ACLs.
#   5. Apply     -- the approved steps back to back, one result line each, closed by
#                   the final "claimed" ✓.
# A re-claim with ownership in place also scans for interior drift (acl_drift_scan:
# shared-looking paths brought into the tree without inheriting the group/ACL) and folds
# the group+ACL re-apply into the proceed confirm and secret gate -- repair never runs
# unconfirmed. A first claim skips the report: its normal walk repairs the whole tree.
# path_detail_lines <path...>  -- print each path prefixed with its owner:group and mode, the
# columns that show at a glance why a path is flagged (the foreign or agent group) and whether
# its mode is what the operator expects. Shared by the claim's drift report and the unclaim's
# residue report: both answer the same question about a path, so both show the same columns.
path_detail_lines() {
    local _p _og _m
    for _p in "$@"; do
        IFS=' ' read -r _og _m < <(stat -c '%U:%G %a' "${_p}" 2>/dev/null) \
            || { _og='?'; _m='?'; }
        printf '        %s%-18s %-4s %s%s\n' "${C_DIM}" "${_og}" "${_m}" "${_p}" "${C_RST}"
    done
}

# offer_full_listing <label> <path...>  -- after a truncated sample, offer the full list with
# ownership and mode. Default yes: it is read-only and the point of asking is that the list is
# long, so Enter shows it and a piped/delegated run prints it too (grep-able).
offer_full_listing() {
    local _label="$1"; shift
    confirm "      List all $# ${_label} with ownership and mode?" y || return 0
    path_detail_lines "$@"
}

# path_listing <label> <path...>  -- report a set of paths: in FULL when there are few enough that
# the whole list is shorter than a sample plus the question about it, otherwise a three-path sample
# and an offer to see the rest. One decision in one place, because getting it wrong is invisible in
# the code and glaring on screen: sampling four paths prints three, says "... and 1 more", asks a
# question, and then prints all four again -- seven lines and a prompt to show four paths.
# SAMPLE is the sample size; the full-list cut-off is twice it, the point past which the sample is
# genuinely saving the reader something.
readonly PATH_LISTING_SAMPLE=3
path_listing() {
    local _label="$1"; shift
    if (( $# <= 2 * PATH_LISTING_SAMPLE )); then
        path_detail_lines "$@"
        return 0
    fi
    path_detail_lines "${@:1:PATH_LISTING_SAMPLE}"
    say "        ${C_DIM}... and $(( $# - PATH_LISTING_SAMPLE )) more${C_RST}"
    offer_full_listing "${_label}" "$@"
}

# under_skip_listed_name <base> <path>  -- 0 when <path> sits under a skip-listed directory NAME
# (build output, dependencies, caches) relative to <base>, honoring the relative artifact
# exclusions that re-open a subtree to the walks. The single predicate behind both the claim's
# "drift I cannot repair" split and the unclaim's "residue the default walk will not reach"
# split, so one skip contract decides both. Returns 1 when the skip list is unavailable, which
# treats every hit as reachable -- the fail-soft direction for a walk-cost optimization.
under_skip_listed_name() {
    local _base="$1" _path="$2" _rel _seg _name _s _x
    [[ "${#AI_TOOLS_SKIP_NAMES[@]}" -gt 0 ]] || return 1
    _rel="${_path#"${_base}"/}"
    IFS=/ read -ra _seg <<< "${_rel}"
    for _name in "${AI_TOOLS_SKIP_NAMES[@]}"; do
        for _s in "${_seg[@]}"; do
            if [[ "${_s}" == "${_name}" ]]; then
                # A relative artifact exclusion re-opens its subtree to the walks, so a hit
                # under one is reachable, not skip-listed.
                for _x in "${AI_TOOLS_SKIP_ARTIFACT_DIRS_EXCLUDED_PATHS_RELATIVE[@]:-}"; do
                    [[ -z "${_x}" ]] && continue
                    _x="${_x%/}"
                    [[ "${_rel}" == "${_x}" || "${_rel}" == "${_x}"/* ]] && return 1
                done
                return 0
            fi
        done
    done
    return 1
}

cmd_project_claim() {
    # -y/--yes pre-answers the claim's own proceed prompt ("Apply the pending steps IN
    # PLACE?", default NO) -- an explicit per-invocation flag, passed by a caller that
    # already confirmed the same decision (the launch wrapper's delegated claim). The
    # scoped opt-ins (secret lockdown, .git history, ancestor traversal) are separate
    # questions it does not answer.
    local a path="" ASSUME_YES=false
    for a in "$@"; do
        case "${a}" in
            -y|--yes) ASSUME_YES=true ;;
            -*) die "unknown --project-claim option: ${a} (allowed: -y/--yes)" ;;
            *)  if [[ -z "${path}" ]]; then path="${a}"
                else die "--project-claim takes a single path"; fi ;;
        esac
    done
    local d; d="$(resolve_dir "${path:-$PWD}")"
    [[ -d "${d}" ]] || die "not a directory: ${d}"
    # Refuse to claim a protected system directory before it ever reaches the allowlist. The
    # safe-paths guard is guaranteed loaded (the top-level source fails closed otherwise).
    ai_tools_assert_safe_target "${d}" "project claim" || exit 3

    local listed safedir filemode owngap acl labelled git
    # project_state prints seven SPACE-separated tokens; this script's global IFS is
    # $'\n\t' (no space), so a bare read would collapse the whole line into the first
    # field and leave the rest empty -- silently skipping the label/ACL/ownership steps.
    # Pin IFS=' ' for this read so the tokens split as intended.
    IFS=' ' read -r listed safedir filemode owngap acl labelled git < <(project_state "${d}")
    local need_label=false; [[ "${labelled}" == false ]] && need_label=true
    local need_filemode=false; [[ "${filemode}" == false ]] && need_filemode=true
    local need_acl=false; [[ "${acl}" == true ]] && need_acl=true
    local need_git=false; [[ "${git}" == true ]] && need_git=true

    # Interior drift: the root-level state says nothing about paths brought INTO a claimed
    # tree without inheriting the group/ACL (mv keeps the old group). Detect them here;
    # the repair applies further down behind the same confirm + secret gate as the other
    # in-place steps. Scanned only on a RE-CLAIM whose ownership is already in place: a
    # first claim (or one with the setgid step still pending) walks and repairs the whole
    # tree anyway, and its every path would trivially match the drift predicate -- a
    # 200-line report of what the claim is about to fix is noise, not signal.
    local -a drift=()
    if [[ "${listed}" == true && "${owngap}" == false ]]; then
        mapfile -t drift < <(acl_drift_scan "${d}" | head -n 200)
    fi

    # Split the hits on the shared skip list: the sweeps AND the claim walks leave a
    # skip-listed directory's contents alone (one skip contract), so a re-claim cannot
    # repair a hit under one -- it gets its own report with the remedies that can.
    local -a drift_skipped=()
    if ai_tools_skip_find_expr sweep 2>/dev/null && (( ${#AI_TOOLS_SKIP_NAMES[@]} )); then
        local -a _keep=()
        local _hit
        for _hit in "${drift[@]}"; do
            if under_skip_listed_name "${d}" "${_hit}"; then
                drift_skipped+=("${_hit}")
            else
                _keep+=("${_hit}")
            fi
        done
        drift=("${_keep[@]}")
    fi

    # A setgid bit on a sealed dir that belongs to some third group is the one piece of residue
    # the claim walks decline to remove, so it is surfaced here rather than left to the helper's
    # stderr, where it scrolls past under the Apply step.
    local -a sealed_setgid=()
    mapfile -t sealed_setgid < <(sealed_setgid_scan "${d}" | head -n 200)

    sealed_setgid_note() {
        (( ${#sealed_setgid[@]} )) || return 0
        headline_warn "NOTICE: setgid on an owner-only directory" \
            "${#sealed_setgid[@]} sealed director(ies) carry a setgid bit set to a group that is neither ${SANDBOX_GROUP} nor yours. The claim keeps it -- it cannot tell a deliberate choice from a leftover -- so new files there are still born in that group."
        path_listing "director(ies)" "${sealed_setgid[@]}"
        say "      ${C_DIM}if it was not intended, clear it yourself:  chmod g-s <dir>${C_RST}"
    }

    # skip_listed_note: the skip-listed hits are informational either way -- shown both on
    # the fully-claimed early return and in the pending flow.
    skip_listed_note() {
        (( ${#drift_skipped[@]} )) || return 0
        headline_warn "NOTICE: drift under skip-listed directories" \
            "${#drift_skipped[@]} path(s) with a foreign group sit under skip-listed directory names (build output, dependencies, caches); claim leaves those trees untouched."
        path_listing "path(s)" "${drift_skipped[@]}"
        say "      ${C_DIM}if one is source in this project, exempt it in /etc/ai-tools/operator.conf --${C_RST}"
        say "      ${C_DIM}narrow the category (SKIP_ARTIFACT_DIRS=...) or list the path relative to the${C_RST}"
        say "      ${C_DIM}project root in SKIP_ARTIFACT_DIRS_EXCLUDED_PATHS_RELATIVE -- then re-claim;${C_RST}"
        say "      ${C_DIM}ownership only: ai-tools --reclaim --full${C_RST}"
    }

    # ── Review block: the flow headline, the pending-step overview, and the drift
    # reports, so the proceed confirm that closes it covers exactly what was just
    # shown. Every later block is announced here with a "you will be asked" marker. ──
    local heavy=false
    local -a head=("${d}")
    if [[ "${owngap}" == true ]] || ${need_acl} || ${need_label} || (( ${#drift[@]} )); then
        heavy=true
        head+=("claiming in place grants the agent group access to this whole tree")
        # Said plainly, before the confirm that authorizes it: the steps below rewrite metadata
        # across the tree, and unclaim NORMALIZES rather than restores (setfacl -b clears ACLs
        # that predate the claim; the result is 640/750). No prior state is recorded anywhere,
        # so no command can put it back -- which makes "back up first" the only real safeguard.
        head+=("It MODIFIES group, permissions and ACLs throughout this tree, sets setgid on its directories, and removes world access. Files and directories that are owner-only (0600/0700) are left alone, out of the agent's reach. The previous permissions are NOT recorded anywhere, so this is NOT reversible -- unclaiming later normalizes the tree rather than restoring it. Back up first. See: man ai-tools")
    fi
    headline "Claim project (in place)" "${head[@]}"

    reach_scan "${d}"

    # The project root being owner-only is reach_scan's problem one level down: ai-tools-setfacl
    # honours a 0600/0700 mode and skips the path, so every later step still succeeds and the
    # claim closes with its ✓ while the sandbox account cannot enter the tree at all. Stated
    # here, before the confirm, rather than left to the helper's skip count afterwards.
    local root_mode
    root_mode="$(stat -c '%a' "${d}" 2>/dev/null || echo 755)"
    if (( ( 8#${root_mode} & 077 ) == 0 )); then
        headline_warn "NOTICE: this project directory is owner-only" \
            "${d} is mode ${root_mode}, which keeps it out of the sandbox account's reach: the claim honours that mode and grants nothing on it."
        say ""
    fi

    if [[ "${listed}" == true && "${safedir}" == true && "${owngap}" == false ]] \
            && ! ${need_filemode} && ! ${need_acl} && ! ${need_label} && ! ${need_git} \
            && (( ${#drift[@]} == 0 )); then
        skip_listed_note
        sealed_setgid_note
        # A claimed project can still sit under a non-traversable parent (a later
        # chmod 700 above it), so the reachability block runs on the no-op path too.
        reg_reach "${d}"
        ok "already fully claimed -- nothing to do"
        return 0
    fi

    # The gate runs whenever any pending step widens the agent's access -- the setgid
    # group change, the group ACL, drift repair, .git normalization, the SELinux label --
    # and on every first claim (a tree can be group-accessible by setgid inheritance yet
    # never scanned). Only pure registry additions (safedir, filemode) skip it.
    local need_gate=false
    if [[ "${listed}" != true || "${owngap}" == true ]] \
            || ${need_acl} || ${need_git} || ${need_label} || (( ${#drift[@]} )); then
        need_gate=true
    fi

    say ""
    say "  pending:"
    [[ "${listed}"  == true  ]] || say "    - add to allowed-projects"
    [[ "${safedir}" == true  ]] || say "    - add git safe.directory"
    ${need_filemode} && say "    - set git core.filemode true"
    [[ "${owngap}"  == true  ]] && say "    - set group ${SANDBOX_GROUP} + setgid on the project directories"
    ${need_acl} && say "    - apply group-permission ACL (default + access g:${SANDBOX_GROUP}:rwX)"
    ${need_label} && say "    - apply SELinux ai_tools_project_t label"
    (( ${#drift[@]} )) && say "    - re-apply group ${SANDBOX_GROUP} + ACL to ${#drift[@]} drifted path(s) -- details below"
    ${need_gate} && say "    - scan for secret-named files and lock them down -- you will confirm"
    ${need_git} && say "    - normalize .git so the agent can access git history -- you will be asked"
    (( ${#REACH_GRANT[@]} )) && say "    - grant traverse-only access on ${#REACH_GRANT[@]} parent path(s) -- you will be asked"

    if (( ${#drift[@]} )); then
        headline_warn "WARNING: interior permission drift" \
            "${#drift[@]} path(s) inside the tree carry a foreign group yet stay group-accessible (they arrived without inheriting the project group or ACL)."
        path_listing "path(s)" "${drift[@]}"
        # The cap is a property of the SCAN, not of this listing, so it is said whether the paths
        # were sampled or shown in full.
        if (( ${#drift[@]} >= 200 )); then
            say "        ${C_DIM}(scan capped at 200 paths)${C_RST}"
        fi
    fi
    skip_listed_note
    sealed_setgid_note

    # Heavy steps (recursive chgrp; sudo relabel/ACL; drift repair) close the Review
    # block behind the proceed confirm; pure registry additions do not. --yes pre-answers
    # exactly this prompt: the launch wrapper passes it after taking its own "Claim it in
    # place now?" confirmation, so a delegated claim does not ask the same question
    # twice. The scoped opt-ins below (secret lockdown, .git history, ancestor traversal)
    # still ask on their own terms.
    if ${heavy}; then
        ${ASSUME_YES} || confirm "Apply the pending steps above IN PLACE?" n \
            || die "aborted"
    fi

    # Allowlist first: ai-tools-lockdown only scans an allowlisted path. Rolled back on
    # a failed gate.
    [[ "${listed}" == true ]] || reg_allow "${d}"

    if ${need_gate}; then
        if ! secret_gate "${d}"; then
            [[ "${listed}" == true ]] || unreg_allow "${d}"
            say "    lock down secrets first, then re-run the claim:"
            say "      ${C_BOLD}ai-tools --lockdown ${d}${C_RST}"
            die "claim stopped -- secrets not locked down"
        fi
    fi

    # .git access is opt-in (default yes), asked separately from the proceed prompt --
    # which --yes covers; this one it does not, so a wrapper-delegated claim still asks
    # before exposing the repo's full git history.
    local do_git=false
    if ${need_git}; then
        headline_warn "WARNING: git history exposure" \
            "normalizing .git lets the agent read this repo's full git history"
        if confirm "Normalize .git so the agent can access git history here?" y; then
            do_git=true
        else
            say "    .git: left as-is (history not accessible to the agent)"
        fi
    fi

    reg_reach "${d}"

    # ── Apply block: the approved steps run back to back, each reporting one result
    # line; the closing ✓ is the claim's completion. ──
    headline "Applying claim steps" "${d}"
    [[ "${safedir}" == true  ]] || reg_safedir "${d}"
    ${need_filemode} && reg_filemode "${d}"
    if [[ "${owngap}" == true ]]; then
        reg_ownership "${d}"
    elif (( ${#drift[@]} )); then
        reg_ownership "${d}" force
    fi
    { ${need_acl} || ${do_git} || (( ${#drift[@]} )); } && claim_setfacl "${d}" "${do_git}"
    ${need_label} && claim_relabel "${d}"
    say ""
    ok "claimed ${d}"
    ai_tools_log_info "claimed project ${d}"
}

# cmd_project_create [path]  -- back-compat alias for cmd_project_claim. Claiming is
# idempotent now, so "create" and "claim" are the same operation.
cmd_project_create() { cmd_project_claim "$@"; }

# positive_project_entries  -- print each allowed-projects entry that names a real,
# resolvable project directory (canonicalized), one per line, skipping blanks, comments,
# and '!' exclusions. Read with the shared config grammar so it agrees with cmd_list and
# the launch wrapper on what a line denotes. Stale (unresolvable) lines are omitted -- they
# name nothing on disk, so they can neither be nor contain an unclaim target.
positive_project_entries() {
    local entry dir
    [[ -f "${ALLOWLIST}" ]] || return 0
    while IFS= read -r entry || [[ -n "${entry}" ]]; do
        ai_tools_conf_path_entry "${entry}" || continue
        entry="${_ai_tools_conf_value}"
        [[ "${entry}" == '!'* ]] && continue
        dir="$(realpath -e "${entry}" 2>/dev/null)" || continue
        printf '%s\n' "${dir}"
    done < "${ALLOWLIST}"
}

# covered_by_project <dir>  -- 0 when <dir> is at or under a positive allowed-projects entry in the
# invoking operator's own allowlist, honoring '!' exclusions (an exclusion wins). The CLI front-line
# for the per-project verbs (reclaim, lockdown): a path outside every claimed project is refused up
# front with a clear message, not a silent helper no-op. Scoped to the operator's own allowlist like
# every other CLI read; the root helpers re-check coverage (multi-operator) independently. Mirrors
# operator.lib's ai_tools_allowlist_covers.
covered_by_project() {
    local d="$1" entry val dir covered=1
    [[ -f "${ALLOWLIST}" ]] || return 1
    while IFS= read -r entry || [[ -n "${entry}" ]]; do
        ai_tools_conf_path_entry "${entry}" || continue
        val="${_ai_tools_conf_value}"
        if [[ "${val}" == '!'* ]]; then
            val="${val#!}"; val="${val%/}"
            # SC2053: the unquoted RHS is the operator-owned glob pattern (see shellcheck.rule.md).
            [[ "${d}" == ${val} ]] && return 1                                  # exclusion wins
            [[ "${val}" != *'*'* && "${d}" == "${val}/"* ]] && return 1
        else
            dir="$(realpath -e "${val}" 2>/dev/null)" || continue
            [[ "${d}" == "${dir}" || "${d}" == "${dir}/"* ]] && covered=0
        fi
    done < "${ALLOWLIST}"
    return "${covered}"
}

# unclaim_one <dir> <group|""> <hint> [helper-flag...]  -- revert one claimed project. Order
# matters: revert
# the SELinux label first (keeps the invariant "labelled => allowlisted"), then run the
# filesystem hand-back WHILE THE ALLOWLIST ENTRY IS STILL PRESENT (ai-tools-unclaim refuses a
# target not in allowed-projects), and only then drop the two registries. <group> empty means
# "unregister only, leave permissions"; <hint> non-empty prints the manual hand-back command
# (used when the hand-back was wanted but could not run). Best-effort throughout: a step warns
# with its manual command and never aborts the pass.
unclaim_one() {
    local d="$1" group="$2" hint="$3"; shift 3
    local flags=""; (( $# )) && flags=" $*"
    if command -v sudo >/dev/null 2>&1 \
            && command -v getenforce >/dev/null 2>&1 \
            && [[ "$(getenforce 2>/dev/null)" != "Disabled" ]]; then
        run_relabel "${d}" --remove \
            || warn "could not revert SELinux label -- run: sudo ${RELABEL_BIN} --remove ${d}"
    fi
    if [[ -n "${group}" ]]; then
        if run_unclaim "${d}" "${group}" "$@"; then
            ok "handed ${d} back to group ${group}, agent write access removed"
        else
            warn "could not hand the files back -- run it by hand:"
            say  "      ${C_BOLD}sudo ${UNCLAIM_BIN} ${d} ${group}${flags}${C_RST}"
        fi
    elif [[ -n "${hint}" ]]; then
        say  "      run it later with: ${C_BOLD}sudo ${UNCLAIM_BIN} ${d} <group>${flags}${C_RST}"
    fi
    unreg_safedir "${d}"
    unreg_allow "${d}"
    ok "unclaimed ${d}"
    ai_tools_log_info "unclaimed project ${d}"
}

# residue_scan <dir>  -- fill RESIDUE and RESIDUE_SKIPPED with every path under <dir> that still
# carries ai-tools ownership or group: the on-disk fingerprint of a claim. RESIDUE holds what the
# default helper walk reaches, RESIDUE_SKIPPED what only --full does; .git counts as reachable
# because the helper reverts it in a dedicated pass regardless of the skip list. Read-only and
# unprivileged, so it is a PREVIEW: the helper re-derives the same predicate as root, where it
# also sees the ACL-only paths this scan cannot cheaply detect and the paths this operator cannot
# traverse. Under-reporting is the safe direction -- the gate it feeds only ever decides whether
# there is anything to offer, never what may be touched.
residue_scan() {
    local d="$1" hit
    RESIDUE=(); RESIDUE_SKIPPED=()
    ai_tools_skip_find_expr sweep 2>/dev/null || true
    while IFS= read -r hit; do
        [[ -n "${hit}" ]] || continue
        if [[ "${hit}" != "${d}/.git/"* && "${hit}" != "${d}/.git" ]] \
                && under_skip_listed_name "${d}" "${hit}"; then
            RESIDUE_SKIPPED+=("${hit}")
        else
            RESIDUE+=("${hit}")
        fi
    done < <(find "${d}" -xdev \
                  '(' -user "${SANDBOX_USER}" -o -group "${SANDBOX_GROUP}" ')' \
                  '(' -type d -o -type f ')' -print 2>/dev/null)
}

# resolve_handback_group <group-opt>  -- decide the filesystem hand-back's target group. It has
# TWO results and sets both as globals in the CALLER's shell:
#   HANDBACK_GROUP  the target group; empty means "unregister only, leave permissions alone".
#   HANDBACK_HINT   non-empty when a hand-back was wanted but cannot run, so the caller prints
#                   the manual command instead of silently doing nothing.
# Globals, not stdout, precisely BECAUSE there are two: a `$(...)` capture runs the function in a
# subshell, where the second result is lost -- and reading it back under `set -u` aborts the whole
# unclaim before it touches anything. Prompts draw on /dev/tty and warnings on stderr, so a caller
# needs neither redirection nor a capture.
# --group answers both questions at once (whether to hand back, and to which group), so an
# automated run never depends on the prompt's no-terminal fallback quietly picking the invoking
# user's group. Without it the default-YES confirm and the user->group prompt run as before.
HANDBACK_GROUP=""
HANDBACK_HINT=""
resolve_handback_group() {
    local group_opt="$1" hb_user
    HANDBACK_GROUP=""
    HANDBACK_HINT=""
    if [[ -n "${group_opt}" ]]; then
        if command -v sudo >/dev/null 2>&1; then
            HANDBACK_GROUP="${group_opt}"
        else
            warn "sudo not found -- cannot hand the files back automatically"
            HANDBACK_HINT=1
        fi
        return 0
    fi
    # Default YES: the natural completion of an unclaim. Still confirmed, because it rewrites
    # ownership and permissions across the tree.
    if confirm "Hand the files back to a group and remove the agent's write access?" y; then
        hb_user="$(ask "  Hand the files to which user's group?" "${ME}")"
        if ! HANDBACK_GROUP="$(id -gn "${hb_user}" 2>/dev/null)"; then
            warn "no such user '${hb_user}' -- skipping the filesystem hand-back"
            HANDBACK_GROUP=""; HANDBACK_HINT=1
        elif ! command -v sudo >/dev/null 2>&1; then
            warn "sudo not found -- cannot hand the files back automatically"
            HANDBACK_GROUP=""; HANDBACK_HINT=1
        fi
    fi
    return 0
}

# cmd_unclaim_unlisted <dir> <force> <full> <dry> <assume-yes> <group-opt>  -- the UNRELATED
# branch: no allowlist entry covers <dir>. Detection guides; only --force acts, and even then the
# helper touches a path solely while it still carries the ai-tools fingerprint, so running this on
# a directory that was never claimed changes nothing at all. That per-path gate -- not any
# conservatism about which bits to write -- is what makes the mode safe on a mistyped path: what
# it DOES to a path it accepts is identical to a registered unclaim.
cmd_unclaim_unlisted() {
    local d="$1" force="$2" full="$3" dry="$4" assume_yes="$5" group_opt="$6"

    ai_tools_assert_safe_target "${d}" "project unclaim" || exit 3
    # A sandbox clone has its own lifecycle verb, which also removes the clone itself.
    if [[ "${d}" == "${SANDBOX_ROOT}/"* ]]; then
        die "that is a sandbox clone: ${d}" \
            "       remove it with: ai-tools --sandbox-remove ${d}"
    fi

    residue_scan "${d}"
    local n_res="${#RESIDUE[@]}" n_skip="${#RESIDUE_SKIPPED[@]}"
    if (( n_res == 0 && n_skip == 0 )); then
        die "nothing to unclaim here: ${d}" \
            "       it is not a registered project, and nothing in it carries ai-tools ownership or group" \
            "       list your registered projects with: ai-tools --list"
    fi

    local extra=""
    (( n_skip )) && extra=", plus ${n_skip} more under skip-listed directories (--full reaches those)"

    # Detection GUIDES but never lowers the gate: the fingerprint improves the message, --force
    # still authorizes, and the confirm below still executes.
    if [[ "${force}" != true ]]; then
        ai_tools_msg_notice \
            "ai-tools: not a registered project, but it carries ai-tools permissions:" \
            "${d}" \
            "${n_res} path(s) owned by or grouped to ${SANDBOX_USER}${extra}." \
            "This looks like a claimed project copied or moved here without unclaiming. To normalize its permissions without registering it, re-run with --force:"
        say ""
        say "   preview:  ${C_BOLD}ai-tools --project-unclaim --force --dry-run ${d}${C_RST}"
        say "   apply:    ${C_BOLD}ai-tools --project-unclaim --force ${d}${C_RST}"
        say "   see:      ${C_BOLD}man ai-tools${C_RST}"
        exit 0
    fi

    if [[ "${dry}" == true ]]; then
        section "Dry run -- nothing is changed"
        say "  ${d}"
        say ""
        say "  ${n_res} path(s) the default walk reaches:"
        path_detail_lines "${RESIDUE[@]}"
        if (( n_skip )); then
            say ""
            say "  ${n_skip} path(s) under skip-listed directories, reached only with --full:"
            path_detail_lines "${RESIDUE_SKIPPED[@]}"
        fi
        say ""
        say "  ${C_DIM}the helper re-derives this as root, where it also sees ACL-only paths${C_RST}"
        say "  ${C_DIM}and any path this account cannot traverse${C_RST}"
        exit 0
    fi

    headline_warn "WARNING: unclaim an unregistered tree" \
        "${d} is NOT a registered project. Only paths still carrying ai-tools ownership, group, or ACL are changed; every other path is left untouched." \
        "On each matching path it clears the ACLs, regroups to the target group and removes group write -- landing on 640, or 750 where the owner has execute -- clears the setgid bit and resets the SELinux label. World access, which the claim removed, is NOT restored. The previous permissions are recorded nowhere, so this is IRREVERSIBLE. Back up first. See: man ai-tools"
    say ""
    say "    ${n_res} path(s)${extra}"
    path_listing "path(s)" "${RESIDUE[@]}"
    say ""

    # Heavy trees: informational unless --full was asked for. With --full the operator has already
    # recorded the intent on the command line, so the confirm defaults YES -- an automated run
    # carries through on that default while an interactive one still sees and answers it.
    local -a helper_flags=(--unlisted)
    if (( n_skip )); then
        if [[ "${full}" == true ]]; then
            headline_warn "Skip-listed directories (--full)" \
                "${n_skip} path(s) carrying ai-tools ownership or group sit under skip-listed directory names (build output, dependencies, caches). --full includes them in this pass."
            path_listing "path(s)" "${RESIDUE_SKIPPED[@]}"
            say ""
            confirm "Include these ${n_skip} path(s) under skip-listed directories?" y \
                && helper_flags+=(--full)
        else
            headline_warn "NOTICE: residue under skip-listed directories" \
                "${n_skip} path(s) carrying ai-tools ownership or group sit under skip-listed directory names (build output, dependencies, caches). This pass leaves them untouched; add --full to include them."
            path_detail_lines "${RESIDUE_SKIPPED[@]:0:3}"
            (( n_skip > 3 )) && say "        ${C_DIM}... and $(( n_skip - 3 )) more${C_RST}"
            say ""
        fi
    fi

    # The one decision --force does not make for you. -y pre-answers it, the same explicit
    # per-invocation convention as --project-claim -y.
    if [[ "${assume_yes}" != true ]]; then
        confirm "Unclaim this unregistered tree?" n || die "aborted"
    fi

    local hb_group hb_hint
    resolve_handback_group "${group_opt}"
    hb_group="${HANDBACK_GROUP}"; hb_hint="${HANDBACK_HINT}"
    if [[ -z "${hb_group}" ]]; then
        [[ -n "${hb_hint}" ]] \
            && say "      run it later with: ${C_BOLD}sudo ${UNCLAIM_BIN} ${d} <group> ${helper_flags[*]}${C_RST}"
        die "nothing to do without a hand-back group -- there are no registries to drop for an unregistered tree"
    fi

    if run_unclaim "${d}" "${hb_group}" "${helper_flags[@]}"; then
        ok "normalized ${d} to group ${hb_group}, ai-tools access removed"
        ai_tools_log_info "unclaimed unregistered tree ${d} (group -> ${hb_group})"
    else
        warn "could not normalize the tree -- run it by hand:"
        say  "      ${C_BOLD}sudo ${UNCLAIM_BIN} ${d} ${hb_group} ${helper_flags[*]}${C_RST}"
        exit 1
    fi
}

# cmd_project_unclaim [path]  -- undo an in-place claim (default: cwd): revert the SELinux
# label, drop both registries, and (default-yes confirm) hand the tree's filesystem back to a
# target group with the agent's write access revoked. The directory itself is left on disk. The
# filesystem hand-back (ai-tools-unclaim) clears the agent ACL + default ACL, regroups every
# eligible file to the target group, and removes group write (660->640, 770->750, 400 stays 400).
# The target group defaults to the invoking user's own group; any other system user can be named.
#
# The path is classified against allowed-projects into five outcomes, so unclaim only ever
# modifies permissions where something authorizes it:
#   EXACT       the path is itself a claimed project -- unclaim it.
#   ANCESTOR    claimed projects are nested under it -- list them and, behind a single default-NO
#               warning, unclaim each, outermost first.
#   DESCENDANT  the path sits INSIDE a claimed project -- refuse, naming the nearest claimed
#               parent (the longest matching entry) and the command that does work.
#   UNRELATED + residue   no allowlist entry covers it, but it still carries the ai-tools
#               fingerprint: a claimed project copied or moved here and never unclaimed. Detection
#               only GUIDES; acting needs an explicit --force, which swaps the allowlist gate for
#               a per-path residue gate in the helper.
#   UNRELATED, clean      refuse -- nothing here was ever claimed, so there is nothing to undo.
# A protected system path is refused up front. For a registered project this only guards a
# hand-edited allowlist (claim/setgid/setfacl never let one become a claimed project), whose
# cleanup ai-tools --list reports; --force never relaxes it.
cmd_project_unclaim() {
    # --force gates on the on-disk fingerprint instead of allowlist membership; it never relaxes
    # the protected-paths backstop, the owner guard, or the secret/'!' skips. -y/--yes pre-answers
    # the default-NO confirm in EVERY mode -- the registered project's, the ancestor batch's, and
    # --force's -- the same explicit-flag convention as --project-claim -y; it never answers the
    # hand-back or skip-listed questions, which ask on their own terms. --group names the hand-back
    # group outright, so a script never depends on the prompt's no-tty fallback -- and it works in
    # both modes.
    local a path="" force=false full=false dry=false assume_yes=false group_opt="" want_group=false
    for a in "$@"; do
        if ${want_group}; then group_opt="${a}"; want_group=false; continue; fi
        case "${a}" in
            --force)      force=true ;;
            --full)       full=true ;;
            -n|--dry-run) dry=true ;;
            -y|--yes)     assume_yes=true ;;
            --group)      want_group=true ;;
            --group=*)    group_opt="${a#--group=}" ;;
            -*) die "unknown --project-unclaim option: ${a}" \
                    "       allowed: --force, --full, -n/--dry-run, -y/--yes, --group <group>" ;;
            *)  if [[ -z "${path}" ]]; then path="${a}"
                else die "--project-unclaim takes a single path"; fi ;;
        esac
    done
    ${want_group} && die "--group needs a group name"
    if [[ -n "${group_opt}" ]] && ! getent group "${group_opt}" >/dev/null 2>&1; then
        die "no such group: ${group_opt}"
    fi
    if ${dry} && ! ${force}; then
        die "-n/--dry-run applies to --force only" \
            "       a registered project's unclaim previews itself: it lists what it will do and asks before acting"
    fi

    local d; d="$(resolve_dir "${path:-$PWD}")"
    [[ -d "${d}" ]] || die "not a directory: ${d}"

    # Classify d: exact entry, ancestor of entries, descendant of one, or unrelated.
    local -a entries=() targets=()
    local e
    while IFS= read -r e; do [[ -n "${e}" ]] && entries+=("${e}"); done \
        < <(positive_project_entries)
    local mode=unrelated nearest=""
    for e in "${entries[@]:-}"; do
        [[ "${e}" == "${d}" ]] && { mode=exact; targets=("${d}"); break; }
    done
    if [[ "${mode}" == unrelated ]]; then
        for e in "${entries[@]:-}"; do
            [[ "${e}" == "${d}/"* ]] && targets+=("${e}")
        done
        if (( ${#targets[@]} )); then
            mode=ancestor
            # Outermost first: a parent path sorts before every path nested in it (it is their
            # prefix), so a containing project is handed back before one inside it. Each still
            # needs its own registry and label drop, so a nested entry is never skipped.
            mapfile -t targets < <(printf '%s\n' "${targets[@]}" | sort)
        fi
    fi
    if [[ "${mode}" == unrelated ]]; then
        # Nearest claimed parent: the LONGEST entry that is a prefix of d. With both /a and /a/b
        # claimed, /a/b/c belongs to /a/b. One length comparison -- no tree, no traversal.
        for e in "${entries[@]:-}"; do
            if [[ "${d}" == "${e}/"* ]] && (( ${#e} > ${#nearest} )); then nearest="${e}"; fi
        done
        [[ -n "${nearest}" ]] && mode=descendant
    fi

    if [[ "${mode}" == descendant ]]; then
        die "this path is inside a claimed project, not a project itself: ${d}" \
            "       the claimed project is: ${nearest}" \
            "       unclaim that instead: ai-tools --project-unclaim ${nearest}"
    fi

    if [[ "${mode}" == unrelated ]]; then
        cmd_unclaim_unlisted "${d}" "${force}" "${full}" "${dry}" "${assume_yes}" "${group_opt}"
        return
    fi

    # Protected-path front line: never modify permissions on a protected system path. Guard
    # each MODIFICATION target (in ancestor mode the search root may be protected, e.g. /home,
    # while the projects nested under it are not).
    local t
    for t in "${targets[@]}"; do
        ai_tools_assert_safe_target "${t}" "project unclaim" || exit 3
    done

    # --force is about reaching a tree the allowlist does not cover; here one does. Say so
    # rather than silently ignoring the flag, and name the project that made it unnecessary.
    if ${force}; then
        ai_tools_msg_notice \
            "ai-tools: --force is not needed here -- this path is covered by the allowlist:" \
            "${targets[0]}" \
            "unclaiming it the normal way, which reverts the whole registered tree."
    fi

    if [[ "${mode}" == exact ]]; then
        section "Unclaim project"
        say "  ${d}"
        say "  ${C_DIM}(the directory itself is left on disk)${C_RST}"
        ${assume_yes} || confirm "Unclaim this project?" n || die "aborted"
    else
        headline_warn "WARNING: unclaim multiple projects" \
            "${d} is not itself a claimed project, but ${#targets[@]} claimed project(s) are nested under it." \
            "Unclaiming MODIFIES FILE PERMISSIONS AND OWNERSHIP in ALL of the projects listed below." \
            "The directories themselves are left on disk."
        for t in "${targets[@]}"; do printf '    %s\n' "${t}"; done
        say ""
        ${assume_yes} || confirm "Unclaim ALL ${#targets[@]} projects listed above?" n || die "aborted"
    fi

    # Filesystem hand-back: decided ONCE for the whole batch.
    local hb_group hb_hint
    resolve_handback_group "${group_opt}"
    hb_group="${HANDBACK_GROUP}"; hb_hint="${HANDBACK_HINT}"

    local -a helper_flags=()
    ${full} && helper_flags=(--full)

    for t in "${targets[@]}"; do
        unclaim_one "${t}" "${hb_group}" "${hb_hint}" "${helper_flags[@]}"
    done

    # Mixed tree: the registered projects are done, but ai-tools residue can still sit elsewhere
    # under this path (another copy, a leftover from a tree that was never registered). Reported
    # only when --force asked about residue in the first place, so the common path pays no scan.
    # The projects just unclaimed are no longer registered, so a re-run now classifies the whole
    # path as unrelated and the one command finishes the job.
    if ${force} && [[ "${mode}" == ancestor ]]; then
        residue_scan "${d}"
        local left=$(( ${#RESIDUE[@]} + ${#RESIDUE_SKIPPED[@]} ))
        if (( left )); then
            say ""
            ai_tools_msg_notice \
                "ai-tools: ${left} path(s) under this directory still carry ai-tools ownership or group, outside the projects just unclaimed." \
                "Re-run to normalize them now that nothing here is registered:"
            say ""
            say "   ${C_BOLD}ai-tools --project-unclaim --force --dry-run ${d}${C_RST}"
        fi
    fi
}

# sandbox_finalize <dst>  -- the access-granting tail of every sandbox create, run only
# AFTER the clone exists: allowlist (the lockdown scan acts only on an allowlisted path;
# rolled back on a failed gate), the secret-lockdown gate, then -- strictly past the
# gate -- normalize (pruning the locked paths), relabel, and register. FAIL CLOSED: a
# declined or failed gate leaves the clone on disk but private to the operator -- cloned
# under umask 077, so nothing in it is group-readable -- not normalized, not relabelled,
# not registered, with a guard CLAUDE.md dropped and the resume command printed.
# Re-running --sandbox-create on the existing clone path resumes here.
sandbox_finalize() {
    local dst="$1"
    reg_allow "${dst}"
    if ! secret_gate "${dst}"; then
        unreg_allow "${dst}"
        drop_lockdown_guard "${dst}"
        warn "sandbox not secured -- the clone stays private to you:" \
             "not group-accessible, not registered; the agent has no access to it"
        say  "    handle the secrets, then finish the create:"
        say  "      ${C_BOLD}ai-tools --sandbox-create ${dst}${C_RST}"
        die "sandbox create stopped -- secrets not locked down"
    fi
    clear_lockdown_guard "${dst}"
    normalize_clone "${dst}" "${SECRET_GATE_LOCKED[@]}"
    say "    access: group ${SANDBOX_GROUP} rwX + setgid dirs (locked secrets stay private)"
    relabel_clone "${dst}"
    reg_safedir "${dst}"
    say ""
    ok "sandbox ready: ${dst}"
    ai_tools_log_info "sandbox secured and registered: ${dst}"

    section "Next"
    say "  run the agent  : ${C_BOLD}cd ${dst} && claude${C_RST}"
    say "  push its work  : ${C_BOLD}ai-tools --sandbox-push ${dst}${C_RST}"
    say "  ${C_YEL}shallow${C_RST}        : push-only -- never git pull/fetch here, or you pull the full history"
}

# sandbox_default_branch <from>  -- echo the DEFAULT sandbox branch name for a fork of <from>:
# "sandbox/<leaf>", where <leaf> is <from>'s last path component (so a fork of develop defaults to
# sandbox/develop, and origin/feature/x to sandbox/x). The literal "sandbox" carries NO host,
# machine, or operator identity by design -- the branch is pushed to a shared remote, so the default
# must leak nothing about who or where created it. It is only a DEFAULT: the operator overrides the
# whole name with --branch (or the prompt), and any valid git ref is accepted, so the sandbox
# workflow is not tied to this shape. Pure; unit-tested (tests/unit/sandbox.sh).
sandbox_default_branch() {
    printf 'sandbox/%s' "${1##*/}"
}

# sandbox_resolve_base <top> <remote> <base>  -- echo a ref naming <base>'s tip in the source
# repo, or return 1 if none does. Tries <base> as a local branch first, then the remote-tracking
# form <remote>/<base> (so a base that lives only on the remote -- e.g. master while you are on
# develop -- resolves without a local checkout), then any other commit-ish (a tag or SHA). This is
# what lets the sandbox branch be forked from a base OTHER than the current HEAD. Read-only (no
# ref is created here); unit-tested against a fixture repo (tests/unit/sandbox.sh).
sandbox_resolve_base() {
    local top="$1" remote="$2" base="$3"
    git -C "${top}" rev-parse --verify --quiet "refs/heads/${base}" >/dev/null 2>&1 \
        && { printf '%s' "${base}"; return 0; }
    git -C "${top}" rev-parse --verify --quiet "refs/remotes/${remote}/${base}" >/dev/null 2>&1 \
        && { printf 'refs/remotes/%s/%s' "${remote}" "${base}"; return 0; }
    git -C "${top}" rev-parse --verify --quiet "${base}^{commit}" >/dev/null 2>&1 \
        && { printf '%s' "${base}"; return 0; }
    return 1
}

# cmd_sandbox_create [path] [--from <ref>] [--branch <name>] [--dir <name>] [-y|--yes]
#   -- create or reuse a sandbox branch, shallow-clone it PRIVATELY (umask 077) into SANDBOX_ROOT,
#   then hand off to sandbox_finalize: secret lockdown first, and only past that gate normalize +
#   relabel + register (fail-closed otherwise). Pointed at an EXISTING clone under SANDBOX_ROOT, it
#   resumes sandbox_finalize on it (flags are then irrelevant) -- the recovery path for a create
#   whose gate was declined or failed.
#
#   Every input has a default and an optional flag, so the command is fully scriptable and the
#   prompts are only the interactive fallback (a flag skips its prompt; no flag + no tty takes the
#   default). The branch is a FULL git ref of any shape -- the "sandbox/<leaf>" default is a
#   convention, not a required structure (see sandbox_default_branch); it is validated with
#   git check-ref-format, never silently rewritten. -y/--yes pre-answers the create confirm only;
#   the secret-lockdown and .git gates in sandbox_finalize still apply (messaging.rule.md doctrine).
cmd_sandbox_create() {
    local o_path="" o_from="" o_branch="" o_dir="" o_yes=false
    local have_from=false have_branch=false have_dir=false
    # _need_value <flag> [remaining args...]: die unless a value follows the flag AND that value is
    # not itself option-shaped. A leading '-' is a mistyped flag far more often than a real ref or
    # directory name, and taking it at face value hands it to git as an option -- so the run would
    # fail with git's own parse error, which names neither this flag nor the value. Refused here,
    # where the message can name both, and before the push.
    _need_value() {
        local flag="$1"; shift
        (( $# )) || die "${flag} needs a value"
        [[ "$1" != -* ]] || die "${flag} needs a value, not another option: $1"
    }
    while (( $# )); do
        case "$1" in
            --from)   _need_value --from   "${@:2}"; o_from="$2";   have_from=true;   shift 2 ;;
            --branch) _need_value --branch "${@:2}"; o_branch="$2"; have_branch=true; shift 2 ;;
            --dir)    _need_value --dir    "${@:2}"; o_dir="$2";    have_dir=true;    shift 2 ;;
            -y|--yes) o_yes=true; shift ;;
            --)       shift ;;
            -*)       die "unknown option: $1 (see: ai-tools --help)" ;;
            *)        [[ -z "${o_path}" ]] || die "unexpected extra argument: $1"; o_path="$1"; shift ;;
        esac
    done
    local src; src="$(resolve_dir "${o_path:-$PWD}")"

    case "${src}/" in
        "${SANDBOX_ROOT}"/*)
            git -C "${src}" rev-parse --is-inside-work-tree >/dev/null 2>&1 \
                || die "not a git clone: ${src}"
            headline "Resume sandbox project" "${src}" \
                "securing and registering an existing clone"
            sandbox_finalize "${src}"
            return 0 ;;
    esac
    git -C "${src}" rev-parse --is-inside-work-tree >/dev/null 2>&1 \
        || die "not a git repository: ${src}"
    local top; top="$(git -C "${src}" rev-parse --show-toplevel)"
    local cur
    cur="$(git -C "${top}" symbolic-ref --short HEAD 2>/dev/null)" \
        || die "repository is in detached HEAD; check out a branch first: ${top}"

    local remote
    if git -C "${top}" remote | grep -qx "origin"; then
        remote="origin"
    else
        remote="$(git -C "${top}" remote | head -1)"
    fi
    [[ -n "${remote}" ]] || die "repository has no remote; the sandbox workflow needs one: ${top}"
    local remote_url; remote_url="$(git -C "${top}" remote get-url "${remote}")"

    headline "Create sandbox project" \
        "an isolated shallow clone of this repo, registered for the agent; work is pushed to a dedicated branch that you merge back"
    say "  source repo    : ${top}"
    say "  current branch : ${cur}"
    say "  remote         : ${remote}  ${C_DIM}${remote_url}${C_RST}"

    # Resolve every input BEFORE any push or checkout: a flag wins, else the prompt (interactive) or
    # the default (no tty). So an Enter-through reproduces the previous shape and a fully-flagged run
    # needs no terminal, while a bad value stops here rather than after the push.

    # Base to fork from -- defaults to the current branch, but can be any base (e.g. main for a
    # hotfix while you sit on develop): a local branch, a <remote>/<base>, or any ref.
    local base
    if ${have_from}; then base="${o_from}"; else base="$(ask "Base branch to fork from" "${cur}")"; fi
    [[ -n "${base}" ]] || die "base branch cannot be empty"
    local base_ref
    base_ref="$(sandbox_resolve_base "${top}" "${remote}" "${base}")" \
        || die "base not found: ${base} (not a local branch, ${remote}/${base}, or a known ref)"

    # Sandbox branch -- a FULL git ref of any shape. Defaults to the convention sandbox/<leaf-of-base>
    # but the operator may enter anything (a flat name, a hotfix/x, or the old ai-tools/... form).
    # Validated with git check-ref-format and refused if invalid -- never silently rewritten.
    local br
    if ${have_branch}; then br="${o_branch}"
    else br="$(ask "Sandbox branch to create/track" "$(sandbox_default_branch "${base}")")"; fi
    [[ -n "${br}" ]] || die "sandbox branch cannot be empty"
    git check-ref-format "refs/heads/${br}" 2>/dev/null \
        || die "invalid branch name: ${br} (must be a valid git ref -- see git-check-ref-format(1))"

    local name
    if ${have_dir}; then name="${o_dir}"
    else name="$(ask "Sandbox directory name under ${SANDBOX_ROOT}" "$(basename "${top}")")"; fi
    # One component, and a real one: '.' and '..' pass the no-slash test but name the clone area
    # itself or its parent, where the next check would refuse them as "already exists" -- true, but
    # not what went wrong.
    [[ -n "${name}" && "${name}" != */* && "${name}" != . && "${name}" != .. ]] \
        || die "invalid directory name: ${name} (one path component, under ${SANDBOX_ROOT})"
    local dst="${SANDBOX_ROOT}/${name}"
    if [[ -e "${dst}" ]]; then
        say "    to finish securing/registering an earlier clone of this name:"
        say "      ${C_BOLD}ai-tools --sandbox-create ${dst}${C_RST}"
        die "destination already exists: ${dst}"
    fi
    [[ -d "${SANDBOX_ROOT}" ]] || die "sandbox area missing: ${SANDBOX_ROOT} -- run install first"

    # git silently ignores --depth for a clone from a local path, which would copy the FULL history
    # into the sandbox and defeat the isolation. Force the file:// transport for local-path remotes
    # so depth=1 is honored; network remotes (ssh/https) honor it natively and keep their URL.
    # Computed here (not after the confirm) so the preview shows the exact clone command.
    local clone_url="${remote_url}"
    case "${remote_url}" in
        /*|./*|../*) clone_url="file://$(realpath -m "${remote_url}")" ;;
    esac

    # If the branch already exists on the remote (a prior sandbox of this repo), reuse it rather
    # than force-pushing over it -- this resumes earlier work and never discards commits. To reset
    # it, delete the remote branch or pick a new leaf.
    local br_exists=false
    [[ -n "$(git -C "${top}" ls-remote --heads "${remote}" "${br}" 2>/dev/null)" ]] \
        && br_exists=true

    # Preview the ACTUAL commands, verbatim on their own lines (a long clone line overflows the
    # frame intact rather than wrapping -- see messaging.rule.md / console-command-formatting).
    say ""
    if ${br_exists}; then
        say "  will run (reusing existing remote branch ${br}; ${base} is NOT pushed over it):"
    else
        say "  will run:"
        say "    git branch -f ${br} ${base_ref}"
        say "    git push ${remote} ${br}"
    fi
    say "    git clone --depth=1 -b ${br} ${clone_url} ${dst}"
    say ""
    say "  then: lock down tip-commit secrets, grant the agent access, register the clone"
    # -y/--yes pre-answers this create confirm only (an auditable per-invocation flag, as elsewhere);
    # the secret-lockdown and .git gates in sandbox_finalize still prompt on their own terms.
    ${o_yes} || confirm "Create the sandbox clone?" y || die "aborted"

    if ${br_exists}; then
        ok "reusing existing remote branch ${br}"
    else
        git -C "${top}" branch -f "${br}" "${base_ref}"
        git -C "${top}" push "${remote}" "${br}"
        ok "pushed ${br} to ${remote}"
    fi

    # umask 077: the clone is born OWNER-ONLY, so the tip commit's files -- possibly checked-in
    # credentials -- are unreadable to the sandbox account (the setgid SANDBOX_ROOT already puts
    # them in group SANDBOX_GROUP) until the secret gate has run and normalize_clone deliberately
    # opens the non-secret paths.
    ( umask 077 && git clone --depth=1 -b "${br}" "${clone_url}" "${dst}" )
    ok "shallow-cloned into ${dst} (private until secured)"
    ai_tools_log_info "created sandbox clone ${dst} (branch ${br}, base ${base_ref}, remote ${remote})"

    sandbox_finalize "${dst}"
}

# cmd_sandbox_push [path]  -- push the sandbox clone's commits ahead of its upstream
# branch, after listing them and confirming. No-op when already up to date.
cmd_sandbox_push() {
    local d; d="$(resolve_dir "${1:-$PWD}")"
    require_sandbox_clone "${d}"
    local up
    up="$(git -C "${d}" rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>/dev/null)" \
        || die "no upstream configured for the current branch in ${d}"
    local n; n="$(git -C "${d}" rev-list --count '@{u}..HEAD' 2>/dev/null || echo 0)"

    section "Push sandbox work"
    say "  sandbox  : ${d}"
    say "  upstream : ${up}"
    if [[ "${n}" == "0" ]]; then
        ok "nothing to push (already up to date with ${up})"
        return 0
    fi
    say "  ${n} commit(s) to push:"
    git -C "${d}" --no-pager log --oneline '@{u}..HEAD' | sed 's/^/      /'
    confirm "Push ${n} commit(s) to ${up}?" y || die "aborted"
    git -C "${d}" push
    ok "pushed ${n} commit(s) to ${up}"
    ai_tools_log_info "pushed ${n} commit(s) from sandbox ${d} to ${up}"
}

# cmd_sandbox_remove [path]  -- delete a sandbox clone and unregister it, warning
# first about any unpushed commits. The remote branch is left intact.
cmd_sandbox_remove() {
    local d; d="$(resolve_dir "${1:-$PWD}")"
    require_sandbox_clone "${d}"
    section "Remove sandbox project"
    say "  ${d}"

    # require_sandbox_clone guarantees a git worktree; @{u} may be absent (no upstream) -> 0.
    local n; n="$(git -C "${d}" rev-list --count '@{u}..HEAD' 2>/dev/null || echo 0)"
    if [[ "${n}" != "0" ]]; then
        warn "${n} unpushed commit(s) will be lost (already-pushed work stays on the remote)"
        confirm "Discard ${n} unpushed commit(s) and remove ${d}?" n || die "aborted"
    else
        confirm "Remove ${d} and unregister it?" n || die "aborted"
    fi

    rm -rf "${d}"
    unreg_allow "${d}"
    unreg_safedir "${d}"
    ok "removed ${d} and unregistered it"
    ai_tools_log_info "removed sandbox ${d} and unregistered it"
    say "  ${C_DIM}remote branch left intact -- others may still merge it${C_RST}"
}

# cmd_lockdown [path] [-n|-y]  -- run ai-tools-lockdown (via sudo) on the project to
# revoke ai-tools' read access to secret files; clears any guard CLAUDE.md on a real
# (non-dry-run) success. -n/--dry-run and -y/--yes pass through to the helper.
cmd_lockdown() {
    local d="" a dry=false; local -a passthru=()
    for a in "$@"; do
        case "${a}" in
            -n|--dry-run) passthru+=("${a}"); dry=true ;;
            -y|--yes)     passthru+=("${a}") ;;
            -*)           die "unknown --lockdown option: ${a} (allowed: --dry-run, --yes)" ;;
            *)            if [[ -z "${d}" ]]; then d="${a}"; else die "--lockdown takes a single path"; fi ;;
        esac
    done
    d="$(resolve_dir "${d:-$PWD}")"
    [[ -d "${d}" ]] || die "not a directory: ${d}"
    covered_by_project "${d}" \
        || die "not a claimed project: ${d}" \
               "it is not at or under any project in your allowed-projects" \
               "       list your registered projects with: ai-tools --list"
    # No readable-path pre-check: /usr/local/libexec/ai-tools is 750 root:root, so the
    # projects user cannot even stat the helper -- only sudo (as root) can reach it.
    # If it is genuinely missing, sudo reports it and run_lockdown returns non-zero.
    section "Lock down project secrets"
    say "  ${d}"
    say "  ${C_DIM}secret-matching files -> 600, dirs -> 700, owner ${ME}:${MY_GROUP}${C_RST}"
    if run_lockdown "${d}" "${passthru[@]}"; then
        ${dry} || clear_lockdown_guard "${d}"
        ok "lockdown done: ${d}"
        ${dry} || ai_tools_log_info "locked down secrets in ${d}"
    else
        die "lockdown failed for ${d}"
    fi
}

# cmd_reclaim [--full] [path]  -- hand agent-written files under the project (default: cwd) back to
# ${ME}:${SANDBOX_GROUP} via ai-tools-reclaim (sudo). Reclaims the .git tree the per-session sweeps
# skip; run it before an ACL-unaware backup so ownership (not the per-project ACL) carries the
# operator's access into the copy. --full also reclaims the heavy trees the default run skips
# (node_modules, .venv, ...).
cmd_reclaim() {
    local d="" a full=false; local -a passthru=()
    for a in "$@"; do
        case "${a}" in
            --full) passthru+=("${a}"); full=true ;;
            -*)     die "unknown --reclaim option: ${a} (allowed: --full)" ;;
            *)      if [[ -z "${d}" ]]; then d="${a}"; else die "--reclaim takes a single path"; fi ;;
        esac
    done
    d="$(resolve_dir "${d:-$PWD}")"
    [[ -d "${d}" ]] || die "not a directory: ${d}"
    covered_by_project "${d}" \
        || die "not a claimed project: ${d}" \
               "it is not at or under any project in your allowed-projects" \
               "       list your registered projects with: ai-tools --list"
    section "Reclaim agent-written files"
    say "  ${d}${C_DIM}$(${full} && printf ' (--full: incl. node_modules, .venv, ...)')${C_RST}"
    say "  ${C_DIM}-> ${ME}:${SANDBOX_GROUP} (secret-named files stay ${ME}:${ME} 600)${C_RST}"
    # The helper reports the outcome itself -- the pre-scan count, the one whole-set
    # confirm, then "handed back N" / "nothing to reclaim" / "declined" -- so no blanket
    # success line here: the CLI states only what actually happened.
    run_reclaim "${d}" "${passthru[@]}" || die "reclaim failed for ${d}"
    ai_tools_log_info "reclaim run for ${d}$(${full} && printf ' (full)')"
}

# cmd_relabel  -- restore the ai_tools_exec_t SELinux label on each enabled agent's entrypoint
# after a Node auto-upgrade, via the root helper (sudo, no password: the dedicated rule). An
# nvm-update installs a fresh agent binary that npm leaves mislabelled (bin_t), so the domain
# transition stops firing and ai-tools-run refuses to launch (fail-closed) until the label is
# restored. Takes no path -- the helper resolves the entrypoints from the agent manifests.
#
# Design note: if post-upgrade maintenance ever grows beyond this one step, fold the steps
# under a `--postupgrade` umbrella verb that runs them in sequence; while relabel is the
# only step, the explicit `--relabel` is clearer in the UX, so there is no umbrella yet.
cmd_relabel() {
    [[ "$#" -eq 0 ]] || die "--relabel takes no arguments"
    section "Relabel the agent entrypoints (after a Node upgrade)"
    say "  A Node auto-upgrade installs new agent binaries that must be relabelled so"
    say "  the sandbox can confine the session; until then the agent refuses to launch."
    command -v sudo >/dev/null 2>&1 \
        || die "sudo not found -- cannot relabel; run as root: ${RELABEL_ENTRYPOINT_BIN}"
    # Reaches the helper through the dedicated fixed-path NOPASSWD rule (the same one the
    # nvm-update timer uses), so this runs as root without a password prompt.
    if sudo "${RELABEL_ENTRYPOINT_BIN}"; then
        ok "entrypoints relabelled -- exit any running session and relaunch"
        ai_tools_log_info "relabelled the agent entrypoints (post-upgrade)"
    else
        die "relabel failed -- see the message above"
    fi
}

# cmd_providers  -- report the installed providers of both kinds and, for each, whether a
# session gets it and why. Read-only: it resolves through providers.lib.sh, the same resolver
# ai-tools-run and the toolchain layer use, so what it reports is what a session gets rather than
# a second reading of operator.conf. The resolver's refusals -- an untrusted manifest, an
# enabled-but-uninstalled name -- go to its stderr and are captured and shown here; at launch
# they reach only the terminal and journald.
cmd_providers() {
    [[ "$#" -eq 0 ]] || die "--providers takes no arguments"
    local providers_lib=/usr/local/lib/ai-tools/providers.lib.sh
    # shellcheck source=SCRIPTDIR/../lib/ai-tools/providers.lib.sh
    if ! source "${providers_lib}" 2>/dev/null \
            || ! declare -F ai_tools_enabled_agents >/dev/null 2>&1 \
            || ! declare -F ai_tools_provider_gate  >/dev/null 2>&1; then
        die "cannot load ${providers_lib} -- reinstall the ai-tools package"
    fi

    # The resolvers report every refusal on stderr; collect both kinds' into one file so they
    # are shown together at the end instead of interleaved with the listings.
    local refusals; refusals="$(mktemp)"

    # gate_line <conf-key> -- the gating decision for one kind, in the operator's terms.
    gate_line() {
        case "$(ai_tools_provider_gate "$1")" in
            allowlist) printf '%s in %s (an exact allowlist)' "$1" "${AI_TOOLS_OPERATOR_CONF}" ;;
            untrusted) printf '%sdefault_enable only -- %s is ignored (not root-owned, or writable by group/other)%s' \
                           "${C_YEL}" "${AI_TOOLS_OPERATOR_CONF}" "${C_RST}" ;;
            *)         printf 'default_enable (no %s in %s)' "$1" "${AI_TOOLS_OPERATOR_CONF}" ;;
        esac
    }
    # agent_detail <name> -- an agent manifest's own description of itself. Empty for a manifest
    # the trust predicate refuses; that is the refusals block's story to tell.
    agent_detail() {
        local package launcher handback
        package="$( ai_tools_agent_manifest_field "$1" npm_package || true)"
        launcher="$(ai_tools_agent_manifest_field "$1" launcher    || true)"
        handback="$(ai_tools_agent_manifest_field "$1" handback    || true)"
        [[ -n "${package}" ]] || return 0
        printf '%s%s%s' "${package}" "${launcher:+, launcher ${launcher}}" \
            "${handback:+, handback ${handback}}"
    }
    # kind_block <label> <conf-key> <manifest-dir> <resolver> <detail-fn|-> -- one section per
    # provider kind: the gating decision, then every INSTALLED manifest marked enabled or
    # disabled. Installed comes from the directory listing and enabled from the resolver, so a
    # manifest the resolver refuses shows as disabled with its reason in the refusals block.
    kind_block() {
        local label="$1" conf_key="$2" dir="$3" resolver="$4" detail_fn="$5"
        local enabled manifest name detail state colour found=0
        section "${label}"
        say "  enabled by: $(gate_line "${conf_key}")"
        # cut -f1 reads both resolvers the same way (agents print further TAB-separated fields).
        enabled="$("${resolver}" 2>>"${refusals}" | cut -f1)"
        for manifest in "${dir}"/*.conf; do
            [[ -e "${manifest}" ]] || continue
            found=1
            name="${manifest##*/}"; name="${name%.conf}"
            detail=""; [[ "${detail_fn}" == - ]] || detail="$("${detail_fn}" "${name}")"
            if grep -qxF -- "${name}" <<<"${enabled}"; then
                state=enabled;  colour="${C_GRN}"
            else
                state=disabled; colour="${C_DIM}"
            fi
            printf '    %s%-8s%s %-16s %s\n' "${colour}" "${state}" "${C_RST}" "${name}" "${detail}"
        done
        (( found )) || say "    (none installed)"
    }

    kind_block "Agents"       AI_TOOLS_AGENTS       "${AI_TOOLS_AGENTS_DIR}" \
               ai_tools_enabled_agents agent_detail
    kind_block "Integrations" AI_TOOLS_INTEGRATIONS "${AI_TOOLS_INTEGRATIONS_DIR}" \
               ai_tools_enabled_integrations -

    # The enabled integration names, reused by the SELinux advisory below. stderr is dropped here
    # (the integrations kind_block already captured any refusals into ${refusals}).
    local enabled_integrations
    enabled_integrations="$(ai_tools_enabled_integrations 2>/dev/null | cut -f1)"

    # SELinux policy groups -- reported only where the MAC layer is active (Enforcing/Permissive);
    # a DAC-only or SELinux-absent host skips the whole block. Read-only and unprivileged: getenforce
    # and `semodule -l` read without root (the same read the confinement preflight does as the
    # sandbox account); if the store is not readable unprivileged it degrades to a pointer rather
    # than misreporting. The group set + predicates come from the shared registry.
    selinux_groups_block() {
        local enforce; enforce="$(getenforce 2>/dev/null || true)"
        [[ -n "${enforce}" && "${enforce}" != "Disabled" ]] || return 0
        command -v semodule >/dev/null 2>&1 || return 0
        local groups_lib=/usr/local/lib/ai-tools/selinux-groups.lib.sh
        # shellcheck source=SCRIPTDIR/../lib/ai-tools/selinux-groups.lib.sh
        source "${groups_lib}" 2>/dev/null \
            && declare -F ai_tools_selinux_group_name >/dev/null 2>&1 || return 0

        # Read the loaded module list FIRST. If it is not readable unprivileged (common: the policy
        # store is root-only on many hosts), omit the whole section rather than print a section that
        # only says "cannot read" -- the group/dependency reporting below all needs this list, so
        # without it there is nothing accurate to show. `sudo ai-tools-admin selinux list-groups` is
        # where an operator inspects policy groups.
        local modules
        { modules="$(semodule -l 2>/dev/null)" && [[ -n "${modules}" ]]; } || return 0
        group_loaded() { grep -qxF "ai_tools_$1" <<<"${modules}"; }

        section "SELinux policy groups (${enforce})"

        if grep -qxF 'ai_tools' <<<"${modules}"; then
            say "  core module ai_tools: ${C_GRN}loaded${C_RST}"
        else
            say "  core module ai_tools: ${C_DIM}not loaded (DAC-only confinement)${C_RST}"
        fi
        local entry gname loaded_any=0
        for entry in "${AI_TOOLS_SELINUX_GROUPS[@]}"; do
            gname="$(ai_tools_selinux_group_name "${entry}")"
            if group_loaded "${gname}"; then
                printf '    %sloaded%s   %s -- %s\n' "${C_GRN}" "${C_RST}" \
                    "${gname}" "$(ai_tools_selinux_group_desc "${entry}")"
                loaded_any=1
            fi
        done
        (( loaded_any )) || say "    ${C_DIM}(no optional groups loaded)${C_RST}"
        say "    ${C_DIM}toggle with: sudo ai-tools-admin selinux enable-group <name>${C_RST}"

        # dotnet <-> tmpmap: dotnet restore/build mmaps a shared-memory file under /tmp, which
        # needs the 'tmpmap' group. Under enforcing, if dotnet is enabled but tmpmap is not loaded
        # the build fails with an opaque EACCES -- surface the exact fix here instead.
        if [[ "${enforce}" == "Enforcing" ]] \
                && grep -qxF dotnet <<<"${enabled_integrations}" \
                && ! group_loaded tmpmap; then
            say ""
            say "  ${C_YEL}dotnet is enabled but the 'tmpmap' SELinux group is not loaded:${C_RST}"
            say "  ${C_YEL}dotnet restore/build will fail under enforcing (EACCES on mmap of /tmp).${C_RST}"
            say "  fix: sudo ai-tools-admin selinux enable-group tmpmap"
        fi
        # dotnet <-> apphost: executable/host projects run their apphost/JIT code from an
        # anonymous memfd file, which needs the 'apphost' group -- disjoint from tmpmap (that
        # is /tmp mmap; this is memfd execute), so a full build-and-run workflow wants both.
        # apphost is experimental, so its fix is the source enable path, not ai-tools-admin
        # (which loads only prebuilt stable groups).
        if [[ "${enforce}" == "Enforcing" ]] \
                && grep -qxF dotnet <<<"${enabled_integrations}" \
                && ! group_loaded apphost; then
            say ""
            say "  ${C_YEL}dotnet is enabled but the 'apphost' SELinux group is not loaded:${C_RST}"
            say "  ${C_YEL}executable/host projects (dotnet run, ASP.NET Core, xunit.v3) will fail (memfd exec denied).${C_RST}"
            say "  ${C_DIM}library builds and in-process test runners (MSTest) are unaffected.${C_RST}"
            say "  fix: sudo selinux/install-selinux.sh enable-group apphost  ${C_DIM}(from a source checkout)${C_RST}"
        fi
        # dotnet <-> netcore: the runtime's diagnostic sockets/FIFOs (dotnet test, multi-node
        # MSBuild pipes) and running a binary built in the project tree. Experimental, so the fix
        # is the source enable path. See .claude/rules/dotnet.rule.md.
        if [[ "${enforce}" == "Enforcing" ]] \
                && grep -qxF dotnet <<<"${enabled_integrations}" \
                && ! group_loaded netcore; then
            say ""
            say "  ${C_YEL}dotnet is enabled but the 'netcore' SELinux group is not loaded:${C_RST}"
            say "  ${C_YEL}dotnet test can't open its diagnostic socket, multi-node MSBuild hangs, and a built${C_RST}"
            say "  ${C_YEL}binary won't run from the project tree.${C_RST}"
            say "  fix: sudo selinux/install-selinux.sh enable-group netcore  ${C_DIM}(from a source checkout)${C_RST}"
        fi
    }
    selinux_groups_block

    if [[ -s "${refusals}" ]]; then
        section "Refused inputs"
        sed 's/^/    /' "${refusals}"
        say "    a refusal always means LESS access -- the provider is skipped, never guessed."
    fi
    rm -f "${refusals}"
    say ""
    say "  ${C_DIM}providers are enabled by name in ${AI_TOOLS_OPERATOR_CONF} (root-owned, root-edited)${C_RST}"
}

# list_maintenance_note  -- the compact pointer to the existing per-project verbs, printed
# below the listing so --list doubles as a reconciliation/maintenance view.
list_maintenance_note() {
    section "Maintenance"
    say "  ai-tools --project-claim <path>     claim a project / finish claiming one"
    say "  ai-tools --project-unclaim <path>   release a project (revoke agent access)"
    say "  ai-tools --reclaim [--full] <path>  take back ownership; project stays claimed"
    say "  ai-tools --lockdown <path>          lock down secret-named files"
    say "  ai-tools --relabel                  relabel the agent entrypoints after a Node upgrade"
}

# status_fmt_age <seconds>  -- render an age the way an operator reads it ("3 days ago"), not as a
# duration to be mentally subtracted from now. Coarsens with distance: the exact minute matters for
# a run that just happened and not at all for one from last week. Empty input prints nothing, so a
# caller can drop the clause entirely when the age is unknown.
status_fmt_age() {
    local s="${1:-}"
    [[ "${s}" =~ ^[0-9]+$ ]] || return 0
    if   [[ "${s}" -lt 90      ]]; then printf 'just now'
    elif [[ "${s}" -lt 5400    ]]; then printf '%d min ago'  "$(( s / 60 ))"
    elif [[ "${s}" -lt 172800  ]]; then printf '%d hours ago' "$(( s / 3600 ))"
    else                                printf '%d days ago'  "$(( s / 86400 ))"
    fi
}

# status_sandbox_unit_commands <unit>  -- print the three commands that inspect and re-run a unit
# living in the SANDBOX account's own `systemd --user` manager. That manager is unreachable from
# the operator's session, so every one of them goes through root:
#   * status/restart use the MACHINE transport (systemctl --user -M <account>@.host), which reaches
#     that manager over the system bus where root is already authorized. A plain
#     `sudo -u <account> systemctl --user` gets that account's own bus refused even when the manager
#     is healthy (no XDG_RUNTIME_DIR) -- the same reason tests' sandbox_systemctl prefers this form.
#   * the journal query matches on the JOURNAL FIELDS instead: `journalctl --user-unit` as root
#     reads ROOT's user units, never another account's, so the unit is selected by
#     _SYSTEMD_USER_UNIT and narrowed to the sandbox account by _UID (different field names AND
#     together). This catches the unit's own output and the `systemd-cat` lines its script emits,
#     since both are logged from the same cgroup.
# Composed here rather than stored in services.lib.sh because each names the sandbox account, and
# that library is deployed with no ai-tools substitution.
status_sandbox_unit_commands() {
    local unit="$1" uid
    uid="$(id -u "${SANDBOX_USER}" 2>/dev/null || true)"
    say "      ${C_BOLD}sudo systemctl --user -M ${SANDBOX_USER}@.host status ${unit}${C_RST}"
    if [[ -n "${uid}" ]]; then
        say "      ${C_BOLD}sudo journalctl _SYSTEMD_USER_UNIT=${unit} _UID=${uid} -n 50 --no-pager${C_RST}"
    fi
    say "      ${C_BOLD}sudo systemctl --user -M ${SANDBOX_USER}@.host restart ${unit}${C_RST}"
}

# cmd_status  -- report the host's ai-tools service health: provisioning state, then each managed
# systemd unit (OK / SKIPPED / STALE / DOWN / FAILED / n/a / ?) and, for anything not plainly
# healthy, its consequence and the exact commands that inspect and fix it.
# Reuses services.lib.sh -- the SAME registry the launch-time warning reads -- so the status view and
# the launch warning never disagree. Informational (no operator gate), like --list/--providers.
cmd_status() {
    local problems=0

    section "Version"
    say "  ai-tools ${AI_TOOLS_VERSION}"
    # The agent version lives in the sandbox toolchain the operator cannot read, so it stays a
    # pointer. Node does not have to: the updater records the version it left active in its stamp,
    # so read it from whichever registry record publishes one -- no unit is named here, and a host
    # whose updater has not run yet simply keeps the pointer.
    local rec node_ver=""
    if declare -F ai_tools_service_stamp_field >/dev/null 2>&1; then
        while IFS= read -r rec; do
            node_ver="$(ai_tools_service_stamp_field "$(ai_tools_service_field "${rec}" 7)" NODE)"
            [[ -n "${node_ver}" && "${node_ver}" != unknown ]] && break
            node_ver=""
        done < <(ai_tools_service_records)
    fi
    [[ -n "${node_ver}" ]] && say "  node ${node_ver} ${C_DIM}(as of the last toolchain update)${C_RST}"
    say "  ${C_DIM}agent version: run 'claude --version'${C_RST}"

    section "Provisioning"
    # CLAUDE_LINK is bootstrap's last artifact (the gate require_bootstrap keys on), so its presence
    # means the toolchain is installed.
    if [[ -L "${CLAUDE_LINK}" ]]; then
        ok "toolchain provisioned"
    else
        say "  ${C_YEL}not provisioned${C_RST} -- run: ${C_BOLD}sudo ai-tools-bootstrap${C_RST}"
    fi

    section "Services"
    # A missing registry is a broken install, not an unknowable state, so this is one of the
    # conditions --status exits non-zero on rather than reporting a clean bill it cannot support.
    if ! declare -F ai_tools_service_records >/dev/null 2>&1 \
            || ! declare -F ai_tools_service_state_of >/dev/null 2>&1 \
            || ! declare -F ai_tools_service_stamp_field >/dev/null 2>&1; then
        warn "service registry unavailable (${SERVICES_LIB}) -- cannot report service health"
        return 1
    fi
    local unit scope stamp mode state age when exit_code reason remedy
    while IFS= read -r rec; do
        unit="$(ai_tools_service_field "${rec}" 1)"
        scope="$(ai_tools_service_field "${rec}" 2)"
        stamp="$(ai_tools_service_field "${rec}" 7)"
        mode="$(ai_tools_service_field "${rec}" 8)"
        state="$(ai_tools_service_state_of "${rec}")"
        # A stamped unit is reported from its LAST RUN, not live, so every line says WHEN -- relative
        # first, since "3 days ago" is the part an operator acts on. An unknown age prints nothing
        # rather than a placeholder.
        age=""; when=""
        if [[ -n "${stamp}" ]]; then
            age="$(status_fmt_age "$(ai_tools_service_stamp_age "${stamp}")")"
            [[ -n "${age}" ]] && when=" ${C_DIM}(last run ${age})${C_RST}"
        fi
        case "${state}" in
            # In 'fired' mode the stamp belongs to another unit; this one is only inferred from the
            # fact that a run happened at all, so the line says so rather than claiming a live check.
            active) if [[ "${mode}" == fired && -n "${age}" ]]; then
                        printf '  %-28s %sOK%s %s(inferred -- a run completed %s)%s\n' \
                            "${unit}" "${C_GRN}" "${C_RST}" "${C_DIM}" "${age}" "${C_RST}"
                    else
                        printf '  %-28s %sOK%s%s\n' "${unit}" "${C_GRN}" "${C_RST}" "${when}"
                    fi ;;
            down)   printf '  %-28s %sDOWN%s\n' "${unit}" "${C_YEL}" "${C_RST}" ;;
            # A run that correctly did nothing (the updater with an unreachable registry) is dim,
            # not yellow: yellow is this report's attention colour, and there is nothing to attend
            # to -- the previous toolchain is intact and the next run will try again. If the
            # condition persists the line turns STALE on its own once the stamp ages past its
            # grace, which is where the operator is meant to look.
            skipped) reason="$(ai_tools_service_stamp_field "${stamp}" REASON)"
                    printf '  %-28s %sSKIPPED%s %s(last run %s%s -- nothing was changed)%s\n' \
                        "${unit}" "${C_DIM}" "${C_RST}" "${C_DIM}" "${age:-at an unknown time}" \
                        "${reason:+, ${reason}}" "${C_RST}" ;;
            failed) exit_code="$(ai_tools_service_stamp_field "${stamp}" EXIT_CODE)"
                    printf '  %-28s %sFAILED%s %s(last run %s, exit %s)%s\n' "${unit}" \
                        "${C_RED}" "${C_RST}" "${C_DIM}" "${age:-at an unknown time}" \
                        "${exit_code:-?}" "${C_RST}" ;;
            stale)  printf '  %-28s %sSTALE%s %s(last run %s)%s\n' \
                        "${unit}" "${C_YEL}" "${C_RST}" "${C_DIM}" "${age:-long ago}" "${C_RST}" ;;
            absent) printf '  %-28s %sn/a (not installed)%s\n' "${unit}" "${C_DIM}" "${C_RST}" ;;
            # 'unknown' is not a problem report -- it says only that this vantage point cannot tell.
            # It stays a single line carrying the one command that CAN tell, so a healthy host's
            # report does not grow a diagnostic block per unit it simply cannot query.
            *)      if [[ "${scope}" == sandbox-user ]]; then
                        printf '  %-28s %s? (sandbox --user unit -- check: sudo systemctl --user -M %s@.host status %s)%s\n' \
                            "${unit}" "${C_DIM}" "${SANDBOX_USER}" "${unit}" "${C_RST}"
                    else
                        printf '  %-28s %s? (systemctl unavailable)%s\n' "${unit}" "${C_DIM}" "${C_RST}"
                    fi ;;
        esac
        # A unit that IS reported broken names its consequence, then every command that inspects and
        # fixes it. A sandbox-user unit's are composed here rather than stored in the registry: they
        # name the sandbox ACCOUNT, and services.lib.sh is deployed with no ai-tools pass.
        if ai_tools_service_needs_attention "${state}"; then
            problems=$(( problems + 1 ))
            say "      $(ai_tools_service_field "${rec}" 5)"
            if [[ "${scope}" == sandbox-user ]]; then
                status_sandbox_unit_commands "${unit}"
            fi
            remedy="$(ai_tools_service_field "${rec}" 6)"
            if [[ -n "${remedy}" ]]; then
                say "      ${C_BOLD}${remedy}${C_RST}"
            fi
        fi
    done < <(ai_tools_service_records)

    # Pointers, not duplication: name the sibling read-only reports (which own their own detail) and
    # where the full command list lives, so --status is a hub without re-implementing --providers or
    # --help.
    section "More"
    say "  ai-tools --providers   installed agents/integrations and which are enabled"
    say "  ai-tools --list        registered projects (real and sandbox)"
    say "  ai-tools --help        the full command list"

    # Exit non-zero when anything is actually broken, so --status is usable unattended (a cron
    # check, a monitor) without parsing this output. 'unknown' and 'n/a' are not faults and do not
    # count -- an unqueryable unit must not make a healthy host alarm every night.
    [[ "${problems}" -eq 0 ]]
}

# cmd_list  -- print each allowlist entry as project, sandbox, or exclude, with its git
# safe.directory status, then flag inconsistent hand-edited entries under "Suggested cleanup"
# with copy-paste remediation commands (the allowlist is operator-owned and hand-editable, so a
# line can name a protected system path the tools refuse to touch, a stale path that no longer
# exists, or a project listed but never fully claimed). All read-only, reusing existing predicates
# and verbs -- no recovery machinery of its own.
cmd_list() {
    [[ -f "${ALLOWLIST}" ]] || { say "no allowlist at ${ALLOWLIST}"; return 0; }
    section "Registered projects"
    local raw entry excl kind safe sd shown=0
    local -a cleanup=()

    # _is_labelled <dir>  -- 0 when SELinux is active and <dir> carries ai_tools_project_t.
    _is_labelled() {
        command -v getenforce >/dev/null 2>&1 \
            && [[ "$(getenforce 2>/dev/null)" != "Disabled" ]] || return 1
        ls -Zd "$1" 2>/dev/null | grep -q ':ai_tools_project_t:'
    }
    # _has_glob <str>  -- 0 when <str> carries a shell glob metacharacter (* ? [). Globs are
    # honored only in '!' exclusion lines (both the wrapper and ai-tools-chown match them as
    # globs); an allow line is realpath'd, so a glob there resolves to nothing and is inert.
    _has_glob() { [[ "$1" == *[*?[]* ]]; }
    # _remove_line_cmd <raw-line>  -- the copy-paste sed that deletes the VERBATIM allowlist line
    # (comment and all), so it matches what is stored even when the entry carries an end-of-line
    # comment or quotes; allow_escape makes the line a literal BRE.
    _remove_line_cmd() { printf "          sed -i '\\\\|^%s\$|d' %s" "$(allow_escape "$1")" "${ALLOWLIST}"; }
    # _reconcile <entry> <kind> <safedir-yes> <raw-line>  -- append a remediation block for an
    # inconsistent entry (stale / protected / listed-but-not-fully-claimed). Nested so it shares
    # `cleanup`; <raw-line> is the verbatim source line the removal command deletes.
    _reconcile() {
        local e="$1" k="$2" sdy="$3" raw="$4"
        if ! realpath -e "${e}" >/dev/null 2>&1; then
            cleanup+=( "  ${e}" \
                "      ${C_YEL}no longer exists${C_RST} (stale entry); remove it:" \
                "$(_remove_line_cmd "${raw}")" )
            ${sdy} && cleanup+=( "          sudo ${SAFEDIR_BIN} --remove ${e}" )
            return 0                                    # ${sdy}=false returns 1; don't kill cmd_list's set -e loop
        fi
        if ai_tools_protected_path_match "${e}" >/dev/null 2>&1; then
            cleanup+=( "  ${e}" \
                "      ${C_YEL}protected system path${C_RST} -- the tools refuse to operate on it; remove it:" \
                "$(_remove_line_cmd "${raw}")" )
            ${sdy} && cleanup+=( "          sudo ${SAFEDIR_BIN} --remove ${e}" )
            _is_labelled "${e}" && cleanup+=( "          sudo ${RELABEL_BIN} --remove ${e}" )
            return 0                                    # trailing conditionals above return 1; don't kill the loop
        fi
        [[ "${k}" == project ]] || return 0            # sandbox clones are managed by --sandbox-*
        # Not fully claimed: agent has no group access, the ACL is missing, or (SELinux active)
        # the tree is unlabelled. Read from the same project_state tokens the claim flow uses.
        local listed safedir filemode owngap acl labelled git
        IFS=' ' read -r listed safedir filemode owngap acl labelled git < <(project_state "${e}")
        if [[ "${owngap}" == true || "${acl}" == true || "${labelled}" == false ]]; then
            cleanup+=( "  ${e}" \
                "      ${C_YEL}listed but not fully claimed${C_RST}; finish claiming it:" \
                "          ai-tools --project-claim ${e}" )
        fi
    }

    while IFS= read -r raw || [[ -n "${raw}" ]]; do
        # Same shared grammar the wrapper and the chown helper read this file with; keep the
        # verbatim ${raw} line so a stale/protected remediation deletes exactly what is stored.
        ai_tools_conf_path_entry "${raw}" || continue
        entry="${_ai_tools_conf_value}"
        shown=1
        if [[ "${entry}" == '!'* ]]; then
            excl="${entry:1}"
            printf '  %-8s %s\n' "exclude" "${excl}"
            # A stale exclusion excludes nothing. Flag a non-glob '!' path that no longer exists;
            # a glob exclusion is valid as written (it need not resolve today), so leave it.
            if ! _has_glob "${excl}" && ! realpath -e "${excl}" >/dev/null 2>&1; then
                cleanup+=( "  ${entry}" \
                    "      ${C_YEL}no longer exists${C_RST} (stale exclusion); remove it:" \
                    "$(_remove_line_cmd "${raw}")" )
            fi
            continue
        fi
        # A glob in an ALLOW line is silently inert -- the wrapper realpath's allow entries, so
        # the pattern resolves to nothing and never gates a launch. Flag it rather than letting it
        # masquerade as a claimable project (globs belong on '!' lines).
        if _has_glob "${entry}"; then
            printf '  %-8s %-50s %s\n' "unusable" "${entry}" "${C_YEL}glob in allow line${C_RST}"
            cleanup+=( "  ${entry}" \
                "      ${C_YEL}glob in an allow line${C_RST} -- globs work only in '!' exclusion lines; an allow entry must be a literal directory. Remove it:" \
                "$(_remove_line_cmd "${raw}")" )
            continue
        fi
        case "${entry}/" in
            "${SANDBOX_ROOT}"/*) kind="sandbox" ;;
            *)                   kind="project" ;;
        esac
        if git config --file "${GITCONFIG}" --get-all safe.directory 2>/dev/null \
                | grep -qxF "${entry}"; then
            safe="safe.dir:yes"; sd=true
        else
            safe="safe.dir:${C_YEL}NO${C_RST}"; sd=false
        fi
        printf '  %-8s %-50s %s\n' "${kind}" "${entry}" "${safe}"
        _reconcile "${entry}" "${kind}" "${sd}" "${raw}"
    done < "${ALLOWLIST}"
    (( shown )) || say "  (none)"

    # Reverse reconciliation: a git safe.directory entry with no matching allowlist line is an
    # ORPHAN -- git still trusts the tree though nothing lists it (the allowlist line was
    # hand-deleted, or an unclaim was interrupted before the safedir drop). Removing the stale
    # safedir (and its label) is the cleanup; the entry is not a claimed project, so it is not
    # offered --project-unclaim, which would refuse an unlisted target. Control-plane entries
    # (/opt/ai-tools) are registered deliberately and are protected paths, so they are skipped.
    local sdir
    while IFS= read -r sdir; do
        [[ -n "${sdir}" ]] || continue
        ai_tools_protected_path_match "${sdir}" >/dev/null 2>&1 && continue
        ai_tools_conf_allowlist_has_entry "${ALLOWLIST}" "${sdir}" && continue
        cleanup+=( "  ${sdir}" \
            "      ${C_YEL}git safe.directory with no allowlist entry${C_RST} (orphaned); remove it:" \
            "          sudo ${SAFEDIR_BIN} --remove ${sdir}" )
        _is_labelled "${sdir}" && cleanup+=( "          sudo ${RELABEL_BIN} --remove ${sdir}" )
    done < <(git config --file "${GITCONFIG}" --get-all safe.directory 2>/dev/null || true)

    if (( ${#cleanup[@]} )); then
        section "Suggested cleanup"
        printf '%s\n' "${cleanup[@]}"
        say "  ${C_DIM}review each path before running the command; the allowlist is yours to edit${C_RST}"
    fi
    list_maintenance_note
}

# usage() is paired with the ai-tools(1) man page (src/usr/local/share/man/man1/
# ai-tools.1): tests/unit/man.sh asserts the two long-option sets match, so an option
# added, renamed, or removed here changes the man page in the same commit.
usage() {
    cat <<EOF
ai-tools -- manage Claude Code sandbox projects (run as the projects user)

  ai-tools --project-claim [-y] [path]  claim a project in place: grant the agent access (default: cwd)
  ai-tools --project-create  [path]  alias for --project-claim (back-compat)
  ai-tools --project-unclaim [path]  release a project: revoke agent access, return the tree to your group
  ai-tools --project-remove  [path]  alias for --project-unclaim (back-compat)
  ai-tools --sandbox-create [path]   shallow-clone a repo into the sandbox area
  ai-tools --sandbox-push   [path]   push the sandbox clone's commits to its branch
  ai-tools --sandbox-remove [path]   remove a sandbox clone and unregister it
  ai-tools --lockdown [path] [-n|-y] lock down secret files (sudo; default: cwd)
  ai-tools --reclaim [--full] [path] take back ownership of agent files; project stays claimed (sudo; default: cwd)
  ai-tools --relabel                 relabel the agent entrypoints after a Node upgrade (sudo)
  ai-tools --providers               list installed agents/integrations and which are enabled
  ai-tools --status                  report service health (handback socket, relabel watcher, updater)
  ai-tools --list                    list registered projects
  ai-tools --version
  ai-tools --help

  --project-claim options: -y/--yes (pre-answer the proceed prompt; the secret-lockdown,
                      .git-history, and ancestor-traversal questions still ask)
  --project-unclaim options: --group <group> (hand back to <group> without prompting),
                      --full (also cover node_modules, .venv, ...), -y/--yes (pre-answer
                      the confirm), --force (normalize a COPY of a claimed project that is
                      not registered; acts only on paths still carrying ai-tools ownership,
                      group, or ACL), -n/--dry-run (with --force: list, change nothing)
  --sandbox-create options: --from <ref> (branch/ref to fork from; default: current branch),
                      --branch <name> (full sandbox branch to create/track; default:
                      sandbox/<leaf of --from>; any valid git ref), --dir <name> (sandbox
                      directory name; default: repo basename), -y/--yes (skip the create confirm)
  --lockdown options: -n/--dry-run (preview only), -y/--yes (skip confirmation)
  --reclaim options:  --full (also reclaim node_modules, .venv, ... not just the work tree + .git)

Sandbox workflow: /var/opt/ai-tools/README.md
EOF
}

# Refuse early on an unprovisioned install. CLAUDE_LINK is bootstrap's last load-bearing
# artifact -- written after the account, Node, and the agent package all succeed -- so
# its presence means provisioning finished. Gate before dispatch so a broken install stops
# here, not mid-operation in a root helper. -L avoids dereferencing the 700 package dir the
# operator cannot traverse. See cli.rule.md (Bootstrap preflight).
require_bootstrap() {
    [[ -L "${CLAUDE_LINK}" ]] && return 0
    die "the sandbox is not provisioned (no ${CLAUDE_LINK}) -- provision it with:" \
        "       sudo ai-tools-bootstrap"
}

# When this file is SOURCED rather than executed (tests/unit/sandbox.sh loads it to exercise the
# pure sandbox_* helpers above), stop here: expose the functions, run none of the gates or
# dispatch below. On execution BASH_SOURCE[0] equals $0, so this is a no-op and the CLI proceeds.
[[ "${BASH_SOURCE[0]}" == "${0}" ]] || return 0

# --status is the one diagnostic meant to run WHEN things may be broken, so it bypasses the
# provisioning gate: cmd_status reports the unprovisioned state (and service health) itself instead
# of being blocked by it. Every other command stays gated.
[[ "${1:-}" == --status ]] || require_bootstrap

# require_operator -- refuse a command that acts as an operator unless the invoking user is
# listed in OPERATORS in operator.conf. The project/sandbox/lockdown/reclaim paths resolve the
# caller's identity from that list (operator.lib.sh, via the root helpers); an unenrolled user
# would otherwise proceed through the registry writes and confirm prompts only to be refused by
# the first root helper that resolves owner (e.g. ai-tools-lockdown says "not in allowed projects
# for current operator"), after partial state was written and rolled back -- the misleading flow
# this gate replaces with one up-front message. operator.conf is 644, so the unprivileged CLI
# reads OPERATORS directly; adding a name there takes effect on the next command (no re-login,
# unlike the ai-ops group the admin verb also grants for launching the agent).
require_operator() {
    local conf="${AI_TOOLS_OPERATOR_CONF:-/etc/ai-tools/operator.conf}"
    local -a ops=(); local op
    if ai_tools_conf_list ops "${conf}" OPERATORS 2>/dev/null; then
        for op in "${ops[@]}"; do [[ "${op}" == "${ME}" ]] && return 0; done
    fi
    die "you (${ME}) are not a configured ai-tools operator -- add your name to OPERATORS in ${conf} with:" \
        "       sudo ai-tools-admin operator add ${ME}"
}

# Gate the operator-acting commands up front; the informational ones (--help/--version/--list/
# --providers) stay open so an unenrolled user can still read usage and inspect the host.
case "${1:-}" in
    --project-claim|--project-create|--project-unclaim|--project-remove|\
    --sandbox-create|--sandbox-push|--sandbox-remove|\
    --lockdown|--reclaim|--relabel) require_operator ;;
esac

# ── Dispatch ─────────────────────────────────────────────────────────────────────
case "${1:-}" in
    --project-claim)   shift; cmd_project_claim   "$@" ;;
    --project-create)  shift; cmd_project_create  "$@" ;;
    --project-unclaim) shift; cmd_project_unclaim "$@" ;;
    --project-remove)  shift; cmd_project_unclaim "$@" ;;
    --sandbox-create) shift; cmd_sandbox_create "$@" ;;
    --sandbox-push)   shift; cmd_sandbox_push   "${1:-}" ;;
    --sandbox-remove) shift; cmd_sandbox_remove "${1:-}" ;;
    --lockdown)       shift; cmd_lockdown "$@" ;;
    --reclaim)        shift; cmd_reclaim "$@" ;;
    --relabel)        shift; cmd_relabel "$@" ;;
    --providers)      shift; cmd_providers "$@" ;;
    --status)         cmd_status ;;
    --list)           cmd_list ;;
    --version|-V)     printf 'ai-tools %s\n' "${AI_TOOLS_VERSION}" ;;
    --help|-h|"")     usage ;;
    *) printf 'ai-tools: unknown command: %s\n\n' "$1" >&2; usage >&2; exit 1 ;;
esac
