#!/usr/bin/bash
# SPDX-License-Identifier: AGPL-3.0-only
# /usr/local/bin/ai-tools
# Project-lifecycle CLI for the ai-tools sandbox. Runs AS the invoking operator (not as root, not as the sandbox
# account). It writes the operator-owned project registry (~/.config/ai-tools/project-registry) directly --
# through conf.lib.sh's allowlist-editing functions, the one implementation shared with the ai-tools-allowlist root
# helper and install.sh -- 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 (the *_BIN constants),
# over the operator's general sudo grant: the drop-in carries no NOPASSWD rule for them, so sudo prompts for a password,
# and the sandbox account has no grant. The one helper with a rule of its own is `stop`'s (STOP_BIN).
#
# A command is a bare word in the resource grammar (cli-grammar.rule.md): `projects <verb> [DIRECTORY]` for the project
# lifecycle, `status`, `providers`, `audit` and `stop` for the host, and `--` introduces an option. The command path is
# read once from the arguments, ahead of every gate, into COMMAND (the rule is at verb_in), and each gate
# and the dispatch key on that one string. The option spelling of a command (`--project-claim`) is kept
# for compatibility: it is rewritten to its path before that read and a notice names the preferred form once the message
# library is loaded (OPTION_SPELLINGS).
#
# The preflight gates run before dispatch, in this order: require_bootstrap (provisioned install);
# for the operator-acting commands (OPERATOR_VERBS), require_operator -- the invoking user must be in OPERATORS
# in operator.conf, since the root helpers resolve the caller's identity from that list; require_sudo_access,
# which refuses a verb whose root helper this caller does not hold a sudo grant for, before sudo prompts for a password
# it will then reject; require_runas_target, which refuses a --for run whose filesystem steps sudo will not run
# as the target; and require_for_target, which validates a --for run and re-points the registry at its target. A help
# request is answered after the principal guards and ahead of all of them, from help.lib.sh; `--version`,
# `projects list` and `providers list` stay open to any user.
#
# The principal guard refuses the sandbox account outright and allows root only the verbs that write no operator state
# (ROOT_ALLOWED_VERBS): the reports -- `audit` needs root by construction, since the trail it reads is 700 root:root --
# plus `stop`, whose helper requires root anyway.
#
# `--for <operator>` runs a command on behalf of another enrolled operator, whose registry the entry then lands
# in (require_for_target validates the run). `projects clone` and `projects push` are the sandbox model, a shallow clone
# under SANDBOX_ROOT pushed to a per-repo branch. The account `--for` serves, the snapshot it reads and the sandbox
# model are cli.rule.md's. The help help.lib.sh renders is the orientation (one line per command) and ai-tools(1)
# the reference for every per-command option; tests/unit/cli-verbs.sh and tests/unit/man.sh hold each to the dispatcher.
# Its domain rules are cli.rule.md (what each command does) and cli-grammar.rule.md (how it is spelled).

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

# git's repository-selection variables, and GIT_CONFIG, cleared before the first git call. The CLI runs
# in the operator's own shell, where `GIT_DIR` makes `git -C <project> rev-parse --show-toplevel` name the project while
# `config --local` writes the repository the variable names, so a claim's core.filemode pin would land there,
# and a create's `git init` would initialise that repository. GIT_CONFIG makes `config --local` refuse ("only one config
# file at a time"), so the pin would not be written. Every git call here names its repository by path (`-C`, `--file`,
# or the directory `init` and `clone` take); AI_TOOLS_GITCONFIG is this CLI's own hook and stays. The configuration
# variables -- GIT_CONFIG_GLOBAL, _SYSTEM, _NOSYSTEM, _COUNT with its KEY/VALUE pairs, _PARAMETERS -- pass through: they
# do not select a repository, and `projects clone` and `projects push` run with the credential helper or URL rewrite
# they set.
unset GIT_DIR GIT_WORK_TREE GIT_COMMON_DIR GIT_INDEX_FILE GIT_OBJECT_DIRECTORY GIT_ALTERNATE_OBJECT_DIRECTORIES \
    GIT_NAMESPACE GIT_CEILING_DIRECTORIES GIT_DISCOVERY_ACROSS_FILESYSTEM GIT_CONFIG

readonly SANDBOX_USER="ai-tools"
readonly SANDBOX_GROUP="ai-tools"
# The file this run executes, resolved once: the claim re-executes it as the project's owner for the owner-side read
# (`projects inspect`), so the owner runs the file the invoker is running. Empty where it does not resolve, and the read
# then fails as one the claim could not make.
SELF_PATH="$(realpath -e -- "${BASH_SOURCE[0]}" 2>/dev/null)" || SELF_PATH=""
readonly SELF_PATH
# Substituted at deploy time (install.sh from packaging/VERSION; the RPM from %{version}); a raw source-tree run reports
# "dev".
AI_TOOLS_VERSION="0.29.0-1.el10"
[[ "${AI_TOOLS_VERSION}" == @*@ ]] && AI_TOOLS_VERSION="dev"
readonly AI_TOOLS_VERSION
# AI_TOOLS_GITCONFIG / AI_TOOLS_ALLOWLIST / AI_TOOLS_SANDBOX_ROOT: test hooks of the family the root helpers carry
# root-only (see tests.rule.md). Here they are operator-settable, since the CLI runs as the operator and not
# through sudo -- and that operator owns the two files and every clone anyway, so an override does not add reach they
# could not already have by editing the files directly or by claiming a clone made elsewhere. sudo strips all three
# (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 before any of them is read. The clone-area override moves where a clone is made
# and which paths read as the sandbox kind; the destructive clone removal stays scoped to a direct child of whatever
# directory that is, and the protected-paths backstop still refuses a system directory there.
readonly GITCONFIG="${AI_TOOLS_GITCONFIG:-/opt/ai-tools/.gitconfig}"
readonly SANDBOX_ROOT="${AI_TOOLS_SANDBOX_ROOT:-/var/opt/ai-tools/sandbox-projects}"
# Where bootstrap writes each enabled agent's stable launcher symlink, its last load-bearing artifact per agent:
# the require_bootstrap gate and `status` key on those links, and each launch wrapper resolves its own.
# AI_TOOLS_LAUNCHER_DIR is the hook relabel.lib.sh reads for the same directory, operator-settable here like
# AI_TOOLS_GITCONFIG, AI_TOOLS_ALLOWLIST and AI_TOOLS_SANDBOX_ROOT: what it moves is a report and an early refusal, no
# access decision reads it, and the sandbox account is refused before it is read.
readonly LAUNCHER_DIR="${AI_TOOLS_LAUNCHER_DIR:-/opt/ai-tools/bin}"
# Root-only secret lockdown helper.
readonly LOCKDOWN_BIN="/usr/local/libexec/ai-tools/ai-tools-lockdown"
# Root-only SELinux project-label helper: 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. 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: applies the project's group-permission ACL (default + access group:SANDBOX_GROUP:rwx
# under a mask of rw- on a file without an owner execute bit, other denied) so files the projects user's git
# checkout/merge writes under a restrictive umask stay group-accessible to the agent. CAP_FOWNER lets it ACL files
# the projects user does not own.
readonly SETFACL_BIN="/usr/local/libexec/ai-tools/ai-tools-setfacl"
# Root-only setgid helper: 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: reverses the filesystem side of a claim -- clears the agent ACL + default ACL, regroups
# the tree to a target group, and removes group write. Root is what a chgrp to an arbitrary group, and a change
# to a file the projects user does not own, need.
readonly UNCLAIM_BIN="/usr/local/libexec/ai-tools/ai-tools-unclaim"
# Root-only git safe.directory helper. /opt/ai-tools/.gitconfig is root-owned 644: world-readable (the agent reads
# safe.directory on startup) and root-write-only, so neither the operator nor the agent writes it directly --
# the operator reaches the validated add and `--remove` through this helper.
readonly SAFEDIR_BIN="/usr/local/libexec/ai-tools/ai-tools-safedir"
# Root-only ownership-reclaim helper: 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"
# Root-only cross-operator allowlist helper: reads and edits ANOTHER enrolled operator's project registry for a `--for`
# run; root is needed for the READ too, since an allowlist is 0600 inside a 0700 .config/ai-tools. Only a `--for` run
# reaches it -- without the flag the CLI writes the invoker's own registry directly.
readonly ALLOWLIST_BIN="/usr/local/libexec/ai-tools/ai-tools-allowlist"

# Reader for the refusal/rejection trails (`audit`). Root-only, since the trail it reads is 700 root:root.
readonly AUDIT_BIN="/usr/local/libexec/ai-tools/ai-tools-audit"
# Session-stop helper (`stop`). Root-only, since a session is a transient unit in the sandbox account's own
# `systemd --user` manager, which no operator can reach. The one helper with a %ai-ops NOPASSWD rule of its own, pinned
# to the zero-argument form by the drop-in's trailing "", so the bare command runs without a prompt and a flagged form
# meets sudo's ordinary prompt. What it accepts, and why so little: cmd_stop.
readonly STOP_BIN="/usr/local/libexec/ai-tools/ai-tools-stop"
# 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"

# ── Verb sets ────────────────────────────────────────────────────────────────────
# Each table names a set of commands by their command path -- the string COMMAND holds, `projects claim` -- and each is
# named ONCE here, so a command added to a set cannot be added to one of its readers and missed by another.
#
# ROOT_ALLOWED_VERBS -- what root may run. The criterion is WRITES NO OPERATOR-OWNED STATE, which is what the root guard
# exists to protect: a registry written by root names an owner whose own launch gate cannot read it. A verb qualifies
# on what it writes, whatever it reads, so `stop` belongs here despite being the one member that ACTS: it does not write
# a registry, and root is the identity an unattended detector usually runs as -- the caller this rung most has to serve.
# Allowing it does not add a capability either, since root can already run ai-tools-stop directly and can signal any
# process on the host; what it removes is a CLI that refused the one principal its own helper requires. Read
# by the principal guard, by that guard's own refusal (which lists them), and by ai-tools(1).
readonly ROOT_ALLOWED_VERBS=("audit" "status" "projects list" "providers list" "stop")
# BOOTSTRAP_EXEMPT_VERBS -- what runs on an unprovisioned host. Deliberately NOT ROOT_ALLOWED_VERBS: each of these is
# meant for a host that may be broken (`status` reports the unprovisioned state itself; `audit` reads a historical
# trail, which an install that never finished does not invalidate; `stop` ends sessions already running, and does not
# read toolchain state to do it -- the gate keys on the enabled agents' launcher symlinks, so leaving `stop` behind it
# would put the incident ladder's last rung out of reach on a host that lost them while sessions were running).
# `--help`, `--version` and the bare invocation describe the CLI rather than the toolchain -- the help
# and AI_TOOLS_VERSION read no installed state -- and gating them leaves a caller who cannot print the help with only
# the gate's own message to find the provisioning command by. A help request is answered ahead of the gate in any case.
# `projects list` and `providers list` describe a toolchain that has to exist first and stay behind the gate.
readonly BOOTSTRAP_EXEMPT_VERBS=("status" "audit" "stop" "--help" "--version")
# OPERATOR_VERBS -- what only an enrolled operator may run. The criterion is ACTS AS AN OPERATOR: the verb resolves
# the caller's identity out of OPERATORS somewhere in its call chain (the root helpers do, via operator.lib.sh),
# so an unenrolled caller would otherwise get through the registry writes and the confirm prompts only to be refused
# by the first helper that resolves an owner. Its complement is the informational set -- `--help`, `--version`,
# `projects list`, `providers list`, `status`, `audit` and `stop` -- which stays open so an unenrolled user can still
# read usage and inspect the host. `projects inspect` is the claim's owner-side read of a tree, run as the account whose
# tree it is.
readonly OPERATOR_VERBS=("projects create" "projects claim" "projects unclaim" "projects remove"
                         "projects enable" "projects disable" "projects clone" "projects push"
                         "projects lockdown" "projects handback" "projects inspect")
# FOR_ALLOWED_VERBS -- what --for accepts: the verbs whose whole effect is decided by WHICH operator's registry covers
# the path. Elsewhere the flag is REFUSED rather than ignored (see
# require_for_target).
readonly FOR_ALLOWED_VERBS=("projects claim" "projects create" "projects unclaim" "projects remove"
                            "projects enable" "projects disable" "projects lockdown" "projects handback"
                            "projects list")
# COLLECTIONS -- the plural nouns a verb follows. A bare one is its `list`, the grammar's zero-argument default.
readonly COLLECTIONS=(projects providers)
# OPTION_SPELLINGS -- the option spelling the CLI still accepts for each command, and the two short options beside them,
# each mapped to what it runs: a command path, or for `-g` the long option it stands for. Every key is kept
# for compatibility, since the typed command surface is the one interface an operator's own scripts bind
# to, and the collection form is the preferred one: rewrite_option_spelling applies the table ahead of every gate,
# so every verb table and the dispatch see the command path alone and no key sits in a dispatch arm or in the help,
# and note_option_spellings names the preferred form. One row per line: tools/generators/option-spellings.sh reads
# the rows by text to generate docs/option-spellings.md, and tests/unit/cli-verbs.sh holds every value to a dispatched
# path.
declare -rA OPTION_SPELLINGS=(
    [--list]="projects list"
    [--project-create]="projects create"
    [--project-claim]="projects claim"
    [--project-unclaim]="projects unclaim"
    [--project-remove]="projects remove"
    [--project-enable]="projects enable"
    [--project-disable]="projects disable"
    [--sandbox-create]="projects clone"
    [--sandbox-push]="projects push"
    [--sandbox-remove]="projects remove"
    [--lockdown]="projects lockdown"
    [--reclaim]="projects handback"
    [--providers]="providers list"
    [--status]="status"
    [--audit]="audit"
    [--stop]="stop"
    [-V]="--version"
    [-g]="--group"
)

# verb_in <command> <path>... -- true when <command> is one of the named command paths.
#
# A command path is the leading argument tokens that spell a command, as one space-joined string: a token matching
# ^[a-z][a-z-]*$ is a command word; the first one is the path, and where it names a COLLECTION the next command word is
# its verb, so the path is at most two tokens and ends at the first option or path argument. A bare collection is its
# `list`, no tokens at all is `--help`, and `-h` and the bare word `help` are `--help`. The options answered
# as commands, `--help` and `--version`, are paths of their own. read_command applies the rule once, to COMMAND,
# and every verb table is keyed on it, the shape tests/unit/man.sh reads for ai-tools-admin(8).
verb_in() {
    local verb="$1"; shift
    local name; for name in "$@"; do [[ "${verb}" == "${name}" ]] && return 0; done
    return 1
}
# join_words <word>... -- the words joined by single spaces, for a message. Pins IFS locally: the CLI runs
# under IFS=$'\n\t', so a bare "${array[*]}" would join on a NEWLINE.
join_words() { local IFS=' '; printf '%s' "$*"; }
# join_paths <path>... -- the command paths joined by a comma and a space, for a message that lists a table: a path is
# itself two words, so a space-joined list would run them together.
join_paths() { local IFS=','; printf '%s' "${*// /_}" | sed 's/,/, /g; s/_/ /g'; }

# read_command <arg>... -- set COMMAND to the command path "$@" spells and COMMAND_TOKENS to the number of leading
# arguments it took, by verb_in's rule. An argument that is neither a command word nor a recognised option -- a path,
# or an option no command answers -- is taken whole as the path, so the dispatch refuses it as an unknown command rather
# than reading the argument after it.
COMMAND=""; COMMAND_TOKENS=0
read_command() {
    case "${1:-}" in
        "")             COMMAND="--help" ;;
        --help|-h|help) COMMAND="--help";    COMMAND_TOKENS=1 ;;
        --version)      COMMAND="--version"; COMMAND_TOKENS=1 ;;
        *)
            COMMAND="$1"; COMMAND_TOKENS=1
            if [[ "$1" =~ ^[a-z][a-z-]*$ ]] && verb_in "$1" "${COLLECTIONS[@]}"; then
                if [[ "${2:-}" =~ ^[a-z][a-z-]*$ ]]; then
                    COMMAND="$1 $2"; COMMAND_TOKENS=2
                else
                    COMMAND="$1 list"
                fi
            fi ;;
    esac
}

# refuse_early <code> <line>...  -- the refusals that fire before msg.lib.sh is sourced: the principal guards
# and --for's argument check, which answer ahead of every library load. They cannot reach die(), so this renders
# what plain mode renders -- the code on its own leading line, then each caller line whole -- and exits 1. The matcher
# is the library's own anchored form (tests/unit/msg.sh holds every inline copy to it).
refuse_early() {
    local code=""
    if [[ "${1-}" =~ ^MSG-[A-Z][0-9][A-Z][0-9]$ ]]; then code="$1"; shift; printf '%s\n' "${code}" >&2; fi
    printf '%s\n' "$@" >&2
    exit 1
}

# ── Invoker guards ───────────────────────────────────────────────────────────────
# This is a user tool. It must run as the projects user, and never as the sandbox account -- the agent must not manage
# its own allowlist. That refusal is unconditional and first: no verb, and no argument, makes the agent a legitimate
# caller.
#
# Root is refused for every verb that WRITES (it would write the operator registries owned by root, where the operator's
# own launch gate cannot read them) and allowed the verbs that write no operator-owned state (ROOT_ALLOWED_VERBS).
# That split is decided once the verb is known -- see "Root and the read-only reports".
INVOKING_USER="$(id -un)"
[[ "${INVOKING_USER}" == "${SANDBOX_USER}" ]] \
    && refuse_early MSG-Q6Q8 "ai-tools: refusing to run as the sandbox account ${SANDBOX_USER}"

HOME_DIR="$(getent passwd "${INVOKING_USER}" | cut -d: -f6)"
[[ -d "${HOME_DIR}" ]] || { echo "ai-tools: cannot resolve home for ${INVOKING_USER}" >&2; exit 1; }
readonly INVOKING_USER HOME_DIR

# ── --for <operator>: act on another enrolled operator's project registry ────────
# A service account that runs an agent has no password, so it cannot authenticate the claim's own root helpers --
# and a project claimed by a human lands in the HUMAN's registry, which is not the one that account's launch gate reads.
# --for closes both: a human operator performs the claim ON BEHALF OF the target, whose allowlist then covers the path,
# so ai-tools-setfacl grants user:<target>, the handback restores to <target>, and that account's own launch finds
# the project already claimed and never reaches a password prompt.
#
# The flag is separated from the command's own arguments HERE, before the registry path is resolved and before dispatch,
# so every command reads one already-decided owner instead of each parsing the flag itself. Validation (is the target
# enrolled, does this verb accept --for) needs conf.lib.sh and runs at the dispatch gate.
FOR_OPERATOR=""
_forless_args=()
while (( $# )); do
    case "$1" in
        # `--` is the delimiter and never a value, so it stays among the arguments, where the help scan stops at it.
        --for)   if [[ "${2-}" == -- ]]; then FOR_OPERATOR=""; shift
                 else FOR_OPERATOR="${2-}"; shift $(( $# > 1 ? 2 : 1 )); fi ;;
        --for=*) FOR_OPERATOR="${1#--for=}"; shift ;;
        *)       _forless_args+=("$1"); shift; continue ;;
    esac
    # A help flag in the value slot is a help request (the help contract counts every position), so it stays
    # among the arguments for the help resolution and the run acts for no one.
    if [[ "${FOR_OPERATOR}" == -h || "${FOR_OPERATOR}" == --help ]]; then
        _forless_args+=("${FOR_OPERATOR}"); FOR_OPERATOR=""; continue
    fi
    # Both spellings are the same situation -- the flag names the operator the run acts for, and neither an empty value
    # nor another option in its place is a name -- so they share one refusal, recorded here and made once the split
    # ends.
    [[ -n "${FOR_OPERATOR}" && "${FOR_OPERATOR}" != -* ]] || { _for_value_refused=1; FOR_OPERATOR=""; }
done
# The refusal steps aside for a help request, a help flag ahead of the first `--`, which the help resolution answers
# and which acts for no one.
if [[ -n "${_for_value_refused:-}" ]]; then
    for _forless_arg in "${_forless_args[@]+"${_forless_args[@]}"}"; do
        [[ "${_forless_arg}" != -- ]] || break
        [[ "${_forless_arg}" != -h && "${_forless_arg}" != --help ]] || _for_value_refused=""
    done
    [[ -z "${_for_value_refused}" ]] || refuse_early MSG-B4G2 "ai-tools: --for needs an operator name"
fi
set -- "${_forless_args[@]}"
unset _forless_args _forless_arg _for_value_refused

# ── A verb that moved to ai-tools-admin ──────────────────────────────────────────
# --relabel is spelled correctly, worked in an earlier release, and is printed as the remedy in release notes that stay
# published, so the unknown-command line a stranger gets would read as a typo. It is a pointer and not an alias:
# the command reconciles a root-owned pin and a file context, so it runs as root, which this CLI refuses.
#
# It answers after the sandbox-account refusal and the `--for` value check, and ahead of the bootstrap gate and the root
# guard, on purpose. The bootstrap gate would otherwise send an unprovisioned host to the provisioning command,
# and the root guard would answer `sudo ai-tools --relabel` -- the spelling the older docs printed -- with a list
# of the verbs root may run, none of which reconciles an entrypoint. Exit 2 is the documented code for a rejected
# command line (ai-tools(1)).
if [[ "${1:-}" == "--relabel" ]]; then
    echo "ai-tools: --relabel is a root command:" >&2
    echo "              atr admin system entrypoints relabel" >&2
    exit 2
fi

# ── Option spellings ─────────────────────────────────────────────────────────────
# rewrite_option_spelling <arg>... -- set REWRITTEN_ARGS to the arguments with each OPTION_SPELLINGS key replaced by its
# value: the leading argument where it is a key, since an option-spelled command leads the command line, and `-g`
# wherever it stands, since it is an option of `projects unclaim`. Each rewrite is appended to OPTION_SPELLINGS_USED
# as the token typed, a tab, and the preferred form -- `ai-tools` and the command path, or the long option alone --
# for note_option_spellings, which prints once msg.lib.sh is loaded. It runs here, after `--for` is separated
# out and ahead of read_command, so every gate and the dispatch read the command path alone. `--relabel` is left
# out of the table: it names a root command this CLI refuses to run, and its pointer answers it ahead of this rewrite.
OPTION_SPELLINGS_USED=()
REWRITTEN_ARGS=()
rewrite_option_spelling() {
    local argument
    REWRITTEN_ARGS=()
    # An empty subscript is a bash error, so the leading argument is tested before it is looked up.
    if [[ -n "${1:-}" && "$1" != -g && -n "${OPTION_SPELLINGS[$1]+set}" ]]; then
        OPTION_SPELLINGS_USED+=("$1"$'\t'"atr ${OPTION_SPELLINGS[$1]}")
        # The value is a space-joined command path; this CLI's IFS has no space, so the split pins its own.
        local IFS=' '
        read -ra REWRITTEN_ARGS <<<"${OPTION_SPELLINGS[$1]}"
        shift
    fi
    for argument in "$@"; do
        if [[ "${argument}" == -g ]]; then
            OPTION_SPELLINGS_USED+=("-g"$'\t'"${OPTION_SPELLINGS[-g]}")
            argument="${OPTION_SPELLINGS[-g]}"
        fi
        REWRITTEN_ARGS+=("${argument}")
    done
}
rewrite_option_spelling "$@"
set -- "${REWRITTEN_ARGS[@]}"
unset REWRITTEN_ARGS

# ── The command path ─────────────────────────────────────────────────────────────
# Read once, after `--for` is separated out and the option spellings are rewritten, before the first gate, so every gate
# and the dispatch key on one string. The arguments left are the command's own.
read_command "$@"
readonly COMMAND COMMAND_TOKENS
# The command line as read, command tokens included, for the help resolution after the sourced-guard: a help request
# names its target by the command words the shift that follows drops.
readonly -a COMMAND_LINE=("$@")
set -- "${@:COMMAND_TOKENS+1}"

# ── Root and the verbs that write no operator state ──────────────────────────────
# Root may run the verbs that write no registry -- the four reports, plus `stop` -- and no other. `audit` is
# why the carve-out exists: the trail it reads is 700 root:root, so the verb needs root by construction, and a blanket
# refusal left it unreachable from BOTH sides on a host whose only operator does not hold a general sudo grant. `stop`
# is here for the mirror of that reason: its helper requires root, and an incident response running as root should reach
# the rung through the same command an operator uses. The mutating verbs keep refusing root for the reason this guard
# has always existed -- they would write the operator registries owned by root, where that operator's own launch gate
# cannot read them.
#
# The check runs HERE, after --for is separated out, for two reasons. Before that point the command is not reliably
# the first argument (`atr --for op projects` leads with the flag). And running it here refuses --for for root in EITHER
# argument order: root is not in OPERATORS, so a --for run performed by root would write an entry that names an owner no
# ownership helper can resolve. require_operator does not cover that on its own -- it gates the mutating verbs,
# and `projects list` is not one of them.
#
# refuse_early, not die(): this runs before msg.lib.sh is sourced, like the sandbox refusal.
root_may_run() {
    [[ -z "${FOR_OPERATOR}" ]] || return 1
    verb_in "${COMMAND}" "${ROOT_ALLOWED_VERBS[@]}"
}
if [[ "${INVOKING_USER}" == "root" ]] && ! root_may_run; then
    refuse_early MSG-H6W7 "ai-tools: do not run as root -- run as the projects user, without sudo" \
        "          (the CLI invokes sudo itself for the steps that need it)" \
        "          as root you can run the verbs that write no operator state: $(join_paths "${ROOT_ALLOWED_VERBS[@]}")"
fi

# The operator this run acts FOR: the --for target, or the invoker. Every message that names the owner a file ends
# up with, and every scan that matches on that owner, reads these rather than INVOKING_USER -- on a --for run the tree
# belongs to the target, so naming the invoker would misreport who ends up holding the files. What a root helper's walk
# treats as "the operator" is still resolved per path from the path's own allowlist coverage, never from either
# of these.
OWNER_USER="${FOR_OPERATOR:-${INVOKING_USER}}"
# Without --for the owner is the invoker, whose group always resolves. With --for the group is resolved
# by require_for_target only AFTER the target is confirmed enrolled: a name that is neither an operator nor a user
# on this host has to be refused with the actionable "not a configured ai-tools operator -- enrol it with ..." message,
# not with a getent failure that names the wrong problem.
OWNER_GROUP=""
if [[ -z "${FOR_OPERATOR}" ]]; then
    OWNER_GROUP="$(id -gn "${OWNER_USER}" 2>/dev/null)" \
        || { echo "ai-tools: cannot resolve the primary group of ${OWNER_USER}" >&2; exit 1; }
fi
readonly FOR_OPERATOR OWNER_USER

# The operator this run acts FOR rides as per-run log context (logging.rule.md). journald stamps the invoking uid
# itself, so the field adds the `--for` case, where the operator whose registries and tree a command edits is not
# the one who ran it.
AI_TOOLS_LOG_OPERATOR="${OWNER_USER}"

# The registry this run reads and writes, ALLOWLIST, and its state, REGISTRY_STATE (`current`, `legacy` or `none`), are
# set once conf.lib.sh is loaded: ai_tools_conf__resolve_registry decides which of the operator's two names is the file.
# With `--for`, load_target_registry_snapshot points ALLOWLIST at a root-side SNAPSHOT of the target's file: a registry
# is 0600 inside a 0700 .config/ai-tools, so one operator cannot read another's at all, and every decision made from it
# would otherwise read an unreadable file as an empty one. 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.
# An operator-settable test hook here -- see the GITCONFIG note for why the override grants the CLI's operator caller no
# new capability.
ALLOWLIST=""
REGISTRY_STATE=none

# ── 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

# Each takes ONE line and prints it. "$1", not "$*": this CLI runs under IFS=$'\n\t', so "$*" would join a second
# argument on a NEWLINE rather than a space -- a silently mis-rendered message for a caller that reasonably expects
# printf-style words.
say()     { printf '%s\n' "$1"; }
section() { printf '\n%s%s%s\n' "${C_BOLD}" "$1" "${C_RST}"; }
ok()      { printf '  %s✓%s %s\n' "${C_GRN}" "${C_RST}" "$1"; }
# emit_alert <emitter> [code] <line...>  -- render <line...> through the msg.lib emitter named, except that a line
# opening with whitespace is printed plain on stderr after the frame, verbatim and in order. The emitter wraps every
# line on whitespace, and a command carries a %q-escaped space a wrap would split, so a message puts each command
# on such a line of its own, after its flush-left lines; ai_tools_msg__block keeps the same line verbatim inside its
# frame, so the two renderers read indentation one way. An argument carrying newlines is split into its lines. In plain
# mode the output is every line in the order given, a message whose indented lines come last unchanged by the split.
emit_alert() {
    local emitter="$1" code="" argument line
    shift
    if ai_tools_msg__is_code "${1-}"; then code="$1"; shift; fi
    local -a framed=() unframed=()
    for argument in "$@"; do
        while IFS= read -r line; do
            if [[ "${line}" == [[:space:]]* ]]; then unframed+=("${line}"); else framed+=("${line}"); fi
        done <<< "${argument}"
    done
    if (( ${#framed[@]} == 0 )); then framed=("${unframed[@]}"); unframed=(); fi
    "${emitter}" ${code:+"${code}"} "${framed[@]}"
    (( ${#unframed[@]} == 0 )) || printf '%s\n' "${unframed[@]}" >&2 || true
}
warn()    { emit_alert ai_tools_msg__warn "$@"; }
note()    { ai_tools_msg__notice "$@"; }
# die takes the library's optional leading code and carries it into the log line -- as the leading token of the text
# and as the AI_TOOLS_MSG field, which ai_tools_log__coded writes
# (logging.rule.md).
# The code is split off so the "ai-tools: " prefix lands on the message rather than on the code.
die() {
    local code=""
    if ai_tools_msg__is_code "${1-}"; then code="$1"; shift; fi
    ai_tools_log__coded error "${code}" "$*"
    emit_alert ai_tools_msg__error ${code:+"${code}"} "ai-tools: $*"
    exit 1
}
# die_usage is die for a command line the verb refuses, and exits 2 -- the usage code ai-tools(1) states.
die_usage() {
    local code=""
    if ai_tools_msg__is_code "${1-}"; then code="$1"; shift; fi
    ai_tools_log__coded error "${code}" "$*"
    emit_alert ai_tools_msg__error ${code:+"${code}"} "ai-tools: $*"
    exit 2
}
# 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 under 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__write() { :; }; ai_tools_log__debug() { :; }; ai_tools_log__info() { :; }
    ai_tools_log__warn() { :; }; ai_tools_log__error() { :; }
    ai_tools_log__structured() { :; }; ai_tools_log__coded() { :; }
    # Not a logger but the display sanitizer the drift records print paths through, so it keeps working, byte for byte
    # the library's: printable ASCII kept, every other byte replaced.
    ai_tools_log__sanitize() { local LC_ALL=C; printf '%s' "${1//[^[:print:]]/?}"; }
fi

# Shared message formatter -- die()/warn() 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: 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

# note_option_spellings -- one notice per token rewrite_option_spelling recorded, ahead of every gate and the dispatch,
# so a refusal that follows still names the spelling that led to it. The command then runs with its exit status
# unchanged: the spelling is kept, and the notice says which form is preferred. The preferred form stands on a line
# of its own, since the alert wraps its text and a command must not break across lines (messaging.rule.md).
note_option_spellings() {
    local used
    for used in "${OPTION_SPELLINGS_USED[@]}"; do
        note MSG-W3W8 "option spelling ${used%%$'\t'*} is kept for compatibility -- the preferred form is:" \
            "${used#*$'\t'}"
    done
}
note_option_spellings

# Protected-paths backstop (safe-paths.lib.sh): refuse to claim a system directory, and vet ancestors
# for the reachability grant (confirm_ancestor_traversal -> 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_safe_paths__assert_safe_target  >/dev/null 2>&1 \
        || ! declare -F ai_tools_safe_paths__match_protected_path >/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 the project registry with (ai_tools_conf__parse_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__parse_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

if [[ -n "${AI_TOOLS_ALLOWLIST:-}" ]]; then
    ALLOWLIST="${AI_TOOLS_ALLOWLIST}"
    REGISTRY_STATE="$(ai_tools_conf__read_registry_state "${ALLOWLIST}")"
else
    IFS=$'\t' read -r REGISTRY_STATE ALLOWLIST \
        < <(ai_tools_conf__resolve_registry "${HOME_DIR}/.config/ai-tools")
fi
# REGISTRY_DISPLAY_PATH -- the registry as a message names it; ALLOWLIST is the file the run reads. The two differ on
# a `--for` run, whose reads go to a snapshot the EXIT trap removes while every message names the target's own file
# (refresh_target_allowlist_snapshot sets both).
REGISTRY_DISPLAY_PATH="${ALLOWLIST}"

# 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 every path as walkable -- 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_dirs__build_find_expression() { AI_TOOLS_SKIP_DIRS__NAMES=(); AI_TOOLS_SKIP_DIRS__FIND_EXPRESSION=(); return 0; }

# Unreadable ancestor configuration (ancestor-config.lib.sh): the claim reports the configuration files in a project's
# ancestry that ai_tools_ancestor_config__can_session_read refuses, so a build that would fail on one says so here
# rather than from inside a session. Best-effort: the report is advisory and does not change any file, so a missing lib
# costs the notice and leaves every claim step as it was. It is sourced AFTER safe-paths, whose backstop bounds its
# walk.
readonly ANCESTOR_CONFIG_LIB="/usr/local/lib/ai-tools/ancestor-config.lib.sh"
# shellcheck source=SCRIPTDIR/../lib/ai-tools/ancestor-config.lib.sh
source "${ANCESTOR_CONFIG_LIB}" 2>/dev/null || true


# Service-health registry (services.lib.sh): the single source `atr status`, the launch wrapper's pre-launch health
# warning and `atr admin status` share, so no two of them disagree on which units matter, what one is doing,
# or how to fix it. 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
# The exit statuses `status` ends with and the fold that computes one (records-base.lib.sh, ai-tools-records(5)). Loaded
# inside cmd_status, the one command that reads it, and required there: a report whose exit contract did not load
# refuses, so it does not exit 0 over a host it did not read. Loading it here would make a broken install refuse `stop`,
# which must reach the incident ladder's last rung on exactly such a host.
readonly RECORDS_BASE_LIB="/usr/local/lib/ai-tools/records-base.lib.sh"
# The claim's outcome records (records-tsv.lib.sh) and the per-path checks it collects drift and verifies repairs
# with (project-permissions.lib.sh). Loaded by claim_load_libraries, for the same reason records-base is loaded late.
readonly RECORDS_TSV_LIB="/usr/local/lib/ai-tools/records-tsv.lib.sh"
readonly PROJECT_PERMISSIONS_LIB="/usr/local/lib/ai-tools/project-permissions.lib.sh"
# The secret-name classifier ai-tools-lockdown matches with (secret-patterns.lib.sh), which marks a secret-named path
# in the claim's drift lists. Loaded by claim_load_libraries and not required: the mark is advisory, and the secret gate
# makes the decision whether or not it loaded.
readonly SECRET_PATTERNS_LIB="/usr/local/lib/ai-tools/secret-patterns.lib.sh"
# The toolchain readers `status` makes from the operator's vantage (toolchain.lib.sh): a disabled agent's remaining
# launcher link, and the Node version the enabled agents' links name. Loaded by the sections that read it,
# through toolchain_lib_loaded, since only `status` reads it.
readonly TOOLCHAIN_LIB="/usr/local/lib/ai-tools/toolchain.lib.sh"
# The cache cleanup state and its per-group verdicts (cache-cleanup.lib.sh), read by status_caches alone, which loads
# it. AI_TOOLS_CACHE_CLEANUP_STATE moves the file that section reads, so tests/unit/cli-agent-set.sh drives it
# over a fixture: it moves a report and no access decision.
readonly CACHE_CLEANUP_LIB="/usr/local/lib/ai-tools/cache-cleanup.lib.sh"
readonly CONFINEMENT_LIB="/usr/local/lib/ai-tools/confinement.lib.sh"
# The account whose `systemd --user` units the registry may read live. Naming it does not by itself enable the probe:
# _ai_tools_services__run_systemctl still requires root and a working machine transport, and refuses this CLI run
# as an operator. So an operator's report is unchanged, while `sudo atr status` completes the reads that need root --
# one resource, degrading by privilege, which is what keeps this report and `atr admin status` from being two
# answers.
if declare -F ai_tools_services__set_sandbox_account >/dev/null 2>&1; then
    ai_tools_services__set_sandbox_account "${SANDBOX_USER}"
fi

# ── Reaching a root helper ───────────────────────────────────────────────────────
# Most verbs do work only root can do, through a helper in /usr/local/libexec/ai-tools (750 root:root -- the operator
# cannot even stat one). Two facts about the caller decide HOW, and WHETHER, that helper is reached; both are answered
# here rather than at each call site.
#
# ALREADY ROOT -- run the helper directly, with no sudo in between. Root reaches only ROOT_ALLOWED_VERBS (the principal
# guard), and of those `audit` and `stop` reach a helper through here. The condition lives here rather than inside each
# command so a verb added to that set later inherits it.
#
# NO SUDO GRANT -- refuse before sudo prompts. Every helper outside the %ai-ops NOPASSWD rules (the shipped sudoers
# drop-in holds their list) is reached by a plain sudo, which assumes the operator ALSO holds a general grant.
# An ai-ops-only account does not -- and sudo authenticates BEFORE it refuses, so such an operator is asked
# for a password and turned away after supplying it, for a decision that was knowable without asking.
# require_sudo_access answers it up front instead, and probes with -n so the probe itself never prompts.
#
# THE PROBE IS NOT A SECURITY GATE and is deliberately fail-OPEN, against the project's usual direction. sudo remains
# the thing that decides; this only replaces a refusal that was going to happen anyway with one that says what to do
# instead. So an inconclusive probe falls through to the call site and lets sudo answer, because the failure it would
# otherwise cause is the serious one: refusing an operator who does hold a grant, on the strength of a message we did
# not parse.

# run_root_helper <bin> [args...] -- run a root helper, directly when the caller is already root and through sudo
# otherwise. The helper's exit status propagates either way (`audit` and `stop` both publish theirs as their own
# contract).
run_root_helper() {
    if [[ "${INVOKING_USER}" == "root" ]]; then "$@"; else sudo "$@"; fi
}

# root_helper_reachable -- false only when no root helper can be reached at all: not root, and no sudo binary. Call
# sites that fall back to "run as root: <helper>" gate on this rather than on a bare `command -v sudo`, which reads
# as missing to root as well.
root_helper_reachable() { [[ "${INVOKING_USER}" == "root" ]] || command -v sudo >/dev/null 2>&1; }

# sudo_grant_missing <bin> -- true only when sudo will refuse <bin> for this caller OUTRIGHT, without a password ever
# being able to help.
#
# `sudo -n -l <bin>` asks sudo the question directly and, with -n, cannot prompt. Four answers, and the second is
# the one this reads:
#
#   exit 0                             the rule exists; sudo echoes the command it would run. This is
#                                      what a general-grant operator gets whether or not a credential is
#                                      cached -- LISTING an allowed command is not itself password-gated
#                                      on a stock sudoers.
#   exit != 0, NO OUTPUT               sudo's answer for "no rule matches this command". It is silent,
#                                      so there is no message to match on: the refusal that reaches the
#                                      terminal ("Sorry, user op is not allowed to execute ...") comes
#                                      from the attempt to RUN the command, never from -l. This is the
#                                      ai-ops-only account the gate exists for.
#   exit != 0, "password is required"  listing is password-gated here (sudoers `listpw`). The
#                                      grant may well exist, so that caller is left to the ordinary
#                                      prompt.
#   exit != 0, any other text          not understood -- fall through and let sudo answer at the call
#                                      site, this probe's fail-open direction.
#
# Silence is only conclusive while sudo is answering at all, so it is confirmed against a bare `sudo -n -l`: that lists
# the caller's whole rule set (an ai-ops member always has one), and its success is what separates "sudo knows this
# caller and has no rule for that command" from a sudo that failed for its own reasons -- an unreachable sudoers
# backend, a host that refuses -l outright. Only the first is read as a missing grant; the second falls open like any
# other answer that cannot be read. LC_ALL=C pins the wording of the one text match. An optional <runas> asks the same
# question about `sudo -u <runas> <bin>` -- the form run_as_owner uses -- so a host whose sudoers restricts Runas
# to root is read here rather than at the call site, where it would abort a create half-way through a tree.
sudo_grant_missing() {
    local bin="$1" runas="${2:-}" answer
    local -a probe=(-n -l)
    [[ -n "${runas}" ]] && probe+=(-u "${runas}")
    [[ "${INVOKING_USER}" == "root" ]] && return 1
    command -v sudo >/dev/null 2>&1 || return 1
    answer="$(LC_ALL=C sudo "${probe[@]}" "${bin}" 2>&1)" && return 1
    [[ "${answer}" == *"password is required"* ]] && return 1
    if [[ -z "${answer//[[:space:]]/}" ]]; then
        LC_ALL=C sudo -n -l >/dev/null 2>&1 && return 0
        return 1
    fi
    [[ "${answer}" == *"not allowed to execute"* || "${answer}" == *"may not run sudo"* ]]
}

# ── Reacting to a root step that did not apply ───────────────────────────────────
# Every root helper authenticates on its own, and a flow cannot pre-authenticate a step: a hardened sudoers may set
# timestamp_timeout=0, where a credential is never cached and every invocation prompts, so one mistyped password costs
# a full round of attempts PER STEP. note_root_failure asks ONCE whether to attempt the rest -- default NO, which is
# also the no-terminal answer -- and once per RUN rather than per step or per project. It is asked rather than inferred:
# a mistyped password and an absent grant are indistinguishable at this point and call for opposite actions.
#
# The answer decides only which steps are ATTEMPTED; what a partial result means is the caller's to report, and differs
# per verb (cli.rule.md).
ROOT_STEP_FAILURES=0
_root_step_asked=false
_root_step_carry_on=1
note_root_failure() {
    ROOT_STEP_FAILURES=$(( ROOT_STEP_FAILURES + 1 ))
    if ! ${_root_step_asked}; then
        _root_step_asked=true
        say ""
        if confirm "That step needed root and did not apply. Try the remaining steps? (each one asks for your password again)" n; then
            _root_step_carry_on=0
        else
            _root_step_carry_on=1
        fi
    fi
    return "${_root_step_carry_on}"
}

# 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
# `projects 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 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 launch-wrapper.lib.sh's
# ai_tools_launch_wrapper__has_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 ─────────────────────────────────────────────────────────────────

# find_invoker_traversal_blocker <path> <var>  -- set the caller's variable <var> to the first ancestor of the absolute
# <path>, walking down from /, that exists and that the invoking user cannot enter (`test -x`, which asks access(2),
# so the mode and the ACL both count), and return 0. The result is returned in <var> rather than printed, since
# a `$(...)` capture strips a line feed that ends a directory's name. Return 1 when the walk reaches a component
# that does not exist or every ancestor can be entered. realpath and `test -e` fail alike for a path that is absent
# and for one under a directory the caller cannot enter; this walk is what tells the two apart. It reads the path
# as typed, component by component as the kernel looks it up, so a `..` is never collapsed lexically: `missing/..` stops
# the walk at `missing`, where the kernel's lookup fails, and a sealed directory before a `..` is read before the `..`
# is. A component `test -e` cannot stat that is a symbolic link -- one leading into a directory the invoker cannot enter
# -- is followed as `realpath -m` resolves it, so that directory is still named; the hop bound is the kernel's own
# (MAXSYMLINKS), and a link `realpath -m` cannot move past, a loop among them, ends the walk.
find_invoker_traversal_blocker() {
    local -n _unenterable_ancestor="$2"
    local _blocker_remaining_path="${1#/}" _blocker_ancestor_path="" _blocker_resolved_path
    local _blocker_link_hops=0 LC_ALL=C
    while [[ "${_blocker_remaining_path}" == */* ]]; do
        _blocker_ancestor_path+="/${_blocker_remaining_path%%/*}"
        _blocker_remaining_path="${_blocker_remaining_path#*/}"
        if [[ -d "${_blocker_ancestor_path}" && ! -x "${_blocker_ancestor_path}" ]]; then
            _unenterable_ancestor="${_blocker_ancestor_path}"
            return 0
        fi
        [[ -e "${_blocker_ancestor_path}" ]] && continue
        [[ -L "${_blocker_ancestor_path}" ]] || return 1
        (( ++_blocker_link_hops <= 40 )) || return 1
        # resolve_existing_path's sentinel: the link's target may end in a line feed.
        _blocker_resolved_path="$(realpath -m -- "${_blocker_ancestor_path}" 2>/dev/null && printf x)" || return 1
        _blocker_resolved_path="${_blocker_resolved_path%x}"
        _blocker_resolved_path="${_blocker_resolved_path%$'\n'}"
        [[ "${_blocker_resolved_path}" != "${_blocker_ancestor_path}" ]] || return 1
        _blocker_remaining_path="${_blocker_resolved_path#/}/${_blocker_remaining_path}"
        _blocker_ancestor_path=""
    done
    return 1
}

# _quote_path <path>  -- print <path> as one bash word, for a command the CLI prints for the operator to paste, in the
# shape of the session-sweep library's private quoter: `printf %q` under the C locale encodes every byte outside
# printable ASCII, so bash reads the word back as the same path under any locale. The path is quoted as it is, not
# sanitized first, since the command has to name the directory that exists.
_quote_path() {
    local LC_ALL=C
    printf '%q' "$1"
}

# refuse_unreachable_path <path> <ancestor>  -- die naming <ancestor>, an ancestor of <path> the invoking user cannot
# enter. The CLI resolves a project's path as the invoker, a `--for` run included, so a project under such a directory
# cannot be named from this account at all. On a `--for` run the remedies print ahead of the refusal: a location
# both accounts reach first, the clone area as the example, then the traverse-only grant that opens the directory
# where grantable_ancestor permits a grant on it -- the predicate the claim's own traverse grant passes, so a protected
# system directory and any home root but the target's own are never offered.
refuse_unreachable_path() {
    local path blocked_ancestor
    path="$(ai_tools_log__sanitize "$1")"
    blocked_ancestor="$(ai_tools_log__sanitize "$2")"
    local -a why=("ai-tools resolves a project's path as the account that runs it, ${INVOKING_USER}, so every directory on the path to the project has to be one ${INVOKING_USER} can enter.")
    if [[ -n "${FOR_OPERATOR}" ]]; then
        why+=("A --for run resolves the path as ${INVOKING_USER} and then reads and writes the tree as ${FOR_OPERATOR}.")
        # Remedies print plain ahead of die(), whose emitter would wrap a command across lines (messaging.rule.md).
        local -a remedy=("Put the project in a directory both ${INVOKING_USER} and ${FOR_OPERATOR} reach, such as one under ${SANDBOX_ROOT}.")
        if grantable_ancestor "$2"; then
            remedy+=("" "Or, since ${blocked_ancestor} belongs to ${OWNER_USER}, let ${INVOKING_USER} enter it without listing it -- run as root or as ${OWNER_USER}:" "" \
                     "    setfacl -m u:${INVOKING_USER}:--x $(_quote_path "$2")")
        fi
        printf '\n' >&2
        printf '  %s\n' "${remedy[@]}" >&2
    fi
    die MSG-D4Z6 "cannot enter ${blocked_ancestor} as ${INVOKING_USER}, so ${path} cannot be reached from this account" "${why[@]}"
}

# resolve_existing_path <path>  -- canonicalize <path> (realpath -e) to stdout; die if it is absent, if it sits
# under a directory the invoking user cannot enter (refuse_unreachable_path), or if its canonical form holds a control
# character. The walk reads the leaf too, so a leaf that is a link into a directory the invoker cannot enter names that
# directory.
resolve_existing_path() {
    local p blocked_ancestor LC_ALL=C
    # realpath ends its answer with one line feed; the sentinel keeps a line feed that is part of the name, which a bare
    # command substitution would strip and so resolve `project<LF>` to its sibling `project`.
    if ! p="$(realpath -e -- "$1" 2>/dev/null && printf x)"; then
        local typed="$1"
        [[ "${typed}" == /* ]] || typed="${PWD}/${typed}"
        if find_invoker_traversal_blocker "${typed}/" blocked_ancestor; then
            refuse_unreachable_path "$1" "${blocked_ancestor}"
        fi
        die "path not found: $(ai_tools_log__sanitize "$1")"
    fi
    p="${p%x}"
    p="${p%$'\n'}"
    refuse_control_character_path "${p}"
    printf '%s' "${p}"
}

# refuse_control_character_path <path>  -- die when <path> holds a control character. A project root is registered one
# per line in the project registry and printed on the claim's page, so a control byte in it, a line feed first
# among them, is refused before any verb acts on the path: resolve_existing_path for a claim, and the create before its
# first write.
refuse_control_character_path() {
    local LC_ALL=C
    if [[ "$1" =~ [[:cntrl:]] ]]; then
        die MSG-V8Z5 "project path holds a control character: $(ai_tools_log__sanitize "$1") -- rename it first"
    fi
}

# require_sandbox_clone_path <path>  -- die unless <path> passes the protected-paths backstop and is a DIRECT child
# of SANDBOX_ROOT (exactly one component under it -- never SANDBOX_ROOT itself, never a nested or system path). It reads
# the path alone, so `projects remove` runs it ahead of a `--for` run's registry snapshot, the run's first `sudo`.
require_sandbox_clone_path() {
    local d="$1" rel
    ai_tools_safe_paths__assert_safe_target "${d}" "sandbox" || exit 3
    [[ "${d}" == "${SANDBOX_ROOT}/"* ]] \
        || die MSG-T4Z6 "not a sandbox clone (must be a clone under ${SANDBOX_ROOT}): ${d}"
    rel="${d#"${SANDBOX_ROOT}/"}"
    [[ -n "${rel}" && "${rel}" != */* ]] \
        || die MSG-W3H3 "not a sandbox clone (expected ${SANDBOX_ROOT}/<clone>, one level deep): ${d}"
}

# require_sandbox_clone_shape <path>  -- die unless <path> passes require_sandbox_clone_path and its tree has a clone's
# shape: the root belongs to the acting owner or to the invoker -- a create made as the target, or a clone the invoker
# made and claimed for the target -- .git is a directory and .git/HEAD a regular file. It gates the clone kind
# of `projects remove` (`rm -rf`) and `projects push`, so neither the shared clone area root nor a tree another account
# holds can be the target. The read does not establish that the tree is a repository or that `projects clone` made it:
# an unregistered tree of that shape is deleted on the operator's confirm, or `-y`. A directory the sandbox account
# creates in the area is born that account's, so the owner rule refuses it.
#
# The shape is read as data, with one `stat` run as the acting owner, and git is not run over the tree: the agent
# co-writes .git, and git run as the operator executes what .git/config names (cmd_project_remove). `stat` without `-L`
# reads a symlink as itself. The read runs as the owner because a declined .git share leaves .git in the owner's group,
# which the invoker cannot enter on a `--for` run; the path does not hold a control character (resolve_existing_path
# refused one).
require_sandbox_clone_shape() {
    local d="$1"
    require_sandbox_clone_path "${d}"

    # One line per path stat could read, `<uid> <raw mode in hex> <name>`; a path it could not read has no line, and
    # %f is locale-independent, so no LC_ALL rides through sudo.
    local readings line uid mode name owner_uid
    local -A uid_of=() mode_of=()
    readings="$(run_as_owner stat -c '%u %f %n' -- "${d}" "${d}/.git" "${d}/.git/HEAD" 2>/dev/null)" || true
    while IFS= read -r line; do
        [[ "${line}" =~ ^([0-9]+)\ ([0-9a-f]+)\ (.+)$ ]] || continue
        uid="${BASH_REMATCH[1]}" mode="${BASH_REMATCH[2]}" name="${BASH_REMATCH[3]}"
        uid_of["${name}"]="${uid}"; mode_of["${name}"]="${mode}"
    done <<< "${readings}"
    owner_uid="$(id -u -- "${OWNER_USER}" 2>/dev/null)" || owner_uid=""

    local refusal_reason=""
    if [[ -z "${uid_of[${d}]+set}" ]]; then
        refusal_reason="its owner could not be read as ${OWNER_USER}"
    elif [[ "${uid_of[${d}]}" != "${EUID}" && "${uid_of[${d}]}" != "${owner_uid}" ]]; then
        refusal_reason="it belongs to uid ${uid_of[${d}]}, neither ${INVOKING_USER} nor ${OWNER_USER}"
    elif [[ -z "${mode_of[${d}/.git]+set}" ]]; then
        refusal_reason=".git could not be read as ${OWNER_USER}"
    elif (( (16#${mode_of[${d}/.git]} & 0xF000) != 0x4000 )); then
        refusal_reason=".git is not a directory"
    elif [[ -z "${mode_of[${d}/.git/HEAD]+set}" ]] || (( (16#${mode_of[${d}/.git/HEAD]} & 0xF000) != 0x8000 )); then
        refusal_reason=".git/HEAD is missing or not a regular file"
    fi
    [[ -z "${refusal_reason}" ]] \
        || die MSG-Z7U6 "not a git clone: ${d} -- ${refusal_reason}; if it is a stray directory, remove it by hand"
}

# run_as_owner <cmd> [args...]  -- run <cmd> as the operator this run acts FOR: directly without --for,
# and under `sudo -u <target> -H` with it. The seam every step that reads or writes a project's tree AS ITS OWNER goes
# through, the claim's owner-side read (`projects inspect`) among them; the general sudo grant `sudo -u` rides, and why
# that adds the caller no authority, are cli.rule.md's.
#
# `-H` is load-bearing: without it (and without sudoers' always_set_home) sudo leaves HOME pointing at the INVOKER's
# home, so a command that reads a dotfile -- git most of all -- would configure the target's tree from the invoker's.
run_as_owner() {
    if [[ -z "${FOR_OPERATOR}" ]]; then "$@"; return; fi
    sudo -u "${OWNER_USER}" -H -- "$@"
}

# ── Registry helpers (the only mutating filesystem writes besides clones) ─────────
# The project registry: one path per line in the grammar ai-tools-project-registry(5) states -- an `@` project, a `!`
# excluded path, a `%` disabled project. 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 (running git as the agent) needs an explicit entry per
# registered path.

# Every edit here goes through conf.lib.sh's allowlist-editing functions -- the one implementation of a registry change,
# shared with the ai-tools-allowlist root helper (a --for run) and install.sh (de-registering its own checkout).
# What stays here is the CLI's half: which principal performs the write, and what the operator is told about it.

# allow_state <dir>  -- the state the lines naming <dir> give it in the registry THIS run reads (the invoker's, or
# a --for target's snapshot): `enabled`, `disabled`, `excluded`, `absent` or `invalid`. See
# ai_tools_conf__read_allowlist_state for what each means.
allow_state() { ai_tools_conf__read_allowlist_state "${ALLOWLIST}" "$1"; }

# disabled_note <dir>  -- print the raw '%' line(s) disabling <dir>, indented, for a message that has just called it
# disabled. What the operator needs next is the line itself, verbatim.
disabled_note() {
    local -a lines=(); local raw
    ai_tools_conf__read_allowlist_disabled_lines lines "${ALLOWLIST}" "$1" || return 0
    for raw in "${lines[@]}"; do say "      ${C_BOLD}${raw}${C_RST}"; done
}

# retag_allow <dir> <enable|disable>  -- disable or re-enable <dir> in place, through the root helper on a --for run
# and directly otherwise. Both directions report their own failure with the command that repeats it; the library
# verifies the resulting state, so a "done" here means the file says so. Returns non-zero if the state did not change
# as asked.
retag_allow() {
    local dir="$1" op="$2" rc=0 done_word="disabled"
    [[ "${op}" == enable ]] && done_word="re-enabled in place"
    if [[ -n "${FOR_OPERATOR}" ]]; then
        if sudo "${ALLOWLIST_BIN}" --operator "${FOR_OPERATOR}" "--${op}" "${dir}" >/dev/null; then
            refresh_target_allowlist_snapshot
            say "    registry: ${done_word} for ${FOR_OPERATOR}"
            return 0
        fi
        if ! report_unrenderable_registry_line; then
            warn "could not ${op} ${dir} in ${FOR_OPERATOR}'s project registry -- run:"
            say  "      ${C_BOLD}sudo ${ALLOWLIST_BIN} --operator ${FOR_OPERATOR} --${op} ${dir}${C_RST}"
        fi
        return 1
    fi
    "ai_tools_conf__allowlist_${op}" "${ALLOWLIST}" "${dir}" || rc=$?
    if (( rc == 4 )) && report_unrenderable_registry_line; then
        return 1
    fi
    if (( rc )); then
        warn "could not ${op} ${dir} in ${REGISTRY_DISPLAY_PATH} -- the file was not changed. Edit the line by hand:"
        [[ "${op}" == enable ]] && disabled_note "${dir}"
        return 1
    fi
    say "    registry: ${done_word}"
}

# die_registry_refused  -- the refusal for a registry this run cannot read as the operator's intent: a line the grammar
# refuses, or an exclusion spelled through a link the sandbox account can change. The library's MSG-B8U2 line, printed
# on stderr ahead of this one, names the line and its remedy.
die_registry_refused() {
    die MSG-Y7K5 "the project registry ${REGISTRY_DISPLAY_PATH} holds a line named above that no reader accepts -- nothing changed" \
        "Fix that line, then re-run. Until it is fixed no session starts under any entry of this file.$(format_registry_edit_clause)"
}

# report_unrenderable_registry_line [<path>]  -- warn about the line of the registry this run reads (the invoker's, or
# a `--for` target's snapshot) that the canonical writer cannot write back, printing the line, and return 0; return 1,
# printing nothing, when every line renders. Every write rewrites the whole file, so one such line makes every edit
# of the file fail (the editors' status 4) whatever path the edit names: an editor calls this once it has failed, and
# a verb whose later step edits the file calls it before its first change. A verb whose registry step removes <path>'s
# entry passes <path>, since that removal deletes the entry's own line before it renders the rest
# (ai_tools_conf__find_unrenderable_registry_line).
report_unrenderable_registry_line() {
    local line
    line="$(ai_tools_conf__find_unrenderable_registry_line "${ALLOWLIST}" "${1-}")" || return 1
    # The grammar refuses a control byte in a path and accepts one in an end-of-line comment, and under `--for` the line
    # is another operator's: it is shown with each control byte as `?`, the display the validator's refusals use.
    printf '\n      %s\n\n' "${line//[[:cntrl:]]/?}" >&2
    warn MSG-A5Y2 "a line of ${REGISTRY_DISPLAY_PATH} names a path the registry cannot write back -- the line is shown above" \
        "Every change to the file rewrites it whole, and a path carrying both a single and a double quote has no quoting a registry line can carry. Rename the directory the line names and edit the line to match, or delete the line; then run the command again.$(format_registry_edit_clause)"
}

# registry_gate <verb>  -- the gate every `projects` verb passes before it classifies a path. A registry not yet
# migrated is read under the grammar it was written in: `projects list` reports it under a banner, and every other verb
# refuses, since an edit would mix two grammars in one file and a `!` there may be a disabled project this release
# reads as an excluded path. A registry no reader accepts refuses every verb. A run with no registry passes, and each
# verb meets its own no-registry refusal. A `--for` run has not read the target's registry when the dispatch reaches
# this gate (REGISTRY_STATE is `none`), so it passes, and load_target_registry_snapshot validates the snapshot once
# the verb has checked its own command line.
registry_gate() {
    local verb="$1" rc=0
    case "${REGISTRY_STATE}" in
        none) return 0 ;;
        legacy)
            if [[ "${verb}" != list ]]; then
                die MSG-J6S8 "your project registry is ${REGISTRY_DISPLAY_PATH}, the allowed-projects an earlier release wrote, and it is not migrated -- nothing changed" \
                    "projects ${verb} writes the format this release reads. Migrate the file to ~/.config/ai-tools/project-registry, which keeps every line and renames this one to a dated backup:" \
                    "       atr admin system post-upgrade"
            fi ;;
    esac
    ai_tools_conf__check_registry "${ALLOWLIST}" || rc=$?
    case "${rc}" in
        0)   ;;
        2|3) die_registry_refused ;;
        *)   die "cannot read the project registry ${REGISTRY_DISPLAY_PATH} -- nothing changed" ;;
    esac
}

# read_path_class <dir>  -- read the classifier's record for <dir> (ai_tools_conf__read_registry_path_class) into
# the CLASS_* globals: CLASS_STATE, CLASS_OWN, CLASS_ENCLOSING, CLASS_ENCLOSING_STATE, CLASS_BLOCKED,
# CLASS_BLOCKED_KIND, CLASS_NESTED and CLASS_BLOCKED_PATH. Every verb decides from this one record, so two verbs cannot
# read one path two ways. A run with no registry reads every path as unregistered; registry_gate has refused a file
# no reader accepts, so a read that fails here is a file that changed under the run.
read_path_class() {
    local record
    CLASS_STATE=unregistered CLASS_OWN=none CLASS_ENCLOSING=- CLASS_ENCLOSING_STATE=- CLASS_BLOCKED=-
    CLASS_BLOCKED_KIND=- CLASS_NESTED=0 CLASS_BLOCKED_PATH=-
    [[ -e "${ALLOWLIST}" || -L "${ALLOWLIST}" ]] || return 0
    record="$(ai_tools_conf__read_registry_path_class "${ALLOWLIST}" "$1")" || die_registry_refused
    IFS=$'\t' read -r CLASS_STATE CLASS_OWN CLASS_ENCLOSING CLASS_ENCLOSING_STATE CLASS_BLOCKED \
        CLASS_BLOCKED_KIND CLASS_NESTED CLASS_BLOCKED_PATH <<< "${record}"
}

# resolve_entry_path <path>  -- print the spelling read_path_class compares for a path a file stores: its canonical form
# (realpath -e), or, for a path that no longer exists, the written path without its trailing slash. A stored entry may
# be spelled through a symlink or a `..`, and the classifier matches canonical paths.
resolve_entry_path() {
    realpath -e -- "$1" 2>/dev/null || printf '%s' "${1%/}"
}

# is_class_reachable  -- 0 when the path read_path_class last read is one a session can reach: listed, or inside
# a listed project, with no '%', '!' or glob line covering it.
is_class_reachable() {
    [[ "${CLASS_BLOCKED}" == - ]] || return 1
    [[ "${CLASS_STATE}" == enabled || ( "${CLASS_STATE}" == inside && "${CLASS_ENCLOSING_STATE}" == enabled ) ]]
}

# list_entries_under <dir>  -- print each registry entry strictly under <dir>, one path per line, in file order.
list_entries_under() {
    local listing
    [[ -e "${ALLOWLIST}" ]] || return 0
    listing="$(ai_tools_conf__list_registry_entries_under "${ALLOWLIST}" "$1")" || die_registry_refused
    [[ -n "${listing}" ]] && cut -f1 <<< "${listing}"
    return 0
}

# list_lines_naming <dir>  -- print every raw registry line whose path, its sign set aside, names <dir>.
list_lines_naming() {
    local raw entry
    [[ -f "${ALLOWLIST}" ]] || return 0
    while IFS= read -r raw || [[ -n "${raw}" ]]; do
        ai_tools_conf__parse_path_entry "${raw}" || continue
        entry="${ai_tools_conf__value}"
        [[ "$(realpath -e -- "${entry}" 2>/dev/null || printf '%s' "${entry%/}")" == "$1" ]] && printf '%s\n' "${raw}"
    done < "${ALLOWLIST}"
    return 0
}

# list_excluded_lines_under <dir>  -- print every raw `!` line whose path is <dir> or lies under it; a glob line counts
# when its pattern does. A `!` line is the operator's own edit and no verb deletes one, so the verbs that release
# a project name these as the lines that stay.
list_excluded_lines_under() {
    local raw entry
    [[ -f "${ALLOWLIST}" ]] || return 0
    while IFS= read -r raw || [[ -n "${raw}" ]]; do
        ai_tools_conf__parse_path_entry "${raw}" || continue
        [[ "${ai_tools_conf__kind}" == excluded ]] || continue
        entry="${ai_tools_conf__value}"
        ai_tools_conf__has_glob_characters "${entry}" \
            || entry="$(realpath -e -- "${entry}" 2>/dev/null || printf '%s' "${entry%/}")"
        [[ "${entry}" == "$1" || "${entry}" == "$1/"* ]] && printf '%s\n' "${raw}"
    done < "${ALLOWLIST}"
    return 0
}

# report_excluded_paths_kept <dir> [removed]  -- after <dir> is released, name each `!` line under it: no verb deletes
# one, so it stays in force over whatever is registered there next. With a second argument the tree is gone,
# and `projects list` reports each such line as stale.
report_excluded_paths_kept() {
    local -a kept=(); local raw
    mapfile -t kept < <(list_excluded_lines_under "$1")
    (( ${#kept[@]} )) || return 0
    if [[ -n "${2:-}" ]]; then
        say "  ${C_DIM}the '!' line(s) under it stay in ${REGISTRY_DISPLAY_PATH}; $(format_projects_command "" "") reports each as stale:${C_RST}"
    else
        say "  ${C_DIM}the '!' line(s) under it stay in ${REGISTRY_DISPLAY_PATH}, still in force:${C_RST}"
    fi
    for raw in "${kept[@]}"; do say "      ${raw}"; done
}

# format_projects_command <verb> <path> [<option>...]  -- print the command
# `atr projects <verb> [--for <target>] [<option>...] <path>`, the path in bash's %q encoding, so a path carrying
# a space, a '#' or a glob character pastes as one word and a plain path prints as written. An empty <verb> prints
# the bare collection, which runs `list`, and an empty <path> prints the command without one. The one place a printed
# command takes `--for`: a verb FOR_ALLOWED_VERBS names carries the run's target, so a remedy acts on the registry
# that refused the command.
format_projects_command() {
    local verb="$1" path="$2" allowed word
    shift 2
    printf 'atr projects%s' "${verb:+ ${verb}}"
    if [[ -n "${FOR_OPERATOR}" ]]; then
        for allowed in "${FOR_ALLOWED_VERBS[@]}"; do
            [[ "${allowed}" == "projects ${verb:-list}" ]] && printf ' --for %s' "${FOR_OPERATOR}"
        done
    fi
    for word in "$@"; do printf ' %s' "${word}"; done
    [[ -z "${path}" ]] || printf ' %q' "${path}"
}

# format_registry_edit_clause  -- the sentence a remedy that edits the registry by hand ends with on a `--for` run:
# the file is the target's, 0600 in a home this operator cannot read, so the edit is made as that account. Prints
# nothing otherwise.
format_registry_edit_clause() {
    [[ -z "${FOR_OPERATOR}" ]] || printf " The file is %s's, so edit it as that account or with sudoedit." "${FOR_OPERATOR}"
}

# format_blocking_line_kind  -- the line kind of CLASS_BLOCKED, as a message names it.
format_blocking_line_kind() {
    case "${CLASS_BLOCKED_KIND}" in
        disabled) printf "'%%'" ;;
        glob) printf "'!' glob" ;;
        *)    printf "'!'" ;;
    esac
}

# format_blocking_line_remedy  -- the remedy for CLASS_BLOCKED by its kind: a `%` line is lifted by `projects enable`
# on the project it disables, named by CLASS_BLOCKED_PATH, since CLASS_BLOCKED is the line as displayed, and the remedy
# is then two lines, the sentence and the command indented under it (emit_alert); a `!` line, a glob among them, is
# the operator's own line, edited in the registry.
format_blocking_line_remedy() {
    local disabled_project
    if [[ "${CLASS_BLOCKED_KIND}" == disabled && "${CLASS_BLOCKED_PATH}" != - ]]; then
        disabled_project="$(realpath -e -- "${CLASS_BLOCKED_PATH}" 2>/dev/null || printf '%s' "${CLASS_BLOCKED_PATH}")"
        printf 'Re-enable the project it disables first:\n       %s' "$(format_projects_command enable "${disabled_project}")"
    else
        printf 'That line excludes the path: no session starts under it. Edit or delete it in %s if that is not what it means.%s' \
            "${REGISTRY_DISPLAY_PATH}" "$(format_registry_edit_clause)"
    fi
}

# die_blocked <dir> <verb> [<project>]  -- refuse <verb> on a path a `%`, `!` or glob line covers without naming
# it: no session reaches the path, and the root helpers do not resolve an owner under such a line, so the verb would
# report steps it did not apply. Read from the record read_path_class last read. <project>, for a directory discovery
# met on the way to it, is named as the target that the verb can take instead when the line is a `!` line or a glob.
die_blocked() {
    local -a whole=()
    [[ -n "${3:-}" && "${CLASS_BLOCKED_KIND}" != disabled ]] \
        && whole=("To act on the whole project, name it:" "       $(format_projects_command "$2" "$3")")
    printf '\n      %s\n\n' "${CLASS_BLOCKED}" >&2
    die MSG-R8C7 "a $(format_blocking_line_kind) line covers this path, so projects $2 cannot act on it: $1" \
        "The line is shown above. $(format_blocking_line_remedy)" "${whole[@]}"
}

# die_excluded <dir> <verb>  -- refuse <verb> on a path a `!` line names: an excluded path, wherever the line sits,
# is not a project, so no verb claims, disables, re-enables or deletes it.
die_excluded() {
    printf '\n' >&2
    list_lines_naming "$1" | sed 's/^/      /' >&2
    printf '\n' >&2
    die MSG-W4S7 "this path is excluded, not a project: $1" \
        "A '!' line excludes its path wherever it sits; a disabled project is written '%'. projects $2 does not act on an excluded path. If the line means something else, edit it in ${REGISTRY_DISPLAY_PATH}.$(format_registry_edit_clause)"
}

# die_inside <dir> <verb>  -- refuse <verb> on a path inside a registered project that has no entry of its own, naming
# the project and what the verb does there by that project's state.
die_inside() {
    local d="$1" verb="$2" x="${CLASS_ENCLOSING}" hint
    case "${verb}:${CLASS_ENCLOSING_STATE}" in
        remove:*)       hint="removing that project deletes all of it, this path included: $(format_projects_command remove "${x}")" ;;
        unclaim:*)      hint="unclaim that instead: $(format_projects_command unclaim "${x}")" ;;
        enable:disabled) hint="that project is disabled; enable it instead: $(format_projects_command enable "${x}")" ;;
        enable:*)       hint="that project is enabled, and this path is part of it" ;;
        disable:disabled) hint="that project is already disabled, and this path with it" ;;
        disable:*)      hint="disable that instead: $(format_projects_command disable "${x}")" ;;
        *)              hint="act on that project instead: $(format_projects_command "${verb}" "${x}")" ;;
    esac
    local -a blocked_line=()
    [[ "${CLASS_BLOCKED}" == - || "${CLASS_BLOCKED_KIND}" == disabled ]] \
        || blocked_line=("       and a $(format_blocking_line_kind) line covers this path: ${CLASS_BLOCKED}")
    die MSG-T5A3 "this path is inside a claimed project, not a project itself: ${d}" \
        "       the claimed project is: ${x} (${CLASS_ENCLOSING_STATE})" \
        "       ${hint}" "${blocked_line[@]}"
}

# offer_reenable <dir> <what>  -- the shared disabled-project gate: report that <dir> is disabled, show the line, ask
# (default NO) whether to re-enable it, and return 0 only once it is listed again. Every verb that needs the project
# REACHABLE goes through it, because the root helpers resolve a path's owner through the same allow/exclude matcher
# the launch gate uses: while the '%' stands, ai-tools-unclaim, -chown, -setfacl and -setgid resolve no owner and exit 0
# having done NOTHING, and ai-tools-lockdown refuses outright. Proceeding over that would report steps as applied
# that never ran -- the one thing these flows may not do. <what> is named in the message; a THIRD argument makes
# a declined confirm return non-zero instead of aborting the command, for the one caller whose remaining work is still
# worth doing.
offer_reenable() {
    local dir="$1" what="$2" decline_returns="${3:-}"
    headline_warn "This project is disabled" \
        "A '%' line in ${REGISTRY_DISPLAY_PATH} disables ${dir}. While it stands the agent cannot launch there, and the root helpers resolve no owner for it -- so ${what} would report steps it did not apply. Re-enabling changes nothing else: the project keeps its permissions, its ACLs and its label."
    disabled_note "${dir}"
    say ""
    if ! confirm "Re-enable this project (delete the '%' from that line)?" n; then
        [[ -n "${decline_returns}" ]] || die "aborted -- ${dir} stays disabled and nothing was changed"
        return 1
    fi
    retag_allow "${dir}" enable
}

# reg_allow <dir>  -- register <dir> as a project: an allow line in the registry this run edits, through the root
# helper on a --for run and directly otherwise.
reg_allow() {
    local dir="$1" rc=0
    # A --for run edits a registry in a home this operator cannot even read, so the write goes through the root helper
    # (which re-reads the real file and applies the same library rules), and the snapshot is refreshed so the rest
    # of this run sees the entry it just added.
    if [[ -n "${FOR_OPERATOR}" ]]; then
        if sudo "${ALLOWLIST_BIN}" --operator "${FOR_OPERATOR}" --add "${dir}" >/dev/null; then
            refresh_target_allowlist_snapshot
            say "    registry: added for ${FOR_OPERATOR}"
            return 0
        fi
        # rc 2 from the helper is the disabled refusal, reported the same way; anything else is a write that did not
        # happen, and the helper's own line names why.
        if [[ "$(allow_state "${dir}")" == disabled ]]; then
            offer_reenable "${dir}" "the claim" \
                || die "the registry was not updated -- ${dir} is still disabled"
            return 0
        fi
        report_unrenderable_registry_line || true
        die MSG-A8Z6 "could not add ${dir} to ${FOR_OPERATOR}'s project registry -- it is not registered"
    fi
    [[ -f "${ALLOWLIST}" ]] || die "no project registry at ${ALLOWLIST} -- nothing changed.
       If this account is meant to run sandboxed sessions, enrol it first with:
       atr admin operators add ${USER:-$(id -un)}"
    local before; before="$(allow_state "${dir}")"
    ai_tools_conf__allowlist_add "${ALLOWLIST}" "${dir}" || rc=$?
    case "${rc}" in
        0) if [[ "${before}" == enabled ]]; then
               say "    registry: already listed"
           else
               say "    registry: added"
           fi ;;
        2) # DISABLED. cmd_project_claim answers this up front, so reaching it here means another
           # caller (or a file changed under a running flow) -- the backstop that keeps the rule in the library rather
           # than in one caller: a second, positive line would leave the '%' winning at the launch gate while the claim
           # reported success.
           [[ "$(allow_state "${dir}")" == disabled ]] \
               || die "could not add ${dir} to the registry -- a line of another kind names it"
           offer_reenable "${dir}" "the claim" \
               || die "the registry was not updated -- ${dir} is still disabled" ;;
        4) ai_tools_conf__is_registry_path_representable "${dir}" || die_unregistrable_name "${dir}"
           report_unrenderable_registry_line || true
           die "could not add ${dir} to the registry -- it is not registered" ;;
        *) die "could not add ${dir} to the registry -- it is not registered" ;;
    esac
}

# die_unregistrable_name <dir> [<remedy>]  -- refuse a project whose path the registry grammar cannot carry: a glob
# character, a control character or both quote characters would make its project line one the grammar refuses,
# and that line would refuse the whole file for every reader, the launch gate included. <remedy> replaces
# the rename-then-claim one, for a verb whose directory does not exist yet. ai-tools-allowlist prints this code
# for a `--for` run.
die_unregistrable_name() {
    die MSG-Z9R2 "this directory's name cannot be written as a registry line: $1" \
        "A project's registry path does not carry '*', '?', '[', a control character, or both a single and a double quote, and this one does. ${2:-Rename the directory, then claim it.}"
}
# 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_project_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'; }

# format_registry_line_removal <raw-line> [<kind> <path>]  -- print the command that deletes <raw-line> from
# the registry this run reads, verbatim, comment and all: `sed -i <expression> <file>`, prefixed `sudo -u <target>`
# on a --for run, whose file is the target's. The expression is the anchored address allow_escape makes literal,
# in bash's %q encoding, so a line carrying a quote or a space pastes as one word. The file is REGISTRY_DISPLAY_PATH,
# %q-encoded on the invoker's run and left bare under --for, since %q escapes the leading `~` of the tilde-user form
# and the shell would then not expand it; a user name does not hold a character the shell splits.
#
# On a --for run, an entry line -- <kind> `project` or `disabled`, as ai_tools_conf__parse_path_entry names it --
# is removed through `ai-tools-allowlist --operator <target> --remove <path>` instead, which edits the target's file
# without the invoker holding a sudo rule as that account. An excluded line keeps the `sed` form, since
# ai_tools_conf__allowlist_remove leaves an excluded line standing, and so does a protected path, which the helper's
# protected-paths backstop refuses. The path is read as that backstop reads it, `realpath -m` with the written path
# as the fallback, so a stale `/etc/gone/..` or a link to a protected directory keeps the `sed` form.
format_registry_line_removal() {
    local expression backstop_path
    if [[ -n "${FOR_OPERATOR}" && ( "${2:-}" == project || "${2:-}" == disabled ) && -n "${3:-}" ]]; then
        backstop_path="$(realpath -m -- "$3" 2>/dev/null)" || backstop_path="$3"
        if ! ai_tools_safe_paths__match_protected_path "${backstop_path}" >/dev/null 2>&1; then
            printf 'sudo %s --operator %s --remove %s' "${ALLOWLIST_BIN}" "${FOR_OPERATOR}" "$(_quote_path "$3")"
            return 0
        fi
    fi
    expression="$(printf '%q' "\\|^$(allow_escape "$1")\$|d")"
    if [[ -n "${FOR_OPERATOR}" ]]; then
        printf 'sudo -u %s sed -i %s %s' "${FOR_OPERATOR}" "${expression}" "${REGISTRY_DISPLAY_PATH}"
    else
        printf 'sed -i %s %q' "${expression}" "${REGISTRY_DISPLAY_PATH}"
    fi
}

unreg_allow() {
    local dir="$1"
    # A removal that did not apply ends the verb in both arms, through the one refusal at the tail: the entry is
    # the launch gate, so a verb that carried on would report access removed while a session can still start there,
    # and cmd_project_remove deletes the tree in its next step.
    if [[ -n "${FOR_OPERATOR}" ]]; then
        # A --for run de-lists through the root helper, which applies the same raw-line matcher to the real file;
        # the snapshot is refreshed so a later read in this run agrees with it.
        if sudo "${ALLOWLIST_BIN}" --operator "${FOR_OPERATOR}" --remove "${dir}" >/dev/null; then
            refresh_target_allowlist_snapshot
            say "    registry: removed for ${FOR_OPERATOR}"
            return 0
        fi
        if ! report_unrenderable_registry_line; then
            warn "could not remove ${dir} from ${FOR_OPERATOR}'s project registry -- while its line stands a session can still launch there. Remove it with:"
            say  "      ${C_BOLD}sudo ${ALLOWLIST_BIN} --operator ${FOR_OPERATOR} --remove $(_quote_path "${dir}")${C_RST}"
        fi
    else
        [[ -f "${ALLOWLIST}" ]] || return 0
        # The library deletes every RAW `@` and `%` line naming ${dir}, matched through the shared grammar rather
        # than rebuilt from the path, so an entry carrying a comment or quotes is not missed, and a project the operator
        # had disabled leaves no '%' behind to disable whatever is claimed at that path next. A `!` line stays.
        local -a lines=() excl=(); local raw before rc=0
        before="$(allow_state "${dir}")"
        ai_tools_conf__read_allowlist_matching_lines  lines "${ALLOWLIST}" "${dir}" || true
        ai_tools_conf__read_allowlist_disabled_lines      excl  "${ALLOWLIST}" "${dir}" || true
        lines+=("${excl[@]}")
        # The library verifies the removal by re-reading the file rather than trusting `sed`, which writes its temporary
        # file into the registry's own DIRECTORY and so fails on a config dir this operator cannot write even
        # when the registry itself is writable.
        ai_tools_conf__allowlist_remove "${ALLOWLIST}" "${dir}" || rc=$?
        if (( rc == 0 )); then
            if [[ "${before}" == absent ]]; then
                say "    registry: not listed"
            else
                say "    registry: removed"
            fi
            return 0
        fi
        if ! { (( rc == 4 )) && report_unrenderable_registry_line; }; then
            warn "could not remove ${dir} from ${REGISTRY_DISPLAY_PATH} -- a line naming it survived. While an '@' line stands a session can still start there, and a '%' line left behind disables the next project claimed at that path. Remove it by hand:"
            for raw in "${lines[@]}"; do
                printf '      %s%s%s\n' "${C_BOLD}" "$(format_registry_line_removal "${raw}")" "${C_RST}"
            done
        fi
    fi
    die MSG-K8S2 "the registry was not updated -- ${dir} is still registered"
}

# 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. A list the read could not make goes to the helper, which reads it
# again.
reg_safedir() {
    local dir="$1"
    if check_safedir_entry "${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}"
        return 1
    fi
}

# unreg_safedir <dir>  -- the unclaim counterpart to reg_safedir: drop <dir> via SAFEDIR_BIN --remove. The helper's
# --remove does not take a registry gate, so the call sits after the registry step in both kinds of `projects remove`,
# and before the registry disposition in an unclaim, whose hand-back precedes both. Best-effort like reg_safedir: warns
# with the manual command and lets the verb carry on. Only a list read without the entry skips the helper: a list
# the read could not make goes to the helper, which reads it again and removes the entry where it stands.
unreg_safedir() {
    local dir="$1" listed=0
    check_safedir_entry "${dir}" || listed=$?
    if (( listed == 1 )); 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}"
        return 1
    fi
}

# reg_filemode <dir>  -- pin core.filemode=true in the project's own .git/config, so git tracks the executable bit
# the same way for BOTH co-writers whatever either user's global git config: repo-LOCAL, since /opt/ai-tools/.gitconfig
# is the agent's global alone. The claim calls it where the owner-side read recorded `filemode false`, a work tree whose
# own repository does not set the key, so the write is the one git call, made through run_as_owner, since under `--for`
# the .git/config it writes belongs to the target operator.
reg_filemode() {
    local dir="$1"
    if run_as_owner 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
}

# 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 / read_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 (see join_words).
    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
}

# drift_walk_read <capture-file> <paths-array>  -- read a walk's NUL-separated capture into <paths-array> in byte order
# (`sort -z` in the C locale, in place): `find` returns a directory's entries in the order the filesystem stores them,
# so without the sort the page and the record stream would list drift in an order that differs between filesystems
# and between two walks of one tree. Returns 1, with the array empty, when the capture is missing, not a regular file,
# cannot be sorted or cannot be read: a walk whose output was not read is not an empty tree.
drift_walk_read() {
    local -n _walk_paths="$2"
    _walk_paths=()
    [[ -f "$1" && ! -L "$1" && -r "$1" ]] || return 1
    LC_ALL=C sort -z -o "$1" -- "$1" 2>/dev/null || return 1
    mapfile -d '' -t _walk_paths 2>/dev/null < "$1" || { _walk_paths=(); return 1; }
}

# drift_walk_failure <status> <stderr-capture>  -- print why a walk did not complete: its exit status and the first line
# of its stderr, sanitized for display.
drift_walk_failure() {
    local first=""
    IFS= read -r first 2>/dev/null < "$2" || true
    printf 'exited %s%s' "$1" "${first:+: $(ai_tools_log__sanitize "${first}")}"
}

# acl_drift_scan <dir> <work-dir> <paths-array> <detail-var>  -- fill <paths-array> with the 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
# neither setgid nor the ACL -- and the agent gets EACCES on it deep inside an allowlisted project. The walk
# is the repair's (ai-tools-setfacl): `-xdev`, `.git` pruned, and this project's '!'-excluded subtrees (SCAN_EXCLUSIONS)
# pruned, since an intentional exclusion stays unreported; owner-only paths (600/700: locked-down secrets, deliberately
# private files) are left out by the `-perm /077` predicate. The paths come NUL-separated, so a name holding a tab
# or a line feed arrives whole. Returns 1, with <detail-var> naming why, when the walk exits non-zero or writes
# to stderr, or its capture cannot be read: a failed walk is not a complete scan of a smaller tree. Read-only
# and unprivileged, detection only.
acl_drift_scan() {
    local dir="$1" work="$2" exclusion status=0
    local -n _acl_scan_paths="$3" _acl_scan_detail="$4"
    # `.git` is pruned as a directory, the `--with-git` pass's; a worktree's or a submodule's `.git` file is a path
    # the walk grants, so one moved in with a foreign group is drift like any other.
    local -a skip=( -name .git -type d -prune )
    _acl_scan_paths=() _acl_scan_detail=""
    for exclusion in "${SCAN_EXCLUSIONS[@]+"${SCAN_EXCLUSIONS[@]}"}"; do
        [[ "${exclusion}" == "${dir}"/* ]] && skip+=( -o -path "${exclusion}" -prune )
    done
    { LC_ALL=C find "${dir}" -xdev \( "${skip[@]}" \) -o \
        \( -user "${OWNER_USER}" -o -user "${SANDBOX_USER}" \) \
        ! -group "${SANDBOX_GROUP}" -perm /077 -print0 \
        > "${work}/acl.walk" 2> "${work}/acl.walk.err"; } 2>/dev/null || status=$?
    if (( status != 0 )) || [[ ! -f "${work}/acl.walk.err" || -s "${work}/acl.walk.err" ]]; then
        _acl_scan_detail="the group walk $(drift_walk_failure "${status}" "${work}/acl.walk.err")"
        drift_walk_read "${work}/acl.walk" _acl_scan_paths || true
        return 1
    fi
    if ! drift_walk_read "${work}/acl.walk" _acl_scan_paths; then
        _acl_scan_detail="the group walk's output could not be read"
        return 1
    fi
    return 0
}

# narrow_acl_entry_scan <dir> <work-dir> <paths-array> <detail-var>  -- fill <paths-array> with the files inside
# a claimed tree whose named entries a claim before the owner-permissions mask wrote narrower than `rwx`
# (ai_tools_project_permissions__find_paths_with_narrow_acl_entries). The candidates are the files ai-tools-setfacl's
# walk reaches and does not regroup: its skip list and `.git` pruned, this project's '!'-excluded subtrees
# (SCAN_EXCLUSIONS) and owner-only directories pruned, held by the operator, in the sandbox group, with a group or other
# bit. A sandbox-owned file is left out: its owner may set its ACL, so the sandbox account could plant narrow entries
# and have every re-claim offer a walk that resets the operator's own chmods across the tree; an operator-owned file's
# ACL is the operator's to set (tests/boundary/access.sh). A hit is then dropped wherever the walk would skip it,
# so listing it would put it on every re-claim: under an excluded or disabled line by the registry's own covering match
# (ai_tools_conf__is_path_excluded over SCAN_EXCLUSIONS, each glob-free one beside the path `realpath -e` gives it
# as the loader adds it for the helper; the walk's `find` prunes the lines naming a path under the root only to save
# work), and where the caller's secret_patterns_loaded (dynamic scope) says the owner's patterns loaded and a component
# of it matches them. Returns 1, with <detail-var> naming why, when the walk, the ACL reader or the covering match did
# not complete; the hits it did read stay in the array.
narrow_acl_entry_scan() {
    local dir="$1" work="$2" exclusion resolved status=0 excluded_status path operator_uid sandbox_gid reader_detail=""
    local -n _narrow_acl_scan_paths="$3" _narrow_acl_scan_detail="$4"
    # `.git` is pruned as a directory, the `--with-git` pass's; a worktree's or a submodule's `.git` file is
    # a candidate, as the walk grants it.
    local -a skip=( -path "${dir}/.git" -type d -prune -o -type d ! -perm /077 -prune ) candidates=() narrow=() covering=()
    _narrow_acl_scan_paths=() _narrow_acl_scan_detail=""
    for exclusion in "${SCAN_EXCLUSIONS[@]+"${SCAN_EXCLUSIONS[@]}"}"; do
        [[ "${exclusion}" == "${dir}"/* ]] && skip+=( -o -path "${exclusion}" -prune )
    done
    AI_TOOLS_SKIP_DIRS__FIND_EXPRESSION=()
    ai_tools_skip_dirs__build_find_expression setfacl '' "${dir}" 2>/dev/null || true
    { LC_ALL=C find "${dir}" -xdev "${AI_TOOLS_SKIP_DIRS__FIND_EXPRESSION[@]}" \( "${skip[@]}" \) -o \
        -type f -user "${OWNER_USER}" -group "${SANDBOX_GROUP}" -perm /077 -print0 \
        > "${work}/entries.walk" 2> "${work}/entries.walk.err"; } 2>/dev/null || status=$?
    if (( status != 0 )) || [[ ! -f "${work}/entries.walk.err" || -s "${work}/entries.walk.err" ]]; then
        _narrow_acl_scan_detail="the entries walk $(drift_walk_failure "${status}" "${work}/entries.walk.err")"
    fi
    if ! drift_walk_read "${work}/entries.walk" candidates; then
        [[ -n "${_narrow_acl_scan_detail}" ]] || _narrow_acl_scan_detail="the entries walk's output could not be read"
        return 1
    fi
    (( ${#candidates[@]} )) || { [[ -z "${_narrow_acl_scan_detail}" ]]; return; }
    # The identities claim_verify_group resolves, read the same way.
    operator_uid="$(id -u "${OWNER_USER}" 2>/dev/null)" || operator_uid=""
    sandbox_gid="$(getent group "${SANDBOX_GROUP}" 2>/dev/null | cut -d: -f3)" || sandbox_gid=""
    if ! claim_write_list "${work}/entries.list" "${candidates[@]}" \
            || ! ai_tools_project_permissions__find_paths_with_narrow_acl_entries "${work}/entries.list" "${work}" \
                "${dir}" "${operator_uid}" "${sandbox_gid}" narrow reader_detail; then
        [[ -n "${_narrow_acl_scan_detail}" ]] \
            || _narrow_acl_scan_detail="$(ai_tools_log__sanitize "${reader_detail:-the path list for the ACL reader could not be written}")"
    fi
    for exclusion in "${SCAN_EXCLUSIONS[@]+"${SCAN_EXCLUSIONS[@]}"}"; do
        covering+=("${exclusion}")
        ai_tools_conf__has_glob_characters "${exclusion}" && continue
        resolved="$(realpath -e -- "${exclusion}" 2>/dev/null)" || continue
        [[ "${resolved}" == "${exclusion}" ]] || covering+=("${resolved}")
    done
    for path in "${narrow[@]}"; do
        excluded_status=0
        ai_tools_conf__is_path_excluded "${path}" covering || excluded_status=$?
        case "${excluded_status}" in
            0) continue ;;
            1) ;;
            *) [[ -n "${_narrow_acl_scan_detail}" ]] \
                   || _narrow_acl_scan_detail="whether a registry line covers a path could not be read: $(ai_tools_log__sanitize "${path}")"
               continue ;;
        esac
        [[ "${secret_patterns_loaded:-false}" == true ]] && is_secret_drift_path "${dir}" "${path}" && continue
        _narrow_acl_scan_paths+=("${path}")
    done
    [[ -z "${_narrow_acl_scan_detail}" ]]
}

# label_drift_scan <dir> <work-dir> <paths-array> <types-map> <detail-var>  -- fill <paths-array> with the paths inside
# a claimed tree whose SELinux type differs from the one the claim's relabel would apply, and <types-map>
# with `<type it carries> -> <type the policy gives it>` under each. The expected type is asked of the policy: a dry run
# of the relabel the claim performs (`restorecon -n -F`, unprivileged, reading the world-readable file contexts).
# The walk is the relabel's scope, the tree `restorecon -FR` walks, which ai_tools_relabel__label_project labels
# through ai-tools-relabel-tree: every directory, `.git` and skip-listed names included, crossing mount points. Every
# walked name without a line feed goes into one non-recursive batch with `-i`, since a path removed after the walk is
# outside the run's scope; a name holding one goes through ai_tools_project_permissions__check_label on its own, so no
# record can be a fragment of another (project-permissions.lib.sh). Only a TYPE difference counts: `-F` also reports
# the SELinux user and the MLS range, and on the file classes the targeted policy constrains the user for create,
# relabelfrom and relabelto alone, and ai_tools_t does not carry mcs_constrained_type, so a user or category difference
# on its own does not deny the agent (`seinfo --constrain` and `seinfo -a mcs_constrained_type -x` read both on a host).
# Owner-only paths and '!'-excluded subtrees are filtered out afterwards, as the group scan leaves them out. Returns 1,
# with <detail-var> naming why, when restorecon is absent, the walk or the batch output is not complete, a per-path
# check reads unknown, or a drifted path's mode cannot be read and the path is not confirmed gone; the drift it did read
# stays in the arrays.
label_drift_scan() {
    local dir="$1" work="$2" status=0 path outcome from to excl skip mode incomplete=0 index
    local -n _label_scan_paths="$3" _label_scan_types="$4" _label_scan_detail="$5"
    local -a walked=() batched=() exclusions=() unread=() unread_absence=()
    local -A batch_listed=() batch_drift=()
    _label_scan_paths=() _label_scan_types=() _label_scan_detail=""
    # The scan runs only over a labelled root, so SELinux is in use: a missing restorecon is a reading this run could
    # not make, not a tree without drift.
    if ! command -v restorecon >/dev/null 2>&1; then
        _label_scan_detail="restorecon is not installed, so the tree's types could not be read"
        return 1
    fi
    { LC_ALL=C find "${dir}" -print0 > "${work}/label.walk" 2> "${work}/label.walk.err"; } 2>/dev/null || status=$?
    if (( status != 0 )) || [[ ! -f "${work}/label.walk.err" || -s "${work}/label.walk.err" ]]; then
        _label_scan_detail="the label walk $(drift_walk_failure "${status}" "${work}/label.walk.err")"
        incomplete=1
    fi
    if ! drift_walk_read "${work}/label.walk" walked; then
        [[ -n "${_label_scan_detail}" ]] || _label_scan_detail="the label walk's output could not be read"
        return 1
    fi
    for path in "${walked[@]}"; do
        [[ "${path}" == *$'\n'* ]] && continue
        batched+=("${path}")
        batch_listed["${path}"]=1
    done
    if (( ${#batched[@]} )); then
        if ! claim_write_list "${work}/label.list" "${batched[@]}"; then
            [[ -n "${_label_scan_detail}" ]] || _label_scan_detail="the path list for the relabel dry run could not be written"
            incomplete=1
        elif ! ai_tools_project_permissions__check_label_batch "${work}/label.list" "${work}" batch_listed batch_drift -i; then
            [[ -n "${_label_scan_detail}" ]] || _label_scan_detail="the relabel dry run's output was not complete"
            incomplete=1
        fi
    fi
    for path in "${walked[@]}"; do
        [[ "${path}" == *$'\n'* ]] || continue
        ai_tools_project_permissions__check_label "${path}" "${work}" outcome from to
        case "${outcome}" in
            drift) batch_drift["${path}"]="${from}"$'\t'"${to}" ;;
            unknown)
                [[ -n "${_label_scan_detail}" ]] \
                    || _label_scan_detail="a name holding a line feed could not be checked: $(ai_tools_log__sanitize "${path}")"
                incomplete=1 ;;
        esac
    done
    exclusions=("${SCAN_EXCLUSIONS[@]+"${SCAN_EXCLUSIONS[@]}"}")
    for path in "${walked[@]}"; do
        [[ -n "${batch_drift[${path}]+set}" ]] || continue
        skip=false
        for excl in "${exclusions[@]}"; do
            # The exclusion is the pattern (a '!' line may be a glob); the path is matched literally.
            [[ "${path}" == ${excl} || "${path}" == ${excl}/* ]] && { skip=true; break; }
        done
        ${skip} && continue
        # A drifted path whose mode stat fails to read goes to the absence check that follows the loop, and is not
        # dropped.
        if ! mode="$(stat -c '%a' -- "${path}" 2>/dev/null)"; then
            unread+=("${path}")
            continue
        fi
        (( (8#${mode} & 077) == 0 )) && continue
        _label_scan_paths+=("${path}")
        _label_scan_types["${path}"]="${batch_drift[${path}]%%$'\t'*} -> ${batch_drift[${path}]#*$'\t'}"
    done
    # A drifted path removed after the batch is outside the run's scope; one whose mode could not be read for any other
    # reason leaves the scan incomplete, so it is not silently dropped.
    if (( ${#unread[@]} )); then
        if ! claim_write_list "${work}/label.unread" "${unread[@]}" \
                || ! ai_tools_project_permissions__read_lstat_outcomes "${work}/label.unread" "${work}" unread_absence; then
            unread_absence=()
        fi
        for index in "${!unread[@]}"; do
            [[ "${unread_absence[index]:-unknown}" == gone ]] && continue
            [[ -n "${_label_scan_detail}" ]] \
                || _label_scan_detail="the mode of a drifted path could not be read: $(ai_tools_log__sanitize "${unread[index]}")"
            incomplete=1
        done
    fi
    return "${incomplete}"
}

# find_sealed_setgid_directories <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, the one piece of residue
# the claim walks keep rather than strip (owner-only.lib.sh). Read-only and unprivileged, detection only: a path
# reported here is one the claim did not touch. "Third party" is decided per path, against the OWNER's primary group
# and not the invoking user's: on a multi-operator host the group the claim walks treat as legitimate is the resolved
# project owner's, since they act only on paths that owner or the sandbox account holds, so reporting
# against the invoker's would flag a bit the claim goes on to strip, or stay silent about one it keeps. Returns 0
# when the walk covered the tree. Returns 1 without printing a path when the account running it, the project's owner
# in the claim's owner-side read, cannot list and enter <dir>, since a walk that cannot start reads like a tree with no
# such directory. Returns 2 when `find` exits non-zero, a subdirectory the walk cannot read among the causes, with every
# path it did reach printed, so a list that may be short is not read as complete. Each path is printed NUL-terminated,
# so a name holding a line feed arrives whole.
find_sealed_setgid_directories() {
    local dir="$1" exclusion find_status=0
    [[ -r "${dir}" && -x "${dir}" ]] || return 1
    local -a skip=( -name .git -prune )
    for exclusion in "${SCAN_EXCLUSIONS[@]+"${SCAN_EXCLUSIONS[@]}"}"; do
        [[ "${exclusion}" == "${dir}"/* ]] && skip+=( -o -path "${exclusion}" -prune )
    done
    # 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\0' 2>/dev/null \
    | while IFS=$'\t' read -r -d '' _uid _group _path; do
          [[ "${_group}" == "$(id -gn "${_uid}" 2>/dev/null || true)" ]] && continue
          printf '%s\0' "${_path}"
      done || find_status="${PIPESTATUS[0]}"
    (( find_status == 0 )) || return 2
}

# reg_ownership <dir> [force]  -- 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 the chgrp
# needs root; the helper carries its own allowlist + owner guard. Pre-existing FILES become agent-accessible
# through the ACL claim_setfacl applies next (ai-tools-setfacl's header states which paths it regroups).
#
# CALLER MUST run secret_gate "${dir}" first: the steps this one leads open existing files to the agent's group,
# and the gate locks secrets to 600/700 ahead of them.
reg_ownership() {
    local dir="$1" force="${2:-}"
    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 it by hand:"
        say  "      ${C_BOLD}sudo ${SETGID_BIN} ${dir}${C_RST}"
        return 1
    fi
}

# agent_can_traverse <dir>  -- 0 if the sandbox account (SANDBOX_USER, a SANDBOX_GROUP member) can ENTER <dir>, decided
# the way the kernel decides it (acl(5)): the owner entry when the account owns the directory; else the named-user entry
# for the account when there is one, whatever the group and other entries say; else the owning-group entry
# when the directory is in SANDBOX_GROUP, and every named-group entry for SANDBOX_GROUP; else the other entry. A named
# entry and the owning-group entry are narrowed by the mask, so `user:SANDBOX_USER:--x` under `mask::---` -- the state
# a `chmod 700` after an earlier grant leaves -- reads as blocked, and the grant is offered again. Returns 1
# when the account cannot enter, and 2 when the answer cannot be read: the stat fails, or getfacl is installed and fails
# on the directory. A mode read in place of an ACL that could not be read would say "traversable" over a named entry
# denying it, so the caller reads 2 as blocked by a directory no grant may cover. Without getfacl on the host the mode
# bits alone decide, since no ACL can be inspected there at all.
agent_can_traverse() {
    local dir="$1" mode owner grp acl
    # IFS pinned (see join_words).
    IFS=' ' read -r mode owner grp < <(stat -c '%a %U %G' -- "${dir}" 2>/dev/null) || return 2
    if command -v getfacl >/dev/null 2>&1; then
        acl="$(getfacl -p -c -E -- "${dir}" 2>/dev/null)" || return 2
        local line tag name perms mask="" named_user="" owner_perms="" other="" found_group=false
        local -a group_perms=()
        while IFS= read -r line; do
            IFS=: read -r tag name perms <<< "${line}"
            case "${tag}:${name}" in
                "user:")                   owner_perms="${perms}" ;;
                "user:${SANDBOX_USER}")    named_user="${perms}" ;;
                "group:")                  [[ "${grp}" == "${SANDBOX_GROUP}" ]] && group_perms+=("${perms}") ;;
                "group:${SANDBOX_GROUP}")  group_perms+=("${perms}") ;;
                "mask:")                   mask="${perms}" ;;
                "other:")                  other="${perms}" ;;
            esac
        done <<< "${acl}"
        _masked_x() { [[ "$1" == *x* ]] && [[ -z "${mask}" || "${mask}" == *x* ]]; }
        if [[ "${owner}" == "${SANDBOX_USER}" ]]; then [[ "${owner_perms}" == *x* ]]; return; fi
        if [[ -n "${named_user}" ]]; then _masked_x "${named_user}"; return; fi
        for perms in "${group_perms[@]}"; do
            found_group=true
            _masked_x "${perms}" && return 0
        done
        ${found_group} && return 1
        [[ "${other}" == *x* ]]; return
    fi
    if [[ "${owner}" == "${SANDBOX_USER}" ]]; then (( 8#${mode} & 0100 )); return; fi
    if [[ "${grp}" == "${SANDBOX_GROUP}" ]]; then (( 8#${mode} & 0010 )); return; fi
    (( 8#${mode} & 0001 ))
}

# grantable_ancestor <dir>  -- 0 if confirm_ancestor_traversal may offer traverse on <dir>: the call site
# of ai_tools_safe_paths__is_traverse_grant_allowed (safe-paths.lib.sh), asked for the owner this run acts FOR. Returns
# 1 when the predicate is not loaded, so a broken install never widens a directory it cannot vet.
grantable_ancestor() {
    local p="$1"
    declare -F ai_tools_safe_paths__is_traverse_grant_allowed >/dev/null 2>&1 || return 1
    ai_tools_safe_paths__is_traverse_grant_allowed "${p}" "${OWNER_USER}"
}

# find_blocking_ancestors <dir>  -- detect the traverse gap between the sandbox account and <dir>: fills
# TRAVERSAL_GRANT_PATHS (each blocking ancestor a grant may cover: operator-owned, not a protected system directory)
# and TRAVERSAL_BLOCKED_PATH (the first blocking ancestor no grant may cover, empty when none),
# with TRAVERSAL_BLOCKED_REASON naming why where the reason is the read rather than the directory. Read-only
# and unprivileged. An ancestor whose ACL could not be read (agent_can_traverse returns 2) is the blocker, whoever owns
# it: no grant is offered on a state the walk did not read. The walk reads every ancestor up to `/`, since the kernel
# resolves each component on its own and a `700` parent of a `755` directory blocks the path as surely as the reverse;
# it ends early only at a blocker no grant covers, since a grant on a directory inside that blocker could not open
# the path.
find_blocking_ancestors() {
    local dir="$1" anc traverse=0
    TRAVERSAL_GRANT_PATHS=(); TRAVERSAL_BLOCKED_PATH=""; TRAVERSAL_BLOCKED_REASON=""
    anc="$(dirname "${dir}")"
    while [[ "${anc}" != / && "${anc}" != . ]]; do
        traverse=0; agent_can_traverse "${anc}" || traverse=$?
        if (( traverse == 2 )); then
            TRAVERSAL_BLOCKED_PATH="${anc}"; TRAVERSAL_BLOCKED_REASON="its permissions could not be read"; break
        elif (( traverse != 0 )); then
            if grantable_ancestor "${anc}"; then
                TRAVERSAL_GRANT_PATHS+=("${anc}")
            else
                TRAVERSAL_BLOCKED_PATH="${anc}"; break
            fi
        fi
        anc="$(dirname "${anc}")"
    done
}

# confirm_ancestor_traversal <dir>  -- the reachability block's question, asked on find_blocking_ancestors's result (the
# CALLER runs it first): a default-NO confirm for a traverse-only `u:SANDBOX_USER:--x` entry on each blocking ancestor,
# listing under each path every entry the grant widens beside the account's own (traverse_grant_plan). Sets
# TRAVERSAL_GRANT_CONFIRMED and does not set the ACL: an accepted grant is an access-widening step, so the caller runs
# the secret gate on it and applies it with grant_ancestor_traversal in the Apply block, after the gate. A blocker no
# grant covers gets the warning naming the clone as the way in. The reachability story is cli.rule.md's.
confirm_ancestor_traversal() {
    local dir="$1" a
    TRAVERSAL_GRANT_CONFIRMED=false
    if [[ -n "${TRAVERSAL_BLOCKED_PATH}" ]]; then
        local why blocked_owner
        blocked_owner="$(stat -c '%U' "${TRAVERSAL_BLOCKED_PATH}" 2>/dev/null || echo '?')"
        if [[ -n "${TRAVERSAL_BLOCKED_REASON}" ]]; then
            why="${TRAVERSAL_BLOCKED_REASON}, so no grant is offered on it"
        elif ! declare -F ai_tools_safe_paths__is_traverse_grant_allowed >/dev/null 2>&1; then
            why="the safe-paths traverse rule is not loaded, so ancestors cannot be vetted"
        elif [[ "${blocked_owner}" != "${OWNER_USER}" ]]; then
            why="owned by ${blocked_owner}, not by ${OWNER_USER}"
        else
            why="a protected system directory"
        fi
        headline_warn "WARNING: project unreachable for the sandbox account" \
            "the sandbox account cannot traverse ${TRAVERSAL_BLOCKED_PATH} (${why}), so it cannot reach ${dir}; an isolated clone under the sandbox area is the way in:"
        say "      ${C_BOLD}atr projects clone ${dir}${C_RST}"
        return 0
    fi
    if (( ${#TRAVERSAL_GRANT_PATHS[@]} == 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"
    local -a grant_argv=() grant_widened=() entry
    for a in "${TRAVERSAL_GRANT_PATHS[@]}"; do
        say "      ${a}"
        traverse_grant_plan "${a}" grant_argv grant_widened || continue
        (( ${#grant_widened[@]} )) || continue
        say "        the mask on it rises to carry execute, so these entries gain traverse with the account:"
        for entry in "${grant_widened[@]}"; do say "          ${entry}"; done
    done

    # The owner's own HOME ROOT is the one entry in that list whose consequence is stated, as a condition the named
    # command answers on this host; the condition itself is safe-paths.rule.md's.
    local owner_home includes_home=false
    owner_home="$(getent passwd "${OWNER_USER}" 2>/dev/null | cut -d: -f6)"
    if [[ -n "${owner_home}" ]]; then
        for a in "${TRAVERSAL_GRANT_PATHS[@]}"; do
            [[ "${a}" == "${owner_home%/}" ]] && { includes_home=true; break; }
        done
    fi
    if ${includes_home}; then
        say ""
        say "  ${owner_home} is ${OWNER_USER}'s home directory. Traversal does not list it and"
        say "  does not open the files in it -- each file's own mode and ACL still decides."
        say "  What it makes reachable is whatever there is already world-readable; this lists it:"
        say ""
        say "      ${C_BOLD}find ${owner_home} -maxdepth 1 -perm -o+r${C_RST}"
        say ""
    fi

    # Default NO, and deliberately not pre-answerable: the grant widens access on the project's ANCESTORS, so neither
    # AI_TOOLS_ASSUME_YES (which only fast-tracks default-YES questions) nor the claim's own -y reaches it. A run
    # with no terminal therefore declines, and prints the commands so the refusal is actionable rather than merely
    # recorded.
    if confirm "Grant the sandbox account traverse-only access on them?" n; then
        TRAVERSAL_GRANT_CONFIRMED=true
    else
        say "    reach: left as-is -- the agent may be unable to enter ${dir}"
        have_tty || for a in "${TRAVERSAL_GRANT_PATHS[@]}"; do
            traverse_grant_plan "${a}" grant_argv grant_widened || grant_argv=(-n -m "u:${SANDBOX_USER}:--x")
            say "      ${C_BOLD}setfacl ${grant_argv[*]} -- ${a}${C_RST}"
        done
    fi
}

# traverse_grant_plan <dir> <argv-var> <widened-var>  -- compute the setfacl arguments that give the sandbox account
# traverse on <dir> without raising the mask past execute, and the entries that gain traverse alongside it. `setfacl -m`
# recalculates the mask to the union of every group-class entry, so a directory holding `group:devs:rwx`
# under `mask::---` would end with `mask::rwx` and that group at full access; `-n` keeps the mask as it is, which leaves
# the new entry with no effect where the mask lacks execute. So the call is `-n` with the mask set explicitly to what it
# was -- `group::` where the directory has no mask, as setfacl derives one -- plus execute. Every other masked entry
# that holds execute then gains it in effect, and <widened-var> names each as `<tag>:<name> <perms>` for the prompt.
# Returns 1 with both empty where the ACL could not be read, so no call is made on a state that was not read.
traverse_grant_plan() {
    local dir="$1"
    local -n _plan_argv="$2" _plan_widened="$3"
    local acl line tag name perms mask="" owning_group_perms="" base
    local -a masked=()
    _plan_argv=(); _plan_widened=()
    acl="$(getfacl -p -c -E -- "${dir}" 2>/dev/null)" || return 1
    while IFS= read -r line; do
        IFS=: read -r tag name perms <<< "${line}"
        case "${tag}:${name}" in
            "mask:")                mask="${perms}" ;;
            "group:")               owning_group_perms="${perms}"; masked+=("${tag}:${name} ${perms}") ;;
            "user:${SANDBOX_USER}") ;;
            user:|other:|default*)  ;;
            user:*|group:*)         masked+=("${tag}:${name} ${perms}") ;;
        esac
    done <<< "${acl}"
    base="${mask:-${owning_group_perms}}"
    [[ "${base}" =~ ^[r-][w-][x-]$ ]] || return 1
    _plan_argv=(-n -m "u:${SANDBOX_USER}:--x" -m "m::${base:0:2}x")
    [[ "${base}" == *x* ]] && return 0
    for line in "${masked[@]}"; do
        [[ "${line##* }" == *x* ]] && _plan_widened+=("${line}")
    done
    return 0
}

# grant_ancestor_traversal  -- apply the grant confirm_ancestor_traversal accepted: one traverse-only ACL entry
# per blocking ancestor, each reported on its own result line, and a manual command for one that could not be set.
# Unprivileged, since the operator owns those directories; the CALLER runs the secret gate first. The call is
# traverse_grant_plan's, so the mask rises to execute and no further. Returns non-zero when any ancestor was not
# granted, an ACL that could not be read included: one left blocking keeps the project out of reach whatever the others
# took, so the caller counts it as a step that did not apply.
grant_ancestor_traversal() {
    local a failed=false
    local -a grant_argv=() grant_widened=()
    for a in "${TRAVERSAL_GRANT_PATHS[@]}"; do
        # A --for run's ancestors belong to the TARGET, so an unprivileged setfacl by the invoker fails on every one
        # of them; run_as_owner applies it as the owner instead.
        if traverse_grant_plan "${a}" grant_argv grant_widened \
                && run_as_owner setfacl "${grant_argv[@]}" -- "${a}" 2>/dev/null; then
            say "    reach: u:${SANDBOX_USER}:--x ${a}"
        else
            failed=true
            (( ${#grant_argv[@]} )) || grant_argv=(-n -m "u:${SANDBOX_USER}:--x")
            warn "reach: could not grant on ${a} -- run it as ${OWNER_USER} or as root:"
            say  "      ${C_BOLD}setfacl ${grant_argv[*]} -- ${a}${C_RST}"
        fi
    done
    ! ${failed}
}

# normalize_clone <dir> [locked-path...]  -- open a freshly created clone's modes and ACL: group rwX on every path
# and `g+s` on every directory (the owner stays the projects user), with every <locked-path> -- the secret gate's finds,
# locked owner-only by ai-tools-lockdown -- PRUNED from both walks, since re-opening one would undo the lockdown this
# step follows. It prunes what THIS run's gate reported alone, which is why sandbox_finalize runs it once, while
# the root is still owner-only (clone_is_private). The gate's walk covers every tree this opens (ai-tools-lockdown's
# header). Both walks act on regular files and directories alone and stay on the clone's filesystem (`-xdev`): chmod
# follows a symlink named on its command line, so a tracked symlink handed to it would change a target outside
# the clone. A locked path is pruned by its literal name: `-path` reads its argument as a pattern, so `[`, `*`, `?`
# and `\` are escaped first (find_pattern_literal), or the walk would re-open the secret.
#
# The walks run as the operator, who is not in SANDBOX_GROUP, and the kernel clears the setgid bit of a directory
# in that group on every chmod and access-ACL write such a caller makes. So on the directories clone_privately left
# in SANDBOX_GROUP the setgid bit does not hold after this walk, and sandbox_finalize's root steps that follow it set
# the bit, and regroup a path git created in the operator's group.
#
# The clone is created without the area's default ACL (clone_privately), so the walk then gives every opened path
# the access ACL the clone area's default ACL would have given it at creation, and every directory that default ACL
# itself: the named and `group::` entries as the default holds them, and the mask the kernel derives from the create
# mode -- `rwx` on a directory and on a file with an owner execute bit, `rw-` on any other file, the rule
# ai-tools-setfacl applies to a claimed file. A umask-077 create does not leave a group or other execute bit, so this is
# also what `chmod g+rwX` leaves after the inherited mask. A plain `setfacl -m` recalculates the mask from the entries
# and would open every file to execute, so each class takes its mask explicitly under `-n`. `user::` and `other::` keep
# the bits the create's umask 077 left.
normalize_clone() {
    local d="$1"; shift
    local -a prune=() p pattern
    for p in "$@"; do
        find_pattern_literal pattern "${p}"
        prune+=( -path "${pattern}" -prune -o )
    done
    find "${d}" -xdev "${prune[@]}" '(' -type f -o -type d ')' -exec chmod g+rwX {} +
    find "${d}" -xdev "${prune[@]}" -type d -exec chmod g+s {} +

    local area_acl entry access="" defaults=""
    if ! area_acl="$(getfacl -p -c -E -d -- "${d%/*}" 2>/dev/null)"; then
        warn "could not read the default ACL of ${d%/*}; the clone is open to group ${SANDBOX_GROUP} alone"
        return 0
    fi
    while IFS= read -r entry; do
        [[ -n "${entry}" ]] || continue
        defaults+="${defaults:+,}d:${entry}"
        case "${entry}" in
            user::*|mask::*|other::*) ;;
            *) access+="${access:+,}${entry}" ;;
        esac
    done <<< "${area_acl}"
    [[ -n "${defaults}" ]] || return 0
    find "${d}" -xdev "${prune[@]}" -type d -exec setfacl -n -m "${access}${access:+,}m::rwx,${defaults}" {} +
    find "${d}" -xdev "${prune[@]}" -type f -perm -u+x -exec setfacl -n -m "${access}${access:+,}m::rwx" {} +
    find "${d}" -xdev "${prune[@]}" -type f ! -perm -u+x -exec setfacl -n -m "${access}${access:+,}m::rw-" {} +
}

# find_pattern_literal <output-variable> <path>  -- set <output-variable> to <path> escaped for find's `-path`/`-name`
# pattern grammar, so it matches the path literally: a backslash escapes `\`, `*`, `?` and `[`, the characters
# fnmatch(3) reads as pattern syntax. The result is set rather than printed: a `$(...)` capture strips a trailing
# newline, so a locked name ending in one would lose it and miss its prune.
find_pattern_literal() {
    local -n _pattern_out="$1"
    local s="$2"
    s="${s//\\/\\\\}"; s="${s//\*/\\*}"; s="${s//\?/\\?}"; s="${s//\[/\\[}"
    _pattern_out="${s}"
}

# clone_is_private <dir>  -- 0 while the clone root is owner-only: the state cmd_project_clone's private create leaves
# a clone in, and a declined gate leaves it in (owner-only.lib.sh states the predicate the root helpers apply). A mode
# that cannot be read counts as opened, so a resume does not widen a tree it could not read.
clone_is_private() {
    local mode
    mode="$(stat -c '%a' "$1" 2>/dev/null)" || return 1
    [[ "${mode}" =~ ^[0-7]+$ ]] || return 1
    (( ( 8#${mode} & 077 ) == 0 ))
}

# clone_access_ready <dir>  -- 0 when an opened clone holds one group throughout: the root and its .git carry
# the setgid bit, and every file and directory with a group permission bit is in the root's group. The clone area's
# setgid hands the root SANDBOX_GROUP, so a path in another group -- the operator's, where git created the tree in it --
# is one the agent's group bits do not reach, and the steps before this one each succeed over such a tree; this reads
# the tree back before sandbox_finalize reports it ready. It reads as the operator, with stat and find, prints each
# finding, and returns 1 for a finding and for a reading it could not make.
clone_access_ready() {
    local d="$1" p group mode root_group="" stray ready=0
    for p in "${d}" "${d}/.git"; do
        if ! IFS=' ' read -r group mode < <(stat -c '%G %a' -- "${p}" 2>/dev/null) \
                || ! [[ "${mode}" =~ ^[0-7]+$ ]]; then
            say "    could not read the group and mode of $(ai_tools_log__sanitize "${p}")"
            ready=1; continue
        fi
        [[ -n "${root_group}" ]] || root_group="${group}"
        if [[ "${group}" != "${root_group}" ]] || (( ( 8#${mode} & 02000 ) == 0 )); then
            say "    $(ai_tools_log__sanitize "${p}"): group ${group}, mode ${mode} -- want group ${root_group} with setgid"
            ready=1
        fi
    done
    [[ -n "${root_group}" ]] || return 1
    if ! stray="$(find "${d}" -xdev '(' -type f -o -type d ')' -perm /070 ! -group "${root_group}" \
                      -print -quit 2>/dev/null)"; then
        say "    could not read every path under $(ai_tools_log__sanitize "${d}") for its group"
        ready=1
    elif [[ -n "${stray}" ]]; then
        say "    $(ai_tools_log__sanitize "${stray}"): group $(stat -c '%G' -- "${stray}" 2>/dev/null || printf '?') -- want ${root_group}"
        ready=1
    fi
    return "${ready}"
}

# 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. Returns 0 when the root carries ai_tools_project_t afterwards,
# read back from the inode as ai_tools_relabel__label_project reads it, since restorecon exits 0 over a type no rule
# maps (the module not loaded); 0 as well where SELinux is disabled or restorecon is absent, with no walk; 1 otherwise.
#
# sandbox_finalize runs it while the clone root is still 0700 and the operator's, ahead of normalize_clone, so no
# directory inside the clone is the sandbox account's to rename mid-walk. It keeps `restorecon -FR`, run
# as the operator: the tree walker is root-only, and no component of the 0700 clone is one the account can rename
# (cli.rule.md, ref-section-e6c6). A resume over a clone already opened does not run a relabel: the first finalize
# labelled it, before it opened.
#
# Where SELinux runs and no ai_tools policy is on the host, ai_tools_project_t does not exist to read the clone
# with, so it returns 0 and says the clone is unlabelled: that reading is
# ai_tools_confinement__evaluate_dac_only_state's `module-absent`, the state ai-tools-run launches through. Every other
# reading keeps the restorecon and the stop -- a module shipped and not loaded, a matchpathcon that could not be read,
# a confinement library that did not load. The line names the policy install, which labels the clone area, since
# a resume does not label a clone already opened.
relabel_clone() {
    local clone_dir="$1" selinux_mode module_present policy_shipped dac_only_state=""
    command -v restorecon >/dev/null 2>&1 || return 0
    selinux_mode="$(getenforce 2>/dev/null || true)"
    [[ "${selinux_mode}" == "Disabled" ]] && return 0
    # shellcheck source=SCRIPTDIR/../lib/ai-tools/confinement.lib.sh
    source "${CONFINEMENT_LIB}" 2>/dev/null || true
    if declare -F ai_tools_confinement__evaluate_dac_only_state >/dev/null 2>&1 \
            && declare -F ai_tools_confinement__read_module_present >/dev/null 2>&1 \
            && declare -F ai_tools_confinement__read_policy_shipped >/dev/null 2>&1 \
            && module_present="$(ai_tools_confinement__read_module_present \
                                     "${AI_TOOLS_CONFINEMENT__MODULE_PROBE_PATH}" 2>/dev/null)"; then
        policy_shipped="$(ai_tools_confinement__read_policy_shipped "${AI_TOOLS_CONFINEMENT__CORE_MODULE_FILE}")"
        dac_only_state="$(ai_tools_confinement__evaluate_dac_only_state \
                              "${selinux_mode}" "${module_present}" "${policy_shipped}")" || dac_only_state=""
    fi
    if [[ "${dac_only_state}" == module-absent ]]; then
        say "  - the clone is not labelled: no ai_tools policy is on this host, so ai_tools_t does not exist to read it;"
        say "    installing ai-tools-selinux labels the sandbox clones (from a checkout: sudo selinux/install-selinux.sh install)"
        return 0
    fi
    local context
    if ! restorecon -FR "${clone_dir}" 2>/dev/null; then
        warn "could not label ${clone_dir} ai_tools_project_t for SELinux -- restorecon -FR failed"
        return 1
    fi
    context="$(stat -c '%C' -- "${clone_dir}" 2>/dev/null)" || context=""
    if [[ "${context}" == *:ai_tools_project_t:* ]]; then
        ok "labelled clone ai_tools_project_t (SELinux)"
        return 0
    fi
    warn "could not label ${clone_dir} ai_tools_project_t for SELinux -- the root reads ${context:-an unreadable context}"
    return 1
}

# ── Lockdown helpers ───────────────────────────────────────────────────────────
# ai-tools-lockdown revokes ai-tools' read access to secret-named files under a project. It is root-only and takes
# the project directory as its argument, so the invoker need not be able to enter the tree.

# run_lockdown <dir> [extra-args...]  -- run the helper on <dir>, its options ahead of `--` and the path after it, so
# a path spelled like an option stays a path; returns its status.
run_lockdown() {
    local d="$1"; shift
    sudo "${LOCKDOWN_BIN}" "$@" -- "${d}"
}

# 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.
# The helper found no label to apply on this host -- SELinux disabled, or no ai_tools policy installed -- and said why
# on stderr.
readonly RELABEL_NOT_APPLICABLE_STATUS=5
# The revert completed except on the hardlinked paths the helper listed on stderr, which keep their current type.
readonly RELABEL_PARTIAL_REVERT_STATUS=4
# The tree walker the helper runs could not start, so no path was reverted; the helper's line says why.
readonly RELABEL_WALKER_UNAVAILABLE_STATUS=6
run_relabel() {
    local d="$1"; shift
    # stdout suppressed: the helper narrates its own success ("labelled <path> ai_tools_project_t"), which lands
    # unindented in the middle of a flow block whose result lines are all indented. stderr is kept -- that is
    # where a failure explains itself.
    sudo "${RELABEL_BIN}" "$@" "${d}" >/dev/null
}

# 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> [skip_drifted]  -- 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,
# and when <skip_drifted> is true, --skip-drifted so the walk leaves each path with a foreign group as it is. Returns
# its status.
run_setfacl() {
    local d="$1" with_git="${2:-false}" skip_drifted="${3:-false}"
    local -a flags=()
    ${with_git} && flags+=(--with-git)
    ${skip_drifted} && flags+=(--skip-drifted)
    sudo "${SETFACL_BIN}" "${flags[@]+"${flags[@]}"}" "${d}"
}

# 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 setgid group
# change, the group ACL, a drift repair, .git normalization, the SELinux label, an accepted traverse grant, the clone
# normalize -- one `ai-tools-lockdown --gate` call (sudo; the first prompt of a claim, so it lands under this block's
# headline) scans, lists, asks and locks, so a host whose sudo does not cache the password asks once. Its walk covers
# every tree the steps after it open (ai-tools-lockdown's header). Its exit decides: 0 locked or found none, 6 declined,
# anything else failed. AI_TOOLS_ASSUME_YES is passed as `--yes`, since sudo does not pass the variable through. Fills
# SECRET_MATCH_PATHS with every path the helper wrote to stdout, for normalize_clone to prune. Returns 0 only
# when the tree is safe to expose; on non-zero the caller fails closed.
secret_gate() {
    local dir="$1" found status=0
    local -a args=(--gate)
    SECRET_MATCH_PATHS=()
    [[ "${AI_TOOLS_ASSUME_YES:-}" == 1 ]] && args+=(--yes)
    headline "Secret lockdown" "${dir}"
    found="$(mktemp)" || { warn "cannot create a temporary file for the secret scan -- not granting access"; return 1; }
    run_lockdown "${dir}" "${args[@]}" > "${found}" || status=$?
    mapfile -d '' -t SECRET_MATCH_PATHS < "${found}"
    rm -f "${found}"
    case "${status}" in
        0)
            if (( ${#SECRET_MATCH_PATHS[@]} )); then
                ok "secrets locked down"
                ai_tools_log__structured info "secret pre-check: secrets locked down under ${dir}" \
                    "AI_TOOLS_PROJECT=${dir}" "AI_TOOLS_RESULT=ok"
            else
                ok "no secret-matching paths found"
                ai_tools_log__structured info \
                    "secret pre-check: clean, no secret-matching paths under ${dir}" \
                    "AI_TOOLS_PROJECT=${dir}" "AI_TOOLS_RESULT=ok"
            fi
            return 0 ;;
        6)
            warn "declined -- access will not be granted while secrets are exposed"
            ai_tools_log__structured warning \
                "secret pre-check: lockdown declined for ${dir}, access not granted" \
                "AI_TOOLS_PROJECT=${dir}" "AI_TOOLS_RESULT=refused"
            return 1 ;;
        *)
            warn "secret lockdown did not complete -- not granting access"
            ai_tools_log__structured error "secret pre-check: lockdown failed under ${dir}, access not granted" \
                "AI_TOOLS_PROJECT=${dir}" "AI_TOOLS_RESULT=failed"
            return 1 ;;
    esac
}

# drop_lockdown_guard <dir>  -- write a placeholder CLAUDE.md telling the agent to wait 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. The command it names
# is the clone's resume: after a declined gate the clone is unregistered, so `projects lockdown` would refuse it
# as outside every claimed project, while `projects clone` on the clone path re-enters the gate.
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 resuming the clone, as the projects user:

      atr projects clone ${d}

Only paths approved in the operator's project registry
(\`~/.config/ai-tools/project-registry\`) 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 ─────────────────────────────────────────────────────────────────────

# read_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, read here, safedir `unknown`
# where the safe.directory list could not be read; the five tree tokens are read_project_tree_state's. `projects list`
# reads all seven as the invoker; the claim reads listed and safedir itself and takes the tree tokens
# from the owner-side read (`projects inspect`). Read-only, no privilege.
read_project_state() {
    local dir="$1" listed=false safedir=false safedir_status=0
    ai_tools_conf__has_allowlist_entry "${ALLOWLIST}" "${dir}" 2>/dev/null && listed=true
    check_safedir_entry "${dir}" || safedir_status=$?
    case "${safedir_status}" in
        0) safedir=true ;;
        2) safedir=unknown ;;
    esac
    printf '%s %s %s\n' "${listed}" "${safedir}" "$(read_project_tree_state "${dir}")"
}

# list_safedir_entries  -- print the git safe.directory values GITCONFIG holds, one per line, as git stores them.
# Returns 0, the list empty when the file or the key is absent (git's exit 1), and non-zero when git could not read
# the file. git runs from /: it reads its working directory at startup and exits 128 when that directory was deleted,
# where a shell stands after `atr projects remove .`.
list_safedir_entries() {
    local status=0
    git -C / config --file "${GITCONFIG}" --get-all safe.directory 2>/dev/null || status=$?
    (( status <= 1 ))
}

# check_safedir_entry <dir>  -- 0 when the git safe.directory list in GITCONFIG holds <dir> exactly, 1 when it does not,
# and 2 when the list could not be read. Three answers, so a caller reads the status rather than a truth value.
check_safedir_entry() {
    local entries
    entries="$(list_safedir_entries)" || return 2
    grep -qxF -- "$1" <<< "${entries}"
}

# read_project_tree_state <dir>  -- print the five readings of <dir>'s tree as space-separated tokens: "<filemode>
# <owngap> <acl> <labelled> <git>". filemode is true when repo-local core.filemode is already true ("na" when <dir> is
# not the toplevel of a git work tree, a subdirectory of an enclosing repository among them); 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. filemode and git read "unknown" when the account running it cannot make the reading: both when
# <dir> cannot be entered; filemode when a .git is present and the repository its git opens is not the project's own
# (another account's repository, which git's ownership check refuses, or an enclosing one git walked to past a .git it
# cannot read); git when a .git is present that is neither a readable gitfile nor a directory the account can list
# and enter, a symlink into a closed directory among them. A reading that could not be made is not reported as a gap or
# as its absence. Read-only, no privilege, and it does not read a registry. 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.
read_project_tree_state() {
    local dir="$1" filemode=na owngap=true acl=false labelled=na git=false
    local toplevel
    if [[ ! -x "${dir}" ]]; then
        filemode=unknown
    elif [[ -e "${dir}/.git" || -L "${dir}/.git" ]]; then
        # The repository git opens must be the project's own. `git rev-parse --show-toplevel` and not
        # `--absolute-git-dir`: a worktree or submodule checkout has a gitfile .git whose git directory is elsewhere
        # and whose toplevel is the project.
        if toplevel="$(git -C "${dir}" rev-parse --show-toplevel 2>/dev/null)" \
                && [[ "$(realpath -e -- "${toplevel}" 2>/dev/null)" == "$(realpath -e -- "${dir}" 2>/dev/null)" ]]; then
            [[ "$(git -C "${dir}" config --local --get core.filemode 2>/dev/null)" == "true" ]] \
                && filemode=true || filemode=false
        else
            filemode=unknown
        fi
    elif toplevel="$(git -C "${dir}" rev-parse --show-toplevel 2>/dev/null)" \
            && [[ "$(realpath -e -- "${toplevel}" 2>/dev/null)" == "$(realpath -e -- "${dir}" 2>/dev/null)" ]]; then
        # Without a .git of its own <dir> reads a repository only where git opens one whose toplevel is <dir>; a
        # subdirectory of an enclosing repository reads na, so the claim does not pin core.filemode in that repository.
        [[ "$(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
    # A readable gitfile is git_gap's documented no-tree case; git_gap reads a .git directory only where the account can
    # list and enter it.
    if [[ ! -x "${dir}" ]]; then
        git=unknown
    elif [[ -f "${dir}/.git" && -r "${dir}/.git" ]]; then
        git=false
    elif [[ -d "${dir}/.git" && -r "${dir}/.git" && -x "${dir}/.git" ]]; then
        git_gap "${dir}" && git=true
    elif [[ -e "${dir}/.git" || -L "${dir}/.git" ]]; then
        git=unknown
    fi
    printf '%s %s %s %s %s' "${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
    local relabel_status=0
    run_relabel "${d}" || relabel_status=$?
    if (( relabel_status == 0 )); then
        say "    SELinux label: ai_tools_project_t applied"
    elif (( relabel_status == RELABEL_NOT_APPLICABLE_STATUS )); then
        say "    SELinux label: not applicable on this host (the helper's line above says why)"
    else
        warn "could not apply the SELinux label -- run it by hand:"
        say  "      ${C_BOLD}sudo ${RELABEL_BIN} ${d}${C_RST}"
        return 1
    fi
}

# claim_setfacl <dir> <with_git> [skip_drifted]  -- 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,
# and when <skip_drifted> is true the walk leaves the group drift the operator declined to repair as it is. 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}" skip_drifted="${3:-false}" flag="" note=""
    ${with_git} && { flag=" --with-git"; note=" (incl. .git)"; }
    ${skip_drifted} && flag+=" --skip-drifted"
    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}" "${skip_drifted}"; 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}"
        return 1
    fi
}

# 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. A caller listing drift sets PATH_DETAIL_MARK=1 for the call, which appends
# drift_secret_mark to each line. A path the claim's owner-side read measured (PATH_DETAIL_BEFORE) is shown with that
# reading, `?` where the owner could not read it, since under `--for` the invoker may not reach the path at all; every
# other path is read here.
declare -A PATH_DETAIL_BEFORE=()
path_detail_lines() {
    local _p _og _m _mark=""
    for _p in "$@"; do
        if [[ -n "${_p}" && -n "${PATH_DETAIL_BEFORE[${_p}]+set}" ]]; then
            IFS=' ' read -r _og _m <<< "${PATH_DETAIL_BEFORE[${_p}]}"
            [[ -n "${_m}" ]] || _m='?'
        else
            IFS=' ' read -r _og _m < <(stat -c '%U:%G %a' "${_p}" 2>/dev/null) \
                || { _og='?'; _m='?'; }
        fi
        [[ -z "${PATH_DETAIL_MARK:-}" ]] || _mark="$(drift_secret_mark "${_p}")"
        printf '        %s%-18s %-4s %s%s%s\n' "${C_DIM}" "${_og}" "${_m}" "$(ai_tools_log__sanitize "${_p}")" "${C_RST}" \
            "${_mark}"
    done
}

# is_secret_drift_path <root> <path>  -- 0 when a component of <path> under the project root <root> matches a secret
# pattern, the match ai-tools-lockdown makes before any repair runs; 1 when none does or the patterns library is not
# loaded. The owner-side read calls it only once its load of the owner's own patterns file succeeded, the file
# ai-tools-lockdown reads for that owner's project.
is_secret_drift_path() {
    declare -F ai_tools_secret_patterns__is_secret_basename >/dev/null 2>&1 || return 1
    local _part
    local -a _parts=()
    # Split on `/` alone: a read would stop at a line feed in a name and match the path on its first line only.
    mapfile -d / -t _parts < <(printf '%s' "${2#"$1"/}")
    for _part in "${_parts[@]}"; do
        [[ -n "${_part}" ]] && ai_tools_secret_patterns__is_secret_basename "${_part}" && return 0
    done
    return 1
}

# drift_secret_mark <path>  -- print ` [secret]` when the owner-side read marked <path> secret-named
# (DRIFT_SECRET_PATHS, filled from its records). Prints nothing for any other path.
declare -A DRIFT_SECRET_PATHS=()
drift_secret_mark() {
    [[ -n "$1" && -n "${DRIFT_SECRET_PATHS[$1]+set}" ]] || return 0
    printf ' %s[secret]%s' "${C_YEL}" "${C_RST}"
}

# drift_secret_legend <path...>  -- under a drift list holding a secret-named path, say what the secret gate does
# with one.
drift_secret_legend() {
    local _p
    for _p in "$@"; do
        [[ -n "$(drift_secret_mark "${_p}")" ]] || continue
        say "      ${C_YEL}[secret]${C_RST} if you accept a repair, the secret gate locks it down (owner-only)"
        say "      first, so no repair shares it with the agent"
        return 0
    done
}

# item_listing <label> <printer> <item...>  -- report a set of items through <printer>, a function printing one line
# per item it is given: in FULL when there are few enough that the whole list is shorter than a sample plus the question
# about it, otherwise a three-item 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 items prints three, says "... and 1 more", asks
# a question, and then prints all four again -- seven lines and a prompt to show four items. 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. The offer
# defaults to yes: it is read-only and the point of asking is that the list is long, so Enter shows it and a piped
# or delegated run prints it too (grep-able). It prints the items the sample did not, so no line is shown twice.
# A caller that asks a question about the set asks it after this returns, so the question follows the last line
# of the list rather than scrolling out of view before it.
readonly PATH_LISTING_SAMPLE=3
item_listing() {
    local _label="$1" _printer="$2"; shift 2
    if (( $# <= 2 * PATH_LISTING_SAMPLE )); then
        "${_printer}" "$@"
        return 0
    fi
    "${_printer}" "${@:1:PATH_LISTING_SAMPLE}"
    say "        ${C_DIM}... and $(( $# - PATH_LISTING_SAMPLE )) more${C_RST}"
    confirm "      List the other $(( $# - PATH_LISTING_SAMPLE )) ${_label}?" y || return 0
    "${_printer}" "${@:PATH_LISTING_SAMPLE+1}"
}

# path_listing <label> <path...>  -- item_listing over paths, each shown with its ownership and mode.
path_listing() { item_listing "$1 with ownership and mode" path_detail_lines "${@:2}"; }

# label_drift_lines <path...>  -- print each path label_drift_scan reported as the type it carries, the type the policy
# gives it, and the path. The types are read from the claim's label_drift_types map, which is in scope wherever
# the claim calls this through item_listing.
label_drift_lines() {
    local _path
    for _path in "$@"; do
        printf '        %s%s  %s%s%s\n' "${C_DIM}" "${label_drift_types[${_path}]:-?}" \
            "$(ai_tools_log__sanitize "${_path}")" "${C_RST}" "$(drift_secret_mark "${_path}")"
    done
}

# outcome_record <outcome> <kind> <path> <detail>  -- one page line recording what a run did about one path, indented
# under its heading with the outcome and kind in aligned columns, uncoloured, so a grep for an outcome finds every path
# it names; `--format tsv` carries the same outcomes for a script. The path is printed through the display sanitizer,
# since a path under a claimed tree may be one the agent named, and that keeps a newline in it from splitting the line.
outcome_record() {
    printf '    %-10s  %-5s  %s  %s\n' "$1" "$2" "$(ai_tools_log__sanitize "$3")" "${4:--}"
}

# claim_load_libraries -- load the record writer and the per-path checks the claim collects its drift and verifies its
# repairs with. Required: a claim whose checks or exit contract did not load would report a repair it could not verify,
# so it refuses before its first write.
claim_load_libraries() {
    local loaded=true function
    # shellcheck source=SCRIPTDIR/../lib/ai-tools/records-base.lib.sh
    source "${RECORDS_BASE_LIB}" 2>/dev/null || loaded=false
    # shellcheck source=SCRIPTDIR/../lib/ai-tools/records-tsv.lib.sh
    ${loaded} && { source "${RECORDS_TSV_LIB}" 2>/dev/null || loaded=false; }
    # shellcheck source=SCRIPTDIR/../lib/ai-tools/project-permissions.lib.sh
    ${loaded} && { source "${PROJECT_PERMISSIONS_LIB}" 2>/dev/null || loaded=false; }
    for function in ai_tools_records_base__begin_report ai_tools_records_base__accumulate_severity \
            ai_tools_records_base__get_exit_status ai_tools_records_tsv__write_record \
            ai_tools_records_tsv__frame_item_components ai_tools_project_permissions__check_label_batch \
            ai_tools_project_permissions__check_label ai_tools_project_permissions__read_lstat_outcomes \
            ai_tools_project_permissions__check_group ai_tools_project_permissions__find_paths_with_narrow_acl_entries; do
        declare -F "${function}" >/dev/null 2>&1 || loaded=false
    done
    if ! ${loaded}; then
        die MSG-X3G7 "cannot load the libraries the claim checks its drift and states its exit codes with -- reinstall the ai-tools package"
    fi
}

# The fresh-tree hint is projects create's alone, so the run clears what the environment hands it. CREATE_LEFT_TREE
# names the directory a create made, from its mkdir until the claim closes with its line, so the EXIT trap
# (remove_run_files) names a tree a create left with its claim unfinished. CREATE_GIT_WITHHELD names the directory whose
# `.git` the create set to its owner's group and modes, so the claim's `.git` question promises a decline only where
# that held.
CLAIM_FRESH_TREE=""
CREATE_LEFT_TREE=""
CREATE_GIT_WITHHELD=""

# ensure_claim_work_directory -- create CLAIM_WORK, the private directory (`mktemp -d`, 0700) holding a claim's path
# lists and the tool output its checks capture, removed by the EXIT trap with the allowlist snapshot.
CLAIM_WORK=""
ensure_claim_work_directory() {
    if [[ -z "${CLAIM_WORK}" ]]; then
        CLAIM_WORK="$(mktemp -d)" || die "cannot create a private work directory for the claim's drift checks"
        trap remove_run_files EXIT
    fi
}

# The claim's outcome rows (ai-tools-records(5)). _claim_info / _claim_attention / _claim_unreadable <code> <finding>
# put the code and severity a finding token carries into _CLAIM_CODE and _CLAIM_SEVERITY -- one word per severity,
# the code first, so the message index reads each table line as the definition it is and the messages page derives
# the severity from the word.
readonly CLAIM_SCAN_CAP=200
CLAIM_FORMAT=page
_CLAIM_CODE=""
_CLAIM_SEVERITY=""
_claim_info()       { _CLAIM_CODE="$1"; _CLAIM_SEVERITY=info; }
_claim_attention()  { _CLAIM_CODE="$1"; _CLAIM_SEVERITY=attention; }
_claim_unreadable() { _CLAIM_CODE="$1"; _CLAIM_SEVERITY=unreadable; }

# claim_write_row <finding> <subject-type> <kind> <path> <detail>  -- record one outcome of the claim's checks and fold
# its severity into the report state the exit is read from. <kind> is the row's one item component: `label`, `group`
# or `narrow-acl-entries` for a drift row, and `git` or `filemode` for the `error` row of a step the invoker could not
# read; the operator is the one the run acts for. Under `--format tsv` the row goes to fd 3 as a record; on the page it
# is an outcome_record line whose first field is the finding without its kind.
claim_write_row() {
    local finding="$1" subject_type="$2" kind="$3" path="$4" detail="$5" item=""
    case "${finding}" in
        label-fixed)      _claim_info       MSG-T8F5 "label-fixed" ;;
        label-not-fixed)  _claim_attention  MSG-Q4G7 "label-not-fixed" ;;
        label-unverified) _claim_unreadable MSG-P4K8 "label-unverified" ;;
        label-gone)       _claim_info       MSG-E4Y3 "label-gone" ;;
        group-fixed)      _claim_info       MSG-J7X4 "group-fixed" ;;
        group-not-fixed)  _claim_attention  MSG-T6G3 "group-not-fixed" ;;
        group-unverified) _claim_unreadable MSG-D3K4 "group-unverified" ;;
        group-gone)       _claim_info       MSG-B6S7 "group-gone" ;;
        narrow-acl-entries-fixed)      _claim_info       MSG-N2Y5 "narrow-acl-entries-fixed" ;;
        narrow-acl-entries-not-fixed)  _claim_attention  MSG-J9P2 "narrow-acl-entries-not-fixed" ;;
        narrow-acl-entries-unverified) _claim_unreadable MSG-Y6B5 "narrow-acl-entries-unverified" ;;
        narrow-acl-entries-gone)       _claim_info       MSG-M4X2 "narrow-acl-entries-gone" ;;
        scan-capped)      _claim_attention  MSG-B7P8 "scan-capped" ;;
        error)            _claim_unreadable MSG-Q6X2 "error" ;;
        *) claim_write_row error directory "${kind}" "${path}" "no code for the finding '${finding}'"; return 0 ;;
    esac
    if [[ "${CLAIM_FORMAT}" == tsv ]]; then
        if ! ai_tools_records_tsv__frame_item_components item "${kind}"; then
            ai_tools_records_base__accumulate_severity unreadable
            return 0
        fi
        ai_tools_records_tsv__write_record "" "${_CLAIM_CODE}" "${_CLAIM_SEVERITY}" "${finding}" "${subject_type}" \
            "${OWNER_USER}" "${item}" "${path}" "${detail}" >&3 || true
        return 0
    fi
    ai_tools_records_base__accumulate_severity "${_CLAIM_SEVERITY}"
    outcome_record "${finding#"${kind}"-}" "${kind}" "${path}" "${detail}"
}

# claim_write_list <file> [<path>...]  -- write the paths NUL-separated to <file>, removing any earlier copy first.
# Returns 1 when the write fails and removes the partial file, so the reader that follows fails too and its paths read
# unknown; a stale list is not read.
claim_write_list() {
    local file="$1"
    shift
    rm -f -- "${file}" 2>/dev/null || return 1
    { printf '%s\0' "$@" > "${file}"; } 2>/dev/null || { rm -f -- "${file}" 2>/dev/null; return 1; }
}

# claim_subject_type <path>  -- print `directory` when the path itself is a directory and `file` for anything else,
# a symlink to a directory included: the subject-type a row records, read at collection so a `gone` row keeps
# what the path was.
claim_subject_type() {
    if [[ -d "$1" && ! -L "$1" ]]; then printf 'directory'; else printf 'file'; fi
}

# claim_verify_label <paths-array> <outcomes-array>  -- after the Apply block, check each listed label-drift path on its
# own and fill <outcomes-array>, index for index, with the outcome tokens claim_write_row records. Absence is looked
# up first (project-permissions.lib.sh); the paths that exist and hold no line feed go into one batch WITHOUT `-i`,
# so a path removed after that lookup fails the batch rather than reading as a match, and a name holding a line feed is
# checked on its own. From a batch whose output is not complete only the drift records it carried whole are read,
# and the lookup runs again over every path still unresolved: a confirmed absence is `gone`, anything else
# `unverified`.
# shellcheck disable=SC2034  # batch_listed is read by the library through the nameref it is passed as
claim_verify_label() {
    local -n _verify_label_paths="$1" _verify_label_outcomes="$2"
    local path index outcome from to complete=true
    local -a absence=() batched=() recheck=() recheck_absence=()
    local -A batch_listed=() batch_drift=()
    _verify_label_outcomes=()
    if ! claim_write_list "${CLAIM_WORK}/verify-label.list" "${_verify_label_paths[@]}" \
            || ! ai_tools_project_permissions__read_lstat_outcomes "${CLAIM_WORK}/verify-label.list" "${CLAIM_WORK}" absence; then
        absence=()
    fi
    for index in "${!_verify_label_paths[@]}"; do
        path="${_verify_label_paths[index]}"
        case "${absence[index]:-unknown}" in
            gone)    _verify_label_outcomes[index]=gone ;;
            unknown) _verify_label_outcomes[index]=unverified ;;
            exists)
                if [[ "${path}" == *$'\n'* ]]; then
                    ai_tools_project_permissions__check_label "${path}" "${CLAIM_WORK}" outcome from to
                    case "${outcome}" in
                        match) _verify_label_outcomes[index]=fixed ;;
                        drift) _verify_label_outcomes[index]=not-fixed ;;
                        *)     recheck+=("${index}") ;;
                    esac
                else
                    batch_listed["${path}"]=1
                    batched+=("${index}")
                fi ;;
        esac
    done
    if (( ${#batched[@]} )); then
        local -a batch_paths=()
        for index in "${batched[@]}"; do batch_paths+=("${_verify_label_paths[index]}"); done
        if ! claim_write_list "${CLAIM_WORK}/verify-label.batch" "${batch_paths[@]}" \
                || ! ai_tools_project_permissions__check_label_batch "${CLAIM_WORK}/verify-label.batch" "${CLAIM_WORK}" \
                    batch_listed batch_drift; then
            complete=false
        fi
        for index in "${batched[@]}"; do
            path="${_verify_label_paths[index]}"
            if [[ -n "${batch_drift[${path}]+set}" ]]; then
                _verify_label_outcomes[index]=not-fixed
            elif ${complete}; then
                _verify_label_outcomes[index]=fixed
            else
                recheck+=("${index}")
            fi
        done
    fi
    (( ${#recheck[@]} )) || return 0
    local -a recheck_paths=()
    for index in "${recheck[@]}"; do recheck_paths+=("${_verify_label_paths[index]}"); done
    if ! claim_write_list "${CLAIM_WORK}/verify-label.recheck" "${recheck_paths[@]}" \
            || ! ai_tools_project_permissions__read_lstat_outcomes "${CLAIM_WORK}/verify-label.recheck" "${CLAIM_WORK}" \
                recheck_absence; then
        recheck_absence=()
    fi
    for index in "${!recheck[@]}"; do
        if [[ "${recheck_absence[index]:-unknown}" == gone ]]; then
            _verify_label_outcomes[recheck[index]]=gone
        else
            _verify_label_outcomes[recheck[index]]=unverified
        fi
    done
}

# claim_verify_group <paths-array> <outcomes-array> <details-array>  -- after the Apply block, check each listed
# group-drift path against the postconditions the repair establishes (ai_tools_project_permissions__check_group)
# and fill <outcomes-array> with the outcome tokens claim_write_row records, and <details-array> with the check's reason
# for a path it did not pass. The identities are resolved to numeric ids once; one that does not resolve leaves every
# path `unverified`.
claim_verify_group() {
    local -n _verify_group_paths="$1" _verify_group_outcomes="$2" _verify_group_details="$3"
    local index outcome detail operator_uid="" sandbox_uid="" sandbox_gid=""
    local -a absence=()
    _verify_group_outcomes=() _verify_group_details=()
    operator_uid="$(id -u "${OWNER_USER}" 2>/dev/null)" || operator_uid=""
    sandbox_uid="$(id -u "${SANDBOX_USER}" 2>/dev/null)" || sandbox_uid=""
    sandbox_gid="$(getent group "${SANDBOX_GROUP}" 2>/dev/null | cut -d: -f3)" || sandbox_gid=""
    if ! claim_write_list "${CLAIM_WORK}/verify-group.list" "${_verify_group_paths[@]}" \
            || ! ai_tools_project_permissions__read_lstat_outcomes "${CLAIM_WORK}/verify-group.list" "${CLAIM_WORK}" absence; then
        absence=()
    fi
    for index in "${!_verify_group_paths[@]}"; do
        case "${absence[index]:-unknown}" in
            gone)
                _verify_group_outcomes[index]=gone _verify_group_details[index]="no entry at the path" ;;
            unknown)
                _verify_group_outcomes[index]=unverified
                _verify_group_details[index]="whether the path exists could not be read" ;;
            exists)
                if [[ ! "${operator_uid}${sandbox_uid}${sandbox_gid}" =~ ^[0-9]+$ || -z "${operator_uid}" \
                        || -z "${sandbox_uid}" || -z "${sandbox_gid}" ]]; then
                    _verify_group_outcomes[index]=unverified
                    _verify_group_details[index]="the operator, the sandbox account or its group did not resolve to an id"
                    continue
                fi
                ai_tools_project_permissions__check_group "${_verify_group_paths[index]}" "${CLAIM_WORK}" \
                    "${operator_uid}" "${sandbox_uid}" "${sandbox_gid}" outcome detail
                case "${outcome}" in
                    match) _verify_group_outcomes[index]=fixed ;;
                    drift) _verify_group_outcomes[index]=not-fixed ;;
                    *)     _verify_group_outcomes[index]=unverified ;;
                esac
                _verify_group_details[index]="${detail}" ;;
        esac
    done
}

# 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. The walks
# prune a skip-listed name as a directory only, so the last component counts only when <path> is a directory:
# a worktree's `.git` pointer file is reachable. The type is read by the path, and the split it decides is which block
# of the claim lists the hit.
under_skip_listed_name() {
    local _base="$1" _path="$2" _rel _seg _name _s _x
    [[ "${#AI_TOOLS_SKIP_DIRS__NAMES[@]}" -gt 0 ]] || return 1
    _rel="${_path#"${_base}"/}"
    # Split on `/` alone: a read would stop at a line feed in the name and judge the path on its first line.
    mapfile -d / -t _seg < <(printf '%s' "${_rel}")
    if (( ${#_seg[@]} )) && [[ ! -d "${_path}" || -L "${_path}" ]]; then
        unset '_seg[-1]'
    fi
    for _name in "${AI_TOOLS_SKIP_DIRS__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
}

# is_project_tree_pristine <dir>  -- 0 when <dir> holds only what `projects create` just put there: no file outside .git
# except README.md, and a git repository whose object store is empty. Two `find` walks that each stop at the first hit
# and two reads of the git directory, unprivileged.
#
# It is re-derived here rather than trusted from the caller, because what it gates is the secret scan: a stale
# or planted CLAIM_FRESH_TREE must not be able to skip that on a tree with content in it. Both halves are the actual
# preconditions -- no files means no secret-named files, and an empty object store means no history -- so the answer is
# a property of the tree, not a claim about it. The claim's owner-side read makes it as the tree's owner, and every
# reading the account running it cannot make returns 1: a root it cannot list and enter, a walk `find` reports an error
# for, and a .git its git does not open as this tree's own repository (git refuses another account's repository,
# and walks past a .git it cannot read to whatever repository encloses the tree), since an unread tree and an empty one
# look the same to `find`. The history half reads the store, not the references: no loose object, no pack and no
# `info/alternates` entry under `objects`, from a walk `find` completes. A count of the commits the references reach
# reads 0 over a commit whose branch was deleted, one under a reference directory the account cannot list, and one
# a template or an alternate brought in; `git init` does not write a file in the store.
is_project_tree_pristine() {
    local d="$1" hit discovered_git_dir expected_git_dir
    [[ -r "${d}" && -x "${d}" ]] || return 1
    hit="$(find "${d}" -path "${d}/.git" -prune -o ! -type d ! -path "${d}/README.md" \
               -print -quit 2>/dev/null)" || return 1
    [[ -z "${hit}" ]] || return 1
    [[ -e "${d}/.git" || -L "${d}/.git" ]] || return 0
    discovered_git_dir="$(git -C "${d}" rev-parse --absolute-git-dir 2>/dev/null)" || return 1
    expected_git_dir="$(realpath -e -- "${d}/.git" 2>/dev/null)" || return 1
    [[ "${discovered_git_dir}" == "${expected_git_dir}" ]] || return 1
    hit="$(find "${expected_git_dir}/objects" ! -type d -print -quit 2>/dev/null)" || return 1
    [[ -z "${hit}" ]]
}

# require_claimable_owner <dir>  -- die unless <dir> is held by the operator this run acts FOR or by the sandbox
# account: ai-tools-setgid and ai-tools-setfacl, the two helpers that grant the agent its access, act only on those two
# owners, so a root held by anyone else would take every registry step and none of the access-granting ones. The case it
# exists for is a `--for` claim over a tree the invoker made (`mkdir ~/proj && atr projects claim --for svc ~/proj`),
# and the refusal names the chown that fixes it, since transferring a tree recursively needs an authority this CLI does
# not hold. The consequence and the remedy are
# cli.rule.md's.
require_claimable_owner() {
    local d="$1" owner
    owner="$(stat -c '%U' "${d}" 2>/dev/null)" || die "cannot read the owner of ${d}"
    [[ "${owner}" == "${OWNER_USER}" || "${owner}" == "${SANDBOX_USER}" ]] && return 0

    # The remedy is a command, so it prints plain and ahead of die(), whose emitter would wrap it across lines
    # (messaging.rule.md).
    printf '\n' >&2
    printf '  %s\n' "Give the tree to ${OWNER_USER}, then re-run the claim:" "" \
                    "    sudo chown -R ${OWNER_USER} ${d}" >&2
    printf '\n' >&2
    # The headline is passed literally rather than out of the array, so the message code labels a string the reference
    # index can read (a quoted value opening with `$` is a citation, not a target) -- see messaging.rule.md.
    local -a why=(
        "The claim's setgid and ACL steps act only on paths held by ${OWNER_USER} or ${SANDBOX_USER}, so here they would apply nothing while the registries and the SELinux label still would -- a claim that reports success and leaves the agent unable to enter the project."
    )
    [[ -n "${FOR_OPERATOR}" ]] && why+=(
        "You are claiming for ${FOR_OPERATOR}, so the tree has to belong to ${FOR_OPERATOR} rather than to you."
    )
    die MSG-U8G4 "this project directory is owned by ${owner}, and the claim grants it to ${OWNER_USER}." \
        "${why[@]}"
}

# require_open_project_root <dir> <root-mode> <has-registry-entry>  -- die when <root-mode>, the owner-side read's mode
# of the project root, has no group or other bit: ai-tools-setgid and ai-tools-setfacl leave an owner-only path as it is
# (owner-only.lib.sh), so a claim would register the root without a grant under it, and the launch gate refuses
# an owner-only directory whichever account owns it (MSG-H2E2). The mode is the operator's decision and its intent is
# not readable from it, so neither refusal names a command that changes it. <has-registry-entry> is true for a root
# a registry line names, whose line the refusal keeps; an empty <root-mode> is a reading the owner could not make
# and passes. A directory projects create made is named by the refusal, so the create's leftover-directory line,
# which names a claim, is not printed. The consequence is cli.rule.md's (Claim in place).
require_open_project_root() {
    local d="$1" mode="$2" has_registry_entry="$3"
    [[ -n "${mode}" ]] && (( ( 8#${mode} & 077 ) == 0 )) || return 0
    [[ "${CREATE_LEFT_TREE}" != "${d}" ]] || CREATE_LEFT_TREE=""
    if [[ "${has_registry_entry}" == true ]]; then
        die MSG-B5D2 "the project root ${d} is mode ${mode} (owner-only) -- the claim leaves it as it is; no change applied, and no session can start here"
    fi
    die MSG-H6J3 "refusing to claim -- ${d} is mode ${mode} (owner-only); a claim leaves an owner-only mode as it is and does not grant the sandbox account access under it, and no session could start here"
}

# ── The owner-side read: `projects inspect` ────────────────────────────────────────
# The claim reads a project's tree as the account whose tree it is: it runs this file again as the owner, through
# run_as_owner (`sudo -u <target> -H` under `--for`, directly otherwise), as `projects inspect`, and reads the record
# stream that child prints. The child takes the project path the parent resolved and, on stdin, records the parent
# composes from its own reads: the registry's exclusions for the read before the Review block, the drifted paths for
# the verification after the Apply block. It does not read a registry or the safe.directory list, and runs git's
# read-only commands as the account whose tree it is. The stream is the internal framing records.rule.md states (each
# field ENC-encoded, one tab between fields, one record per line feed, no header), read by the claim alone, so the verb
# is left out of the help, the man page and the completion; its record kinds are the ones claim_apply_inspect_record
# accepts. Both ends are cli.rule.md's (Claim in place).

# inspect_write_record <field>...  -- write one record of the inspect stream to fd 3. Exits 1 when a field does not
# encode or the write fails, since a stream missing a record reads as a smaller tree.
inspect_write_record() {
    local _field _encoded _line="" _first=true
    for _field in "$@"; do
        ai_tools_records_tsv__encode_field _encoded "${_field}" || exit 1
        if ${_first}; then _line="${_encoded}"; _first=false; else _line+=$'\t'"${_encoded}"; fi
    done
    printf '%s\n' "${_line}" >&3 || exit 1
}

# inspect_parse_record <line> <fields-array>  -- split one record of the inspect stream on its tabs and decode each
# field into <fields-array>. Returns 1, with the array empty, when a field does not decode. The split is made by hand,
# since `IFS=$'\t' read` collapses adjacent empty fields (records.rule.md).
inspect_parse_record() {
    local -n _inspect_fields="$2"
    local _rest="$1" _raw _decoded
    _inspect_fields=()
    while :; do
        _raw="${_rest%%$'\t'*}"
        ai_tools_records_tsv__decode_field _decoded "${_raw}" || { _inspect_fields=(); return 1; }
        _inspect_fields+=("${_decoded}")
        [[ "${_rest}" == *$'\t'* ]] || break
        _rest="${_rest#*$'\t'}"
    done
}

# cmd_project_inspect [--verify] [--scan-group] [--scan-label] -- <dir>  -- the owner-side read of <dir>, run as
# the account whose tree it is: print the inspect stream on stdout and every line for a person on stderr. Without
# `--verify` it writes a `state` record per tree token (read_project_tree_state's five, `pristine` and `root-mode`),
# the drift and sealed-setgid paths with a `scan` record per scan it ran, and the unreadable ancestor configuration;
# `--scan-group` and `--scan-label` say the project is registered, and each drift scan then runs where this read's own
# `owngap false` or `labelled true` allows it, the conditions a re-claim scans under, the entries scan
# under `--scan-group` where `acl false` holds as well. Stdin carries `exclude <path>` records, which the scans prune
# as SCAN_EXCLUSIONS. With `--verify` it reads `label <path>`, `group <path>` and `narrow-acl-entries <path>` records
# and writes one `verify` record per path. A reading that could not be made is a record (`unknown`, `incomplete`,
# `not-made`) and exits 0; a gate refusal, a library that did not load, an input record that did not decode, a path
# that is not a directory and a command line it does not parse exit non-zero, so the claim reads the tree as not read.
cmd_project_inspect() {
    local a verify=false scan_group=false scan_label=false options_ended=false
    local -a paths=()
    for a in "$@"; do
        if ! ${options_ended}; then
            case "${a}" in
                --)           options_ended=true; continue ;;
                --verify)     verify=true; continue ;;
                --scan-group) scan_group=true; continue ;;
                --scan-label) scan_label=true; continue ;;
                -*) die_usage MSG-K7K4 "unknown projects inspect option: $(ai_tools_log__sanitize "${a}")" ;;
            esac
        fi
        paths+=("${a}")
    done
    (( ${#paths[@]} == 1 )) || die_usage MSG-D6T2 "projects inspect takes one directory, the project root the claim resolved"
    local d="${paths[0]}"
    refuse_control_character_path "${d}"
    [[ "${d}" == /* && -d "${d}" ]] || die "not a directory: $(ai_tools_log__sanitize "${d}")"
    ai_tools_safe_paths__assert_safe_target "${d}" "project inspect" || exit 3
    # `sudo -u` keeps the invoker's working directory, which the owner may not be able to enter, and GNU find exits 1
    # when it cannot return to the directory it started in, after a complete walk. Every path here is absolute.
    cd / || die "cannot change to /"
    claim_load_libraries
    exec 3>&1 1>&2
    ensure_claim_work_directory

    local line
    local -a fields=() verify_kinds=() verify_paths=()
    SCAN_EXCLUSIONS=()
    # A terminal on stdin is a person running the command by hand, who has no records to give it.
    if [[ ! -t 0 ]]; then
        while IFS= read -r line || [[ -n "${line}" ]]; do
            if ! inspect_parse_record "${line}" fields || (( ${#fields[@]} != 2 )); then
                die "projects inspect: an input record did not decode"
            fi
            case "${fields[0]}:${verify}" in
                exclude:false)            SCAN_EXCLUSIONS+=("${fields[1]}") ;;
                label:true|group:true|narrow-acl-entries:true)
                    verify_kinds+=("${fields[0]}"); verify_paths+=("${fields[1]}") ;;
                *) die "projects inspect: an input record is not one this mode reads" ;;
            esac
        done
    fi

    if ${verify}; then inspect_write_verify_records; else inspect_write_tree_records; fi
    return 0
}

# inspect_write_verify_records  -- the `--verify` half of cmd_project_inspect: check each path its caller read from
# stdin (verify_kinds, verify_paths, by dynamic scope) with claim_verify_label, or with claim_verify_group for a group
# or an entries path, whose repair is the same walk, and write one `verify <kind> <path> <outcome> <detail> <present>`
# record per path, <present> the path's owner:group and mode as this account reads them after the check (`?` where it
# cannot, empty for a label path), which the claim compares with the reading before the repair.
inspect_write_verify_records() {
    local index
    local -a label_paths=() group_paths=() group_kinds=() label_outcomes=() group_outcomes=() group_details=()
    for index in "${!verify_paths[@]}"; do
        if [[ "${verify_kinds[index]}" == label ]]; then label_paths+=("${verify_paths[index]}")
        else group_paths+=("${verify_paths[index]}"); group_kinds+=("${verify_kinds[index]}"); fi
    done
    if (( ${#label_paths[@]} )); then
        claim_verify_label label_paths label_outcomes
        for index in "${!label_paths[@]}"; do
            inspect_write_record verify label "${label_paths[index]}" "${label_outcomes[index]:-unverified}" "" ""
        done
    fi
    if (( ${#group_paths[@]} )); then
        claim_verify_group group_paths group_outcomes group_details
        local present
        for index in "${!group_paths[@]}"; do
            present="$(stat -c '%U:%G %a' -- "${group_paths[index]}" 2>/dev/null)" || present='?'
            inspect_write_record verify "${group_kinds[index]}" "${group_paths[index]}" \
                "${group_outcomes[index]:-unverified}" "${group_details[index]:-}" "${present}"
        done
    fi
}

# inspect_write_tree_records  -- the read half of cmd_project_inspect over its caller's <d>, scan_group and scan_label
# (dynamic scope). The drift records are capped at CLAIM_SCAN_CAP + 1 per kind and reach, so the claim tells exactly
# the cap from more than the cap (records.rule.md, ref-section-g4k3); what a path is before a repair, its subject type
# and its secret mark are read here, at collection.
inspect_write_tree_records() {
    local filemode owngap acl labelled git pristine=false root_mode
    # IFS pinned (see join_words).
    IFS=' ' read -r filemode owngap acl labelled git <<< "$(read_project_tree_state "${d}")"
    is_project_tree_pristine "${d}" && pristine=true
    root_mode="$(stat -c '%a' -- "${d}" 2>/dev/null)" || root_mode=unknown
    inspect_write_record state filemode "${filemode}"
    inspect_write_record state git "${git}"
    inspect_write_record state owngap "${owngap}"
    inspect_write_record state acl "${acl}"
    inspect_write_record state labelled "${labelled}"
    inspect_write_record state pristine "${pristine}"
    inspect_write_record state root-mode "${root_mode}"

    # The owner's own patterns file, the one ai-tools-lockdown reads for this owner's project; advisory, so a library
    # that does not load leaves every path unmarked.
    local secret_patterns_loaded=false
    # shellcheck source=SCRIPTDIR/../lib/ai-tools/secret-patterns.lib.sh
    if { source "${SECRET_PATTERNS_LIB}" && PROJECTS_HOME="${HOME_DIR}" ai_tools_secret_patterns__load; } 2>/dev/null
    then
        secret_patterns_loaded=true
    fi

    local scanned_path owner_group_mode_before repair_reach secret status detail types_text
    if ${scan_group} && [[ "${owngap}" == false ]]; then
        local -a drift=()
        local skip_list_loaded=false reachable_count=0 skipped_count=0
        status=complete detail=""
        acl_drift_scan "${d}" "${CLAIM_WORK}" drift detail || status=incomplete
        if ai_tools_skip_dirs__build_find_expression sweep 2>/dev/null && (( ${#AI_TOOLS_SKIP_DIRS__NAMES[@]} )); then
            skip_list_loaded=true
        fi
        for scanned_path in "${drift[@]}"; do
            repair_reach=reachable
            if ${skip_list_loaded} && under_skip_listed_name "${d}" "${scanned_path}"; then repair_reach="skip-listed"; fi
            if [[ "${repair_reach}" == reachable ]]; then
                (( reachable_count <= CLAIM_SCAN_CAP )) || continue
                reachable_count=$(( reachable_count + 1 ))
            else
                (( skipped_count <= CLAIM_SCAN_CAP )) || continue
                skipped_count=$(( skipped_count + 1 ))
            fi
            owner_group_mode_before="$(stat -c '%U:%G %a' -- "${scanned_path}" 2>/dev/null)" || owner_group_mode_before='?'
            secret=-; ${secret_patterns_loaded} && is_secret_drift_path "${d}" "${scanned_path}" && secret=secret
            inspect_write_record group-drift "${scanned_path}" "${owner_group_mode_before}" \
                "$(claim_subject_type "${scanned_path}")" "${repair_reach}" "${secret}"
        done
        inspect_write_record scan group "${status}" "${detail}"
    fi

    # The entries an earlier claim wrote are read only where the ACL step is in place: where it is pending, the claim
    # walks the tree anyway.
    if ${scan_group} && [[ "${owngap}" == false && "${acl}" == false ]]; then
        local -a paths_with_narrow_acl_entries=()
        local narrow_acl_entries_written=0
        status=complete detail=""
        narrow_acl_entry_scan "${d}" "${CLAIM_WORK}" paths_with_narrow_acl_entries detail || status=incomplete
        for scanned_path in "${paths_with_narrow_acl_entries[@]}"; do
            (( narrow_acl_entries_written <= CLAIM_SCAN_CAP )) || break
            narrow_acl_entries_written=$(( narrow_acl_entries_written + 1 ))
            owner_group_mode_before="$(stat -c '%U:%G %a' -- "${scanned_path}" 2>/dev/null)" || owner_group_mode_before='?'
            inspect_write_record narrow-acl-entry-path "${scanned_path}" "${owner_group_mode_before}"
        done
        inspect_write_record scan narrow-acl-entries "${status}" "${detail}"
    fi

    if ${scan_label} && [[ "${labelled}" == true ]]; then
        local -a label_drift=()
        local -A label_drift_types=()
        local written=0
        status=complete detail=""
        label_drift_scan "${d}" "${CLAIM_WORK}" label_drift label_drift_types detail || status=incomplete
        for scanned_path in "${label_drift[@]}"; do
            (( written <= CLAIM_SCAN_CAP )) || break
            written=$(( written + 1 ))
            types_text="${label_drift_types[${scanned_path}]:-? -> ?}"
            secret=-; ${secret_patterns_loaded} && is_secret_drift_path "${d}" "${scanned_path}" && secret=secret
            inspect_write_record label-drift "${scanned_path}" "${types_text%% -> *}" "${types_text#* -> }" \
                "$(claim_subject_type "${scanned_path}")" "${secret}"
        done
        inspect_write_record scan label "${status}" "${detail}"
    fi

    # The sealed-setgid scan's status says whether it was made, so its output goes through the work directory rather
    # than a pipe; the list stops at 200 paths, a notice's length. Each path, and each ancestor configuration file,
    # carries its owner:group and mode as this account reads them, for the claim's listing.
    local sealed_status=0 sealed_count=0
    status=complete
    find_sealed_setgid_directories "${d}" > "${CLAIM_WORK}/sealed-setgid" || sealed_status=$?
    case "${sealed_status}" in
        0) ;;
        2) status=incomplete ;;
        *) status=not-made ;;
    esac
    while (( sealed_count < 200 )) && IFS= read -r -d '' scanned_path; do
        owner_group_mode_before="$(stat -c '%U:%G %a' -- "${scanned_path}" 2>/dev/null)" || owner_group_mode_before='?'
        inspect_write_record sealed-setgid "${scanned_path}" "${owner_group_mode_before}"
        sealed_count=$(( sealed_count + 1 ))
    done < "${CLAIM_WORK}/sealed-setgid"
    inspect_write_record scan sealed-setgid "${status}" ""

    if declare -F ai_tools_ancestor_config__find_unreadable_configs >/dev/null 2>&1; then
        local -a ancestor_configs=()
        mapfile -t ancestor_configs < <(ai_tools_ancestor_config__find_unreadable_configs "${d}")
        for scanned_path in "${ancestor_configs[@]}"; do
            owner_group_mode_before="$(stat -c '%U:%G %a' -- "${scanned_path}" 2>/dev/null)" || owner_group_mode_before='?'
            inspect_write_record ancestor-config "${scanned_path}" "${owner_group_mode_before}"
        done
        inspect_write_record scan ancestor-config complete ""
    else
        inspect_write_record scan ancestor-config not-made "the ancestor-config library did not load"
    fi
}

# claim_run_inspect <stdin-file> [<option>...]  -- run the owner-side read of the claim's <d> (dynamic scope) as its
# owner, with <stdin-file> as its input, printing its stream. The path follows `--`, so a path spelled `-h` reaches
# the child as a path and the help resolution does not read it as a flag.
claim_run_inspect() {
    local input="$1"; shift
    run_as_owner "${SELF_PATH}" projects inspect "$@" -- "${d}" < "${input}"
}

# claim_read_owner_inspection <listed>  -- run the owner-side read of the claim's <d> before the Review block and fill
# cmd_project_claim's locals from its records (dynamic scope): the tree tokens, the drift lists with what each path was,
# the scan details and caps, the sealed-setgid and ancestor-configuration paths, and DRIFT_SECRET_PATHS. <listed>
# is `true` where the project is registered, or is a disabled entry the claim offers to re-enable, and asks
# for the drift scans. The registry's exclusions go to the child on stdin, so the run's registry, a `--for` target's
# snapshot included, stays the one read of it. Returns 1 with INSPECT_FAILURE naming why where the child exited
# non-zero, a record did not decode or is not one the stream carries, a state token is missing, repeated or outside its
# set, or a scan the child's contract runs is missing or repeated; the claim then stops before any write.
INSPECT_FAILURE=""
claim_read_owner_inspection() {
    local listed="$1" fd pid status=0 line exclusion encoded failure=""
    local -a options=() fields=()
    local -A state_seen=() scan_seen=()
    INSPECT_FAILURE=""
    if [[ -z "${SELF_PATH}" ]]; then
        INSPECT_FAILURE="the path of the ai-tools command did not resolve, so projects inspect was not run"
        return 1
    fi
    ${listed} && options+=(--scan-group --scan-label)
    # A subshell, so a field that does not encode ends the write and not the claim.
    if ! ( while IFS= read -r exclusion; do
               ai_tools_records_tsv__encode_field encoded "${exclusion}" || exit 1
               printf 'exclude\t%s\n' "${encoded}"
           done < <(allowlist_exclusions) ) > "${CLAIM_WORK}/inspect.in" 2>/dev/null; then
        INSPECT_FAILURE="the registry's exclusions could not be written for projects inspect"
        return 1
    fi
    exec {fd}< <(claim_run_inspect "${CLAIM_WORK}/inspect.in" "${options[@]+"${options[@]}"}"); pid=$!
    while IFS= read -r line <&"${fd}" || [[ -n "${line}" ]]; do
        [[ -z "${failure}" ]] || continue
        if ! inspect_parse_record "${line}" fields; then
            failure="a record of projects inspect did not decode"
            continue
        fi
        claim_apply_inspect_record || failure="projects inspect printed a record the claim does not read"
    done
    exec {fd}<&-
    wait "${pid}" || status=$?
    if (( status )); then
        INSPECT_FAILURE="projects inspect exited ${status}"
        return 1
    fi
    if [[ -n "${failure}" ]]; then INSPECT_FAILURE="${failure}"; return 1; fi
    local token
    for token in filemode git owngap acl labelled pristine root-mode; do
        [[ -n "${state_seen[${token}]+set}" ]] && continue
        INSPECT_FAILURE="projects inspect did not report ${token}"
        return 1
    done
    # The scans the child's contract runs (cmd_project_inspect): the sealed-setgid and ancestor-configuration scans
    # on every run, and each drift scan where this claim asked for it and the child's own state allows it. A missing
    # one would read as a complete scan that found no path.
    local -a required_scans=(sealed-setgid ancestor-config)
    ${listed} && [[ "${owngap}" == false ]] && required_scans+=(group)
    ${listed} && [[ "${owngap}" == false && "${acl}" == false ]] && required_scans+=(narrow-acl-entries)
    ${listed} && [[ "${labelled}" == true ]] && required_scans+=(label)
    for token in "${required_scans[@]}"; do
        [[ -n "${scan_seen[${token}]+set}" ]] && continue
        INSPECT_FAILURE="projects inspect did not report its ${token} scan"
        return 1
    done
}

# claim_apply_inspect_record  -- fold the record in its caller's <fields> into cmd_project_claim's locals (dynamic
# scope); returns 1 for a record kind, a field count or a state value the stream does not carry, and for a second
# record of one state token or one scan kind. Each state token is held to the values read_project_tree_state and
# the child write, so an answer outside the set reads as no answer.
claim_apply_inspect_record() {
    local kind="${fields[0]}"
    case "${kind}:${#fields[@]}" in
        state:3)
            [[ -z "${state_seen[${fields[1]}]+set}" ]] || return 1
            case "${fields[1]}:${fields[2]}" in
                filemode:true|filemode:false|filemode:na|filemode:unknown) filemode="${fields[2]}" ;;
                git:true|git:false|git:unknown)                           git="${fields[2]}" ;;
                owngap:true|owngap:false)                                 owngap="${fields[2]}" ;;
                acl:true|acl:false)                                       acl="${fields[2]}" ;;
                labelled:true|labelled:false|labelled:na)                 labelled="${fields[2]}" ;;
                pristine:true|pristine:false)                             inspect_pristine="${fields[2]}" ;;
                root-mode:unknown)                                        root_mode="" ;;
                root-mode:*)
                    # `stat -c %a` does not write a leading zero, so a mode 0007 root reads `7`.
                    [[ "${fields[2]}" =~ ^[0-7]{1,4}$ ]] || return 1
                    root_mode="${fields[2]}" ;;
                *) return 1 ;;
            esac
            state_seen["${fields[1]}"]=1 ;;
        group-drift:6)
            [[ "${fields[5]}" == secret ]] && DRIFT_SECRET_PATHS["${fields[1]}"]=1
            PATH_DETAIL_BEFORE["${fields[1]}"]="${fields[2]}"
            case "${fields[4]}" in
                reachable)
                    if (( ${#drift[@]} < CLAIM_SCAN_CAP )); then
                        drift+=("${fields[1]}"); drift_before+=("${fields[2]}"); drift_types+=("${fields[3]}")
                    else
                        group_capped=true
                    fi ;;
                skip-listed)
                    if (( ${#drift_skipped[@]} < CLAIM_SCAN_CAP )); then drift_skipped+=("${fields[1]}")
                    else skipped_capped=true; fi ;;
                *) return 1 ;;
            esac ;;
        narrow-acl-entry-path:3)
            PATH_DETAIL_BEFORE["${fields[1]}"]="${fields[2]}"
            if (( ${#paths_with_narrow_acl_entries[@]} < CLAIM_SCAN_CAP )); then
                paths_with_narrow_acl_entries+=("${fields[1]}"); narrow_acl_entry_before+=("${fields[2]}")
            else
                narrow_acl_entry_capped=true
            fi ;;
        label-drift:6)
            [[ "${fields[5]}" == secret ]] && DRIFT_SECRET_PATHS["${fields[1]}"]=1
            if (( ${#label_drift[@]} < CLAIM_SCAN_CAP )); then
                label_drift+=("${fields[1]}")
                label_drift_types["${fields[1]}"]="${fields[2]} -> ${fields[3]}"
                label_subject_types+=("${fields[4]}")
            else
                label_capped=true
            fi ;;
        scan:4)
            [[ -z "${scan_seen[${fields[1]}]+set}" ]] || return 1
            case "${fields[2]}" in complete|incomplete|not-made) ;; *) return 1 ;; esac
            case "${fields[1]}" in
                group)
                    [[ "${fields[2]}" == complete ]] \
                        || group_scan_detail="${fields[3]:-the group scan as ${OWNER_USER} did not complete}" ;;
                narrow-acl-entries)
                    [[ "${fields[2]}" == complete ]] \
                        || narrow_acl_entry_scan_detail="${fields[3]:-the entries scan as ${OWNER_USER} did not complete}" ;;
                label)
                    [[ "${fields[2]}" == complete ]] \
                        || label_scan_detail="${fields[3]:-the label scan as ${OWNER_USER} did not complete}" ;;
                sealed-setgid)   sealed_setgid_scan_result="${fields[2]}" ;;
                ancestor-config) ancestor_config_scan_result="${fields[2]}" ;;
                *) return 1 ;;
            esac
            scan_seen["${fields[1]}"]=1 ;;
        sealed-setgid:3)   sealed_setgid+=("${fields[1]}"); PATH_DETAIL_BEFORE["${fields[1]}"]="${fields[2]}" ;;
        ancestor-config:3) ancestor_configs+=("${fields[1]}"); PATH_DETAIL_BEFORE["${fields[1]}"]="${fields[2]}" ;;
        *) return 1 ;;
    esac
}

# claim_read_owner_verification <label-outcomes> <group-outcomes> <group-details> <group-present>
# <narrow-acl-outcomes> <narrow-acl-details>  -- after the Apply block, check each path in the claim's label_drift,
# drift and paths_with_narrow_acl_entries lists (dynamic scope) through the owner-side read's --verify mode, and fill
# the arrays index for index with the outcome tokens claim_write_row records, the check's reason, and each group path's
# owner:group and mode as the owner read them after the check (`?` where it was not read). An outcome missing
# for a path, a record that does not decode, an outcome outside the set and a child that exits non-zero each leave every
# path without one `unverified`, and write one `error` row naming the pass, so the report state reads 5 and never
# `fixed`.
claim_read_owner_verification() {
    local -n _owner_label_outcomes="$1" _owner_group_outcomes="$2" _owner_group_details="$3" _owner_group_present="$4"
    local -n _owner_narrow_acl_outcomes="$5" _owner_narrow_acl_details="$6"
    local fd pid status=0 line path encoded failure="" index
    local -a fields=()
    local -A label_outcome_by_path=() group_outcome_by_path=() group_detail_by_path=() group_present_by_path=()
    local -A narrow_acl_outcome_by_path=() narrow_acl_detail_by_path=()
    _owner_label_outcomes=() _owner_group_outcomes=() _owner_group_details=() _owner_group_present=()
    _owner_narrow_acl_outcomes=() _owner_narrow_acl_details=()
    if [[ -z "${SELF_PATH}" ]]; then
        failure="the path of the ai-tools command did not resolve"
    elif ! ( for path in "${label_drift[@]}"; do
                 ai_tools_records_tsv__encode_field encoded "${path}" || exit 1
                 printf 'label\t%s\n' "${encoded}"
             done
             for path in "${drift[@]}"; do
                 ai_tools_records_tsv__encode_field encoded "${path}" || exit 1
                 printf 'group\t%s\n' "${encoded}"
             done
             for path in "${paths_with_narrow_acl_entries[@]}"; do
                 ai_tools_records_tsv__encode_field encoded "${path}" || exit 1
                 printf 'narrow-acl-entries\t%s\n' "${encoded}"
             done ) > "${CLAIM_WORK}/verify.in" 2>/dev/null; then
        failure="the drifted paths could not be written for projects inspect --verify"
    else
        exec {fd}< <(claim_run_inspect "${CLAIM_WORK}/verify.in" --verify); pid=$!
        while IFS= read -r line <&"${fd}" || [[ -n "${line}" ]]; do
            [[ -z "${failure}" ]] || continue
            if ! inspect_parse_record "${line}" fields || (( ${#fields[@]} != 6 )) || [[ "${fields[0]}" != verify ]]; then
                failure="a record of projects inspect --verify did not decode"
                continue
            fi
            case "${fields[3]}" in
                fixed|not-fixed|unverified|gone) ;;
                *) failure="projects inspect --verify printed an outcome the claim does not read"; continue ;;
            esac
            case "${fields[1]}" in
                label) label_outcome_by_path["${fields[2]}"]="${fields[3]}" ;;
                group) group_outcome_by_path["${fields[2]}"]="${fields[3]}"
                       group_detail_by_path["${fields[2]}"]="${fields[4]}"
                       group_present_by_path["${fields[2]}"]="${fields[5]}" ;;
                narrow-acl-entries) narrow_acl_outcome_by_path["${fields[2]}"]="${fields[3]}"
                                    narrow_acl_detail_by_path["${fields[2]}"]="${fields[4]}" ;;
                *) failure="projects inspect --verify printed a kind the claim does not read" ;;
            esac
        done
        exec {fd}<&-
        wait "${pid}" || status=$?
        (( status == 0 )) || failure="projects inspect --verify exited ${status}"
    fi
    # A failed pass trusts none of its outcomes, since a stream cut short or corrupted cannot say which of them are
    # whole.
    if [[ -n "${failure}" ]]; then
        label_outcome_by_path=() group_outcome_by_path=() group_detail_by_path=() group_present_by_path=()
        narrow_acl_outcome_by_path=() narrow_acl_detail_by_path=()
    fi
    for index in "${!label_drift[@]}"; do
        path="${label_drift[index]}"
        if [[ -n "${label_outcome_by_path[${path}]+set}" ]]; then
            _owner_label_outcomes[index]="${label_outcome_by_path[${path}]}"
        else
            _owner_label_outcomes[index]=unverified
            [[ -n "${failure}" ]] || failure="projects inspect --verify did not report every path"
        fi
    done
    for index in "${!drift[@]}"; do
        path="${drift[index]}"
        if [[ -n "${group_outcome_by_path[${path}]+set}" ]]; then
            _owner_group_outcomes[index]="${group_outcome_by_path[${path}]}"
            _owner_group_details[index]="${group_detail_by_path[${path}]}"
            _owner_group_present[index]="${group_present_by_path[${path}]}"
        else
            _owner_group_outcomes[index]=unverified
            _owner_group_details[index]="the check was not made as ${OWNER_USER}"
            _owner_group_present[index]='?'
            [[ -n "${failure}" ]] || failure="projects inspect --verify did not report every path"
        fi
    done
    for index in "${!paths_with_narrow_acl_entries[@]}"; do
        path="${paths_with_narrow_acl_entries[index]}"
        if [[ -n "${narrow_acl_outcome_by_path[${path}]+set}" ]]; then
            _owner_narrow_acl_outcomes[index]="${narrow_acl_outcome_by_path[${path}]}"
            _owner_narrow_acl_details[index]="${narrow_acl_detail_by_path[${path}]}"
        else
            _owner_narrow_acl_outcomes[index]=unverified
            _owner_narrow_acl_details[index]="the check was not made as ${OWNER_USER}"
            [[ -n "${failure}" ]] || failure="projects inspect --verify did not report every path"
        fi
    done
    [[ -z "${failure}" ]] || claim_write_row error directory verify "${d}" \
        "the repairs were not checked as ${OWNER_USER} (${failure})"
}

# cmd_project_claim <path> [-y|--yes] [--format tsv]  -- bring a real, IN-PLACE project at <path> to a fully
# claimed state, idempotently: it reads the current state and performs the missing steps alone, so a fully claimed
# project is a quiet no-op. Six blocks in run order -- Review, Interior drift, Reachability, Secret lockdown, `.git`
# history, Apply -- each opened by a headline box and closed by its own confirm or result lines; a first claim skips
# the drift scans, since its normal walks repair the whole tree. `-y`/`--yes` pre-answers the proceed confirm
# and the interior relabel question alone; `--format tsv` puts the outcome rows on stdout as a record stream and every
# other line on stderr. The path is resolved as the invoker and the tree read as its owner, in the owner-side read
# before the Review block and its `--verify` pass after the Apply block. The blocks' order and their defaults are
# cli.rule.md's.
cmd_project_claim() {
    # `-y`/`--yes` is 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)
    # and the group repair are separate questions it does not answer.
    #
    # `--format tsv`: once the command line is parsed, fd 3 takes stdout and stdout takes stderr, so every page line,
    # refusal and hint -- a helper's own output included -- lands on stderr without a change at any call site. A run
    # refused before that point writes only to stderr too, since die and warn already do. The questions keep asking
    # on /dev/tty.
    local a path="" ASSUME_YES=false format="" format_given=false expect_format=false
    for a in "$@"; do
        if ${expect_format}; then format="${a}"; expect_format=false; continue; fi
        case "${a}" in
            -y|--yes) ASSUME_YES=true ;;
            --format) expect_format=true format_given=true ;;
            --format=*) format="${a#--format=}" format_given=true ;;
            -*) die_usage MSG-J2A7 "unknown projects claim option: ${a} (allowed: -y/--yes, --format tsv)" ;;
            *)  if [[ -z "${path}" ]]; then path="${a}"
                else die_usage MSG-F8G9 "projects claim takes a single path"; fi ;;
        esac
    done
    if ${expect_format} || { ${format_given} && [[ "${format}" != tsv ]]; }; then
        die_usage MSG-S9A3 "projects claim --format takes one value, tsv"
    fi
    if [[ "${format}" == tsv ]]; then
        CLAIM_FORMAT=tsv
        exec 3>&1 1>&2
    fi
    [[ -n "${path}" ]] || die_path_required claim
    local d; d="$(resolve_existing_path "${path}")"
    [[ -d "${d}" ]] || die "not a directory: ${d}"
    claim_load_libraries
    ai_tools_safe_paths__assert_safe_target "${d}" "project claim" || exit 3
    require_claimable_owner "${d}"

    # The path's state decides whether a claim applies at all, before any write (cli.rule.md). A path a `%`, `!`
    # or glob line covers without naming it is refused: the helpers resolve no owner under the line, so the claim would
    # close over steps that did not apply. A disabled project is answered after that line, since a covering line refuses
    # it whatever its own entry says, and up front (offer_reenable), after the owner-side read and ahead of the proceed
    # confirm a run with no terminal answers first; declining aborts the claim before any write, and accepting re-reads
    # the path.
    load_target_registry_snapshot
    read_path_class "${d}"
    [[ "${CLASS_STATE}" != excluded ]] || die_excluded "${d}" claim
    [[ "${CLASS_BLOCKED}" == - ]] || die_blocked "${d}" claim
    local reenable=false listed=false safedir=false
    [[ "${CLASS_STATE}" == disabled ]] && reenable=true
    ai_tools_conf__has_allowlist_entry "${ALLOWLIST}" "${d}" 2>/dev/null && listed=true

    # The tree is read by its owner, in one owner-side invocation (claim_read_owner_inspection), before the Review block
    # and before any write; a tree that read did not describe is not claimed over. Interior drift is detected there
    # on a re-claim whose ownership is in place, and repaired in Apply behind the same confirm and gate as the other
    # in-place steps, since a first claim repairs the whole tree (cli.rule.md). Past CLAIM_SCAN_CAP the claim keeps
    # that many paths and writes a `scan-capped` row, the group cap applied after the skip-list split so hits the claim
    # cannot repair do not take the places of ones it can.
    ensure_claim_work_directory
    ai_tools_records_base__begin_report
    local filemode="" owngap="" acl="" labelled="" git="" inspect_pristine="" root_mode=""
    local -a drift=() drift_before=() drift_types=() drift_skipped=()
    local group_scan_detail="" group_capped=false skipped_capped=false
    local -a paths_with_narrow_acl_entries=() narrow_acl_entry_before=()
    local narrow_acl_entry_scan_detail="" narrow_acl_entry_capped=false
    local -a label_drift=() label_subject_types=()
    local -A label_drift_types=()
    local label_scan_detail="" label_capped=false
    local -a sealed_setgid=() ancestor_configs=()
    local sealed_setgid_scan_result=complete ancestor_config_scan_result=complete
    local scan_wanted=false
    { ${listed} || ${reenable}; } && scan_wanted=true
    if ! claim_read_owner_inspection "${scan_wanted}"; then
        warn MSG-X7N2 "the project's tree was not read as ${OWNER_USER} (${INSPECT_FAILURE}) -- the claim did not apply any step" \
            "The claim reads the tree as the account it belongs to and does not claim a tree that read did not describe."
        claim_write_row error directory inspect "${d}" "the tree was not read as ${OWNER_USER} (${INSPECT_FAILURE})"
        claim_end
    fi
    # Ahead of the re-enable offer, the first step that can write. The mode is the owner-side read's, so a `--for` run
    # reads it as the target.
    local has_registry_entry=false
    { ${listed} || ${reenable}; } && has_registry_entry=true
    require_open_project_root "${d}" "${root_mode}" "${has_registry_entry}"

    # The trust list is read ahead of the re-enable offer, the first step that can write: a claim that cannot read it
    # does not describe its pending steps, and applies none.
    local safedir_status=0
    check_safedir_entry "${d}" || safedir_status=$?
    if (( safedir_status == 2 )); then
        warn MSG-S3F4 "the git safe.directory list ${GITCONFIG} could not be read -- the claim did not apply any step" \
            "The claim does not describe its pending steps over a trust list it cannot read. git's own error names the cause:" \
            "      git -C / config --file $(_quote_path "${GITCONFIG}") --get-all safe.directory"
        claim_write_row error directory safedir "${d}" "the git safe.directory list ${GITCONFIG} could not be read"
        claim_end
    fi
    (( safedir_status == 0 )) && safedir=true

    if ${reenable}; then
        offer_reenable "${d}" "the claim" \
            || die "the registry was not updated -- ${d} is still disabled"
        read_path_class "${d}"
        [[ "${CLASS_BLOCKED}" == - ]] || die_blocked "${d}" claim
        ai_tools_conf__has_allowlist_entry "${ALLOWLIST}" "${d}" 2>/dev/null && listed=true
    fi

    # A tree `projects create` just made, by the owner's pristine reading; each question it answers is marked where it
    # is asked.
    local fresh=false
    if [[ "${CLAIM_FRESH_TREE:-}" == "${d}" && "${inspect_pristine}" == true ]]; then fresh=true; fi

    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
    # Files whose named entries an earlier claim wrote narrower than rwx take the ACL step's walk again, behind
    # the proceed confirm and the gate: the walk can widen a file whose owner gained a permission since.
    local needs_acl_entry_repair=false; (( ${#paths_with_narrow_acl_entries[@]} )) && needs_acl_entry_repair=true
    # A project an earlier release claimed carries the entries on most of its files, so the list usually stops
    # at CLAIM_SCAN_CAP; the count then says so rather than naming the cap as the total.
    local narrow_acl_entry_path_count="${#paths_with_narrow_acl_entries[@]}"
    ! ${narrow_acl_entry_capped} || narrow_acl_entry_path_count="more than ${CLAIM_SCAN_CAP}"
    # A step the owner-side read records as `unknown` is not applied (cli.rule.md, ref-section-h7d3).
    local git_unreadable=false filemode_unreadable=false
    [[ "${git}" == unknown ]] && git_unreadable=true
    [[ "${filemode}" == unknown ]] && filemode_unreadable=true

    # 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.
    sealed_setgid_note() {
        if [[ "${sealed_setgid_scan_result}" == not-made ]]; then
            say "      ${C_DIM}the sealed-setgid scan was not made as ${OWNER_USER}${C_RST}"
            return 0
        fi
        if (( ${#sealed_setgid[@]} )); then
            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}"
        fi
        if [[ "${sealed_setgid_scan_result}" == incomplete ]]; then
            say "      ${C_DIM}the sealed-setgid scan did not cover every directory as ${OWNER_USER},${C_RST}"
            if (( ${#sealed_setgid[@]} )); then
                say "      ${C_DIM}so the list may be short${C_RST}"
            else
                say "      ${C_DIM}so a directory it could not read may carry such a bit${C_RST}"
            fi
        fi
    }

    # Configuration a build reads from the project's ancestor directories, listed by the owner-side read. The toolchain
    # walks the ancestry and opens each file it recognises, so one the sandbox account is denied is a hard error naming
    # that path, and the message does not mention the sandbox boundary (ancestor-config.lib.sh). Read-only, and reported
    # rather than repaired: every step of a claim acts inside the project, so none of them closes this.

    ancestor_config_note() {
        if [[ "${ancestor_config_scan_result}" == not-made ]]; then
            say "      ${C_DIM}the ancestor-configuration check was not made as ${OWNER_USER}${C_RST}"
            return 0
        fi
        (( ${#ancestor_configs[@]} )) || return 0
        headline_warn "NOTICE: configuration above this project the agent cannot read" \
            "${#ancestor_configs[@]} file(s) above ${d} are configuration an installed toolchain reads for a build here, and the sandbox account cannot open them -- a build that reads one fails on it, naming a path outside the project. The claim acts inside the project, so it leaves them as they are."
        path_listing "file(s)" "${ancestor_configs[@]}"
        say "      ${C_DIM}a file moved into the project is readable to the sandbox account,${C_RST}"
        say "      ${C_DIM}which one above it is not${C_RST}"
    }

    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[@]}"
        ! ${skipped_capped} || say "        ${C_DIM}(list capped at ${CLAIM_SCAN_CAP} paths)${C_RST}"
        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: $(format_projects_command handback "" --full)${C_RST}"
    }

    # claim_scan_rows: the rows a scan itself earns -- an `error` for a walk or batch whose output was not complete,
    # a `scan-capped` where it stopped at CLAIM_SCAN_CAP. Written on every path the claim ends by, the fully-claimed
    # no-op included, since an incomplete scan without drift rows does not describe a clean tree.
    claim_scan_rows() {
        [[ -z "${label_scan_detail}" ]] || claim_write_row error directory label "${d}" "${label_scan_detail}"
        [[ -z "${group_scan_detail}" ]] || claim_write_row error directory group "${d}" "${group_scan_detail}"
        [[ -z "${narrow_acl_entry_scan_detail}" ]] || claim_write_row error directory narrow-acl-entries "${d}" "${narrow_acl_entry_scan_detail}"
        ! ${label_capped} || claim_write_row scan-capped directory label "${d}" \
            "the label scan stopped at ${CLAIM_SCAN_CAP} paths; re-claim once these are settled"
        ! ${group_capped} || claim_write_row scan-capped directory group "${d}" \
            "the group scan stopped at ${CLAIM_SCAN_CAP} paths; re-claim once these are settled"
        ! ${narrow_acl_entry_capped} || claim_write_row scan-capped directory narrow-acl-entries "${d}" \
            "the entries scan stopped at ${CLAIM_SCAN_CAP} paths; re-claim once these are settled"
        ! ${git_unreadable} || claim_write_row error directory git "${d}/.git" \
            "not read as ${OWNER_USER}; .git sharing was not checked"
        ! ${filemode_unreadable} || claim_write_row error directory filemode "${d}/.git" \
            "not read as ${OWNER_USER}; core.filemode was not checked"
        return 0
    }

    # unreadable_steps_note: the commands that finish each step claim_scan_rows wrote an unread row for. Each is
    # the call the claim makes for that step (claim_setfacl's `--with-git`, reg_filemode's `git config`), run by its
    # owner.
    unreadable_steps_note() {
        ${git_unreadable} || ${filemode_unreadable} || return 0
        [[ "${CLAIM_FORMAT}" == tsv ]] && return 0
        local _owner_command_prefix="" _quoted_project_path _skip_flag=""
        [[ -n "${FOR_OPERATOR}" ]] && _owner_command_prefix="sudo -u ${OWNER_USER} -H "
        _quoted_project_path="$(_quote_path "${d}")"
        # The by-hand walk leaves the group drift the operator declined as the claim's own walk did.
        ! ${skip_drifted:-false} || _skip_flag=" --skip-drifted"
        say "      ${C_DIM}not checked, since ${OWNER_USER} cannot read them; to finish them by hand:${C_RST}"
        ! ${git_unreadable} \
            || say "      ${C_BOLD}sudo ${SETFACL_BIN} --with-git${_skip_flag} ${_quoted_project_path}${C_RST}   ${C_DIM}(shares .git history)${C_RST}"
        ! ${filemode_unreadable} \
            || say "      ${C_BOLD}${_owner_command_prefix}git -C ${_quoted_project_path} config core.filemode true${C_RST}"
    }

    # claim_drift_records: after the Apply block, check each drifted path on its own, as its owner
    # (claim_read_owner_verification), and write one row per path -- `fixed` where its postcondition now holds,
    # `not-fixed` where it does not (declined, not authorized, or a repair that did not take), `unverified` where
    # the check could not be read, and `gone` where the path is confirmed absent.
    # A re-scan of the tree is not what decides `fixed`: a path can be missing from one because it was capped, failed,
    # or excluded (cli.rule.md). The ways to settle a not-fixed path follow the rows.
    claim_drift_records() {
        (( ${#label_drift[@]} || ${#drift[@]} || ${#paths_with_narrow_acl_entries[@]} )) || { claim_scan_rows; return 0; }
        local _i _outcome _detail _left_label=false _left_group=false _mixed=0 _group_fixed _label_fixed
        local -a _label_outcomes=() _group_outcomes=() _group_details=() _group_present=()
        local -a _narrow_acl_outcomes=() _narrow_acl_details=()
        local -A _label_by_path=()
        [[ "${CLAIM_FORMAT}" == tsv ]] || say "  interior drift:"
        claim_scan_rows
        claim_read_owner_verification _label_outcomes _group_outcomes _group_details _group_present \
            _narrow_acl_outcomes _narrow_acl_details
        # An entries path is settled by the walk the step runs, so a not-fixed row is a walk that did not take,
        # which the Apply block has named with its command.
        for _i in "${!paths_with_narrow_acl_entries[@]}"; do
            _outcome="${_narrow_acl_outcomes[_i]:-unverified}"
            case "${_outcome}" in
                fixed) _detail="its named entries are rwx" ;;
                gone)  _detail="no entry at the path" ;;
                *)     _detail="${_narrow_acl_details[_i]:-}" ;;
            esac
            claim_write_row "narrow-acl-entries-${_outcome}" file narrow-acl-entries "${paths_with_narrow_acl_entries[_i]}" "${_detail}"
        done
        if (( ${#label_drift[@]} )); then
            for _i in "${!label_drift[@]}"; do
                _outcome="${_label_outcomes[_i]:-unverified}"
                case "${_outcome}" in
                    unverified) _detail="its type could not be read after the relabel" ;;
                    gone)       _detail="no entry at the path" ;;
                    *)          _detail="${label_drift_types[${label_drift[_i]}]:-}" ;;
                esac
                [[ "${_outcome}" == not-fixed ]] && _left_label=true
                _label_by_path["${label_drift[_i]}"]="${_outcome}"
                claim_write_row "label-${_outcome}" "${label_subject_types[_i]}" label "${label_drift[_i]}" "${_detail}"
            done
        fi
        if (( ${#drift[@]} )); then
            for _i in "${!drift[@]}"; do
                _outcome="${_group_outcomes[_i]:-unverified}"
                _detail="${_group_details[_i]:-}"
                [[ "${_outcome}" == fixed ]] && _detail="was ${drift_before[_i]}"
                # A repair that did not run, over a path still as the scan read it, is said in the fixed row's terms;
                # both readings are the owner's. A path something else changed since -- the secret gate sealing it
                # owner-only -- keeps the check's own reason, which names what it now is.
                if [[ "${_outcome}" == not-fixed ]] && ! ${do_drift} \
                        && [[ "${_group_present[_i]:-?}" != '?' && "${_group_present[_i]}" == "${drift_before[_i]}" ]]; then
                    _detail="still ${drift_before[_i]}"
                fi
                [[ "${_outcome}" == not-fixed ]] && _left_group=true
                # A path on both lists is reachable only once both repairs took: its permissions and its type each
                # refuse the agent on their own, so one fixed and the other not leaves it as closed
                # as before, which neither row says alone.
                if [[ -n "${_label_by_path[${drift[_i]}]+set}" ]]; then
                    _group_fixed=false _label_fixed=false
                    [[ "${_outcome}" == fixed ]] && _group_fixed=true
                    [[ "${_label_by_path[${drift[_i]}]}" == fixed ]] && _label_fixed=true
                    [[ "${_group_fixed}" != "${_label_fixed}" ]] && _mixed=$(( _mixed + 1 ))
                fi
                claim_write_row "group-${_outcome}" "${drift_types[_i]}" group "${drift[_i]}" "${_detail}"
            done
        fi
        [[ "${CLAIM_FORMAT}" == tsv ]] && return 0
        if (( _mixed )); then
            say "      ${_mixed} path(s) were fixed for one kind only -- the agent still cannot open them;"
            say "      re-run the claim and answer yes to the other question to share them"
        fi
        ${_left_label} || ${_left_group} || return 0
        # The settle commands are the per-path form the claim's repairs do not have, since each repair acts on every
        # path it reaches. A path's owner may set its label without sudo, so a relabel of a few paths is one command
        # each; the owner is not in the sandbox group, so the paths to keep from the group repair are sealed or excluded
        # first. Under `--for` those files belong to the target operator, and the commands are theirs to run.
        local _who="you"
        [[ -n "${FOR_OPERATOR}" ]] && _who="${OWNER_USER}"
        say "      ${C_DIM}to share them all with the agent, re-run the claim and answer yes${C_RST}"
        say "      ${C_DIM}to keep one out of its reach, as ${_who}: chmod 600 <path>${C_RST}"
        say "      ${C_DIM}to stop a re-claim asking about one, as ${_who}: add a line !<path>${C_RST}"
        say "      ${C_DIM}to ~/.config/ai-tools/project-registry${C_RST}"
        if ${_left_label}; then
            say "      ${C_DIM}to relabel only some, as ${_who}: restorecon -F <path>${C_RST}"
        fi
        if ${_left_group}; then
            say "      ${C_DIM}to repair the group for only some, chmod 600 or add a ! line for the others,${C_RST}"
            say "      ${C_DIM}then re-run the claim and answer yes${C_RST}"
        fi
        return 0
    }

    # ── 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} || ${needs_acl_entry_repair}; then
        heavy=true
    fi
    # Not said on a pristine tree (cli.rule.md, `projects create`). The warning is what the operator sees of the fact
    # that an unclaim normalizes rather than restores (ai-tools-unclaim's header).
    if ${heavy} && ! ${fresh}; then
        head+=("claiming in place grants the agent group access to this whole tree")
        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[@]}"
    # The `!` lines inside the project name the paths where a session does not start and that the claim's permission
    # walks skip.
    local -a claim_excluded=()
    mapfile -t claim_excluded < <(list_excluded_lines_under "${d}")
    if (( ${#claim_excluded[@]} )); then
        say "  excluded by the '!' line(s) in ${REGISTRY_DISPLAY_PATH} -- no session starts under them, and the claim skips them:"
        printf '      %s\n' "${claim_excluded[@]}"
    fi
    # A claim of an ancestor of registered projects puts each of them inside this one, so the block names them.
    local -a claim_nested=()
    mapfile -t claim_nested < <(list_entries_under "${d}")
    if (( ${#claim_nested[@]} )); then
        say "  registered project(s) under it, each keeping its own line in ${REGISTRY_DISPLAY_PATH}:"
        printf '      %s\n' "${claim_nested[@]}"
    fi

    find_blocking_ancestors "${d}"

    # A claimed project can still sit under a non-traversable parent (a later chmod 700 on an ancestor), and the grant
    # that closes that is a pending step like the others -- it takes the gate -- so a project with one takes
    # the pending-steps flow rather than this path.
    if [[ "${listed}" == true && "${safedir}" == true && "${owngap}" == false ]] \
            && ! ${need_filemode} && ! ${need_acl} && ! ${need_label} && ! ${need_git} \
            && ! ${git_unreadable} && ! ${filemode_unreadable} \
            && (( ${#drift[@]} == 0 && ${#label_drift[@]} == 0 && ${#paths_with_narrow_acl_entries[@]} == 0 \
                  && ${#TRAVERSAL_GRANT_PATHS[@]} == 0 )); then
        skip_listed_note
        sealed_setgid_note
        ancestor_config_note
        # With no grant to offer, this prints the blocked-ancestor warning alone, where there is one.
        confirm_ancestor_traversal "${d}"
        claim_scan_rows
        CREATE_LEFT_TREE=""
        ok "already fully claimed -- nothing to do"
        claim_end
        return 0
    fi

    # The gate runs where a pending step widens access (secret_gate) and on every first claim; a drift repair
    # and the traverse grant count once answered yes, so here they decide only whether the overview announces it.
    local need_gate=false
    if [[ "${listed}" != true || "${owngap}" == true ]] \
            || ${need_acl} || ${need_git} || ${need_label} || ${needs_acl_entry_repair}; then
        need_gate=true
    fi
    # A pristine tree has no secret-named file to find (cli.rule.md, `projects create`).
    if ${fresh}; then need_gate=false; fi
    local gate_announced="${need_gate}"
    (( ${#drift[@]} || ${#label_drift[@]} || ${#TRAVERSAL_GRANT_PATHS[@]} )) && gate_announced=true

    say ""
    say "  pending:"
    [[ "${listed}"  == true  ]] || say "    - add to the project registry"
    [[ "${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, masked to your own permissions on each path)"
    ${needs_acl_entry_repair} \
        && say "    - apply the group-permission ACL again: ${narrow_acl_entry_path_count} file(s) carry an earlier claim's entries"
    ${need_label} && say "    - apply SELinux ai_tools_project_t label"
    (( ${#label_drift[@]} )) \
        && say "    - SELinux type differs on ${#label_drift[@]} path(s) -- you will be asked to relabel the tree (default no)"
    (( ${#drift[@]} )) \
        && say "    - group differs on ${#drift[@]} path(s) -- you will be asked to move them to group ${SANDBOX_GROUP} (default no)"
    if ${need_gate}; then
        say "    - scan for secret-named files and lock them down -- you will confirm"
    elif ${gate_announced}; then
        say "    - scan for secret-named files if you accept a repair -- you will confirm"
    fi
    ${need_git} && say "    - normalize .git so the agent can access git history -- you will be asked"
    (( ${#TRAVERSAL_GRANT_PATHS[@]} )) && say "    - grant traverse-only access on ${#TRAVERSAL_GRANT_PATHS[@]} parent path(s) -- you will be asked"
    ${git_unreadable} && say "    - .git history sharing -- not checked, named at the end"
    ${filemode_unreadable} && say "    - git core.filemode -- not checked, named at the end"

    if ${git_unreadable} || ${filemode_unreadable}; then
        headline_warn "NOTICE: part of this project cannot be read as ${OWNER_USER}" \
            "The claim reads the project's state as ${OWNER_USER}, and could not read the repository at ${d}/.git as that account -- the directory is closed to it, git refuses another account's repository, or the repository git opens there is not this project's own. The steps marked not checked are left as they are; each is named at the end with the command that finishes it, and the claim ends non-zero."
    fi

    skip_listed_note
    sealed_setgid_note
    ancestor_config_note

    # The files listed here are the step's whole reason; the walk it runs covers the tree. An owner's chmod g+x
    # over entries written rwx does not show here, so the step does not reset a deliberate one on every re-claim.
    if ${needs_acl_entry_repair}; then
        headline_warn "NOTICE: ACL entries from an earlier claim" \
            "${narrow_acl_entry_path_count} file(s) carry the ACL entries a claim by an earlier release wrote: yours and the agent's give read and write alone, so a chmod g+x on one does not reach the agent. The pending ACL step writes them again across the tree, so the agent gets what you have on each file and later chmods reach it."
        path_listing "file(s)" "${paths_with_narrow_acl_entries[@]}"
        ! ${narrow_acl_entry_capped} || say "        ${C_DIM}(list capped at ${CLAIM_SCAN_CAP} paths)${C_RST}"
    fi

    # Heavy steps close the Review block behind the proceed confirm, which `--yes` pre-answers with the SELinux type
    # question; a pristine tree skips it with the warnings it authorizes.
    if ${heavy} && ! ${fresh}; then
        ${ASSUME_YES} || confirm "Apply these pending steps to the tree in place?" n \
            || die "aborted"
    fi

    # ── Interior drift: one block per kind, each its list and then its question, so the answer follows the paths it is
    # about. Each repair can widen the agent's access, so each defaults to no (cli.rule.md). A declined repair is
    # reported as not fixed; the claim goes on. ──
    local do_label_drift=false do_drift=false
    if (( ${#label_drift[@]} )); then
        headline_warn "Interior drift: SELinux type" \
            "SELinux type differs on ${#label_drift[@]} path(s) inside the tree: each carries a type other than the one this project's file-context rules give it -- moved in with mv, cp -a or tar --selinux, or relabelled by another tool. The agent is refused them whatever their permissions say."
        item_listing "path(s) with their types" label_drift_lines "${label_drift[@]}"
        drift_secret_legend "${label_drift[@]}"
        ! ${label_capped} || say "        ${C_DIM}(scan capped at ${CLAIM_SCAN_CAP} paths)${C_RST}"
        # What the answer changes, and what it costs, sit directly ahead of the question: the relabel reaches every path
        # in the tree, not only the ones listed.
        say "      a relabel changes SELinux types only; owner, group and mode stay"
        say "      ${C_YEL}it resets every type in the tree: a Podman :Z volume or a directory${C_RST}"
        say "      ${C_YEL}httpd serves is reset too, and that service loses its access${C_RST}"
        # Only an explicit `--yes` answers this without a terminal, and AI_TOOLS_ASSUME_YES does not answer it at all:
        # the relabel resets every type in the tree, so an unattended run relabels only where its caller said
        # so on the command line.
        if ${ASSUME_YES}; then
            do_label_drift=true
        elif have_tty; then
            if AI_TOOLS_ASSUME_YES='' confirm "Relabel the tree to the project's SELinux types?" n; then
                do_label_drift=true
            fi
        else
            say "      relabel not run: no terminal to ask on, and --yes was not given"
        fi
    fi
    # With the relabel not run and every path of this list also on the relabel list, a group repair would share none
    # of them (cli.rule.md), so the question is not asked and the rows report the group as not fixed.
    local group_needs_label=false _p
    # On a host not enforcing, a foreign type does not refuse the agent, so the group repair alone shares the path.
    if (( ${#drift[@]} && ${#label_drift[@]} )) && ! ${do_label_drift} \
            && [[ "$(getenforce 2>/dev/null)" == Enforcing ]]; then
        local -A _on_label_list=()
        for _p in "${label_drift[@]}"; do _on_label_list["${_p}"]=1; done
        group_needs_label=true
        for _p in "${drift[@]}"; do
            [[ -n "${_on_label_list[${_p}]+set}" ]] || { group_needs_label=false; break; }
        done
    fi
    if (( ${#drift[@]} )); then
        headline_warn "Interior drift: group and ACL" \
            "Group differs on ${#drift[@]} path(s) inside the tree: each has a group other than ${SANDBOX_GROUP} and group access -- it arrived without inheriting the project group or ACL." \
            "Keep a file shared with a team group or read by a service's group as it is."
        PATH_DETAIL_MARK=1 path_listing "path(s)" "${drift[@]}"
        drift_secret_legend "${drift[@]}"
        ! ${group_capped} || say "        ${C_DIM}(scan capped at ${CLAIM_SCAN_CAP} paths)${C_RST}"
        if ${group_needs_label}; then
            say "      group repair not offered: these path(s) keep a type the agent is"
            say "      refused, so a group change alone gives it no access -- re-run the"
            say "      claim and relabel to share them"
        else
            say "      a move changes group and ACL only; the SELinux type stays"
            say "      ${C_YEL}the agent gets what each path's group bits grant, and the group it${C_RST}"
            say "      ${C_YEL}has now loses its access${C_RST}"
            if confirm "Move these ${#drift[@]} path(s) to group ${SANDBOX_GROUP} with the project ACL?" n; then
                do_drift=true
            fi
        fi
    fi
    if ${do_label_drift} || ${do_drift}; then
        ${fresh} || need_gate=true
    fi

    # An accepted traverse grant is an access-widening step, so it takes the gate (confirm_ancestor_traversal); the ACL
    # is set in Apply.
    confirm_ancestor_traversal "${d}"
    if ${TRAVERSAL_GRANT_CONFIRMED}; then
        ${fresh} || need_gate=true
    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}$(format_projects_command 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
        # A new repository has no history to expose, and the question is still asked: whether the agent reads the
        # commits made here from now on is the owner's choice, with a real answer either way -- the posture of a service
        # account whose agent works the tree while the operator keeps .git -- so a create asks it like a re-claim does.
        if ${fresh} && [[ "${CREATE_GIT_WITHHELD}" == "${d}" ]]; then
            headline_warn "Git history: a new repository" \
                "This repository has no commits yet. Normalizing .git lets the agent read the commits made here from now on. Declining keeps .git out of the agent's reach: the create left it in its owner's group, which the agent is not in, with no access for other accounts. A later re-claim offers the share again, and a chmod on .git withdraws it."
        elif ${fresh}; then
            headline_warn "Git history: a new repository" \
                "This repository has no commits yet. Normalizing .git lets the agent read the commits made here from now on. The create could not set .git to its owner's group and modes (the warning above), so declining leaves .git as git made it, which the agent may be able to read."
        elif [[ "${inspect_pristine}" == true ]]; then
            # A claim of an existing tree the owner-side read found pristine: its object store is empty, so there is no
            # history yet to expose.
            headline_warn "Git history: a new repository" \
                "This repository has no commits yet. Normalizing .git lets the agent read the commits made here from now on."
        else
            headline_warn "WARNING: git history exposure" \
                "normalizing .git lets the agent read this repo's full git history"
        fi
        if confirm "Normalize .git so the agent can access git history here?" y; then
            do_git=true
        else
            say "    .git: left as-is -- not shared by the claim"
        fi
    fi

    # ── Apply block: the approved steps run back to back, each reporting one result line; the closing ✓ is the claim's
    # completion. The headline opens only over a step that runs: with every repair declined there is none, and an empty
    # block would read as work done. ──
    local apply_steps=false
    if [[ "${safedir}" != true || "${owngap}" == true ]] || ${need_filemode} || ${need_acl} || ${do_git} \
            || ${need_label} || ${do_drift} || ${do_label_drift} || ${needs_acl_entry_repair} || ${TRAVERSAL_GRANT_CONFIRMED}; then
        apply_steps=true
    fi
    if ${apply_steps}; then headline "Applying claim steps" "${d}"; fi

    # The traverse grant goes first: it is unprivileged, so it cannot fail a password round, and a stop
    # by note_root_failure would otherwise skip it. A grant that did not take counts with the root steps that did not
    # apply, and does not ask note_root_failure's question, which is about a password round.
    if ${TRAVERSAL_GRANT_CONFIRMED} && ! grant_ancestor_traversal; then
        ROOT_STEP_FAILURES=$(( ROOT_STEP_FAILURES + 1 ))
    fi

    # A failed step asks once before the next is attempted (note_root_failure); stopping is the safe direction here.
    local stopped=false
    if [[ "${safedir}" != true ]]; then
        reg_safedir "${d}" || note_root_failure || stopped=true
    fi
    ${need_filemode} && reg_filemode "${d}"
    if ! ${stopped}; then
        if [[ "${owngap}" == true ]]; then
            reg_ownership "${d}" || note_root_failure || stopped=true
        elif ${do_drift}; then
            reg_ownership "${d}" force || note_root_failure || stopped=true
        fi
    fi
    # The walk regroups every path with a foreign group unless told otherwise, and it also runs for the ACL and `.git`
    # steps; where the group scan ran and its repair was not accepted, it leaves those paths as the scan found them.
    local skip_drifted=false
    if ${scan_wanted} && [[ "${owngap}" == false ]] && ! ${do_drift}; then skip_drifted=true; fi
    if ! ${stopped} && { ${need_acl} || ${do_git} || ${do_drift} || ${needs_acl_entry_repair}; }; then
        claim_setfacl "${d}" "${do_git}" "${skip_drifted}" || note_root_failure || stopped=true
    fi
    if ! ${stopped} && { ${need_label} || ${do_label_drift}; }; then
        claim_relabel "${d}" || note_root_failure || stopped=true
    fi
    say ""
    claim_drift_records
    unreadable_steps_note

    # A claim whose access-granting steps did not apply exits 1 in place of the ✓: no success mark over a project
    # the agent has no group or ACL entry into. The registry entries stand, so a re-run applies what is missing.
    if (( ROOT_STEP_FAILURES )); then
        headline_warn "WARNING: the claim did not complete" \
            "${d} is registered, but ${ROOT_STEP_FAILURES} step(s) that grant the agent access did not apply, so it cannot work there yet. Each is named above with the command that applies it. Re-running the claim is the simpler route -- it is idempotent and does only what is still missing:"
        say "      ${C_BOLD}$(format_projects_command claim "${d}")${C_RST}"
        ai_tools_log__structured warning \
            "claim of ${d} incomplete -- ${ROOT_STEP_FAILURES} root step(s) did not apply" \
            "AI_TOOLS_PROJECT=${d}" "AI_TOOLS_RESULT=failed"
        exit 1
    fi
    # `no change applied` is said only where no step that writes could have run: no registry entry, no secret scan, no
    # traverse grant offered, and no Apply step. Any other run keeps the plain line, which does not say either way.
    # The ✓ is kept for a claim that left no drift and no capped scan: with a not-fixed or a scan-capped row the claim
    # ends non-zero, and the line takes the mark that status carries.
    # The claim closes with its line here, so a create's leftover-tree report (remove_run_files) has no tree to name.
    CREATE_LEFT_TREE=""
    local closing="claimed ${d}"
    if ! ${apply_steps} && [[ "${listed}" == true ]] && ! ${need_gate} && (( ${#TRAVERSAL_GRANT_PATHS[@]} == 0 )); then
        closing+=" -- no change applied"
    fi
    if ai_tools_records_base__get_exit_status; then
        ok "${closing}"
    else
        say "  ${C_YEL}!${C_RST} ${closing}"
    fi
    ai_tools_log__structured info "claimed project ${d}" \
        "AI_TOOLS_PROJECT=${d}" "AI_TOOLS_RESULT=ok"
    claim_end
}

# claim_end -- end a claim that applied its steps with the report state's status (ai-tools-records(5)), which the rows
# written before it folded their severity into. A root step that failed has already exited 1, which outranks both (1
# over 5 over 4). On the page a non-zero status is said in one line, since the rows printed before it name each path.
claim_end() {
    local status=0
    ai_tools_records_base__get_exit_status || status=$?
    (( status )) || return 0
    if [[ "${CLAIM_FORMAT}" != tsv ]]; then
        if (( status == AI_TOOLS_RECORDS_BASE__EXIT_UNREADABLE )); then
            say "  ${C_DIM}exit ${status}: a check could not be read -- the unverified and error rows name it${C_RST}"
        else
            say "  ${C_DIM}exit ${status}: drift is left in place -- the not-fixed and scan-capped rows name it${C_RST}"
        fi
    fi
    exit "${status}"
}

# refuse_unresolved_parent <parent>  -- die naming <parent>, the parent directory of a create that does not resolve.
# `realpath -e` fails alike for an absent directory and for a symbolic link leading through a directory the invoker
# cannot enter, so the refusal names both.
refuse_unresolved_parent() {
    local parent="$1"
    die MSG-J3R8 "cannot resolve the parent directory: $(ai_tools_log__sanitize "${parent}") -- it does not exist, or a symbolic link on its path leads through a directory ${INVOKING_USER} cannot enter" \
        "projects create creates ONE directory, not a path of them, so a mistyped path is refused here rather than created. Check the path; if it is right and the parent is missing, create the parent yourself and re-run:" \
        "       mkdir -p $(_quote_path "${parent}")"
}

# cmd_project_create <path>  -- create a NEW project directory and claim it: ONE mkdir, an empty git repository,
# a README.md, then cmd_project_claim unchanged on the result. <path> is required with no cwd default. Every filesystem
# step goes through run_as_owner, so a create under `--for` produces a TARGET-owned tree, the one
# require_claimable_owner then accepts. It does not take `-y`: the claim's two questions on a new tree, the traverse
# grant on an ancestor and the `.git` share, are not a flag's to answer, the first answered by a person at a terminal
# alone and the second by its default where no one is. A claim that stops after the mkdir leaves the tree named, with
# the claim that finishes it (remove_run_files). The refusals, the modes it sets and the claim questions it infers are
# cli.rule.md's.
#
# `.git` stays out of the agent's reach until the `.git` question shares it. `git init` builds `.git` under the owner's
# umask, so under `022` it carries other bits, and under a setgid parent -- the clone area among them --
# in that parent's group with any default ACL the parent carries. The create therefore strips every extended entry
# and the default ACL under `.git` (`setfacl -R -b`), regroups it to the owner's primary group, and clears group write
# and the other bits, all as the owner and before the root opens to 0750. Group write is cleared explicitly, because
# `-b` turns the mask it strips into the group bits, and a `002` umask, the default a user-private-group login takes,
# has git build `.git` group-writable. The setgid and ACL walks skip `.git`, so only `ai-tools-setfacl --with-git` opens
# it; a step that fails warns, and the claim's headline for the question says a decline leaves `.git` as git made it.
cmd_project_create() {
    local a path=""
    for a in "$@"; do
        case "${a}" in
            -*) die_usage MSG-C5T8 "unknown projects create option: ${a}" \
                    "       it takes a path and nothing else; see: man ai-tools" ;;
            *)  if [[ -z "${path}" ]]; then path="${a}"
                else die_usage MSG-Z5Y7 "projects create takes a single path"; fi ;;
        esac
    done
    [[ -n "${path}" ]] || die_usage MSG-A7D3 "projects create needs a path: it creates a NEW project directory." \
        "To claim a directory that already exists, use:" \
        "       $(format_projects_command claim "") <path>"

    # The parent is resolved as the kernel resolves it for the mkdir, every component and symlink target read
    # as the invoker (`realpath -e`, and find_invoker_traversal_blocker to name a directory it cannot enter).
    local typed="${path}" leaf parent_typed parent_real d blocked_ancestor
    while [[ "${typed}" == */ && "${typed}" != / ]]; do typed="${typed%/}"; done
    [[ "${typed}" == /* ]] || typed="${PWD}/${typed}"
    leaf="${typed##*/}"
    parent_typed="${typed%/*}"; [[ -n "${parent_typed}" ]] || parent_typed=/
    # Ahead of the existence checks, which read a path under a directory the invoker cannot enter as absent, and ahead
    # of the mkdir: the claim that follows resolves the path as the invoker, and could not resolve it. The trailing
    # slash makes the walk read every component of the parent.
    if find_invoker_traversal_blocker "${parent_typed}/" blocked_ancestor; then
        refuse_unreachable_path "${typed}" "${blocked_ancestor}"
    fi
    # Each capture takes resolve_existing_path's sentinel, or `parent<LF>/new` would be created under `parent`.
    if ! parent_real="$(realpath -e -- "${parent_typed}" 2>/dev/null && printf x)"; then
        refuse_unresolved_parent "${parent_typed}"
    fi
    parent_real="${parent_real%x}"
    parent_real="${parent_real%$'\n'}"
    # A leaf that is a symbolic link, dangling or not, is refused by the name typed: `realpath -m` would resolve it
    # to its target, so a dangling link would have the create make and claim the directory the link points at.
    if [[ -L "${parent_real}/${leaf}" ]]; then
        die MSG-G7D3 "cannot create at a symbolic link: $(ai_tools_log__sanitize "${typed}") -- projects create makes a new directory and does not follow a link there" \
            "Remove the link, or name the directory it points at."
    fi
    # The leaf may be `.` or `..`; each resolves here to a path the existence check reads.
    d="$(realpath -m -- "${parent_real}/${leaf}" 2>/dev/null && printf x)" || die "cannot resolve the path: ${path}"
    d="${d%x}"
    d="${d%$'\n'}"
    if find_invoker_traversal_blocker "${d}" blocked_ancestor; then
        refuse_unreachable_path "${d}" "${blocked_ancestor}"
    fi
    # Ahead of the existence check, so a path holding a control byte is refused under its own code and is not printed.
    refuse_control_character_path "${d}"
    if [[ -e "${d}" ]]; then
        die MSG-T4B9 "this path already exists: ${d}" \
            "projects create only ever creates. Claim what is already there instead:" \
            "       $(format_projects_command claim "${d}")"
    fi

    # ONE directory is created, the final component, and a parent that does not exist is refused: a mistyped path
    # surfaces as a refusal naming the missing directory and does not become a manufactured tree with a claimed project
    # inside it. `realpath -e` has resolved the typed parent and a symbolic-link leaf is refused, so the parent of `d`
    # exists here; the check stays as the guard on that ordering.
    local parent="${d%/*}"; [[ -n "${parent}" ]] || parent=/
    [[ -d "${parent}" ]] || refuse_unresolved_parent "${parent}"

    # The backstop on the target. Only one directory is created, so this is the whole surface: it refuses a create
    # that would MANUFACTURE a protected directory (`/efi` or `/lost+found` on a host without one). It does not refuse
    # a project nested INSIDE a protected tree -- descendants pass by design here exactly as they do for a claim, or no
    # project under a home would work.
    ai_tools_safe_paths__assert_safe_target "${d}" "project create" || exit 3
    # The target's registry is read once the create's own refusals have run (load_target_registry_snapshot), and
    # the claim it runs finds the snapshot loaded.
    load_target_registry_snapshot

    # A line may already name the path, or cover it, though the directory does not exist. A `!` line on the path
    # would be claimed over, and a covered path would be created and then claimed where the helpers resolve
    # no owner, so each is refused before the mkdir. A stale `@` or `%` line naming the path meets the claim's
    # idempotent add or its re-enable offer.
    read_path_class "${d}"
    case "${CLASS_STATE}" in
        excluded)  die_excluded "${d}" create ;;
    esac
    [[ "${CLASS_BLOCKED}" == - ]] || die_blocked "${d}" create
    # The claim's add refuses a name the registry cannot carry, and by then the directory, its repository and its README
    # exist, so the name is checked ahead of the mkdir.
    ai_tools_conf__is_registry_path_representable "${d}" \
        || die_unregistrable_name "${d}" "Choose a name without them; the directory was not created."
    # The claim's registry write rewrites the whole file, so a line of it the writer cannot write back would fail that
    # write after the directory, its repository and its README exist.
    if report_unrenderable_registry_line; then
        die "projects create stopped before any change -- change the registry line named above first"
    fi

    # Reachability pre-flight. The parent exists by now, so this scans the project's real ancestry: a blocker no grant
    # may cover means the sandbox account could never enter this project, so the create is refused BEFORE anything
    # exists rather than leaving a directory to clean up. A blocker the predicate DOES permit is not a refusal -- it
    # becomes the claim's own traverse opt-in, which offers the grant and the exact setfacl for anything it cannot
    # apply.
    find_blocking_ancestors "${d}"
    if [[ -n "${TRAVERSAL_BLOCKED_PATH}" ]]; then
        # State the blocker and why no grant covers it, and stop there. The claim's own version of this refusal points
        # at `projects clone`, which does not apply here: that verb clones an EXISTING repository into the sandbox area,
        # and this verb's whole subject is a project that does not exist yet, so there is no source to name.
        local why blocked_owner
        blocked_owner="$(stat -c '%U' "${TRAVERSAL_BLOCKED_PATH}" 2>/dev/null || true)"
        if [[ -n "${TRAVERSAL_BLOCKED_REASON}" ]]; then
            why="${TRAVERSAL_BLOCKED_REASON}, so no grant is offered on it"
        elif [[ -z "${blocked_owner}" ]]; then
            why="its owner cannot be read from here"
        elif [[ "${blocked_owner}" != "${OWNER_USER}" ]]; then
            why="it belongs to ${blocked_owner}, not to ${OWNER_USER}"
        else
            why="it is a protected system directory"
        fi
        headline_warn "WARNING: the agent could not reach a project here" \
            "the sandbox account cannot traverse ${TRAVERSAL_BLOCKED_PATH} (${why}), so it could not enter a project created at ${d}. Nothing has been created. Create the project somewhere the sandbox account can reach: every parent directory has to be one it can already enter, or one you own and can grant traverse on."

        # One alternative is offered, and only after it has been CHECKED on this host rather than assumed: the owner's
        # home is the usual reachable location, but whether it is depends on its ancestry, which differs per host.
        # A suggestion that cannot be verified is not made at all.
        # shellcheck disable=SC2034  # find_invoker_traversal_blocker writes it through its nameref; the status is the reading
        local home_dir candidate candidate_blocker
        home_dir="$(getent passwd "${OWNER_USER}" 2>/dev/null | cut -d: -f6)"
        if [[ -n "${home_dir}" && -d "${home_dir}" ]]; then
            candidate="${home_dir%/}/${d##*/}"
            find_blocking_ancestors "${candidate}"
            # Reachable by the invoker as well, or the create offered here would be refused
            # by the find_invoker_traversal_blocker pre-flight.
            if [[ -z "${TRAVERSAL_BLOCKED_PATH}" && ! -e "${candidate}" ]] \
                    && ! find_invoker_traversal_blocker "${candidate}" candidate_blocker; then
                say ""
                say "  this location is reachable:"
                say "      ${C_BOLD}$(format_projects_command create "${candidate}")${C_RST}"
            fi
        fi
        die "project create stopped -- the agent could not reach a project at that location"
    fi

    # ── Apply. Deliberately ONE block, not a review followed by an apply: this verb does not ask for confirmation,
    # so a pending list would announce three steps whose result lines follow immediately underneath -- the same
    # information twice -- and the claim opens with a pending list of its own, which made the pair read as one repeated
    # block. ──
    headline "Create project" "${d}" \
        "Creating the directory, an empty git repository in it, and a README.md, then claiming it so the sandbox account can work there."
    # The directory is made 0700 and opened to 0750 last, once .git and the README carry their modes, so no path inside
    # it is reachable before then: under the setgid clone area the sandbox group enters a 0750 directory from the moment
    # it exists. 0750 is set explicitly (`chmod` is not masked by the umask): under a 077 umask the directory would stay
    # 0700, the seal ai-tools-setgid and ai-tools-setfacl honour and skip, and the claim would register a project
    # the agent cannot enter. 0750 and not 0770: write comes from the claim's ACL, and 0750 is the mode an unclaim
    # normalizes a directory back to. Why a umask is not a seal is cli.rule.md's.
    local umask_would_be
    printf -v umask_would_be '%04o' "$(( 0777 & ~0$(umask) ))"
    run_as_owner mkdir -m 0700 -- "${d}" || die "could not create ${d}"
    CREATE_LEFT_TREE="${d}"
    trap remove_run_files EXIT
    say "    created ${d}"
    if (( (8#${umask_would_be} & 077) == 0 )); then
        say "    ${C_DIM}modes set to 0750/0640 -- your umask ($(umask)) would have made them${C_RST}"
        say "    ${C_DIM}owner-only, which the agent cannot read${C_RST}"
    fi
    ai_tools_log__structured info "created project directory ${d}" \
        "AI_TOOLS_PROJECT=${d}" "AI_TOOLS_RESULT=ok"

    # Plain `git init`, so the operator's own init.defaultBranch decides the branch name rather than this tool holding
    # an opinion about it. run_as_owner passes -H, so it is the TARGET's git config that is read on a --for run.
    if run_as_owner git init -q -- "${d}"; then
        # .git is kept from the agent until the claim's `.git` question shares it: git builds it under the owner's umask
        # (other bits under 022, group write under the 002 a user-private-group login takes) and, under a setgid parent
        # such as the clone area, in the parent's group, which can be the sandbox group, with any default ACL the parent
        # carries -- in the clone area `group:ai-ops:rwx` and its default, which reach every operator. So, as the owner:
        # every extended entry and the default ACL stripped (`setfacl -b`), the owner's primary group, and no access
        # for other accounts, so the mode bits alone decide who reads .git, and an operator's access to another
        # account's .git is that owner's to grant. `-b` writes the mask it strips into the group bits, rwx
        # under the clone area's default, so g-w takes write back; write comes from the claim's ACL, as for the work
        # tree. g+rX keeps a .git an 077 umask sealed open to `--with-git`, which skips an owner-only one. The setgid
        # and ACL walks skip .git, so a declined share leaves it as set here.
        if run_as_owner setfacl -R -b -- "${d}/.git" \
                && run_as_owner chgrp -R -- "${OWNER_GROUP}" "${d}/.git" \
                && run_as_owner chmod -R g-w,g+rX,o-rwx -- "${d}/.git"; then
            CREATE_GIT_WITHHELD="${d}"
        else
            warn "could not set .git to the owner's group and modes -- the agent may be able to read it whatever the .git answer"
        fi
        say "    git: initialized an empty repository"
    else
        warn "git init failed -- the directory is created but is not a git repository"
    fi

    # Written through `tee` as the owner, since a shell redirect here would create the file as the INVOKER on a --for
    # run.
    if printf '# %s\n' "${d##*/}" | run_as_owner tee -- "${d}/README.md" >/dev/null; then
        # tee creates under the caller's umask (0600 on an 077 host), a seal ai-tools-setfacl leaves alone; 0640 is
        # the mode an unclaim normalizes a file back to.
        run_as_owner chmod 0640 -- "${d}/README.md" \
            || warn "could not set the mode on README.md -- the agent may not be able to read it"
        say "    wrote README.md"
    else
        warn "could not write ${d}/README.md (continuing)"
    fi
    # chmod keeps a directory's setgid bit under a numeric mode, so a create under the clone area stays setgid.
    run_as_owner chmod 0750 -- "${d}" \
        || warn "could not open ${d} to 0750 -- it stays owner-only, and the agent cannot enter it"

    # The claim runs unchanged on the new tree; CLAIM_FRESH_TREE is a hint it re-derives (is_project_tree_pristine).
    CLAIM_FRESH_TREE="${d}"
    cmd_project_claim "${d}"
}

# report_still_blocked <dir>  -- after an enable, or over a project that already reads as listed, say so when a `%`,
# `!` or glob line that does not name <dir> still covers it. Informational: the entry IS what the operator asked
# for, and the remaining block is a line elsewhere in the file. Reads the record read_path_class last read for <dir>.
report_still_blocked() {
    [[ "${CLASS_BLOCKED}" != - ]] || return 0
    say ""
    warn "a $(format_blocking_line_kind) line still covers this path, so no session can start here:"
    say  "      ${C_BOLD}${CLASS_BLOCKED}${C_RST}"
    say  "  ${C_DIM}$(format_blocking_line_remedy)${C_RST}"
}

# allowlist_exclusions  -- print this registry's excluded and disabled paths, one per line without the sign,
# each read through the shared grammar (ai_tools_conf__parse_path_entry), so a commented or quoted line denotes the same
# path here as in every other reader of the file. The claim hands the list to its owner-side read, whose scans
# (acl_drift_scan, label_drift_scan, find_sealed_setgid_directories) read it as SCAN_EXCLUSIONS and prune each such
# subtree from their walk, as the launch gate refuses a launch there: this run's registry, a `--for` target's snapshot
# included, stays the one read of it. A missing registry yields an empty list.
SCAN_EXCLUSIONS=()
allowlist_exclusions() {
    local line
    [[ -f "${ALLOWLIST}" ]] || return 0
    while IFS= read -r line || [[ -n "${line}" ]]; do
        ai_tools_conf__parse_path_entry "${line}" || continue
        case "${ai_tools_conf__kind}" in
            excluded|disabled) printf '%s\n' "${ai_tools_conf__value}" ;;
        esac
    done < "${ALLOWLIST}"
}

# die_path_required <verb>  -- the refusal for a verb that acts on the directory it is given and was given none: claim,
# clone, remove, unclaim, enable and disable each change what the agent reaches or what is on disk, so the directory is
# named rather than taken from wherever the command was typed.
die_path_required() {
    die_usage MSG-K7D9 "projects $1 needs the directory it acts on; it does not take the current directory" \
        "To act on the directory you are in, name it:" \
        "       $(format_projects_command "$1" .)"
}

# discover_project <verb>  -- print the project the current directory is in, for a verb run without a path: walk from
# the canonical current directory upward and stop at the first directory a registry entry names. A `%`, `!` or glob line
# covering a directory on the way, one that does not name it, refuses: the verb would otherwise act on a tree
# from inside a part of it no session starts in. A `!` line naming a directory on the way refuses the same way. Reaching
# `/` with no entry exits 2. The walk checks every directory between the current one and the project, where the launch
# gate checks the current directory alone; a glob matching a directory on the way covers everything under it
# (ai_tools_conf__find_covering_path), so it stops this walk as it stops a launch there
# (cli.rule.md).
discover_project() {
    local verb="$1" d root
    d="$(pwd -P)"
    while :; do
        read_path_class "${d}"
        if [[ ",${CLASS_OWN}," == *,project,* || ",${CLASS_OWN}," == *,disabled,* ]]; then
            printf '%s' "${d}"
            return 0
        fi
        if [[ "${CLASS_STATE}" == excluded && "${CLASS_BLOCKED}" == - ]]; then
            CLASS_BLOCKED="$(list_lines_naming "${d}" | head -n 1)"; CLASS_BLOCKED_KIND=excluded CLASS_BLOCKED_PATH="${d}"
        fi
        if [[ "${CLASS_BLOCKED}" != - ]]; then
            root=""
            [[ "${CLASS_ENCLOSING}" == - ]] || root="${CLASS_ENCLOSING}"
            die_blocked "${d}" "${verb}" "${root}"
        fi
        if [[ "${d}" == / ]]; then
            die_usage MSG-K8R8 "projects ${verb} without a directory acts on the project the current directory is in, and $(pwd -P) is in none" \
                "Name the directory, or list your registered projects:" \
                "       $(format_projects_command "${verb}" DIRECTORY)" \
                "       $(format_projects_command "" "")"
        fi
        d="${d%/*}"; [[ -n "${d}" ]] || d=/
    done
}

# require_reachable_target <dir> <verb>  -- the table a file verb (handback, lockdown) reads for a path it is given or
# discovers: it acts on a project, or a subtree of one, that a session can reach, and refuses every other state, each
# with the line or the command that applies. The root helpers resolve a path's owner through the same matcher the launch
# gate uses, so acting under a `%` or a `!` line would report steps that did not run.
require_reachable_target() {
    local d="$1" verb="$2"
    read_path_class "${d}"
    case "${CLASS_STATE}" in
        disabled)
            printf '\n' >&2; disabled_note "${d}" >&2; printf '\n' >&2
            die MSG-W3S4 "this project is disabled: ${d}" \
                "a '%' line disables it, so no session runs there and the root helpers act on nothing." \
                "Re-enable it first:" \
                "       $(format_projects_command enable "${d}")" ;;
        excluded)
            CLASS_BLOCKED="$(list_lines_naming "${d}" | head -n 1)"; CLASS_BLOCKED_KIND=excluded CLASS_BLOCKED_PATH="${d}"
            die_blocked "${d}" "${verb}" ;;
        enabled|inside)
            is_class_reachable && return 0
            [[ "${CLASS_BLOCKED}" != - ]] && die_blocked "${d}" "${verb}" ;;
    esac
    die MSG-J3K5 "not a claimed project: ${d}" \
        "it is not at or under any project in your project registry" \
        "       list your registered projects with: $(format_projects_command "" "")"
}

# resolve_verb_target <verb> <path>  -- print the canonical directory a file verb acts on: <path> when one was given,
# else the project discover_project finds, announced first on stderr so the reader sees which tree the verb is about.
resolve_verb_target() {
    local verb="$1" path="$2" d
    if [[ -n "${path}" ]]; then
        d="$(resolve_existing_path "${path}")"
        [[ -d "${d}" ]] || die "not a directory: ${d}"
        printf '%s' "${d}"
        return 0
    fi
    d="$(discover_project "${verb}")" || exit "$?"
    say "projects ${verb}: ${d} (the project the current directory is in)" >&2
    printf '%s' "${d}"
}
# unclaim_one <dir> <group|""> <hint> <retry-command> [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 the registry does not name),
# and only then drop the two registries. <group> empty means "unregister only, leave permissions"; <hint> `1` marks
# a hand-back that was wanted and cannot run, whose reason is already on the screen, and other text is that reason (a
# `%` or a covering line the hand-back cannot run under). The allowlist line is dropped, except where a hand-back
# that was wanted did not run, whatever the reason: that leaves the line disabled, and <retry-command> -- the unclaim
# that completes the reversal once the reason is cleared -- is printed: disabling ends with no session able to start
# here, as a drop does, and keeps the line that run hands back under, where a dropped line would leave
# `projects enable`, `projects handback` and ai-tools-unclaim each refusing a path the registry no longer names.
# Best-effort except for the allowlist line: every other step warns and the pass goes on, while a drop that did not
# apply refuses (unreg_allow) and a disable that did not apply returns 1 without the ✓, since either leaves a session
# able to start here. A label reversal that did not apply, or that left hardlinked paths at their type, returns 1
# without the ✓ as well, after the hand-back and the registry reversal, which run either way (cli.rule.md,
# ref-section-m5n5): a reversal that did not apply prints the helper's re-run, which acts on a path no entry names.
unclaim_one() {
    local d="$1" group="$2" hint="$3" retry="$4"; shift 4
    local stopped=false handback_missing=false label_revert_outcome=""

    if command -v sudo >/dev/null 2>&1 \
            && command -v getenforce >/dev/null 2>&1 \
            && [[ "$(getenforce 2>/dev/null)" != "Disabled" ]]; then
        local relabel_status=0
        run_relabel "${d}" --remove || relabel_status=$?
        if (( relabel_status == 0 )); then
            say "    SELinux label: reverted"
        elif (( relabel_status == RELABEL_NOT_APPLICABLE_STATUS )); then
            say "    SELinux label: not applicable on this host (the helper's line above says why)"
        elif (( relabel_status == RELABEL_PARTIAL_REVERT_STATUS )); then
            # The helper ran past sudo and names what it left, with the command that lists it, so the steps after it are
            # attempted without asking.
            label_revert_outcome=partial
            warn "the SELinux label was reverted except on hardlinked paths, which keep their current type -- the helper's line above names up to 20 and counts the rest"
        elif (( relabel_status == RELABEL_WALKER_UNAVAILABLE_STATUS )); then
            # Past sudo as well, and no path was walked: the re-run reverts the tree once the helper's cause is cleared.
            label_revert_outcome=reverting
            warn "the SELinux label was not reverted: the tree walker could not run (the helper's line above says why) -- once that is resolved, run:"
            say  "      ${C_BOLD}sudo ${RELABEL_BIN} --remove $(_quote_path "${d}")${C_RST}"
        else
            label_revert_outcome=reverting
            warn "could not revert the SELinux label -- run it by hand:"
            say  "      ${C_BOLD}sudo ${RELABEL_BIN} --remove $(_quote_path "${d}")${C_RST}"
            note_root_failure || stopped=true
        fi
    fi

    # The filesystem hand-back is the step that revokes the agent's access to the FILES, so a failure here is the one
    # an operator must not be able to miss: everything else this function does is registry work, which stops the agent
    # launching here but leaves the tree group-owned by it. Recorded rather than merely warned about, and reported
    # in the close.
    if [[ -n "${group}" ]]; then
        if ${stopped}; then
            handback_missing=true
            warn "the files were NOT handed back -- the root steps were stopped"
        elif run_unclaim "${d}" "${group}" "$@"; then
            ok "handed ${d} back to group ${group}, agent write access removed"
        else
            handback_missing=true
            warn "could not hand the files back"
            note_root_failure || stopped=true
        fi
    elif [[ "${hint}" == 1 ]]; then
        handback_missing=true
    elif [[ -n "${hint}" ]]; then
        handback_missing=true
        warn "the files were NOT handed back -- ${hint}"
    fi
    if ${handback_missing}; then
        say  "      the entry stays disabled; once that is cleared, complete the unclaim with:"
        say  "      ${C_BOLD}${retry}${C_RST}"
    fi

    # The registry drops run REGARDLESS of the hand-back decision, and that is the difference from a claim: dropping
    # them is what moves to LESS access -- the agent can no longer launch here -- so stopping short of them would be
    # the unsafe direction. Only the safe.directory removal, which is cleanup and needs its own authentication, is
    # skipped once the operator has said to stop; `atr projects` reports the entry it leaves behind.
    ${stopped} || unreg_safedir "${d}" || note_root_failure || true
    # The allowlist line is dropped, and either disposition ends with no session able to start here. A hand-back that
    # did not run disables the line instead, so the unclaim that completes the reversal resolves its owner through it,
    # and a line already disabled (a declined re-enable) is left as it is. Both run AFTER the hand-back: the helpers
    # resolve this path's owner through the allowlist, and an exclusion stops them as surely as a missing entry.
    local disposition=drop entry_active=false entry_word="out of the project registry"
    ${handback_missing} && disposition=disable
    if [[ "${disposition}" == disable ]]; then
        entry_word="disabled in ${REGISTRY_DISPLAY_PATH}"
        if [[ "$(allow_state "${d}")" == disabled ]]; then
            say "    registry: left disabled"
        else
            retag_allow "${d}" disable || entry_active=true
        fi
    else
        unreg_allow "${d}"
    fi

    # No bare ✓ over an unclaim whose hand-back did not run, or whose entry still lets a session start. A claim
    # that under-applies leaves the agent with too little access, which is merely inconvenient; an unclaim
    # that under-applies leaves it with access the operator has just been told was removed.
    if ${entry_active}; then
        local files_note=""
        ${handback_missing} && files_note=" Its files were not handed back either, so they still carry group ${SANDBOX_GROUP}."
        headline_warn "WARNING: the registry entry was not disabled" \
            "${d} is still enabled in ${REGISTRY_DISPLAY_PATH}, so a session can still start there.${files_note} The lines above name what completes the reversal."
        ai_tools_log__structured warning \
            "unclaim of ${d} incomplete (registry entry not disabled, still active)" \
            "AI_TOOLS_PROJECT=${d}" "AI_TOOLS_RESULT=failed"
        return 1
    fi
    if ${handback_missing}; then
        headline_warn "WARNING: disabled, but the files were not handed back" \
            "${d} is ${entry_word}, so no session can launch there, and its line stays for the unclaim that completes the reversal. Its files still carry group ${SANDBOX_GROUP}, so an agent session that can reach the path keeps its access to them. The command above completes the reversal."
        ai_tools_log__structured warning \
            "unclaim of ${d} incomplete (registry entry disabled; filesystem hand-back did NOT run)" \
            "AI_TOOLS_PROJECT=${d}" "AI_TOOLS_RESULT=failed"
        return 1
    fi
    if [[ "${label_revert_outcome}" == partial ]]; then
        headline_warn "WARNING: the SELinux label was not reverted on every path" \
            "${d} is ${entry_word}, so no session can launch there. The hardlinked paths keep their current type: through another name each may belong to another tree, so the walk does not write them. The helper's line above names up to 20, and its find command lists the hardlinked files the tree holds now."
        ai_tools_log__structured warning \
            "unclaimed ${d} (registries dropped; SELinux label NOT reverted on hardlinked paths)" \
            "AI_TOOLS_PROJECT=${d}" "AI_TOOLS_RESULT=failed"
        return 1
    fi
    if [[ -n "${label_revert_outcome}" ]]; then
        headline_warn "WARNING: the SELinux label was not reverted" \
            "${d} is ${entry_word}, so no session can launch there. It still carries the type the agent reads and writes. The command above reverts it."
        ai_tools_log__structured warning \
            "unclaimed ${d} (registries dropped; SELinux label NOT reverted)" \
            "AI_TOOLS_PROJECT=${d}" "AI_TOOLS_RESULT=failed"
        return 1
    fi
    ok "unclaimed ${d}"
    ai_tools_log__structured info "unclaimed project ${d}" \
        "AI_TOOLS_PROJECT=${d}" "AI_TOOLS_RESULT=ok"
}

# undeletable_scan <dir>  -- print every directory under <dir> the ACTING OWNER can neither write nor traverse, one
# per line, capped. Read-only and run AS that owner, so it answers the question the removal depends on: rm -rf needs
# write+execute on a directory to unlink what is in it, and the realistic blocker is a sandbox-owned 0700 directory
# a session left behind.
#
# This is the pre-flight that keeps `projects remove` from having the one failure mode a destructive verb must not have:
# a tree deleted to the first directory the owner has no traverse on, with no registry entry left to find the remains
# by. Under-reporting is not the safe direction here -- unlike residue_scan, whose gate only decides what to OFFER --
# so the walk reports a directory it cannot descend rather than skipping it silently.
undeletable_scan() {
    local d="$1"
    run_as_owner find "${d}" -xdev -type d '(' -not -writable -o -not -executable ')' \
        -print 2>/dev/null | head -n 50
}

# 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_dirs__build_find_expression 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 skipping the step.
# 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?" "${OWNER_USER}")"
        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 leaves every path as it found
# it. 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_safe_paths__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: $(format_projects_command remove "${d}")"
    fi

    residue_scan "${d}"
    local n_res="${#RESIDUE[@]}" n_skip="${#RESIDUE_SKIPPED[@]}"
    if (( n_res == 0 && n_skip == 0 )); then
        die MSG-P8W2 "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: $(format_projects_command "" "")"
    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 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}atr projects unclaim --force --dry-run ${d}${C_RST}"
        say "   apply:    ${C_BOLD}atr projects 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 `projects 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__structured info "unclaimed unregistered tree ${d} (group -> ${hb_group})" \
            "AI_TOOLS_PROJECT=${d}" "AI_TOOLS_RESULT=ok"
    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> [--force] [--full] [--dry-run] [-y|--yes] [--group <group>]  -- undo an in-place claim:
# revert the SELinux label, drop both registries, and behind a default-yes confirm hand the tree back to a target group
# through ai-tools-unclaim, with the agent's write access revoked; the directory itself is left on disk. The target is
# classified against the project registry first (cli.rule.md, ref-section-m5n5), and a protected system path is refused
# up front, whatever the mode: the backstop reads the path alone, so `--force` does not relax it. A disabled target is
# answered up front (offer_reenable). `-y`/`--yes` pre-answers the default-NO confirm in every mode and is not read
# by 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.
cmd_project_unclaim() {
    # `--force` swaps the allowlist gate for the helper's per-path residue gate (ai-tools-unclaim's header) and does not
    # relax any other gate.
    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 ;;
            --dry-run)    dry=true ;;
            -y|--yes)     assume_yes=true ;;
            --group)      want_group=true ;;
            --group=*)    group_opt="${a#--group=}" ;;
            -*) die_usage MSG-F7J9 "unknown projects unclaim option: ${a}" \
                    "       allowed: --force, --full, --dry-run, -y/--yes, --group <group>" ;;
            *)  if [[ -z "${path}" ]]; then path="${a}"
                else die_usage MSG-C8P8 "projects 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} && ${assume_yes}; then
        die_usage MSG-P4D2 "--yes has no effect with --dry-run, which neither changes a path nor asks"
    fi
    if ${dry} && ! ${force}; then
        die "--dry-run applies to --force only" \
            "       a registered project's unclaim previews itself: it lists what it will do and asks before acting"
    fi

    [[ -n "${path}" ]] || die_path_required unclaim
    local d; d="$(resolve_existing_path "${path}")"
    [[ -d "${d}" ]] || die "not a directory: ${d}"
    # --force reaches a tree NO registry names, so ai-tools-unclaim cannot resolve its owner from an entry and binds
    # the walk to the INVOKING uid instead -- the guard that stops one operator rewriting another's files. Honouring
    # --for there would have the CLI name one operator while the helper acted as another. Refused ahead of the target's
    # registry snapshot, the run's first sudo.
    if ${force} && [[ -n "${FOR_OPERATOR}" ]]; then
        die MSG-B5K3 "--for cannot be combined with --force" \
            "an unlisted tree has no registry entry naming its owner, so the unclaim is bound to" \
            "       you as the invoking operator; run it as ${FOR_OPERATOR}, or unclaim the registered" \
            "       project without --force"
    fi
    load_target_registry_snapshot

    # The path's state decides the mode (cli.rule.md): a project of its own, an ancestor of registered projects (all
    # of them, outermost first), or a tree no entry names, which takes the residue predicate. An excluded path is not
    # a project, and a path inside one is unclaimed by unclaiming the project.
    local -a targets=()
    local mode target
    read_path_class "${d}"
    case "${CLASS_STATE}" in
        excluded)      die_excluded "${d}" unclaim ;;
        inside)        die_inside "${d}" unclaim ;;
        enabled|disabled) mode=exact; targets=("${d}") ;;
        ancestor)      mode=ancestor; mapfile -t targets < <(list_entries_under "${d}" | sort) ;;
        *)
            cmd_unclaim_unlisted "${d}" "${force}" "${full}" "${dry}" "${assume_yes}" "${group_opt}"
            return ;;
    esac

    # 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).
    for target in "${targets[@]}"; do
        ai_tools_safe_paths__assert_safe_target "${target}" "project unclaim" || exit 3
    done

    # --force is about reaching a tree the allowlist does not cover; here one does. Refused, like every other flag
    # that does not apply to the run it was given: a flag accepted and ignored hides the difference
    # between what the operator asked for and what ran, and ai-tools(1) states the refusal. The registered unclaim is
    # one word away.
    if ${force}; then
        die "--force does not apply here -- this path is covered by the project registry: ${targets[0]}" \
            "       --force reaches a tree the project registry does not name; unclaim a registered project without it:" \
            "       $(format_projects_command unclaim "${targets[0]}")"
    fi

    # Every registry write rewrites the whole file, and the re-enable offer and the reversal at the end each write it:
    # a line the writer cannot write back would fail the reversal after the hand-back had run, so the unclaim refuses
    # before its first change. The reversal of one target deletes that target's own line before it renders the rest,
    # so the exact mode does not count it; in ancestor mode each removal renders the other targets' lines, and a target
    # whose own line cannot be written back is unclaimed on its own first. Where a later write keeps the target's line
    # -- the re-enable offer, the disable a failed hand-back leaves -- that editor's status 4 names it.
    local unrenderable_exempt=""
    [[ "${mode}" == exact ]] && unrenderable_exempt="${targets[0]}"
    if report_unrenderable_registry_line "${unrenderable_exempt}"; then
        die "projects unclaim stopped before any change -- change the registry line named above first"
    fi

    # A disabled target is answered up front (offer_reenable), since under its `%` the hand-back would exit 0 without
    # handing back a path; a target a `%`, `!` or glob line covers without naming it cannot be handed back
    # either. The covering line is read first, so the re-enable is not offered where that line decides the outcome
    # whatever the answer. Declining, or a covering line, does not abort: the hand-back alone is given up, the line is
    # left disabled, and the close names the reason and this unclaim run again as what completes the reversal, with
    # a non-zero exit (unclaim_one, cli.rule.md).
    local -A handback_hint=()
    for target in "${targets[@]}"; do
        read_path_class "${target}"
        if [[ "${CLASS_BLOCKED}" != - ]]; then
            handback_hint["${target}"]="a $(format_blocking_line_kind) line covers it: ${CLASS_BLOCKED} -- $(format_blocking_line_remedy)"
        elif [[ "${CLASS_STATE}" == disabled ]]; then
            if offer_reenable "${target}" "the unclaim" decline-returns; then
                read_path_class "${target}"
            else
                handback_hint["${target}"]="it stays disabled, and the hand-back does not run under its '%' line; a second unclaim offers the re-enable again"
            fi
        fi
    done

    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 target in "${targets[@]}"; do printf '    %s\n' "${target}"; 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)

    # The unclaim that completes a reversal whose hand-back did not run: this run's own options, so the second run
    # applies the walk the operator asked for.
    local -a retry_options=()
    ${full} && retry_options+=(--full)
    [[ -n "${group_opt}" ]] && retry_options+=(--group "$(printf '%q' "${group_opt}")")

    local incomplete=0
    for target in "${targets[@]}"; do
        # A target whose exclusion was left standing takes the registry-only path: no group means unclaim_one skips
        # the hand-back, and the hint is what it prints in its place.
        local target_group="${hb_group}" target_hint="${hb_hint}"
        if [[ -n "${handback_hint[${target}]:-}" ]]; then target_group=""; target_hint="${handback_hint[${target}]}"; fi
        unclaim_one "${target}" "${target_group}" "${target_hint}" \
            "$(format_projects_command unclaim "${target}" "${retry_options[@]}")" "${helper_flags[@]}" \
            || incomplete=$(( incomplete + 1 ))
        report_excluded_paths_kept "${target}"
    done

    # An incomplete reversal reaches the exit status, so a script sees it; the registries drop regardless, which is
    # why this reports rather than aborts.
    if (( incomplete )); then
        ai_tools_log__structured warning \
            "unclaim finished with ${incomplete} of ${#targets[@]} project(s) not fully reversed" \
            "AI_TOOLS_RESULT=failed"
    fi

    # 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 does not pay for a 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}atr projects unclaim --force --dry-run ${d}${C_RST}"
        fi
    fi
    (( incomplete == 0 ))
}

# list_unregistered_unclaim_remedy [<path>]  -- print the remedy lines that hand back a tree no entry names, one per
# line: the `unclaim --force` command, its path in bash's `%q` encoding (`<path>` where none was given). `--force` binds
# the hand-back to the account running it, so require_for_target refuses it beside `--for` (MSG-B5K3); a --for run
# therefore names the target as the account to run the plain command, and no line carries both flags.
list_unregistered_unclaim_remedy() {
    local p='<path>'
    [[ -z "${1:-}" ]] || p="$(_quote_path "$1")"
    [[ -z "${FOR_OPERATOR}" ]] \
        || printf '%s\n' "An unregistered tree is handed back by the account that owns it, since --force binds the hand-back to the account running the command. As ${FOR_OPERATOR}, run:"
    printf '       %s\n' "atr projects unclaim --force ${p}"
}

# get_covering_entry  -- print the enabled project the path read_path_class last read is nested in, where no disabled
# or excluded line covers that path, and an empty result otherwise. The launch gate checks the disabled and excluded
# lines ahead of the allow lines, and CLASS_BLOCKED leaves out the path's own line, the one a removal deletes, so this
# is the entry that authorizes a launch in a tree whose deletion fails (is_class_reachable's reading).
get_covering_entry() {
    [[ "${CLASS_ENCLOSING_STATE}" != enabled || "${CLASS_BLOCKED}" != - ]] || printf '%s' "${CLASS_ENCLOSING}"
}

# How many hardlinked files a removal's inventory prints a command for; the count of the rest is stated beside them.
readonly REMOVE_HARDLINKS_LISTED=20

# read_hardlink_inventory <dir>  -- print `<inode> <mount>` once per hardlinked non-directory under <dir>, read as
# the acting owner through run_as_owner, sorted, and `other-device` once where an entry sits on another device than
# <dir>, a mount inside the tree, whose mount point this read does not name; returns non-zero where part of the tree was
# not read. The inode and the device are find's own `-printf` reading of each entry, made inside the directory find
# opened, so a directory the agent swaps for a symlink during the read does not substitute another file; the mount is
# read once, from <dir>. find does not follow a symlink, and git is not run: the tree's names are the agent's to choose,
# and its .git/config the agent's to write (cli.rule.md, ref-section-k3v7). A removal reads it ahead of the deletion,
# since once the tree is gone the inode and the mount are what find the file's other names.
read_hardlink_inventory() {
    local root_identity root_device root_mount listing line status=0
    root_identity="$(run_as_owner stat -c '%d %m' -- "$1" 2>/dev/null)" || return 1
    root_device="${root_identity%% *}" root_mount="${root_identity#* }"
    listing="$(run_as_owner find "$1" ! -type d -links +1 -printf '%D %i\n' 2>/dev/null)" || status=$?
    while IFS= read -r line; do
        [[ -n "${line}" ]] || continue
        if [[ "${line%% *}" == "${root_device}" ]]; then
            printf '%s %s\n' "${line#* }" "${root_mount}"
        else
            printf 'other-device\n'
        fi
    done <<< "${listing}" | LC_ALL=C sort -u
    return "${status}"
}

# report_hardlink_inventory <find-status> <listing>  -- after a removal's deletion, print one `sudo find <mount> -xdev
# -inum <inode> -ls` for each of the first REMOVE_HARDLINKS_LISTED files of read_hardlink_inventory's <listing>,
# under one warning stating how many files the tree held and that the type of their other names is the operator's
# to decide. A <find-status> other than 0, or a line that does not read as an inode and an absolute mount, adds
# that the read was incomplete, and a read that was incomplete and listed no file is its own warning. Prints nothing
# where the listing is empty and the read complete.
report_hardlink_inventory() {
    local find_status="$1" listing="$2" line inode mount total=0 incomplete=false
    local -a commands=()
    [[ "${find_status}" == 0 ]] || incomplete=true
    while IFS= read -r line; do
        [[ -n "${line}" ]] || continue
        inode="${line%% *}" mount="${line#* }"
        if [[ ! "${inode}" =~ ^[0-9]+$ || "${mount}" != /* ]]; then incomplete=true; continue; fi
        total=$(( total + 1 ))
        if (( total <= REMOVE_HARDLINKS_LISTED )); then
            commands+=("      sudo find $(_quote_path "${mount}") -xdev -inum ${inode} -ls")
        fi
    done <<< "${listing}"
    if (( total == 0 )); then
        ${incomplete} || return 0
        warn MSG-Y7P8 "the read of the tree's hardlinked files before the deletion did not complete, so this removal does not list the other names of a file it did not reach; those names keep the SELinux type they carry, and the type they take is yours to decide"
        return 0
    fi
    local listed_clause="" incomplete_clause=""
    if (( total > REMOVE_HARDLINKS_LISTED )); then listed_clause=", for the first ${REMOVE_HARDLINKS_LISTED} of them"; fi
    if ${incomplete}; then
        incomplete_clause=" -- the read of the tree before the deletion did not complete, so a file it did not reach is not counted"
    fi
    warn MSG-S6J8 "before the deletion the tree held ${total} hardlinked file(s); each one's names outside the tree keep the SELinux type they carry, and the type those names take is yours to decide -- each command below lists the other names of one file${listed_clause}${incomplete_clause}" \
        "${commands[@]}"
}

# die_remove_incomplete <dir> <kind> <own-entry> <label-outcome> [<covering-entry>]  -- report a removal whose deletion
# left paths under <dir>, and die. The warning states what the run verified: what became of <kind>'s own registry entry,
# a session already running keeps what it holds until it ends, and the files left keep their group grant, since
# a removal does not hand them back. <own-entry> is `removed`, the entry is removed and no longer authorizes a launch
# there, or `none`, <dir> had no entry of its own: a clone is authorized by its place, its owner and its shape
# (require_sandbox_clone_shape), so one a failed gate left private and unregistered reaches this arm, and unreg_allow
# returns 0 over a path no line names. Any other value is a caller error. <covering-entry> is get_covering_entry's
# result, and the warning names it. <label-outcome> is what the label step ahead of the deletion did: `completed` says
# the step ran and its helper returned success, which does not read the type the tree carries, since a rule
# on an ancestor of the project keeps the project type through the step; `failed` says it did not complete,
# and that the relabel command the failed step printed still applies, since `ai-tools-relabel --remove` does not read
# the registry the removal has already changed; `partial` says the step left the hardlinked paths its helper listed;
# `unwalked` says the step dropped the rules and its walker could not run, so the paths left keep their type; `skipped`
# (SELinux off, no sudo or getenforce, or a clone, whose type comes from the policy's file contexts) does not name
# a label. Any other value is a caller error. The remedy is the deletion run_as_owner ran, the path in bash's `%q`
# encoding, named as the target's under `--for`.
die_remove_incomplete() {
    local d="$1" kind="$2" own_entry="$3" label_revert_outcome="$4" covering_entry="${5-}" entry_clause label_clause
    local covering_clause=""
    case "${own_entry}" in
        removed) entry_clause="the ${kind}'s own registry entry is removed and no longer authorizes a launch there" ;;
        none)    entry_clause="the ${kind} had no registry entry of its own" ;;
        *)       die "die_remove_incomplete: unknown own-entry state: ${own_entry}" ;;
    esac
    case "${label_revert_outcome}" in
        completed) label_clause="; the SELinux label step completed" ;;
        failed)    label_clause="; the SELinux label step did not complete, and the relabel command printed above still applies" ;;
        partial)   label_clause="; the SELinux label step left the hardlinked paths its helper listed at their current type, which their other names keep" ;;
        unwalked)  label_clause="; the SELinux label step did not walk the tree, so the paths left keep their current type, as do the other names of its hardlinked files" ;;
        skipped)   label_clause="" ;;
        *)         die "die_remove_incomplete: unknown label outcome: ${label_revert_outcome}" ;;
    esac
    [[ -z "${covering_entry}" ]] \
        || covering_clause=" -- the entry for ${covering_entry} still authorizes a launch there"
    warn "the deletion did not complete -- ${entry_clause}${covering_clause} -- a session already running keeps its access until it ends, and the files left keep their group grant, which a removal does not hand back${label_clause} -- remove what is left by hand:"
    [[ -z "${FOR_OPERATOR}" ]] || say "    As ${FOR_OPERATOR}, run:"
    say  "      ${C_BOLD}rm -rf $(_quote_path "${d}")${C_RST}"
    die "project remove incomplete -- paths were left behind"
}

# cmd_project_remove <path> [-y|--yes]  -- unclaim a project AND delete its directory, which the path must name;
# `projects unclaim` is the non-destructive reversal these refusals point at. One verb, two kinds: a path
# under SANDBOX_ROOT is a sandbox clone and takes remove_clone, every other path is a project claimed in place.
# A project's authorization is an EXACT registry entry, enabled or disabled; a clone's is its place, its owner and its
# shape (remove_clone). The verb does not take `--force`. Teardown in either kind is registries first, deletion last,
# so a failed deletion leaves an unregistered tree, and the filesystem hand-back is not run over files about to be
# deleted (cli.rule.md, ref-section-k3v7). `-y` pre-answers the default-NO confirm of either kind and the in-place
# kind's typed-name challenge; the clone kind does not ask for a typed name.
cmd_project_remove() {
    local a path="" assume_yes=false
    local -a remedy=()
    for a in "$@"; do
        case "${a}" in
            -y|--yes) assume_yes=true ;;
            --force)
                mapfile -t remedy < <(list_unregistered_unclaim_remedy "${path}")
                die MSG-S6Q5 "projects remove has no --force: a project is deleted on its registry entry and a clone on its place, its owner and its shape, and no flag stands in for either." \
                    "To reverse a claim on an unregistered tree, and then remove it yourself:" "${remedy[@]}" ;;
            # Deliberately does not enumerate the options the way the other verbs' refusals do: the only one this verb
            # has pre-answers both the confirmation and the typed-name challenge, and a caller who has just mistyped
            # a flag is not who that is for.
            -*) die_usage MSG-M3Y5 "unknown projects remove option: ${a}" \
                    "       the options this verb takes are in: man ai-tools" ;;
            *)  if [[ -z "${path}" ]]; then path="${a}"
                else die_usage MSG-M2U9 "projects remove takes a single path"; fi ;;
        esac
    done
    # The verb deletes the directory it acts on, so that directory is always named rather than taken from wherever
    # the command was typed, with or without `-y`.
    [[ -n "${path}" ]] || die_path_required remove

    local d; d="$(resolve_existing_path "${path}")"
    [[ -d "${d}" ]] || die "not a directory: ${d}"
    # Read while the directory exists: getcwd fails once it is deleted.
    local cwd_real; cwd_real="$(pwd -P 2>/dev/null)" || cwd_real=""
    # The path checks of either kind read the path alone, so they run ahead of the snapshot, a `--for` run's first
    # `sudo`; the snapshot comes ahead of the clone branch, whose removal reaches the registry too.
    if [[ "${d}" == "${SANDBOX_ROOT}/"* ]]; then
        require_sandbox_clone_path "${d}"
    else
        ai_tools_safe_paths__assert_safe_target "${d}" "project remove" || exit 3
    fi
    load_target_registry_snapshot
    if [[ "${d}" == "${SANDBOX_ROOT}/"* ]]; then
        remove_clone "${d}" "${assume_yes}" "${cwd_real}"
        return
    fi

    # ── Classification: the path's own entry, `@` or `%`, and only that, is a removal target; a disabled entry
    # authorizes it as an enabled one does (cli.rule.md). A `!` line is not an entry, so it neither authorizes
    # a deletion nor counts as a project nested in this one. ──
    read_path_class "${d}"
    local -a nested=()
    case "${CLASS_STATE}" in
        excluded)  die_excluded "${d}" remove ;;
        inside)    die_inside "${d}" remove ;;
        ancestor)
            mapfile -t nested < <(list_entries_under "${d}")
            printf '\n' >&2
            printf '    %s\n' "${nested[@]}" >&2
            printf '\n' >&2
            die MSG-F4D8 "this is not a claimed project, but ${#nested[@]} claimed project(s) are nested under it: ${d}" \
                "projects remove deletes one registered project, never a directory that merely contains some. Reverse the claims first:" \
                "       $(format_projects_command unclaim "${d}")" ;;
        unregistered)
            mapfile -t remedy < <(list_unregistered_unclaim_remedy "${d}")
            die MSG-P8Y8 "not a claimed project: ${d}" \
                "projects remove deletes only a registered project -- the registry entry is what authorizes the deletion. See what is registered, or reverse a claim on an unregistered tree and then remove it yourself:" \
                "       $(format_projects_command "" "")" \
                "${remedy[@]}" ;;
    esac
    local removing_state="${CLASS_STATE}"
    # The entry a tree whose deletion fails stays launchable under, read from the one record before the registry
    # changes.
    local covering_entry; covering_entry="$(get_covering_entry)"

    # An entry containing other claimed projects is refused (cli.rule.md); the check sees the registry THIS run reads
    # alone, so another operator's nested project is not visible here.
    if (( CLASS_NESTED > 0 )); then
        mapfile -t nested < <(list_entries_under "${d}")
        printf '\n' >&2
        printf '    %s\n' "${nested[@]}" >&2
        printf '\n' >&2
        die MSG-Q3R9 "this project contains ${#nested[@]} other claimed project(s), listed above: ${d}" \
            "Deleting it would delete them too, leaving each one registered, git-trusted and SELinux-labelled at a path that no longer exists. Remove or unclaim those first, then re-run this."
    fi

    # The removal's registry step rewrites the whole file and runs after the label is reverted: a line the writer cannot
    # write back would stop the removal there, so it is refused before any change. The project's own line is the one
    # the step deletes, so it is not counted.
    if report_unrenderable_registry_line "${d}"; then
        die "projects remove stopped before any change -- change the registry line named above first"
    fi

    # ── Deletability pre-flight: read-only, run as the acting owner. ── The parent first: `rm -rf <d>` ends
    # by unlinking <d> from it, which the walk over the tree does not cover (cli.rule.md).
    local rm_parent="${d%/*}"; [[ -n "${rm_parent}" ]] || rm_parent=/
    if [[ -z "$(run_as_owner find "${rm_parent}" -maxdepth 0 -writable -executable 2>/dev/null)" ]]; then
        die MSG-H3F6 "the parent directory is not writable by ${OWNER_USER}: ${rm_parent}" \
            "Removing ${d} means unlinking it from that directory, and ${OWNER_USER} cannot write there. Nothing has been changed. To release the project and leave the files where they are, use:" \
            "       $(format_projects_command unclaim "${d}")"
    fi

    local -a undeletable=()
    mapfile -t undeletable < <(undeletable_scan "${d}")
    if (( ${#undeletable[@]} )); then
        headline_warn "WARNING: this tree cannot be fully deleted" \
            "${#undeletable[@]} director(ies) under ${d} cannot be written or entered by ${OWNER_USER}, so a removal would stop partway and leave the rest behind -- unregistered, and harder to find than it is now. Nothing has been changed."
        path_listing "director(ies)" "${undeletable[@]}"
        say ""
        say "  take ownership of the tree first, then re-run the removal:"
        say "      ${C_BOLD}$(format_projects_command handback "${d}" --full)${C_RST}"
        die "project remove stopped -- the tree is not fully deletable by ${OWNER_USER}"
    fi

    # ── No git read over the tree. The agent co-writes .git, so .git/config is the agent's to write, and this runs
    # as the operator: a ref read that meets a missing object fetches it from a promisor remote through the transport
    # that file names (core.sshCommand), and an index refresh runs core.fsmonitor -- each a command of the agent's
    # choosing, run as the operator (cli.rule.md). What deleting loses -- unpushed commits, uncommitted changes -- is
    # therefore not counted here; the deletion warning names it as the operator's to check. ──

    # A disabled project gets its own notice and its own default-NO confirm, BEFORE the deletion warning: the operator
    # disabled this tree deliberately, so "you disabled this on purpose" is a different question from "this deletes
    # everything", and answering the second does not answer the first. No entry is re-enabled -- the removal does not
    # need a launch gate open, and the allowlist line goes with the tree.
    if [[ "${removing_state}" == disabled ]]; then
        headline_warn "This project is disabled" \
            "A '%' line in ${REGISTRY_DISPLAY_PATH} disables ${d}, so it was taken out of service rather than released. Removing it deletes the directory and the lines naming it."
        disabled_note "${d}"
        say ""
        if ! ${assume_yes}; then
            confirm "Continue removing this disabled project?" n || die "aborted"
        fi
    fi

    # ── Confirmation: a default-NO confirm, then the typed name; this command's own `-y` is the one thing that answers
    # them ahead of time, and with no terminal each declines on its own (messaging.rule.md). ──
    headline_warn "WARNING: this deletes the project directory" \
        "${d} and everything in it is deleted. This is NOT reversible: there is no undo, and the tree is not moved to a trash location. Unpushed commits and uncommitted changes go with it, and this command does not read the repository to count them. To release the project and keep the files, use atr projects unclaim instead."
    if ! ${assume_yes}; then
        confirm "Delete this project directory and everything in it?" n || die "aborted"
        ai_tools_msg__challenge "  Confirm the project to delete" "${d##*/}" \
            || die "aborted -- the name did not match"
    fi

    # ── Apply: registries first, deletion last. ──
    headline "Removing the project" "${d}"
    # A failed cleanup step asks once (note_root_failure) and does not stop the removal: the registries drop regardless,
    # and `ai-tools projects` reports what is left behind (cli.rule.md, ref-section-k3v7). The label step stays ahead
    # of the registry step, since ai-tools-relabel resolves membership from the entry, so a de-list that does not apply
    # (MSG-K8S2) leaves a registered project whose label is reverted, which the listing reports and `projects claim`
    # restores. The registry step precedes the safe.directory step, so that refusal leaves the trust entry in place.
    local cleanup_stopped=false label_revert_outcome=skipped inventory_read=false inventory_status=0 inventory=""
    if command -v sudo >/dev/null 2>&1 \
            && command -v getenforce >/dev/null 2>&1 \
            && [[ "$(getenforce 2>/dev/null)" != "Disabled" ]]; then
        local relabel_status=0
        run_relabel "${d}" --remove || relabel_status=$?
        if (( relabel_status == RELABEL_NOT_APPLICABLE_STATUS )); then
            say "    SELinux label: not applicable on this host (the helper's line above says why)"
        elif (( relabel_status == RELABEL_PARTIAL_REVERT_STATUS )); then
            # The helper ran past sudo, so the step is counted and the question about the remaining root steps is not
            # asked: it is about a step that did not apply.
            label_revert_outcome=partial
            warn "the SELinux label was reverted except on hardlinked paths (the helper's line above names up to 20 and counts the rest); the deletion removes their names in this tree, and a hardlinked path's other names outside it keep its current type"
            ROOT_STEP_FAILURES=$(( ROOT_STEP_FAILURES + 1 ))
        elif (( relabel_status == RELABEL_WALKER_UNAVAILABLE_STATUS )); then
            # Past sudo as well: the rules are dropped and no path was walked. A re-run of the revert after the deletion
            # does not reach any name the tree held, so none is printed; report_hardlink_inventory names the files
            # whose other names stand outside it.
            label_revert_outcome=unwalked
            warn "the SELinux label was not reverted: the tree walker could not run (the helper's line above says why); the deletion removes the tree's names, and a hardlinked path's other names outside it keep its current type"
            ROOT_STEP_FAILURES=$(( ROOT_STEP_FAILURES + 1 ))
        elif (( relabel_status != 0 )); then
            label_revert_outcome=failed
            warn "could not revert the SELinux label -- run it by hand:"
            say  "      ${C_BOLD}sudo ${RELABEL_BIN} --remove $(_quote_path "${d}")${C_RST}"
            note_root_failure || cleanup_stopped=true
        else
            label_revert_outcome=completed
            say "    SELinux label: reverted"
        fi
    fi
    # A revert that did not complete -- it left hardlinked paths, did not walk, or failed -- leaves each hardlinked
    # file's names outside the tree at their type. Their inode and mount are read while the tree stands, since
    # after the deletion they are what finds those names.
    if [[ "${label_revert_outcome}" == partial || "${label_revert_outcome}" == unwalked \
            || "${label_revert_outcome}" == failed ]]; then
        inventory_read=true
        inventory="$(read_hardlink_inventory "${d}")" || inventory_status=$?
    fi
    unreg_allow "${d}"
    ${cleanup_stopped} || unreg_safedir "${d}" || note_root_failure || true

    local deletion_complete=true
    if ! run_as_owner rm -rf -- "${d}" || [[ -e "${d}" ]]; then deletion_complete=false; fi
    if ${inventory_read}; then report_hardlink_inventory "${inventory_status}" "${inventory}"; fi
    if ! ${deletion_complete}; then
        die_remove_incomplete "${d}" project removed "${label_revert_outcome}" "${covering_entry}"
    fi

    say ""
    report_excluded_paths_kept "${d}" removed
    ai_tools_log__structured info "removed project ${d}" \
        "AI_TOOLS_PROJECT=${d}" "AI_TOOLS_RESULT=ok"
    # The tree is gone either way, but a green ✓ is this project's report card and there is no reading of it that covers
    # "and two cleanup steps failed". So the check mark is reserved for a clean run, and a run with failures closes
    # by stating both facts and exits non-zero, which is also what lets a script tell the two apart.
    if (( ROOT_STEP_FAILURES )); then
        warn MSG-S6V2 "removed ${d}, but ${ROOT_STEP_FAILURES} cleanup step(s) did not run"
        say  "  Each is named above, with what it left. Registry entries left behind now point"
        say  "  at a path that no longer exists; this lists every entry that needs attention,"
        say  "  across all your projects:"
        say  ""
        say  "      ${C_BOLD}$(format_projects_command "" "")${C_RST}"
        ai_tools_log__structured warning \
            "removed ${d} with ${ROOT_STEP_FAILURES} cleanup step(s) incomplete" \
            "AI_TOOLS_PROJECT=${d}" "AI_TOOLS_RESULT=failed"
    else
        ok "removed ${d}"
    fi
    report_cwd_in_removed_tree "${d}" "${cwd_real}"
    (( ROOT_STEP_FAILURES == 0 ))
}

# sandbox_finalize <dst>  -- the access-granting tail of every sandbox create, run once the clone exists: allowlist
# entry (the lockdown scan acts only on an allowlisted path; rolled back on a failed gate), the secret gate, then --
# past the gate alone -- relabel, normalize (pruning the locked paths), the root group and setgid steps, a read-back
# of the result (clone_access_ready), and register. A declined or failed gate leaves the clone on disk, private
# and unregistered, with a guard CLAUDE.md and the resume command printed (fail closed: cli.rule.md, ref-section-u9a9).
# The relabel and the normalize run once, while the root is still owner-only (clone_is_private), in that order
# (relabel_clone states why), and the relabel before the guard comes off: a clone that could not be labelled stops
# the create the same way, since the agent cannot enter a clone opened unlabelled and a resume over an opened clone does
# not label it.
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}atr projects clone ${dst}${C_RST}"
        die "sandbox create stopped -- secrets not locked down"
    fi
    # A resume over a clone already opened leaves the tree as it is (normalize_clone's header states why).
    local opening=false
    clone_is_private "${dst}" && opening=true
    if ${opening} && ! relabel_clone "${dst}"; then
        unreg_allow "${dst}"
        warn "sandbox not labelled -- the clone stays private to you:" \
             "not group-accessible, not registered; the agent has no access to it"
        say  "    with the ai-tools-selinux policy loaded, finish the create:"
        say  "      ${C_BOLD}atr projects clone ${dst}${C_RST}"
        die MSG-Z7B9 "sandbox create stopped -- the clone could not be labelled ai_tools_project_t"
    fi
    clear_lockdown_guard "${dst}"
    # The access steps and the safe.directory step each return non-zero when they did not take, so a bare call
    # under `set -e` would abort the create; each failure is reported at the close rather than left under the ✓.
    local access_ok=true
    if ${opening}; then
        normalize_clone "${dst}" "${SECRET_MATCH_PATHS[@]}"
        say "    access: opened to group ${SANDBOX_GROUP} (locked secrets stay private)"
        # The operator cannot give a path SANDBOX_GROUP or keep a setgid bit in it (normalize_clone's header), so root
        # does both through the two helpers a claim runs, in the claim's order: ai-tools-setgid on the work tree's
        # directories, then `ai-tools-setfacl --with-git`, which regroups each opened path left in another group
        # and normalizes .git. Each re-validates the registry entry reg_allow wrote and leaves an owner-only
        # or secret-named path, which covers every path the gate locked, as it is. They run after normalize_clone,
        # whose chmods would clear the setgid bit again.
        reg_ownership "${dst}" force || access_ok=false
        claim_setfacl "${dst}" true || access_ok=false
    else
        say "    access: already granted; the tree is left as it is"
    fi
    clone_access_ready "${dst}" || access_ok=false
    # A clone exists to run git in, and without safe.directory the agent's git refuses the tree ("dubious ownership").
    local safedir_ok=true
    reg_safedir "${dst}" || safedir_ok=false
    say ""
    if ! ${access_ok}; then
        headline_warn "WARNING: the agent cannot use the clone yet" \
            "${dst} is created, secured and registered, but a path in it is outside the clone's group or a directory lacks the setgid bit, so the agent cannot read the tree. These commands set group ${SANDBOX_GROUP} and the setgid bit; nothing else about the clone needs redoing:"
        say  "      ${C_BOLD}sudo ${SETGID_BIN} ${dst}${C_RST}"
        say  "      ${C_BOLD}sudo ${SETFACL_BIN} --with-git ${dst}${C_RST}"
        ai_tools_log__structured warning \
            "sandbox ${dst} registered with a path outside the clone's group or a directory without setgid" \
            "AI_TOOLS_PROJECT=${dst}"
    fi
    if ! ${safedir_ok}; then
        headline_warn "WARNING: the clone is not git-ready" \
            "${dst} is created, secured and registered, but git safe.directory could not be added, so the agent's git will refuse to operate in it. The command above adds the entry; nothing else about the clone needs redoing."
        ai_tools_log__structured warning \
            "sandbox ${dst} registered without a git safe.directory entry" \
            "AI_TOOLS_PROJECT=${dst}"
    fi
    if ! ${access_ok} || ! ${safedir_ok}; then
        return 1
    fi
    ok "sandbox ready: ${dst}"
    ai_tools_log__structured info "sandbox secured and registered: ${dst}" \
        "AI_TOOLS_PROJECT=${dst}" "AI_TOOLS_RESULT=ok"

    section "Next"
    # One line per enabled agent: the gate resolved the set before dispatch, so it is read, never re-resolved, here.
    local agent_launcher
    resolve_enabled_agents || true
    while IFS= read -r agent_launcher; do
        say "  run the agent  : ${C_BOLD}cd ${dst} && ${agent_launcher}${C_RST}"
    done < <(enabled_agent_launchers)
    say "  push its work  : ${C_BOLD}atr projects 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 no detail of 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
}

# clone_privately <url> <branch> <dst>  -- shallow-clone <branch> of <url> into <dst>, a directory this creates,
# owner-only throughout: the tip commit's files -- possibly checked-in credentials -- stay unreadable to the sandbox
# account until the gate has run and normalize_clone opens the non-secret paths. A umask alone does not do it: the clone
# area carries a default ACL, and a path created under one takes that ACL in place of the umask (acl(5)), so git's own
# mkdir would make the root group-accessible at creation. The root is therefore made here, 0700, and its inherited
# default ACL removed before git writes into it, and git creates every path inside under umask 077; git accepts
# an existing empty directory as a clone destination. A failure removes the directory while it is empty, and dies.
#
# The root takes the area's group and setgid bit at creation, so every path git creates is in SANDBOX_GROUP. Only
# the default ACL is removed (`setfacl -k`): the kernel clears a directory's setgid bit on a mode or access-ACL change
# by a caller outside its group, which the operator is, so `setfacl -b` would leave git to create the tree
# in the operator's own group. The access entries the root inherited stay, masked to `---` by the 0700 create mode.
clone_privately() {
    local url="$1" branch="$2" dst="$3"
    mkdir -m 0700 -- "${dst}" || die "could not create ${dst}"
    if ! setfacl -k -- "${dst}"; then
        rmdir -- "${dst}" 2>/dev/null || true
        die MSG-U3Q6 "could not remove the inherited default ACL from ${dst} -- the clone was not created"
    fi
    if ! ( umask 077 && git clone --depth=1 -b "${branch}" "${url}" "${dst}" ); then
        rmdir -- "${dst}" 2>/dev/null || true
        die "git clone into ${dst} failed"
    fi
}

# cmd_project_clone <path> [--from <ref>] [--branch <name>] [--dir <name>] [-y|--yes]  -- create or reuse a sandbox
# branch, shallow-clone it PRIVATELY (an owner-only root without an ACL, umask 077 inside) into SANDBOX_ROOT, then hand
# off to sandbox_finalize, which gates and opens it. Pointed at an EXISTING clone under SANDBOX_ROOT, it resumes
# sandbox_finalize on it (the flags are then not read). Every input has a default and an optional flag, so the command
# is scriptable and the prompts are the interactive fallback: a flag skips its prompt, and no flag with no tty takes
# the default. The branch is a FULL git ref of any shape -- the "sandbox/<leaf>" default is a convention
# (sandbox_default_branch) -- validated with git check-ref-format and refused rather than rewritten.
cmd_project_clone() {
    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 MSG-J4P9 "a value is required after ${flag}"
        [[ "$1" != -* ]] || die MSG-Z5V5 "a value is required after ${flag}, 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_usage MSG-U8N3 "unknown projects clone option: $1 (see: atr --help)" ;;
            *)        [[ -z "${o_path}" ]] || die_usage MSG-J3Q9 "projects clone takes a single path: unexpected extra argument $1"; o_path="$1"; shift ;;
        esac
    done
    [[ -n "${o_path}" ]] || die_path_required clone
    local src; src="$(resolve_existing_path "${o_path}")"

    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 does not need a 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}"
    # The clone is registered last, after the branch is pushed and the tree is cloned, so a name the registry cannot
    # carry is refused here, before either.
    ai_tools_conf__is_registry_path_representable "${dst}" \
        || die_unregistrable_name "${dst}" "Choose another --dir; the branch and the clone were not created."
    if [[ -e "${dst}" ]]; then
        say "    to finish securing/registering an earlier clone of this name:"
        say "      ${C_BOLD}atr projects clone ${dst}${C_RST}"
        die MSG-H2D4 "destination already exists: ${dst}"
    fi
    # The registration rewrites the whole registry, so a line of it the writer cannot write back would fail that write
    # after the branch is pushed and the tree is cloned.
    if report_unrenderable_registry_line; then
        die "projects clone stopped before any change -- change the registry line named above first"
    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
    # gate in sandbox_finalize still prompts on its 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

    clone_privately "${clone_url}" "${br}" "${dst}"
    ok "shallow-cloned into ${dst} (private until secured)"
    ai_tools_log__structured info \
        "created sandbox clone ${dst} (branch ${br}, base ${base_ref}, remote ${remote})" \
        "AI_TOOLS_PROJECT=${dst}" "AI_TOOLS_RESULT=ok"

    sandbox_finalize "${dst}"
}

# cmd_project_push [path]  -- push the sandbox clone's commits ahead of its upstream branch, after listing them
# and confirming; without a path, the clone the current directory is in (discover_project). No-op when already
# up to date, and a refusal when git cannot count the commits ahead of the upstream, since a count read as zero would
# report the clone up to date. The verb does not take any option, so one is refused with the usage status rather than
# dropped: the push confirm defaults to yes and is taken without a terminal, so a `--dry-run` read as a path or silently
# discarded would push where the caller asked to look.
cmd_project_push() {
    local d="" a
    for a in "$@"; do
        case "${a}" in
            -*) die_usage MSG-C2U5 "unknown projects push option: ${a} (projects push takes no options)" ;;
            *)  if [[ -z "${d}" ]]; then d="${a}"; else die_usage MSG-K4R8 "projects push takes a single path"; fi ;;
        esac
    done
    # Without a path, the project the current directory is in; either way the target must be a clone directly
    # under the sandbox area (require_sandbox_clone_shape), and push does not read the registry state beyond finding it.
    if [[ -z "${d}" ]]; then
        d="$(resolve_verb_target push "")" || exit "$?"
    else
        d="$(resolve_existing_path "${d}")"
    fi
    require_sandbox_clone_shape "${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}"
    # git's own stderr is not echoed, the clone being co-written; the refusal prints the command that shows it.
    local n
    n="$(git -C "${d}" rev-list --count '@{u}..HEAD' 2>/dev/null)" \
        || die MSG-H6R3 "could not count the commits ahead of ${up} in ${d} -- the push did not run, and git's own error names the cause:" \
            "      git -C $(_quote_path "${d}") rev-list --count '@{u}..HEAD'"

    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__structured info "pushed ${n} commit(s) from sandbox ${d} to ${up}" \
        "AI_TOOLS_PROJECT=${d}" "AI_TOOLS_RESULT=ok"
}

# report_cwd_in_removed_tree <dir> <cwd>  -- after a removal of <dir>, name `cd` when <cwd>, the canonical current
# directory read before the deletion, was <dir> or under it. The CLI cannot change its parent shell's directory,
# and every later command in that shell prints bash's shell-init and job-working-directory errors.
report_cwd_in_removed_tree() {
    [[ -n "$2" && ( "$2" == "$1" || "$2" == "$1/"* ) ]] || return 0
    say "  ${C_DIM}your shell is still in the deleted directory -- cd somewhere else${C_RST}"
}

# remove_clone <dir> <assume-yes> <cwd>  -- the clone kind of `projects remove`: unregister a sandbox clone, then
# delete it. A clone is authorized by its place, its owner and its shape (require_sandbox_clone_shape), and its own
# lines go with it. Teardown takes the in-place kind's order: the registry entry, the safe.directory entry, then
# the tree. The registry step is first and fatal, so a de-list that does not apply (MSG-K8S2) leaves the clone as it
# was: on disk, registered and trusted, and a deletion that does not complete leaves an unregistered tree. <assume-yes>
# true pre-answers the one default-NO confirm, and <cwd> is the canonical current directory read before the deletion.
# The remote branch is left intact. No git read is made over the clone (cmd_project_remove states why), so a line ahead
# of the confirm states what an unpushed commit meets instead of counting them. The registry record is read
# for what a failed deletion's warning names alone: whether the clone had an entry of its own and which entry still
# covers it; the authorization stays require_sandbox_clone_shape.
remove_clone() {
    local d="$1" assume_yes="$2" cwd_real="$3" own_entry=none covering_entry
    require_sandbox_clone_shape "${d}"
    read_path_class "${d}"
    [[ "${CLASS_STATE}" != enabled && "${CLASS_STATE}" != disabled ]] || own_entry=removed
    covering_entry="$(get_covering_entry)"
    section "Remove sandbox project"
    say "  ${d}"
    say "  a commit not yet pushed is deleted with the clone; already-pushed work stays on the remote"
    ${assume_yes} || confirm "Unregister ${d} and remove it?" n || die "aborted"

    unreg_allow "${d}"
    # `|| true`: a safe.directory entry that could not be removed is a stale line the listing reports, and not
    # a reason to keep a clone the operator asked to delete. unreg_safedir signals failure, and a bare call under set -e
    # would end the removal there.
    unreg_safedir "${d}" || true
    # As the owner: a clone claimed for another operator belongs to that operator, and the run's `--for` is what names
    # them.
    if ! run_as_owner rm -rf -- "${d}"; then
        die_remove_incomplete "${d}" clone "${own_entry}" skipped "${covering_entry}"
    fi
    ok "removed ${d} and unregistered it"
    ai_tools_log__structured info "removed sandbox ${d} and unregistered it" \
        "AI_TOOLS_PROJECT=${d}" "AI_TOOLS_RESULT=ok"
    say "  ${C_DIM}remote branch left intact -- others may still merge it${C_RST}"
    report_cwd_in_removed_tree "${d}" "${cwd_real}"
}

# cmd_project_lockdown [path] [--dry-run] [-y]  -- run ai-tools-lockdown (via sudo) on the project, or the one
# the current directory is in (discover_project), to revoke ai-tools' read access to secret files; clears any guard
# CLAUDE.md on a real (non-dry-run) success. --dry-run and -y/--yes pass through to the helper.
cmd_project_lockdown() {
    local d="" a dry=false assume_yes=false; local -a passthru=()
    for a in "$@"; do
        case "${a}" in
            --dry-run)    passthru+=("${a}"); dry=true ;;
            -y|--yes)     passthru+=("${a}"); assume_yes=true ;;
            -*)           die_usage MSG-R3H8 "unknown projects lockdown option: ${a} (allowed: --dry-run, --yes)" ;;
            *)            if [[ -z "${d}" ]]; then d="${a}"; else die_usage MSG-Y6V3 "projects lockdown takes a single path"; fi ;;
        esac
    done
    # Refused here, before the helper's sudo.
    if ${dry} && ${assume_yes}; then
        die_usage MSG-P5P8 "--yes has no effect with --dry-run, which neither changes a path nor asks"
    fi
    # Discovery reads the registry, so the target's is loaded first.
    load_target_registry_snapshot
    d="$(resolve_verb_target lockdown "${d}")" || exit "$?"
    require_reachable_target "${d}" lockdown
    # No pre-check of the helper's path: the libexec directory is root-only (cli.rule.md), so a missing helper is
    # reported by sudo.
    section "Lock down project secrets"
    say "  ${d}"
    say "  ${C_DIM}secret-matching files -> 600, dirs -> 700, owner ${OWNER_USER}:${OWNER_GROUP}${C_RST}"
    local status=0
    run_lockdown "${d}" "${passthru[@]}" || status=$?
    case "${status}" in
        0)
            ${dry} || clear_lockdown_guard "${d}"
            ok "lockdown done: ${d}"
            ${dry} || ai_tools_log__structured info "locked down secrets in ${d}" \
                "AI_TOOLS_PROJECT=${d}" "AI_TOOLS_RESULT=ok"
            ;;
        6)  # the helper's decline: nothing changed, and the exit carries it (ai-tools(1))
            say "  declined -- no path was changed"
            exit 6 ;;
        *)  die "lockdown failed for ${d}" ;;
    esac
}

# ── Enable / disable a claimed project ───────────────────────────────────────────
# Each is a REGISTRY edit through conf.lib.sh's allowlist editing -- the tree keeps its group, its ACLs, its setgid bits
# and its SELinux label -- so neither runs the secret gate, and disable does not ask for confirmation, since it moves
# to less access. What disabling costs downstream of the allowlist is offer_reenable's header and cli.rule.md's.

# no_entry_die <dir> <what>  -- the enable/disable pair's shared refusal. Neither verb invents an entry (registering
# a project is a claim, which scans for secrets first), so a path the file does not name is refused by both and pointed
# at the claim. One function rather than one per verb, so the situation carries one message code.
no_entry_die() {
    local -a remedy=("there is no registry entry to $2. List what is registered, or register it:"
        "       $(format_projects_command "" "")"
        "       $(format_projects_command claim "$1")")
    if [[ "${CLASS_STATE}" == ancestor ]]; then
        printf '\n' >&2
        list_entries_under "$1" | sed 's/^/    /' >&2
        printf '\n' >&2
        remedy=("it holds the registered project(s) listed above, and has no entry of its own to $2. Name one of them:" \
            "       $(format_projects_command "$2" "") <project>")
    fi
    die MSG-T4A8 "not a claimed project: $1" "${remedy[@]}"
}

# cmd_project_disable <path>  -- disable a claimed project: turn the '@' of its registry line into '%', nested or not.
cmd_project_disable() {
    local d="" a
    for a in "$@"; do
        case "${a}" in
            -*) die_usage MSG-K5W9 "unknown projects disable option: ${a} (it takes a path only)" ;;
            *)  if [[ -z "${d}" ]]; then d="${a}"; else die_usage MSG-W3C7 "projects disable takes a single path"; fi ;;
        esac
    done
    [[ -n "${d}" ]] || die_path_required disable
    d="$(resolve_existing_path "${d}")"
    [[ -d "${d}" ]] || die "not a directory: ${d}"
    load_target_registry_snapshot

    read_path_class "${d}"
    case "${CLASS_STATE}" in
        excluded)  die_excluded "${d}" disable ;;
        inside)    die_inside "${d}" disable ;;
        disabled)
            section "Disable project"
            say "  ${d}"
            say "    registry: already disabled"
            disabled_note "${d}"
            return 0 ;;
        enabled)   ;;
        *)         no_entry_die "${d}" disable ;;
    esac

    section "Disable project"
    say "  ${d}"
    retag_allow "${d}" disable || die "the registry was not updated -- ${d} is still enabled"
    ai_tools_log__structured info "project disabled: ${d} (owner ${OWNER_USER})" \
        "AI_TOOLS_PROJECT=${d}" "AI_TOOLS_RESULT=ok"
    say ""
    say "  ${C_DIM}no session can start here until it is re-enabled, and the ownership handback no"
    say "  longer restores files written under it. The files, their group, ACLs and label are"
    say "  unchanged.${C_RST}"
    say "  re-enable it with: ${C_BOLD}$(format_projects_command enable "${d}")${C_RST}"
}

# cmd_project_enable <path>  -- re-enable a disabled project: turn the '%' of its line back into '@'.
cmd_project_enable() {
    local d="" a
    for a in "$@"; do
        case "${a}" in
            -*) die_usage MSG-Q3K7 "unknown projects enable option: ${a} (it takes a path only)" ;;
            *)  if [[ -z "${d}" ]]; then d="${a}"; else die_usage MSG-T5S8 "projects enable takes a single path"; fi ;;
        esac
    done
    [[ -n "${d}" ]] || die_path_required enable
    d="$(resolve_existing_path "${d}")"
    [[ -d "${d}" ]] || die "not a directory: ${d}"
    load_target_registry_snapshot

    read_path_class "${d}"
    case "${CLASS_STATE}" in
        excluded)  die_excluded "${d}" enable ;;
        inside)    die_inside "${d}" enable ;;
        enabled)
            section "Enable project"
            say "  ${d}"
            say "    registry: already enabled"
            report_still_blocked
            return 0 ;;
        disabled)  ;;
        # Deliberately not an implicit claim: claiming runs a secret scan and grants the agent access to the tree,
        # which is a different decision from lifting a '%' the operator put there.
        *)         no_entry_die "${d}" enable ;;
    esac

    section "Enable project"
    say "  ${d}"
    retag_allow "${d}" enable || die "the registry was not updated -- ${d} is still disabled"
    ai_tools_log__structured info "project enabled: ${d} (owner ${OWNER_USER})" \
        "AI_TOOLS_PROJECT=${d}" "AI_TOOLS_RESULT=ok"
    read_path_class "${d}"
    # The entry is what the verb was asked for, so a covering line elsewhere leaves the exit 0; the close then says
    # the gate still refuses the path and points at no claim, which would refuse it too.
    if [[ "${CLASS_BLOCKED}" != - ]]; then
        report_still_blocked
        say ""
        say "  ${C_DIM}the entry is enabled; no session starts here until that line is lifted.${C_RST}"
        return 0
    fi
    # A project disabled long enough may have drifted out of a fully claimed state; the claim is idempotent and reports
    # what is missing, so point at it rather than re-deriving that here.
    say ""
    say "  ${C_DIM}sessions may start here again. If the project was disabled across an upgrade or a"
    say "  permission change, re-run the claim to reconcile it:${C_RST}"
    say "  ${C_BOLD}$(format_projects_command claim "${d}")${C_RST}"
}

# cmd_project_handback [--full] [path]  -- hand agent-written files under the project, or the one the current directory
# is in (discover_project), back to ${OWNER_USER}:${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_project_handback() {
    local d="" a full=false; local -a passthru=()
    for a in "$@"; do
        case "${a}" in
            --full) passthru+=("${a}"); full=true ;;
            -*)     die_usage MSG-U3R3 "unknown projects handback option: ${a} (allowed: --full)" ;;
            *)      if [[ -z "${d}" ]]; then d="${a}"; else die_usage MSG-D9C7 "projects handback takes a single path"; fi ;;
        esac
    done
    # Discovery reads the registry, so the target's is loaded first.
    load_target_registry_snapshot
    d="$(resolve_verb_target handback "${d}")" || exit "$?"
    require_reachable_target "${d}" handback
    section "Reclaim agent-written files"
    say "  ${d}${C_DIM}$(${full} && printf ' (--full: incl. node_modules, .venv, ...)')${C_RST}"
    say "  ${C_DIM}-> ${OWNER_USER}:${SANDBOX_GROUP} (secret-named files stay ${OWNER_USER}:${OWNER_GROUP} 600)${C_RST}"
    # The helper reports the outcome itself -- the pre-scan count, the one whole-set confirm, then the `handed back N` /
    # `nothing to reclaim` / `declined` line -- so no blanket success line here: the CLI states only what happened.
    run_reclaim "${d}" "${passthru[@]}" || die "reclaim failed for ${d}"
    ai_tools_log__structured info "reclaim run for ${d}$(${full} && printf ' (full)')" \
        "AI_TOOLS_PROJECT=${d}" "AI_TOOLS_RESULT=ok"
}

# cmd_audit -- report what has refused, been rejected, been stranded or been flagged since a given time. A thin
# pass-through to the root helper, which does the reading and the rendering: the trail is 700 root:root, so there is no
# use this unprivileged CLI could make of it first. The helper's EXIT STATUS is propagated deliberately -- non-zero
# means findings -- so `ai-tools audit` is usable from cron or a login banner without parsing its output, the same
# contract `status` already offers.
cmd_audit() {
    root_helper_reachable \
        || die "sudo not found -- cannot read the root-only trail; run as root: ${AUDIT_BIN}"
    run_root_helper "${AUDIT_BIN}" "$@"
}

# cmd_stop -- terminate every running agent session, through ai-tools-stop. Thin by design, and the thinness is
# the whole contract: the command accepts neither a target nor an authorization input, so this side has no decision left
# to make. What a stop reaches follows from membership of the sandbox account's cgroup slice, which only the root helper
# can read, and every remaining decision is a security decision that must not be made twice in two places. Option
# grammar is all that lives here. Why the command is shaped this way: stop.rule.md.
#
# The helper's EXIT STATUS propagates unchanged, so a caller reads one set of codes whichever side refused. They are
# listed in ai-tools(1) and are not restated here, so the two cannot drift.
#
# die_stop_usage -- refuse a `stop` command line in the HELPER's exit-code space (2 = usage), not the CLI's own (die
# exits 1). Because cmd_stop propagates the helper's status, 2 is what a caller reading `stop`'s exit code is told
# a usage error is (ai-tools(1)) -- and WHICH SIDE refused is an implementation detail of the ordering, not something
# the caller asked about. Exiting 1 here would report the same mistake as one code from the CLI and another
# from a direct root call, and 1 already means "a process survived SIGKILL". It splits a leading code off exactly
# as die() does, so a `stop` refusal carries one.
die_stop_usage() {
    local code=""
    if ai_tools_msg__is_code "${1-}"; then code="$1"; shift; fi
    ai_tools_log__coded error "${code}" "$*"
    ai_tools_msg__error ${code:+"${code}"} "ai-tools: $*"
    exit 2
}

cmd_stop() {
    local argument; local -a passthru=()
    for argument in "$@"; do
        case "${argument}" in
            # --all is accepted and inert; ai-tools(1) says why it exists at all.
            --all|--dry-run|-y|--yes|--force) passthru+=("${argument}") ;;
            -*) die_stop_usage MSG-B7K4 "unknown option for stop: ${argument}" \
                    "allowed: --all, --dry-run, --yes/-y, --force" ;;
            # A PATH IS REFUSED HERE, NOT PASSED ON. The helper refuses it too -- that is the last line, for a direct
            # root call -- but the refusal has to happen on this side as well, BEFORE the sudo: a command that is going
            # to be refused must not first prompt for a password (the ordering rule --for follows). Why refusing beats
            # ignoring is in the helper's refuse_positional_argument.
            #
            # THIS TEXT IS A DELIBERATE TWIN of that function's, and the duplication is unavoidable: the two run
            # in different processes and the helper is 750 root:root, so neither can source the other, while an operator
            # meets whichever side refused. The two must say the same thing and offer the same four commands -- change
            # one, change both.
            #
            # The commands are printed PLAIN, ahead of die(): die() joins its arguments and wraps them through the error
            # emitter, which would break a command across lines
            # (messaging.rule.md).
            *)  printf '\n' >&2
                printf '  %s\n' \
                    "Terminate every session:    atr stop" \
                    "See what is running first:  atr stop --dry-run" \
                    "End one session cleanly:    /exit inside it, which runs its session-end handback" \
                    "Terminate one by hand:      sudo systemctl --user -M ${SANDBOX_USER}@.host stop <unit>" >&2
                printf '\n' >&2
                die_stop_usage MSG-A3M9 "stop takes no path: ${argument}. It TERMINATES every agent session on this host -- killing the process tree, so no session-end handback runs -- and has no per-project form, because a session is attributed to a project by the sandbox account's own user manager -- the account being stopped -- so that attribution is reported, never trusted to decide what a stop reaches." ;;
        esac
    done
    root_helper_reachable \
        || die "sudo not found -- a session runs in the sandbox account's cgroups, which only root can signal" \
               "run as root: ${STOP_BIN}"
    run_root_helper "${STOP_BIN}" "${passthru[@]}"
}

# 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_usage MSG-H2P7 "providers list 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_providers__list_enabled_agents >/dev/null 2>&1 \
            || ! declare -F ai_tools_providers__read_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_providers__read_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}" ;;
            unreadable) printf '%sdefault_enable only -- %s cannot be read by this account%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_providers__read_agent_manifest_field "$1" npm_package || true)"
        launcher="$(ai_tools_providers__read_agent_manifest_field "$1" launcher    || true)"
        handback="$(ai_tools_providers__read_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_providers__list_enabled_agents agent_detail
    kind_block "Integrations" AI_TOOLS_INTEGRATIONS "${AI_TOOLS_INTEGRATIONS_DIR}" \
               ai_tools_providers__list_enabled_integrations -

    # The enabled integration names, reused by the SELinux advisory. stderr is dropped here (the integrations kind_block
    # already captured any refusals into ${refusals}).
    local enabled_integrations
    enabled_integrations="$(ai_tools_providers__list_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_groups__get_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 all needs this list, so without it there is no accurate report to show.
        # `sudo ai-tools-admin selinux 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__REGISTRY[@]}"; do
            gname="$(ai_tools_selinux_groups__get_name "${entry}")"
            if group_loaded "${gname}"; then
                printf '    %sloaded%s   %s -- %s\n' "${C_GRN}" "${C_RST}" \
                    "${gname}" "$(ai_tools_selinux_groups__get_description "${entry}")"
                loaded_any=1
            fi
        done
        (( loaded_any )) || say "    ${C_DIM}(no optional groups loaded)${C_RST}"
        say "    ${C_DIM}toggle with: atr admin selinux groups enable <name>${C_RST}"

        # Each enabled integration declares the policy groups its toolchain needs under enforcing (selinux_groups in its
        # manifest, ai-tools-providers(5)); the ones not loaded are named here with the command that enables them, since
        # the failure they cause inside a session is an opaque EACCES. Stable groups take one ai-tools-admin command;
        # an experimental one is compiled from a source checkout, so it is named on its own line.
        [[ "${enforce}" == "Enforcing" ]] || return 0
        declare -F ai_tools_providers__read_provider_manifest_field >/dev/null 2>&1 || return 0
        local integration declared missing_stable missing_experimental gname gdesc
        local -a declared_groups
        while IFS= read -r integration; do
            [[ -n "${integration}" ]] || continue
            declared="$(ai_tools_providers__read_provider_manifest_field "${integration}" selinux_groups 2>/dev/null || true)"
            [[ -n "${declared}" ]] || continue
            declared_groups=()
            ai_tools_conf__split_list_value declared_groups "${declared}" 0 "selinux_groups in the ${integration} manifest"
            missing_stable=""; missing_experimental=""
            for gname in "${declared_groups[@]}"; do
                ai_tools_selinux_groups__is_valid "${gname}" || continue
                group_loaded "${gname}" && continue
                if ai_tools_selinux_groups__is_experimental "${gname}"; then
                    missing_experimental+="${missing_experimental:+ }${gname}"
                else
                    missing_stable+="${missing_stable:+ }${gname}"
                fi
            done
            [[ -n "${missing_stable}${missing_experimental}" ]] || continue
            say ""
            say "  ${C_YEL}${integration} is enabled but not every SELinux group it needs is loaded:${C_RST}"
            for gname in ${missing_stable} ${missing_experimental}; do
                for entry in "${AI_TOOLS_SELINUX_GROUPS__REGISTRY[@]}"; do
                    [[ "$(ai_tools_selinux_groups__get_name "${entry}")" == "${gname}" ]] || continue
                    gdesc="$(ai_tools_selinux_groups__get_description "${entry}")"
                    say "    ${C_YEL}${gname}${C_RST} -- ${gdesc%%:*}"
                done
            done
            [[ -z "${missing_stable}" ]] \
                || say "  fix: atr admin selinux groups enable ${missing_stable}"
            [[ -z "${missing_experimental}" ]] \
                || say "  and, from a source checkout (experimental): sudo selinux/install-selinux.sh enable-group ${missing_experimental}"
        done <<<"${enabled_integrations}"
    }
    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 under the listing
# so `projects list` doubles as a reconciliation/maintenance view.
list_maintenance_note() {
    local -a rows=(
        "atr projects claim DIRECTORY"$'\t'"claim a project / finish claiming one"
        "atr projects unclaim DIRECTORY"$'\t'"release a project (revoke agent access)"
        "atr projects handback [--full] DIRECTORY"$'\t'"take back ownership; project stays claimed"
        "atr projects lockdown DIRECTORY"$'\t'"lock down secret-named files"
    )
    local row command width=0
    # The description column follows the longest command, so a row added or reworded keeps the block aligned.
    for row in "${rows[@]}"; do
        command="${row%%$'\t'*}"
        if (( ${#command} > width )); then width="${#command}"; fi
    done
    section "Maintenance"
    for row in "${rows[@]}"; do
        printf '  %-*s  %s\n' "${width}" "${row%%$'\t'*}" "${row#*$'\t'}"
    done
}

# status_fmt_age <seconds>  -- render an age the way an operator reads it ("3 days ago"). The wording is single-sourced
# in services.lib.sh beside the age it formats, so this report and `atr admin status` cannot describe the same stamp two
# ways; this stays a local name because the report calls it on nearly every line. Fail-soft, like the rest
# of that library's use here: a missing lib drops the relative clause rather than the line.
status_fmt_age() {
    declare -F ai_tools_services__format_age >/dev/null 2>&1 || return 0
    ai_tools_services__format_age "${1:-}"
}

# 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
# `projects list`/`providers list`. The exit is the report state's (ai-tools-records(5)): 4 where a section read
# a fault, 5 where a section could not make a reading it promises (a library base ships did not load), and a `?`
# or `n/a` line -- a reading this vantage cannot make -- leaves it at 0. status_entrypoint_pins  -- report, per enabled
# agent, whether its entrypoint carries a verified checksum, and under it (status_entrypoint_label) what the last
# reconciliation could do about that agent's labels. The entrypoint itself lives in a 0750 toolchain the operator cannot
# read, so both lines report root-written records placed where they can. Without them the only signals are a warning
# in a journal the operator cannot reach and, eventually, a refused launch.
#
# The pin is written in the same KEY=value stamp grammar as the updater's last-run record, so it is read
# through the SAME accessors -- charset-clamped fields and one age implementation - rather than a second reader
# that could drift. Its path comes from entrypoint-verify.lib.sh, never hardcoded.
#
# Returns STATUS_UNREADABLE when the provider or entrypoint library did not load, and 1 for a stale pin, a recorded
# labelling failure, and an unpinned entrypoint where the operator has required verification: everywhere else unpinned
# is a legitimate state (an air-gapped host, a release the vendor published no manifest for) and must not make a healthy
# host alarm, the same rule the unqueryable units follow. A pin this account has no read on is reported as unknown,
# which ai_tools_services__is_attention_needed does not count as a fault: `status` stays open to a non-operator, whom
# the state directory's mode keeps out. status_path_order -- where THIS shell finds each enabled agent's launcher. The
# one reading this report can make for free and no other vantage can make at all: the CLI runs in the operator's own
# login shell, so `command -v claude` resolves exactly what typing `claude` would run. A launcher resolving to a file
# other than the wrapper starts UNCONFINED, as the operator, so it counts toward the report's exit status and names the
# command that repairs it. What each state means is launch.rule.md's PATH ordering section; what this report says about
# each is cli.rule.md.
#
# path-order.lib.sh and providers.lib.sh ship with base, so a reader either did not define is a broken install: reported
# under MSG-N9Q3 and returned as STATUS_UNREADABLE, the later sections still printed. The launcher list comes
# from the provider resolver, and without it the reader does not list a launcher and the section would print nothing.
#
# Run as root, the CLI reads root's PATH, which is not an operator's shell, and the remedy `atr admin operators add`
# refuses root (MSG-C4Y4): each launcher prints n/a and the section does not count.
status_path_order() {
    local path_order_lib=/usr/local/lib/ai-tools/path-order.lib.sh
    local providers_lib=/usr/local/lib/ai-tools/providers.lib.sh
    # shellcheck source=SCRIPTDIR/../lib/ai-tools/providers.lib.sh
    source "${providers_lib}" 2>/dev/null || true
    # shellcheck source=SCRIPTDIR/../lib/ai-tools/path-order.lib.sh
    source "${path_order_lib}" 2>/dev/null || true
    if ! declare -F ai_tools_path_order__read_here >/dev/null 2>&1 \
            || ! declare -F ai_tools_providers__list_enabled_agents >/dev/null 2>&1; then
        section "PATH ordering"
        warn MSG-N9Q3 "the PATH ordering or provider library (${path_order_lib}, ${providers_lib}) did not load -- no launcher was resolved in this shell; reinstall the ai-tools package"
        return "${STATUS_UNREADABLE}"
    fi

    local state pair launcher winner seen=0
    ai_tools_path_order__read_here || true
    state="${AI_TOOLS_PATH_ORDER__STATE:-unknown}"
    for pair in "${AI_TOOLS_PATH_ORDER__WINNERS[@]+"${AI_TOOLS_PATH_ORDER__WINNERS[@]}"}"; do
        launcher="${pair%%=*}"; winner="${pair#*=}"
        (( seen++ == 0 )) && section "PATH ordering"
        if [[ "${INVOKING_USER}" == root ]]; then
            printf '  %-28s %sn/a (root is not an operator -- run status as one)%s\n' "${launcher}" "${C_DIM}" "${C_RST}"
            continue
        fi
        case "${winner}" in
            # This host does not install a wrapper of that name, so its PATH has no ordering to get wrong here --
            # reported, and not a fault.
            '')  printf '  %-28s %sn/a (no wrapper installed)%s\n' "${launcher}" "${C_DIM}" "${C_RST}" ;;
            '?') printf '  %-28s %s? (this shell cannot resolve the name)%s\n' "${launcher}" "${C_DIM}" "${C_RST}" ;;
            "${AI_TOOLS_PATH_ORDER__WRAPPER_DIR}/${launcher}")
                 printf '  %-28s %sOK%s %s(%s -- the sandbox wrapper)%s\n' \
                     "${launcher}" "${C_GRN}" "${C_RST}" "${C_DIM}" "${winner}" "${C_RST}" ;;
            *)   printf '  %-28s %sUNCONFINED%s %s(%s)%s\n' \
                     "${launcher}" "${C_RED}" "${C_RST}" "${C_DIM}" "${winner}" "${C_RST}" ;;
        esac
    done
    (( seen )) || return 0
    [[ "${INVOKING_USER}" != root ]] || return 0

    case "${state}" in
        shadowed)
            say "      typing that name starts an agent OUTSIDE the sandbox: as you, with your"
            say "      credentials and home, and no project registry, confinement or ownership handback"
            say "      ${C_BOLD}atr admin operators add ${INVOKING_USER}${C_RST}"
            return 1 ;;
        # Reaching the wrapper without the ordering line is right today and right by accident: the next thing
        # that prepends to PATH takes it away silently. Dim rather than yellow, since this account's sessions are
        # sandboxed today: it does not alarm and does not count toward the exit status.
        clear)
            say "  ${C_DIM}the PATH ordering line is not in your shell init -- add it with:"
            say "  atr admin operators add ${INVOKING_USER}${C_RST}" ;;
    esac
    return 0
}

status_entrypoint_pins() {
    local providers_lib=/usr/local/lib/ai-tools/providers.lib.sh
    local verify_lib=/usr/local/lib/ai-tools/entrypoint-verify.lib.sh
    # shellcheck source=SCRIPTDIR/../lib/ai-tools/providers.lib.sh
    source "${providers_lib}" 2>/dev/null || true
    # shellcheck source=SCRIPTDIR/../lib/ai-tools/entrypoint-verify.lib.sh
    source "${verify_lib}" 2>/dev/null || true
    # Both libraries ship with base, so one that did not load is a broken install: a reading this section promises
    # and could not make. Every reader an agent's lines call is checked here, ahead of the first agent, so a missing one
    # does not leave the pin's own fields to render as VERIFIED, a missing verification-policy reader does not leave
    # an unpinned agent to render as not blocked where the host requires verification, and an agent that takes
    # `continue` before its label line still has its label reader checked; status_entrypoint_label keeps its own guard
    # (MSG-K8K4). The service registry's absence is the Services section's to report (MSG-X5Z8).
    if ! declare -F ai_tools_providers__list_enabled_agents >/dev/null 2>&1 \
            || ! declare -F ai_tools_providers__read_agent_manifest_field >/dev/null 2>&1 \
            || ! declare -F ai_tools_entrypoint_verify__get_pin_path >/dev/null 2>&1 \
            || ! declare -F ai_tools_entrypoint_verify__get_stale_path >/dev/null 2>&1 \
            || ! declare -F ai_tools_entrypoint_verify__read_pin_kind >/dev/null 2>&1 \
            || ! declare -F ai_tools_entrypoint_verify__is_required >/dev/null 2>&1 \
            || ! declare -F ai_tools_entrypoint_verify__get_label_path >/dev/null 2>&1; then
        section "Entrypoint verification"
        warn MSG-E5C3 "the provider or entrypoint library (${providers_lib}, ${verify_lib}) did not load -- no entrypoint pin was read; reinstall the ai-tools package"
        return "${STATUS_UNREADABLE}"
    fi
    declare -F ai_tools_services__read_stamp_field >/dev/null 2>&1 || return 0

    local strict=no
    ai_tools_entrypoint_verify__is_required && strict=yes

    local agent pin version verified age kind seen=0 blocking=0 mislabelled=0 stale=0 unreadable=0 label_status
    local label_record unpinned
    while IFS=$'\t' read -r agent _ _; do
        [[ -n "${agent}" ]] || continue
        # An agent whose package declares no release manifest has no published checksum to verify against. Root records
        # what is installed for it instead, so it is reported once that pin exists. Before then it is reported
        # where the operator required verification, since its launch is refused until a reconcile records the pin,
        # and where a label record exists: ai-tools-relabel-agent files a label outcome for every enabled agent, one it
        # could not pin included, and a failed one stops that agent's launch. With neither, there is nothing
        # yet to report.
        pin="$(ai_tools_entrypoint_verify__get_pin_path "${agent}" 2>/dev/null || true)"
        unpinned=""
        if [[ -z "$(ai_tools_providers__read_agent_manifest_field "${agent}" release_manifest_url 2>/dev/null || true)" \
              && ! -e "${pin}" ]]; then
            label_record="$(ai_tools_entrypoint_verify__get_label_path "${agent}" 2>/dev/null || true)"
            [[ "${strict}" == yes || ( -n "${label_record}" && -e "${label_record}" ) ]] || continue
            unpinned=yes
        fi
        (( seen++ == 0 )) && section "Entrypoint verification"
        version="$(ai_tools_services__read_stamp_field "${pin}" VERSION)"
        # Read BEFORE the pin's own fields, and reported in place of them. A reconciliation that refused to re-record
        # leaves the pin exactly as it was -- that staleness is what makes the next launch refuse -- so the pin still
        # carries a version and a date and would otherwise render as a fresh, successful verification beside the line
        # saying it no longer describes the installed binary.
        if [[ -n "${unpinned}" && "${strict}" == yes ]]; then
            blocking=$(( blocking + 1 ))
            printf '  %-28s %sUNVERIFIED%s %s(no pin recorded)%s\n' "${agent}" "${C_YEL}" "${C_RST}" "${C_DIM}" "${C_RST}"
            say "      this host requires verification, so its sessions will not launch"
            say "      ${C_BOLD}atr admin system entrypoints relabel${C_RST} ${C_DIM}(records the entrypoint as installed)${C_RST}"
        elif [[ -n "${unpinned}" ]]; then
            printf '  %-28s %sno pin recorded%s\n' "${agent}" "${C_DIM}" "${C_RST}"
        elif status_entrypoint_stale "${agent}"; then
            stale=$(( stale + 1 ))
        elif [[ -n "${version}" ]]; then
            verified="$(ai_tools_services__read_stamp_age "${pin}" VERIFIED)"
            age="$(status_fmt_age "${verified}")"
            kind="$(ai_tools_entrypoint_verify__read_pin_kind "${agent}" 2>/dev/null || true)"
            if [[ "${kind}" == observed ]]; then
                # The weaker tier states what the comparison proves -- the binary is the one root recorded -- and not
                # VERIFIED, which claims a vendor signature this agent's channel does not publish, nor any word
                # asserting the binary was sound when it was first recorded, which no pin can say. It is a pin, so it
                # satisfies the strictness switch and blocks no launch; what it does not carry is the origin.
                printf '  %-28s %sUNCHANGED%s %s(%s%s, as installed)%s\n' "${agent}" "${C_GRN}" "${C_RST}" \
                    "${C_DIM}" "${version}" "${age:+, ${age}}" "${C_RST}"
            else
                printf '  %-28s %sVERIFIED%s %s(%s%s)%s\n' "${agent}" "${C_GRN}" "${C_RST}" \
                    "${C_DIM}" "${version}" "${age:+, ${age}}" "${C_RST}"
            fi
        elif [[ -e "${pin}" && ! -r "${pin}" ]]; then
            # Not a fault: `status` stays open to a non-operator, whom the state directory's mode keeps out. It says
            # only that this vantage has no reading to give.
            printf '  %-28s %s? (pin not readable from this account)%s\n' "${agent}" "${C_DIM}" "${C_RST}"
        elif [[ -e "${pin}" ]]; then
            # Readable but carrying no VERSION the clamped reader will accept. Distinct from both other states
            # and from a missing pin, because the remedy is to rewrite it -- and it is never read as verified, since
            # the version check is what gates that line.
            printf '  %-28s %sunverified%s %s(pin present but unreadable)%s\n' \
                "${agent}" "${C_DIM}" "${C_RST}" "${C_DIM}" "${C_RST}"
            say "      ${C_BOLD}atr admin system entrypoints relabel${C_RST} ${C_DIM}(rewrites the pin)${C_RST}"
        else
            blocking=$(( blocking + 1 ))
            if [[ "${strict}" == yes ]]; then
                printf '  %-28s %sUNVERIFIED%s\n' "${agent}" "${C_YEL}" "${C_RST}"
                say "      this host requires verification, so its sessions will not launch"
                say "      ${C_BOLD}atr admin system entrypoints relabel${C_RST} ${C_DIM}(needs network -- it fetches the vendor's signed manifest)${C_RST}"
            else
                printf '  %-28s %sunverified%s %s(no pin -- launches are not blocked)%s\n' \
                    "${agent}" "${C_DIM}" "${C_RST}" "${C_DIM}" "${C_RST}"
            fi
        fi
        label_status=0
        status_entrypoint_label "${agent}" || label_status=$?
        if (( label_status == STATUS_UNREADABLE )); then unreadable=$(( unreadable + 1 ))
        elif (( label_status != 0 )); then mislabelled=$(( mislabelled + 1 )); fi
    done < <(ai_tools_providers__list_enabled_agents 2>/dev/null)

    [[ "${unreadable}" -gt 0 ]] && return "${STATUS_UNREADABLE}"
    [[ "${stale}" -gt 0 ]] && return 1
    [[ "${mislabelled}" -gt 0 ]] && return 1
    [[ "${strict}" == yes && "${blocking}" -gt 0 ]] && return 1
    return 0
}

# status_selinux_attestation [operator-conf] -- render the rows ai_tools_confinement__list_attestation_report prints
# (confinement.lib.sh): the per-domain mode of ai_tools_t and the Booleans that widen it, read and classified
# by the launch shim's own functions, so this report, atr admin status and the launch cannot disagree. Returns 1
# for a finding only where AI_TOOLS_REQUIRE_SELINUX makes it refuse every launch (the report's verdict row) or makes
# every launch warn -- a host without SELinux confinement by its own configuration
# (ai_tools_confinement__evaluate_dac_only_state), where the shim launches DAC-only and names the key to set --
# STATUS_UNREADABLE when the library did not load, and 0 otherwise. The section is omitted where getenforce cannot say.
status_selinux_attestation() {
    local operator_conf="${1:-${AI_TOOLS_OPERATOR_CONF:-/etc/ai-tools/operator.conf}}"
    local selinux_mode module_present policy_shipped dac_only_state=""
    selinux_mode="$(getenforce 2>/dev/null || true)"
    [[ -n "${selinux_mode}" ]] || return 0
    section "SELinux status"
    # shellcheck source=SCRIPTDIR/../lib/ai-tools/confinement.lib.sh
    source "${CONFINEMENT_LIB}" 2>/dev/null || true
    if ! declare -F ai_tools_confinement__list_attestation_report >/dev/null 2>&1 \
            || ! declare -F ai_tools_confinement__evaluate_dac_only_state >/dev/null 2>&1; then
        warn MSG-M9H2 "the confinement library ${CONFINEMENT_LIB} did not load its attestation readers -- reinstall ai-tools-base"
        return "${STATUS_UNREADABLE}"
    fi
    module_present="$(ai_tools_confinement__read_module_present "${AI_TOOLS_CONFINEMENT__MODULE_PROBE_PATH}" 2>/dev/null || true)"
    policy_shipped="$(ai_tools_confinement__read_policy_shipped "${AI_TOOLS_CONFINEMENT__CORE_MODULE_FILE}")"
    dac_only_state="$(ai_tools_confinement__evaluate_dac_only_state "${selinux_mode}" "${module_present}" "${policy_shipped}")" \
        || true
    if [[ -n "${dac_only_state}" ]]; then
        case "${dac_only_state}" in
            disabled) say "  SELinux is disabled on this host -- there is no domain to attest" ;;
            *)        say "  the ai_tools policy is not installed (SELinux ${selinux_mode}) -- there is no domain to attest" ;;
        esac
        if ai_tools_confinement__is_selinux_required "${operator_conf}"; then
            say "  AI_TOOLS_REQUIRE_SELINUX is set, so every launch runs DAC-only and warns that the requirement is not met"
            say "      declare this host DAC-only:  set AI_TOOLS_REQUIRE_SELINUX=no in ${operator_conf}"
            say "      or install the policy:       sudo dnf install ai-tools-selinux"
            return 1
        fi
        say "  ${C_DIM}AI_TOOLS_REQUIRE_SELINUX is not set, so a launch runs DAC-only without a warning${C_RST}"
        return 0
    fi
    local -a row
    local origin_note effect_note enforcement_note section_status=0
    # A value keeps normal contrast whatever it reads, since off is a reading and not a disabled control; only the note
    # beside it is dim. Whether a requirement is enforced is known ahead of the verdict row, so each row can say it.
    enforcement_note="not enforced"
    ai_tools_confinement__is_selinux_required "${operator_conf}" && enforcement_note="launch blocked"
    while IFS=$'\t' read -r -a row; do
        case "${row[0]}" in
            domain)   # <yes|no|unread> <remedy|->
                case "${row[1]}" in
                    no)  printf '  %-28s %senforcing%s\n' "ai_tools_t" "${C_GRN}" "${C_RST}" ;;
                    yes) printf '  %-28s %sPERMISSIVE%s %s(its denials are logged and not enforced)%s\n' \
                             "ai_tools_t" "${C_YEL}" "${C_RST}" "${C_DIM}" "${C_RST}"
                         say "      ${C_BOLD}${row[2]}${C_RST}" ;;
                    *)   printf '  %-28s %s? (whether it is a permissive domain could not be read)%s\n' \
                             "ai_tools_t" "${C_DIM}" "${C_RST}" ;;
                esac ;;
            boolean)  # <name> <classification> <state> <required> <origin> <opening> <grants> <remedy|->
                origin_note=""
                [[ "${row[5]}" == operator.conf ]] && origin_note=", operator.conf"
                [[ "${row[5]}" == built-in ]] && origin_note=", built in"
                effect_note=""
                [[ "${row[6]}" == on || "${row[6]}" == off ]] && effect_note="when ${row[6]}, allows ${row[7]}"
                case "${row[2]}" in
                    matches) printf '  %-28s %s %s(required: %s%s)%s\n' "${row[1]}" "${row[3]}" "${C_DIM}" \
                                 "${row[4]}" "${origin_note}" "${C_RST}" ;;
                    differs) printf '  %-28s %s%s%s %s(required: %s%s, %s%s)%s\n' "${row[1]}" "${C_YEL}" "${row[3]^^}" \
                                 "${C_RST}" "${C_DIM}" "${row[4]}" "${origin_note}" "${enforcement_note}" \
                                 "${effect_note:+ -- ${effect_note}}" "${C_RST}"
                             say "      ${C_BOLD}${row[8]}${C_RST}" ;;
                    open)    printf '  %-28s %s %s(%s)%s\n' "${row[1]}" "${row[3]}" "${C_DIM}" "${effect_note}" \
                                 "${C_RST}" ;;
                    closed)  if [[ -n "${effect_note}" ]]; then
                                 printf '  %-28s %s %s(%s)%s\n' "${row[1]}" "${row[3]}" "${C_DIM}" \
                                     "${effect_note/, allows/, would allow}" "${C_RST}"
                             else
                                 printf '  %-28s %s\n' "${row[1]}" "${row[3]}"
                             fi ;;
                    malformed) printf '  %-28s %sMALFORMED%s %s(%s has %s)%s\n' "${row[1]}" "${C_YEL}" "${C_RST}" \
                                   "${C_DIM}" "${operator_conf}" "${row[7]}" "${C_RST}"
                               say "      every launch refuses until it is fixed" ;;
                    *)       printf '  %-28s %s? (could not be read)%s\n' "${row[1]}" "${C_DIM}" "${C_RST}" ;;
                esac ;;
            verdict)  # <ok|permissive|boolean|unknown> <required: yes|no>
                [[ "${row[1]}" == ok ]] && continue
                if [[ "${row[2]}" == yes ]]; then
                    say "  AI_TOOLS_REQUIRE_SELINUX is set, so every launch refuses while this stands"
                    section_status=1
                else
                    say "  ${C_DIM}AI_TOOLS_REQUIRE_SELINUX is no, so launches are not refused for this${C_RST}"
                fi ;;
        esac
    done < <(ai_tools_confinement__list_attestation_report "${operator_conf}")
    return "${section_status}"
}

# status_entrypoint_stale <agent>  -- report, and return 0, when the last reconciliation REFUSED to re-record this
# agent's pin: the entrypoint changed in a way no update explains, so the pin was deliberately left standing
# and the next launch refuses. Returns non-zero when there is no such mark, which is the ordinary state.
#
# This is the one line in the section that reports a REFUSAL: the pin a refusal leaves behind is a valid record
# of a verification that once succeeded, so without the mark each status report renders it green while every status
# reports render it green while every launch of that agent is already refused. The remedy is the reconcile command,
# which re-reads the installed binary -- and the commands that replace it are the relabel helper's to print, since only
# root can name the package directory. status_entrypoint_pins checks the stale-path reader before the first agent.
status_entrypoint_stale() {
    local agent="$1" record state version reason detected age
    record="$(ai_tools_entrypoint_verify__get_stale_path "${agent}" 2>/dev/null || true)"
    [[ -n "${record}" && -r "${record}" ]] || return 1
    state="$(ai_tools_services__read_stamp_field "${record}" STATE)"
    [[ "${state}" == stale ]] || return 1
    version="$(ai_tools_services__read_stamp_field "${record}" VERSION)"
    reason="$(ai_tools_services__read_stamp_field "${record}" REASON)"
    detected="$(ai_tools_services__read_stamp_age "${record}" DETECTED)"
    age="$(status_fmt_age "${detected}")"
    printf '  %-28s %sPIN STALE%s %s(%s%s)%s\n' "${agent}" "${C_RED}" "${C_RST}" \
        "${C_DIM}" "${reason:-refused}" "${age:+, ${age}}" "${C_RST}"
    say "      the pinned binary${version:+ (${version})} is not the one installed, so this agent's sessions refuse to start"
    say "      ${C_BOLD}atr admin system entrypoints relabel${C_RST} ${C_DIM}(re-reads the entrypoint and prints how to replace it)${C_RST}"
    return 0
}

# status_entrypoint_label <agent>  -- report what the last reconciliation could do about that agent's SELinux labels,
# under its verification line. The two halves of one reconciliation are reported together because they are asked
# from the same vantage and fail independently: on a host whose relabel could not register its file-context rules,
# the pin line alone reads as a fresh green all-clear for the half that did work.
#
# The label itself stays unreadable from here -- the entrypoint lives in a 0750 toolchain this account cannot traverse,
# and matchpathcon computes only what a label SHOULD be -- so this reports the root-written record instead,
# through the same stamp accessors as the pin. It reports an EVENT: what the last run could do, and when -- not
# the label the entrypoint carries now, which the record does not hold: a refused rule ends the run before its verify
# pass, and the launch reads the live type rather than this record. So the failure line names `atr admin status`,
# which reads the labels as root, and the service start that re-runs the work and clears the unit's own recorded failure
# with it.
#
# Returns 1 for a recorded failure, which is the one state that stops a launch, and STATUS_UNREADABLE when
# entrypoint-verify.lib.sh's record-path reader is not defined, a library base ships that did not load.
status_entrypoint_label() {
    local agent="$1" record result reason age
    if ! declare -F ai_tools_entrypoint_verify__get_label_path >/dev/null 2>&1; then
        warn MSG-K8K4 "the entrypoint library did not load its label-record reader -- the labelling of ${agent} was not read; reinstall the ai-tools package"
        return "${STATUS_UNREADABLE}"
    fi
    record="$(ai_tools_entrypoint_verify__get_label_path "${agent}" 2>/dev/null || true)"
    result="$(ai_tools_services__read_stamp_field "${record}" RESULT)"
    age="$(status_fmt_age "$(ai_tools_services__read_stamp_age "${record}" LABELLED)")"
    reason="$(ai_tools_services__read_stamp_field "${record}" REASON)"
    case "${result}" in
        ok)      printf '  %-28s %slabelled%s %s(%s)%s\n' "" "${C_DIM}" "${C_RST}" \
                     "${C_DIM}" "${age:-at an unknown time}" "${C_RST}" ;;
        # One reason is read rather than only printed: `incomplete-package` (written by ai-tools-relabel-agent) says
        # the executable the manifest declares is not installed, which no relabel can supply -- so naming the relabel
        # as the retry would send the operator around the loop this report exists to end. The package is what has to be
        # reinstalled, and the provisioning command is what does it. Every other reason, one an earlier release wrote
        # among them, takes the relabel retry.
        failed)  printf '  %-28s %sNOT LABELLED%s %s(%s%s)%s\n' "" "${C_RED}" "${C_RST}" \
                     "${C_DIM}" "${age:-at an unknown time}" "${reason:+, ${reason}}" "${C_RST}"
                 if [[ "${reason}" == incomplete-package ]]; then
                     say "      this agent's package does not hold the executable its manifest declares, so there is"
                     say "      nothing to label and no session of it starts; reinstall the package:"
                     say "      ${C_BOLD}atr admin system bootstrap${C_RST} ${C_DIM}(its output names an install that did not complete)${C_RST}"
                 else
                     say "      the last reconciliation could not apply this agent's labels; the label its"
                     say "      entrypoint carries now is read by: ${C_BOLD}atr admin status${C_RST}"
                     say "      retry: ${C_BOLD}sudo systemctl start ai-tools-relabel.service${C_RST} ${C_DIM}(then: journalctl -t ai-tools-relabel-agent)${C_RST}"
                 fi
                 return 1 ;;
        # Nothing to label: a DAC-only host, or an agent the toolchain has not provisioned yet. Neither is a fault,
        # so neither is coloured or counted.
        skipped) printf '  %-28s %snot labelled (%s)%s\n' "" "${C_DIM}" \
                     "${reason:-nothing to label}" "${C_RST}" ;;
        # No record at all: this host has not run a reconciliation since the record was introduced, or the state
        # directory is unreadable from this account. It says only that, and never counts against the exit status --
        # the same rule the unqueryable units follow.
        *)       printf '  %-28s %s? (no labelling recorded -- run: atr admin system entrypoints relabel)%s\n' \
                     "" "${C_DIM}" "${C_RST}" ;;
    esac
    return 0
}

# status_provisioning -- the Provisioning section: one line per enabled agent, provisioned or not, keyed on the same
# launcher symlink the bootstrap gate reads (bootstrap's last artifact per agent), so the gate's refusal and this report
# cannot disagree about which agent lacks its link. An unprovisioned agent and an empty enabled set are reported and not
# counted: an unfinished install is what this section exists to say, not a fault in a finished one. Returns non-zero
# when the enabled agents cannot be read at all, which is a broken install, like a missing service registry,
# and when an installed agent that is not enabled still has its link (status_residue), the state every launch is refused
# in. A reader status_managed_files or status_residue reports missing makes it return STATUS_UNREADABLE.
status_provisioning() {
    local rec agent_name launcher reason faults=0 unreadable managed_status managed_unreadable=0
    section "Provisioning"
    # The resolver not loading is a broken install, a reading this section could not make: STATUS_UNREADABLE,
    # so the report exits 5 rather than counting it among the faults it did read.
    if ! resolve_enabled_agents; then
        say "  ${C_YEL}cannot read the enabled agents${C_RST} -- ${ENABLED_AGENTS_ERROR}"
        return "${STATUS_UNREADABLE}"
    fi
    status_operator_conf || faults=$(( faults + 1 ))
    if (( ${#ENABLED_AGENTS[@]} == 0 )); then
        IFS=$'\t' read -r _ reason <<<"$(ai_tools_providers__evaluate_empty_agents)"
        say "  ${C_YEL}no agent enabled${C_RST} -- ${reason}"
    fi
    for rec in "${ENABLED_AGENTS[@]+"${ENABLED_AGENTS[@]}"}"; do
        IFS=$'\t' read -r agent_name _ launcher <<<"${rec}"
        if agent_provisioned "${launcher}"; then
            ok "${agent_name} provisioned (${launcher})"
        else
            say "  ${C_YEL}${agent_name} not provisioned${C_RST} -- run: ${C_BOLD}atr admin system bootstrap${C_RST}"
        fi
        managed_status=0
        status_managed_files "${agent_name}" || managed_status=$?
        if (( managed_status == STATUS_UNREADABLE )); then managed_unreadable=1
        elif (( managed_status != 0 )); then faults=$(( faults + 1 )); fi
    done
    status_residue || {
        unreadable=$?
        if (( unreadable == STATUS_UNREADABLE )); then return "${STATUS_UNREADABLE}"; fi
        faults=$(( faults + 1 ))
    }
    (( managed_unreadable == 0 )) || return "${STATUS_UNREADABLE}"
    (( faults == 0 ))
}

# status_operator_conf -- the operator.conf line, printed where this account cannot read a present, trusted file
# (ai_tools_providers__read_gate's `unreadable`): the enabled agents and integrations then fall back to the default-
# enabled ones and the set the file declares is unknown. Returns 1 for that line, so it counts toward the exit status;
# does not print a line otherwise. The account read is the operator's own: whether the sandbox account reads the file is
# a reading root makes, in atr admin status.
status_operator_conf() {
    [[ "$(ai_tools_providers__read_gate AI_TOOLS_AGENTS)" == unreadable ]] || return 0
    say "  ${C_RED}cannot read ${AI_TOOLS_OPERATOR_CONF}${C_RST} -- only the default-enabled providers apply: $(ai_tools_conf__read_unreadable_reason "${AI_TOOLS_OPERATOR_CONF}")"
    return 1
}

# status_registry -- the invoker's project registry and the state every `projects` verb reads it in, read as the launch
# gate reads it (ai_tools_conf__check_registry, the read registry_gate makes), so the report and the gate cannot
# disagree about a file. A file no reader accepts, one that cannot be read, and an allowed-projects an earlier release
# wrote each return 1 with what clears it, since the projects verbs refuse over each (a refused file stops every launch
# too); no registry is reported and not counted, since enrolment seeds one.
status_registry() {
    local rc=0
    section "Project registry"
    if [[ "${REGISTRY_STATE}" == none ]]; then
        say "  ${C_DIM}no registry at ${ALLOWLIST} -- atr admin operators add seeds one for an operator${C_RST}"
        return 0
    fi
    ai_tools_conf__check_registry "${ALLOWLIST}" 2>/dev/null || rc=$?
    if (( rc != 0 )); then
        if (( rc == 1 )); then
            say "  ${C_RED}unreadable${C_RST} ${ALLOWLIST} -- the file cannot be read, so no session starts under it:"
        else
            say "  ${C_RED}refused${C_RST} ${ALLOWLIST} -- it holds a line no reader accepts, so no session starts under it:"
        fi
        { ai_tools_conf__check_registry "${ALLOWLIST}" 2>&1 || true; } | sed 's/^/      /'
        return 1
    fi
    if [[ "${REGISTRY_STATE}" == legacy ]]; then
        say "  ${C_YEL}not migrated${C_RST} ${ALLOWLIST} -- the allowed-projects an earlier release wrote; sessions start as before, and the projects verbs refuse until it is migrated:"
        say "      ${C_BOLD}atr admin system post-upgrade${C_RST}"
        return 1
    fi
    ok "${ALLOWLIST} (format $(ai_tools_conf__read_registry_format "${ALLOWLIST}"))"
}

# status_residue -- one line per agent this host installed, did not enable, and still holds the stable launcher link
# of: the operator-side read of a package left in the sandbox toolchain (ai_tools_toolchain__find_agent_residue_links,
# toolchain.lib.sh), which the launch wrapper refuses every launch on from the same link and the shim from the tree.
# Counted, since the host is in the state where no session starts, and the line names the run that clears it. Returns 1
# for a residue line, and STATUS_UNREADABLE for a library that will not load: the report then has no reading of whether
# a launch is refused, which is a broken install like a missing registry.
status_residue() {
    local agent launcher rc=0
    if ! toolchain_lib_loaded; then
        say "  ${C_YEL}cannot check the toolchain for a disabled agent's package${C_RST} -- cannot load ${TOOLCHAIN_LIB}; reinstall the ai-tools package"
        return "${STATUS_UNREADABLE}"
    fi
    while IFS=$'\t' read -r agent launcher; do
        [[ -n "${agent}" ]] || continue
        say "  ${C_RED}${agent} is installed but not enabled, and its package is still in the toolchain${C_RST} (${LAUNCHER_DIR}/${launcher})"
        say "      every launch is refused until it is removed -- run: ${C_BOLD}atr admin system bootstrap${C_RST}"
        rc=1
    done < <(ai_tools_toolchain__find_agent_residue_links "${LAUNCHER_DIR}" 2>/dev/null)
    return "${rc}"
}

# toolchain_lib_loaded -- source toolchain.lib.sh and succeed once its readers are defined. The library is
# include-guarded, so each section that reads it calls this and the second call is a no-op; it requires providers.lib.sh
# and returns non-zero WITHOUT defining a reader when that is missing, so the probe is on the readers rather than
# on the source's own status.
toolchain_lib_loaded() {
    # shellcheck source=SCRIPTDIR/../lib/ai-tools/toolchain.lib.sh
    source "${TOOLCHAIN_LIB}" 2>/dev/null || true
    declare -F ai_tools_toolchain__find_agent_residue_links >/dev/null 2>&1 \
        && declare -F ai_tools_toolchain__list_agent_link_node_versions >/dev/null 2>&1 \
        && declare -F ai_tools_toolchain__evaluate_node_versions >/dev/null 2>&1
}

# status_node_version -- the Version section's Node line. The active version is read from the enabled agents' stable
# launcher links (ai_tools_toolchain__list_agent_link_node_versions): every path that changes Node repoints them,
# so the line is right after a bootstrap as much as after an update, and the read is unprivileged -- one readlink hop,
# the read the launch wrapper makes. The updater's stamp records the version its last run left active and is shown only
# where it differs, the one fact the link cannot carry: the toolchain changed after the updater last ran. Which case
# applies is ai_tools_toolchain__evaluate_node_versions's, so this and ai-tools-admin cannot disagree; a host
# with neither a link nor a stamp gets no Node line, and Provisioning says why. Never counted: no case here is a fault.
status_node_version() {
    local rec stamp_node="" verdict kind version stamp_seen
    if declare -F ai_tools_services__read_stamp_field >/dev/null 2>&1; then
        while IFS= read -r rec; do
            stamp_node="$(ai_tools_services__read_stamp_field "$(ai_tools_services__get_field "${rec}" 7)" NODE)"
            [[ -n "${stamp_node}" && "${stamp_node}" != unknown ]] && break
            stamp_node=""
        done < <(ai_tools_services__list_records)
    fi
    if toolchain_lib_loaded; then
        verdict="$(ai_tools_toolchain__list_agent_link_node_versions "${LAUNCHER_DIR}" 2>/dev/null \
                       | ai_tools_toolchain__evaluate_node_versions "${stamp_node}")"
    elif [[ -n "${stamp_node}" ]]; then
        verdict=$'stamp\t'"${stamp_node}"     # no link reader: the stamp is the only reading left
    else
        verdict=none
    fi
    IFS=$'\t' read -r kind version stamp_seen <<<"${verdict}"
    case "${kind}" in
        active) if [[ -n "${stamp_seen}" ]]; then
                    say "  node ${version} ${C_DIM}(active; the last update run saw ${stamp_seen})${C_RST}"
                else
                    say "  node ${version}"
                fi ;;
        split)  say "  node ${C_YEL}${version}${C_RST} ${C_DIM}(the enabled agents' launchers name different Node versions -- an update may be in progress)${C_RST}" ;;
        stamp)  say "  node ${version} ${C_DIM}(as of the last toolchain update -- no launcher link names one)${C_RST}" ;;
    esac
    return 0
}

# status_managed_files <agent> -- one line per managed file the agent's manifest names (managed_files,
# ai-tools-providers(5)) whose live copy is not the shipped one. The package never overwrites such a file, so the report
# is where an operator learns the live file differs: the agent reads it alone, a key this release adds is not in it,
# and what it declares is the host's rather than the package's. A file matching the shipped copy is not reported,
# and an edited one is not counted -- it is a supported state -- while a missing one is, since the package is then
# broken and a reinstall is the remedy. Returns 1 for a missing file, and STATUS_UNREADABLE under MSG-K9T3
# where providers.lib.sh did not define the managed-files reader or the comparison it runs per file, a library base
# ships.
status_managed_files() {
    local agent="$1" live reference state rc=0
    if ! declare -F ai_tools_providers__list_agent_managed_files >/dev/null 2>&1 \
            || ! declare -F ai_tools_providers__evaluate_managed_file >/dev/null 2>&1; then
        warn MSG-K9T3 "the provider library did not load its managed-files reader -- the managed files of ${agent} were not compared; reinstall the ai-tools package"
        return "${STATUS_UNREADABLE}"
    fi
    while IFS=$'\t' read -r live reference; do
        [[ -n "${live}" ]] || continue
        state="$(ai_tools_providers__evaluate_managed_file "${live}" "${reference}")"
        case "${state}" in
            shipped) ;;
            edited)  say "  ${C_YEL}${agent}: ${live} differs from the shipped copy${C_RST}"
                     say "      ${agent} reads the live file alone: a key this release adds is not in it, and what it declares is the host's"
                     say "      shipped copy: ${reference}" ;;
            missing) say "  ${C_RED}${agent}: ${live} is missing${C_RST} -- reinstall the ${agent} package"
                     rc=1 ;;
            *)       say "  ${C_DIM}${agent}: ${live} -- cannot compare with the shipped copy ${reference}${C_RST}" ;;
        esac
    done < <(ai_tools_providers__list_agent_managed_files "${agent}")
    return "${rc}"
}

# status_caches -- the Caches section: per cache group, what its last cleanup did and when, from the state file its
# writers rewrite. Each verdict and each remedy is cache-cleanup.lib.sh's, so this report and atr admin status cannot
# disagree. A failed group, and one holding links its producer does not write, is counted; an overdue one is shown
# with its line and not counted; an empty file reads `?`, as does a file this account cannot read (a non-operator),
# and neither is counted. Returns 1 for a counted line, STATUS_UNREADABLE for a library that did not
# load.
status_caches() {
    local state="${AI_TOOLS_CACHE_CLEANUP_STATE:-/var/opt/ai-tools/state/cache-cleanup.status}" rc=0
    local name relative verdict result bytes links reason checked_age cleaned_age token colour detail last_cleaned
    section "Caches"
    # shellcheck source=SCRIPTDIR/../lib/ai-tools/cache-cleanup.lib.sh
    source "${CACHE_CLEANUP_LIB}" 2>/dev/null || true
    if ! declare -F ai_tools_cache_cleanup__list_group_statuses >/dev/null 2>&1; then
        say "  ${C_YEL}cannot report the cache cleanup${C_RST} -- cannot load ${CACHE_CLEANUP_LIB}; reinstall the ai-tools package"
        return "${STATUS_UNREADABLE}"
    fi
    if ai_tools_services__is_stamp_unwritten "${state}"; then
        say "  ${C_DIM}? (no cleanup recorded yet -- the updater's first run and the first session launch write it)${C_RST}"
        return 0
    fi
    if [[ -L "${state}" || ! -r "${state}" ]]; then
        say "  ${C_DIM}? (${state} cannot be read from this account)${C_RST}"
        return 0
    fi
    while IFS=$'\t' read -r name relative verdict result bytes links reason checked_age cleaned_age; do
        [[ -n "${name}" ]] || continue
        [[ "${checked_age}" == - ]] && checked_age=""
        [[ "${cleaned_age}" == - ]] && cleaned_age=""
        detail="$(ai_tools_cache_cleanup__format_result "${result}" "${bytes}" "${reason}")"
        [[ -n "${checked_age}" ]] && detail+=", $(status_fmt_age "${checked_age}")"
        last_cleaned=""
        if [[ "${result}" != removed && "${result}" != absent && -n "${cleaned_age}" ]]; then
            last_cleaned=" -- last cleaned $(status_fmt_age "${cleaned_age}")"
        fi
        case "${verdict}" in
            unknown) printf '  %-28s %s? (no cleanup recorded yet)%s\n' "${relative}" "${C_DIM}" "${C_RST}"; continue ;;
            failed)  token=FAILED;  colour="${C_RED}" ;;
            links)   token=LINKS;   colour="${C_RED}"; detail="${links} symbolic link(s) its producer does not write, ${detail}" ;;
            overdue) token=OVERDUE; colour="${C_YEL}" ;;
            *)       case "${result}" in
                         removed|absent) token=OK; colour="${C_GRN}" ;;
                         # The token is the result here, so the detail keeps its reason alone.
                         *)              token="${result^^}"; colour="${C_DIM}"; detail="${detail#"${result} "}" ;;
                     esac ;;
        esac
        printf '  %-28s %s%s%s %s%s%s%s\n' "${relative}" "${colour}" "${token}" "${C_RST}" "${C_DIM}" "${detail}" \
            "${last_cleaned}" "${C_RST}"
        case "${verdict}" in
            failed|links) say "      $(ai_tools_cache_cleanup__get_status_remedy "${verdict}" "${reason}")"; rc=1 ;;
            overdue)      say "      ${C_DIM}$(ai_tools_cache_cleanup__get_status_remedy "${verdict}" "${reason}")${C_RST}" ;;
        esac
    done < <(ai_tools_cache_cleanup__list_group_statuses "${state}")
    return "${rc}"
}

# A section reports a reading it could not make by returning this, apart from 1, which is a fault it read. cmd_status
# folds the first as `unreadable` and the second as `attention`, so the sections stay free of the records library
# and drivable on their own.
readonly STATUS_UNREADABLE=2

# status_fold <section status> -- fold one section's return into the report state: STATUS_UNREADABLE as `unreadable`,
# any other non-zero as `attention`. Called as `section || status_fold $?`, where `$?` is the section's status.
status_fold() {
    if (( $1 == STATUS_UNREADABLE )); then
        ai_tools_records_base__accumulate_severity unreadable
    else
        ai_tools_records_base__accumulate_severity attention
    fi
}

cmd_status() {
    # A report does not take any argument; one given is refused with the usage status rather than ignored, so a caller
    # reading the exit code from cron learns the command line is wrong instead of reading a report it did not ask for.
    [[ "$#" -eq 0 ]] || die_usage MSG-X9Z9 "status takes no arguments"
    # shellcheck source=SCRIPTDIR/../lib/ai-tools/records-base.lib.sh
    if ! source "${RECORDS_BASE_LIB}" 2>/dev/null \
            || ! declare -F ai_tools_records_base__get_exit_status >/dev/null 2>&1; then
        die MSG-B9A2 "cannot load ${RECORDS_BASE_LIB}, which states this report's exit codes -- reinstall the ai-tools package"
    fi
    ai_tools_records_base__begin_report

    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: its version is in the launcher link's target (status_node_version).
    status_node_version
    # One pointer per enabled agent whose wrapper this host installs (an agent without one is the PATH ordering
    # section's to report). Through ai_tools_msg__format_command, so the command printed here is the one that reaches
    # the sandbox: it renders the bare name only while this shell resolves it to the wrapper, and the absolute path
    # otherwise. On a shadowed account the bare name resolves to the binary the PATH ordering section reports, so this
    # line prints the wrapper's own path instead of sending them there.
    local rec agent_name launcher agents_read=1
    resolve_enabled_agents || agents_read=0
    if (( agents_read )); then
        for rec in "${ENABLED_AGENTS[@]+"${ENABLED_AGENTS[@]}"}"; do
            IFS=$'\t' read -r agent_name _ launcher <<<"${rec}"
            [[ -x "/usr/local/bin/${launcher}" ]] || continue
            say "  ${C_DIM}${agent_name} version: run '$(ai_tools_msg__format_command "/usr/local/bin/${launcher}") --version'${C_RST}"
        done
    fi

    status_provisioning || status_fold $?
    status_registry || status_fold $?

    section "Services"
    # A missing registry is a broken install, not an unknowable state: a reading this report could not make, which exits
    # 5, since a clean bill would have no reading behind it. The sections after it are still read, so the page carries
    # every reading that could be made beside the one that could not.
    if ! declare -F ai_tools_services__list_records >/dev/null 2>&1 \
            || ! declare -F ai_tools_services__read_record_state >/dev/null 2>&1 \
            || ! declare -F ai_tools_services__read_stamp_field >/dev/null 2>&1; then
        warn MSG-X5Z8 "service registry unavailable (${SERVICES_LIB}) -- cannot report service health; reinstall the ai-tools package"
        ai_tools_records_base__accumulate_severity unreadable
    else
        status_services
    fi
    status_caches || status_fold $?

    status_path_order      || status_fold $?
    status_entrypoint_pins || status_fold $?
    status_selinux_attestation "${AI_TOOLS_OPERATOR_CONF:-/etc/ai-tools/operator.conf}" || status_fold $?

    # 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 list` or
    # `--help`.
    section "More"
    say "  atr providers   installed agents/integrations and which are enabled"
    say "  atr projects    registered projects (in place and sandbox clones)"
    say "  atr --help      the full command list"

    # The exit is the report state's (ai-tools-records(5)): 4 when something is broken, 5 when a reading could not be
    # made, so `status` is usable unattended (a cron check, a monitor) without parsing this output. 'unknown' and 'n/a'
    # do not count (cli.rule.md, ref-section-r8u5).
    ai_tools_records_base__get_exit_status || return $?
    return 0
}

# status_services -- each unit the registry names, with its consequence and remedy where one needs attention. Runs
# in the report's own shell so its fold reaches the report state; requires the registry readers cmd_status checked.
status_services() {
    local rec unit scope stamp mode state age when exit_code reason remedy
    while IFS= read -r rec; do
        unit="$(ai_tools_services__get_field "${rec}" 1)"
        scope="$(ai_tools_services__get_field "${rec}" 2)"
        stamp="$(ai_tools_services__get_field "${rec}" 7)"
        mode="$(ai_tools_services__get_field "${rec}" 8)"
        state="$(ai_tools_services__read_record_state "${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 omits the relative form rather than a placeholder.
        age=""; when=""
        if [[ -n "${stamp}" ]]; then
            age="$(status_fmt_age "$(ai_tools_services__read_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 declined to act (the updater with an unreachable registry) is dim, not yellow: yellow
            # is this report's attention colour, and there is no fault to attend to -- the previous toolchain is intact
            # and the next run will try again. A persisting condition ages into STALE (updater.rule.md,
            # ref-section-r3m8).
            skipped) reason="$(ai_tools_services__read_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}" ;;
            # Two forms, because the two kinds of failed unit know different things about the run. A stamped unit
            # records when it ran; a system oneshot's result comes from systemd, which knows the exit status but is read
            # here without a time, so the line does not claim one.
            failed) if [[ -n "${stamp}" ]]; then
                        exit_code="$(ai_tools_services__read_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}"
                    else
                        exit_code="$(ai_tools_services__read_unit_property "${unit}" ExecMainStatus)"
                        printf '  %-28s %sFAILED%s %s(its last run exited %s)%s\n' "${unit}" \
                            "${C_RED}" "${C_RST}" "${C_DIM}" "${exit_code:-non-zero}" "${C_RST}"
                    fi ;;
            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}" ;;
            # The `?` readings, told apart by checks that completed, and why the oneshot line does not name a command:
            # cli.rule.md (ref-section-r8u5).
            *)      if [[ "${scope}" == sandbox-user ]]; then
                        if declare -F ai_tools_services__is_stamp_unwritten >/dev/null 2>&1 \
                                && ai_tools_services__is_stamp_unwritten "${stamp}"; then
                            printf '  %-28s %s? (no run recorded yet -- its first scheduled run has not happened; check: sudo systemctl --user -M %s@.host status %s)%s\n' \
                                "${unit}" "${C_DIM}" "${SANDBOX_USER}" "${unit}" "${C_RST}"
                        else
                            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}"
                        fi
                    elif ! command -v systemctl >/dev/null 2>&1; then
                        printf '  %-28s %s? (systemctl unavailable)%s\n' "${unit}" "${C_DIM}" "${C_RST}"
                    elif declare -F ai_tools_services__is_oneshot_unrun >/dev/null 2>&1 \
                            && ai_tools_services__is_oneshot_unrun "${unit}"; then
                        printf "  %-28s %s? (has not run since boot -- a oneshot's state is its last run's, known after its first)%s\n" \
                            "${unit}" "${C_DIM}" "${C_RST}"
                    else
                        printf '  %-28s %s? (its state could not be read -- check: systemctl status %s)%s\n' \
                            "${unit}" "${C_DIM}" "${unit}" "${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_services__is_attention_needed "${state}"; then
            ai_tools_records_base__accumulate_severity attention
            say "      $(ai_tools_services__get_field "${rec}" 5)"
            if [[ "${scope}" == sandbox-user ]]; then
                status_sandbox_unit_commands "${unit}"
            fi
            remedy="$(ai_tools_services__read_remedy "${rec}")"
            if [[ -n "${remedy}" ]]; then
                say "      ${C_BOLD}${remedy}${C_RST}"
            fi
        fi
    done < <(ai_tools_services__list_records)
    return 0
}

# cmd_project_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. A removal is offered only for an entry this run can
# attribute to the registry it reads; a reading the invoker cannot make withholds the removal and is counted or named
# instead. An absent registry is the listing's body, and the safe.directory reconciliation still runs, over an empty
# entry set: read_path_class reads an absent file as unregistered, so a registry removed by hand leaves its
# safe.directory entries reported.
cmd_project_list() {
    [[ "$#" -eq 0 ]] || die_usage MSG-E2A5 "projects list takes no arguments"
    load_target_registry_snapshot
    local registry_source="${ALLOWLIST}"
    [[ -e "${ALLOWLIST}" || -L "${ALLOWLIST}" ]] || registry_source=/dev/null
    # Name the operator on a --for run: the listed entries are that account's launch gate, not the invoker's,
    # and an unlabelled listing of someone else's projects reads as your own.
    if [[ -n "${FOR_OPERATOR}" ]]; then
        section "Registered projects for ${FOR_OPERATOR}"
    else
        section "Registered projects"
    fi
    # Root reads ROOT's allowlist, which no bootstrap creates -- so the report is empty, and correct, and reads
    # as a fault. An allowlist is per-operator by design (it is that operator's own launch gate), so say whose registry
    # this is and name the ones that hold projects. Root cannot follow this with --for: that flag needs an enrolled
    # invoker, and root is not one.
    if [[ "${INVOKING_USER}" == "root" ]]; then
        local -a enrolled=()
        ai_tools_conf__read_list enrolled "${AI_TOOLS_OPERATOR_CONF:-/etc/ai-tools/operator.conf}" \
            OPERATORS 2>/dev/null || enrolled=()
        say "  ${C_DIM}root's own registry -- projects are registered per operator${C_RST}"
        (( ${#enrolled[@]} )) && say \
            "  ${C_DIM}read one as that operator ($(join_words "${enrolled[@]}")): su - <operator> -c 'atr projects'${C_RST}"
    fi
    # A registry not yet migrated is listed under the grammar it was written in, where every '!' line is an exclusion;
    # a project disabled with one before the upgrade is written '%' once the file is migrated.
    if [[ "${REGISTRY_STATE}" == legacy ]]; then
        say "  ${C_YEL}${REGISTRY_DISPLAY_PATH} is the allowed-projects an earlier release wrote, not yet migrated;${C_RST}"
        say "  ${C_YEL}the projects verbs other than this list refuse until it is. Migrate it with:${C_RST}"
        say "      ${C_BOLD}atr admin system post-upgrade${C_RST}"
    fi
    local raw entry excl kind safe sd project_root_mode 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 (* ? [). A glob is a `!` line alone, matched
    # as a pattern and never resolved to one path; the grammar refuses one on an `@` or `%` line.
    _has_glob() { [[ "$1" == *[*?[]* ]]; }
    # The safe.directory list is read once, for every row's column and the reverse reconciliation. A list git could not
    # read is named under Suggested cleanup and reads as no entry on any row.
    local safedir_entries safedir_readable=true
    safedir_entries="$(list_safedir_entries)" || safedir_readable=false
    # _read_safedir_column <entry>  -- set `safe` to the safe.dir column for <entry> and `sd` to whether git
    # safe.directory names it, the reading every registered row prints. Where the list was not read the column is `?`
    # and `sd` is true, so a stale or protected row still offers the helper's removal, which removes a value only
    # where the list holds it.
    _read_safedir_column() {
        if ! ${safedir_readable}; then
            safe="safe.dir:${C_YEL}?${C_RST}"; sd=true
        elif grep -qxF -- "$1" <<< "${safedir_entries}"; then
            safe="safe.dir:yes"; sd=true
        else
            safe="safe.dir:${C_YEL}NO${C_RST}"; sd=false
        fi
    }
    # _record_unreadable_entry <label> <entry>  -- when an ancestor of <entry> is a directory the invoker cannot enter,
    # append a block naming it and return 0; return 1 otherwise. realpath fails alike for such a path and for one that
    # is gone, so this is read first: an entry the invoker cannot read is not reported stale, and is not given
    # the removal a stale one gets.
    _record_unreadable_entry() {
        local unreadable_blocker
        find_invoker_traversal_blocker "$2/" unreadable_blocker || return 1
        cleanup+=( "  $1" \
            "      ${C_YEL}cannot be read as ${INVOKING_USER}${C_RST}: ${INVOKING_USER} cannot enter ${unreadable_blocker}" )
        [[ -z "${FOR_OPERATOR}" ]] || cleanup+=( "      ${FOR_OPERATOR}'s own atr projects reads it" )
        return 0
    }
    # _read_owner_only_mode <dir>  -- print the mode of the directory <dir> names and return 0 when it has no group
    # or other bit, as the invoker reads it; return 1 for any other mode and for a mode the invoker cannot read.
    _read_owner_only_mode() {
        local project_root_mode
        project_root_mode="$(stat -L -c '%a' -- "$1" 2>/dev/null)" || return 1
        [[ "${project_root_mode}" =~ ^[0-7]{1,4}$ ]] && (( ( 8#${project_root_mode} & 077 ) == 0 )) || return 1
        printf '%s\n' "${project_root_mode}"
    }
    # _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" e_real
        _record_unreadable_entry "${e}" "${e}" && return 0
        if ! e_real="$(realpath -e -- "${e}" 2>/dev/null)"; then
            cleanup+=( "  ${e}" \
                "      ${C_YEL}no longer exists${C_RST} (stale entry); remove it:" \
                "          $(format_registry_line_removal "${raw}" project "${e}")" )
            ${sdy} && cleanup+=( "          sudo ${SAFEDIR_BIN} --remove $(_quote_path "${e}")" )
            return 0                                    # ${sdy}=false returns 1; don't kill cmd_project_list's set -e loop
        fi
        # The real path, as the helpers' backstop reads it, so a listed link to a protected directory is reported here
        # and not as a partial claim.
        if ai_tools_safe_paths__match_protected_path "${e_real}" >/dev/null 2>&1; then
            cleanup+=( "  ${e}" \
                "      ${C_YEL}protected system path${C_RST} -- the tools refuse to operate on it; remove it:" \
                "          $(format_registry_line_removal "${raw}" project "${e}")" )
            ${sdy} && cleanup+=( "          sudo ${SAFEDIR_BIN} --remove $(_quote_path "${e}")" )
            _is_labelled "${e}" && cleanup+=( "          sudo ${RELABEL_BIN} --remove $(_quote_path "${e}")" )
            return 0                                    # trailing conditionals above return 1; don't kill the loop
        fi
        [[ "${k}" == project ]] || return 0            # a sandbox clone takes the clone kind of `projects remove`
        # Not fully claimed: agent has no group access, the ACL is missing, or (SELinux active) the tree is unlabelled.
        # Read from the same read_project_state tokens the claim flow uses.
        # An owner-only root is annotated on its row: the claim leaves that mode as it is, so a claim command is not
        # a remedy for it.
        _read_owner_only_mode "${e}" >/dev/null && return 0
        local listed safedir filemode owngap acl labelled git
        IFS=' ' read -r listed safedir filemode owngap acl labelled git < <(read_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:" \
                "          $(format_projects_command 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__parse_path_entry "${raw}" || continue
        entry="${ai_tools_conf__value}"
        shown=1
        if [[ "${ai_tools_conf__kind}" == excluded ]]; then
            # A '!' line excludes its path wherever it sits: no session starts in the subtree, and the hand-back and
            # the claim's permission walks skip it. It works as written, so it has no remedy to offer.
            excl="${entry}"
            printf '  %-8s %s\n' "exclude" "${excl}"
            # A stale exclusion does not match a path on disk. 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}" && ! _record_unreadable_entry "! ${entry}" "${excl}" \
                    && ! realpath -e "${excl}" >/dev/null 2>&1; then
                cleanup+=( "  ! ${entry}" \
                    "      ${C_YEL}no longer exists${C_RST} (stale excluded path); remove it:" \
                    "          $(format_registry_line_removal "${raw}" excluded "${excl}")" )
            fi
            continue
        fi
        if [[ "${ai_tools_conf__kind}" == disabled ]]; then
            # A disabled project: registered, with no session starting there until it is re-enabled in place.
            excl="${entry}"
            _read_safedir_column "${excl}"
            printf '  %-8s %-50s %s  %s\n' "disabled" "${excl}" "${safe}" "${C_DIM}no session may start here${C_RST}"
            say "           ${C_DIM}re-enable: $(format_projects_command enable "${excl}")${C_RST}"
            if ! _record_unreadable_entry "% ${entry}" "${excl}" && ! realpath -e "${excl}" >/dev/null 2>&1; then
                cleanup+=( "  % ${entry}" \
                    "      ${C_YEL}no longer exists${C_RST} (stale disabled project); remove it:" \
                    "          $(format_registry_line_removal "${raw}" disabled "${excl}")" )
                ${sd} && cleanup+=( "          sudo ${SAFEDIR_BIN} --remove $(_quote_path "${excl}")" )
            fi
            continue
        fi
        case "${entry}/" in
            "${SANDBOX_ROOT}"/*) kind="sandbox" ;;
            *)                   kind="project" ;;
        esac
        _read_safedir_column "${entry}"
        read_path_class "$(resolve_entry_path "${entry}")"
        if project_root_mode="$(_read_owner_only_mode "${entry}")"; then
            printf '  %-8s %-50s %s  %s\n' "${kind}" "${entry}" "${safe}" \
                "${C_YEL}mode ${project_root_mode} (owner-only)${C_RST} ${C_DIM}-- no session can start here${C_RST}"
        else
            printf '  %-8s %-50s %s\n' "${kind}" "${entry}" "${safe}"
        fi
        # A listed project under a `%`, a `!` or a glob line is still this operator's entry, and no session reaches it
        # while that line stands.
        [[ "${CLASS_STATE}" != enabled || "${CLASS_BLOCKED}" == - ]] \
            || say "           ${C_YEL}blocked${C_RST} ${C_DIM}by: ${CLASS_BLOCKED}${C_RST}"
        _reconcile "${entry}" "${kind}" "${sd}" "${raw}"
    done < "${registry_source}"
    if [[ "${registry_source}" == /dev/null ]]; then
        say "  no registry at ${REGISTRY_DISPLAY_PATH}"
    elif (( ! shown )); then
        say "  (none)"
    fi

    # Reverse reconciliation: a git safe.directory entry no registry entry names is an ORPHAN -- git still trusts
    # the tree though no line names it (the line was hand-deleted, or an unclaim was interrupted before the safedir
    # drop). An entry is an `@` or a `%` line, read through the classifier: a disabled project is still registered.
    # A git entry is classified by its real path, as the registry section reads its own lines, and the cleanup names it
    # as git stores it. Control-plane entries (/opt/ai-tools) are registered deliberately and are protected paths,
    # so they are skipped.
    #
    # safe.directory is one host-wide list every operator's claims write into, while this run reads one registry, so
    # an uncovered entry is attributed before a remedy is offered. A value ai-tools-safedir does not write -- git's `*`
    # and `<dir>/*` trust rules, a path git expands from a leading `~` or `%(prefix)/`, any other value that is not
    # absolute -- is named by its form with the command that lists the file, does not take a removal, and is not
    # counted: git does not read it as one directory this listing can attribute, and the exact-value removal would
    # delete a live rule. Of the absolute paths left, one whose root OWNER_USER holds is this registry's orphan; one
    # the sandbox account holds is named with its removal under the condition that the tree is the reader's, since
    # require_claimable_owner accepts that owner for any operator and no registry attributes it; a path that no
    # longer exists is stale; a path under a directory the invoker cannot enter, one another account
    # holds, and one whose owner cannot be read are counted and not listed. The claim's owner rule
    # (require_claimable_owner) is what makes the owner the attribution key. A reading the invoker cannot make
    # withholds the removal, since `ai-tools-safedir --remove` does not consult any registry, and the count says
    # the entries exist. Each printed path is one bash word (_quote_path), and `ai-tools-safedir --remove` removes
    # a value as git holds it, so a stored spelling is named as stored.
    # shellcheck disable=SC2034  # safedir_entry_blocker is filled through its name by find_invoker_traversal_blocker
    local safedir_entry safedir_entry_blocker safedir_entry_real safedir_entry_owner safedir_entry_form
    local safedir_entry_trusted unattributed=0
    while IFS= read -r safedir_entry; do
        [[ -n "${safedir_entry}" ]] || continue
        # The forms git reads by its own rules (git-config(1), safe.directory), matched in git's order: `*` before any
        # expansion, then a leading `~` or `%(prefix)/`, then a value ending in `/*` (git 2.46 and later).
        safedir_entry_form=""
        # shellcheck disable=SC2088  # the quoted ~ matches git's literal leading ~, which a bare one would expand
        case "${safedir_entry}" in
            '*')
                safedir_entry_form="${C_YEL}trust rule${C_RST} -- git trusts every repository on this host" ;;
            '~'|'~/'*)
                safedir_entry_form="${C_YEL}expanded path${C_RST} -- git expands the leading ~ to the home directory of the account that runs git, the sandbox account's in a session" ;;
            '~'*|'%(prefix)/'*)
                safedir_entry_form="${C_YEL}expanded path${C_RST} -- git expands the leading ${safedir_entry%%/*} when it reads the value" ;;
            '/*'|/*'/*')
                safedir_entry_trusted="${safedir_entry%/\*}"
                safedir_entry_form="${C_YEL}trust rule${C_RST} -- git trusts every repository under ${safedir_entry_trusted:-/}" ;;
            /*) ;;
            *)
                safedir_entry_form="${C_YEL}not an absolute path${C_RST}" ;;
        esac
        if [[ -n "${safedir_entry_form}" ]]; then
            cleanup+=( "  ${safedir_entry}" \
                "      ${safedir_entry_form}; ai-tools-safedir writes one absolute path per claim, and this value is a hand edit to review:" \
                "          git -C / config --file $(_quote_path "${GITCONFIG}") --get-all safe.directory" )
            continue
        fi
        ai_tools_safe_paths__match_protected_path "${safedir_entry}" >/dev/null 2>&1 && continue
        read_path_class "$(resolve_entry_path "${safedir_entry}")"
        case "${CLASS_STATE}" in enabled|disabled) continue ;; esac
        if find_invoker_traversal_blocker "${safedir_entry}/" safedir_entry_blocker; then
            unattributed=$(( unattributed + 1 )); continue
        fi
        if ! safedir_entry_real="$(realpath -e -- "${safedir_entry}" 2>/dev/null)"; then
            cleanup+=( "  ${safedir_entry}" \
                "      ${C_YEL}no longer exists${C_RST} (stale git safe.directory); remove it:" \
                "          sudo ${SAFEDIR_BIN} --remove $(_quote_path "${safedir_entry}")" )
            continue
        fi
        safedir_entry_owner="$(stat -c '%U' -- "${safedir_entry_real}" 2>/dev/null)" || safedir_entry_owner=""
        if [[ "${safedir_entry_owner}" == "${SANDBOX_USER}" ]]; then
            cleanup+=( "  ${safedir_entry}" \
                "      ${C_YEL}held by the sandbox account${C_RST}; no line of this registry names it, and a claim accepts that owner for any operator, so remove it only if the tree is yours:" \
                "          sudo ${SAFEDIR_BIN} --remove $(_quote_path "${safedir_entry}")" )
            _is_labelled "${safedir_entry}" && cleanup+=( "          sudo ${RELABEL_BIN} --remove $(_quote_path "${safedir_entry}")" )
            continue
        fi
        if [[ "${safedir_entry_owner}" != "${OWNER_USER}" ]]; then
            unattributed=$(( unattributed + 1 )); continue
        fi
        cleanup+=( "  ${safedir_entry}" \
            "      ${C_YEL}git safe.directory with no allowlist entry${C_RST} (orphaned); remove it:" \
            "          sudo ${SAFEDIR_BIN} --remove $(_quote_path "${safedir_entry}")" )
        _is_labelled "${safedir_entry}" && cleanup+=( "          sudo ${RELABEL_BIN} --remove $(_quote_path "${safedir_entry}")" )
        continue
    done <<< "${safedir_entries}"
    if ! ${safedir_readable}; then
        cleanup+=( "  ${GITCONFIG}" \
            "      ${C_YEL}could not be read${C_RST}: each row reads safe.dir:?, and no orphaned or stale safe.directory entry is listed; list it with:" \
            "          git -C / config --file $(_quote_path "${GITCONFIG}") --get-all safe.directory" )
    fi

    if (( ${#cleanup[@]} )); then
        section "Suggested cleanup"
        printf '%s\n' "${cleanup[@]}"
        if [[ -n "${FOR_OPERATOR}" ]]; then
            say "  ${C_DIM}review each path before running the command; the registry is ${FOR_OPERATOR}'s to edit${C_RST}"
        else
            say "  ${C_DIM}review each path before running the command; the registry is yours to edit${C_RST}"
        fi
    fi
    if (( unattributed )); then
        local entries_word="entries are" reader_word="${INVOKING_USER} "
        (( unattributed == 1 )) && entries_word="entry is"
        [[ "${INVOKING_USER}" != "${OWNER_USER}" ]] || reader_word=""
        say "  ${C_DIM}${unattributed} git safe.directory ${entries_word} in trees ${OWNER_USER} does not hold or ${reader_word}cannot read, and not listed; each operator's own atr projects reports theirs. The whole list: git -C / config --file $(_quote_path "${GITCONFIG}") --get-all safe.directory${C_RST}"
    fi
    list_maintenance_note
}

# ── The enabled agents: what the provisioning gate, `status` and the clone hint resolve from ──────────────────────
# resolve_enabled_agents -- fill ENABLED_AGENTS with one "name<TAB>npm_package<TAB>launcher" line per enabled installed
# agent, from providers.lib.sh: the resolver ai-tools-run and the toolchain layer provision from, so this CLI does not
# name an agent of its own and a host that enables one agent, or several, is read the same way. Cached, since the gate,
# `status` and the clone hint each read it once per run. Returns non-zero when the library will not load,
# with ENABLED_AGENTS_ERROR naming it, so the gate refuses and the diagnostic says so; neither reads an empty set as "no
# agent" on that failure.
ENABLED_AGENTS=(); ENABLED_AGENTS_ERROR=""
resolve_enabled_agents() {
    if [[ -n "${_ENABLED_AGENTS_RESOLVED:-}" ]]; then [[ -z "${ENABLED_AGENTS_ERROR}" ]]; return; fi
    _ENABLED_AGENTS_RESOLVED=1
    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_providers__list_enabled_agents      >/dev/null 2>&1 \
            || ! declare -F ai_tools_providers__evaluate_empty_agents >/dev/null 2>&1; then
        ENABLED_AGENTS_ERROR="cannot load ${providers_lib} -- reinstall the ai-tools package"
        return 1
    fi
    mapfile -t ENABLED_AGENTS < <(ai_tools_providers__list_enabled_agents)
    return 0
}

# enabled_agent_launchers -- one launcher name per enabled agent, in manifest order, on stdout.
enabled_agent_launchers() {
    local rec launcher
    for rec in "${ENABLED_AGENTS[@]+"${ENABLED_AGENTS[@]}"}"; do
        IFS=$'\t' read -r _ _ launcher <<<"${rec}"
        [[ -n "${launcher}" ]] && printf '%s\n' "${launcher}"
    done
    return 0
}

# agent_provisioned <launcher> -- succeed when that agent's stable launcher symlink exists. `-L`, not `-e`: `-e`
# dereferences into the 0750 toolchain, where the operator's stat fails with EACCES, and reports a valid link
# as missing.
agent_provisioned() { [[ -L "${LAUNCHER_DIR}/$1" ]]; }

# Refuse early on an unprovisioned install. A launcher symlink is bootstrap's last load-bearing artifact per agent --
# written after the account, Node and that agent's package all succeed -- so one existing for any enabled agent means
# provisioning finished. Gate before dispatch so a broken install stops here, not mid-operation in a root helper. Each
# way the read can fail refuses rather than passes: an unloadable resolver, an enabled set none of whose links exist,
# and an empty enabled set, whose reason the resolver's verdict names (an input it refused, or a configuration that asks
# for no agent). See cli.rule.md (Bootstrap preflight).
require_bootstrap() {
    resolve_enabled_agents || die MSG-V3N7 "the provider resolver is unavailable: ${ENABLED_AGENTS_ERROR}"
    local rec name launcher verdict reason remedy joined
    local -a unlinked=()
    for rec in "${ENABLED_AGENTS[@]+"${ENABLED_AGENTS[@]}"}"; do
        IFS=$'\t' read -r name _ launcher <<<"${rec}"
        agent_provisioned "${launcher}" && return 0
        unlinked+=("${name} (no ${LAUNCHER_DIR}/${launcher})")
    done
    if (( ${#unlinked[@]} > 0 )); then
        printf -v joined '%s, ' "${unlinked[@]}"
        die MSG-X9H7 "the sandbox is not provisioned for any enabled agent: ${joined%, } -- provision it with:" \
            "       atr admin system bootstrap"
    fi
    IFS=$'\t' read -r verdict reason <<<"$(ai_tools_providers__evaluate_empty_agents)"
    case "${verdict}" in
        none) remedy="       enable one in /etc/ai-tools/operator.conf (AI_TOOLS_AGENTS), or install an ai-tools-agents package" ;;
        *)    remedy="       repair the input named there, then rerun" ;;
    esac
    die MSG-K7A6 "no agent is enabled or resolved, so there is no agent to provision a project for -- ${reason}" \
        "${remedy}"
}

# die_unknown_command <code> <message> -- the refusal for a command path no arm accepts, exit 2: the code on its own
# line and the one message line, printed plain rather than through die_usage, since the message carries the help command
# and the alert frame would wrap it between its words (messaging.rule.md). Logged as die_usage logs.
die_unknown_command() {
    ai_tools_log__coded error "$1" "$2"
    printf '%s\nai-tools: %s\n' "$1" "$2" >&2
    exit 2
}
# unknown_command <path> -- refuse <path>, naming the help command for the deepest part of it the help holds.
unknown_command() {
    local path="$1" help_command
    help_command="$(ai_tools_help__format_help_command ai-tools "${path}")"
    die_unknown_command MSG-S2H5 "unknown command: ${path} -- run '${help_command}'"
}

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

# ── The help ─────────────────────────────────────────────────────────────────────
# A help request is answered here, after the principal guards and ahead of every gate from the bootstrap gate on: it
# does not read installed state, does not run the command, and does not call sudo. ai_tools_help__resolve_request states
# what a help request is; one on an unknown path is the unknown-command refusal.
#
# help.lib.sh is loaded here rather than with msg.lib.sh, safe-paths.lib.sh and conf.lib.sh, which every command
# requires: `stop` is the incident rung, so a stop without a help flag ahead of `--` runs without this library, read
# by the same scan atr makes. Every other command line refuses without it, since the CLI's unknown-command refusal reads
# it too.
readonly HELP_LIB="/usr/local/lib/ai-tools/help.lib.sh"
# shellcheck source=SCRIPTDIR/../lib/ai-tools/help.lib.sh
if source "${HELP_LIB}" 2>/dev/null && declare -F ai_tools_help__resolve_request >/dev/null 2>&1; then
    help_status=0
    help_target="$(ai_tools_help__resolve_request ai-tools -- "${COMMAND_LINE[@]+"${COMMAND_LINE[@]}"}")" || help_status=$?
    case "${help_status}" in
        0)  if [[ "${help_target}" == - ]]; then ai_tools_help__render ai-tools
            else ai_tools_help__render_hint ai-tools "${help_target}"; fi
            exit 0 ;;
        2)  unknown_command "${help_target}" ;;
    esac
    unset help_status help_target
else
    help_flag=0
    for help_argument in "${COMMAND_LINE[@]+"${COMMAND_LINE[@]}"}"; do
        [[ "${help_argument}" != -- ]] || break
        [[ "${help_argument}" != -h && "${help_argument}" != --help ]] || help_flag=1
    done
    if [[ "${COMMAND}" != stop || "${help_flag}" -ne 0 ]]; then
        command -v logger >/dev/null 2>&1 \
            && logger -t ai-tools -p user.err \
                "required library ${HELP_LIB} unavailable -- ai-tools refused (fail closed)"
        ai_tools_msg__error "ai-tools: cannot load required library ${HELP_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
    unset help_flag help_argument
fi

# The verbs meant to run WHEN things may be broken bypass the provisioning gate. Which ones, and what each of them reads
# on a host whose install never finished, is at BOOTSTRAP_EXEMPT_VERBS. Every other command stays gated.
verb_in "${COMMAND}" "${BOOTSTRAP_EXEMPT_VERBS[@]}" || 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__read_list ops "${conf}" OPERATORS 2>/dev/null; then
        for op in "${ops[@]}"; do [[ "${op}" == "${INVOKING_USER}" ]] && return 0; done
    fi
    die MSG-X6U2 "you (${INVOKING_USER}) are not a configured ai-tools operator -- add your name to OPERATORS in ${conf} with:" \
        "       atr admin operators add ${INVOKING_USER}"
}

# handover_target [args...] -- the project path to name in a handed-over command. Naming it explicitly is the point:
# the operator who runs that command is standing somewhere else, so a path-less suggestion would resolve against THEIR
# directory.
#
# It falls back to the current directory only when the caller named no path, which is what these verbs default
# to anyway. A path the caller DID name is passed through as typed even when it does not exist, because substituting
# the current directory there composes a command against a directory nobody named -- and since the suggestion is
# a claim, a plausible-looking one the operator pastes would grant the agent access to whatever they happened to be
# standing in. Mistyping a path must cost a re-typed path, so the mistyped one is what the message shows.
#
# Arguments are matched positionally: a flag is skipped, and so is the value of one that takes one, so `--group <name>`
# cannot be read as the project.
handover_target() {
    local argument first_named="" skip_value=0
    for argument in "$@"; do
        if (( skip_value )); then skip_value=0; continue; fi
        case "${argument}" in
            -g|--group|--from|--branch|--dir|--since) skip_value=1; continue ;;
            -*) continue ;;
        esac
        [[ -d "${argument}" ]] && { printf '%s' "${argument}"; return 0; }
        [[ -n "${first_named}" ]] || first_named="${argument}"
    done
    printf '%s' "${first_named:-${PWD}}"
}

# require_sudo_access [verb-args...] -- refuse COMMAND when this caller does not hold a sudo grant for the root helper
# it reaches, and say who can run it instead.
#
# The case it exists for is an ai-ops-only account: in the operators group, in no sudoers rule. That is a supported
# shape, not a misconfiguration -- it is what --for was built for -- and it is NOT the same as having no password.
# An operator who has one is the worse case today: sudo authenticates before it decides, so they are asked
# for a password and refused after supplying it. Nothing here changes what anyone is granted; it moves a refusal
# that was already coming to before the prompt, and attaches the route to the result.
#
# Every verb that reaches a root helper is covered, and each is probed on the FIRST helper it reaches -- a site
# that grants some helpers and not others is then answered accurately rather than by a single representative.
# The refusal precedes the run's first sudo, which is the same ordering require_for_target follows: a command that is
# going to be refused must not prompt first.
require_sudo_access() {
    local bin="" what="" delegable=false
    # A `--for` run's first helper is the registry READER (`ai-tools-allowlist --print`), ahead of the verb's own and
    # whatever the verb, so every `--for` run is probed on it: the case names what the run does, and no arm returns
    # ahead of the probe under `--for`. A verb FOR_ALLOWED_VERBS does not name is left to require_for_target's
    # refusal, which does not reach sudo.
    if [[ -n "${FOR_OPERATOR}" ]]; then
        verb_in "${COMMAND}" "${FOR_ALLOWED_VERBS[@]}" || return 0
    fi
    case "${COMMAND}" in
        audit)                bin="${AUDIT_BIN}"    what="reading the refusal trail" ;;
        "projects lockdown")  bin="${LOCKDOWN_BIN}" what="locking down secret files"; delegable=true ;;
        "projects handback")  bin="${RECLAIM_BIN}"  what="handing back agent-written files"; delegable=true ;;
        "projects claim")     bin="${LOCKDOWN_BIN}" what="claiming a project"; delegable=true ;;
        # A create skips the secret gate (its tree is empty by construction), so the first helper it reaches is
        # the safe.directory registration, not the lockdown scan.
        "projects create")    bin="${SAFEDIR_BIN}"  what="creating a project"; delegable=true ;;
        "projects unclaim")   bin="${UNCLAIM_BIN}"  what="unclaiming a project"; delegable=true ;;
        # `projects remove` does not run ai-tools-unclaim: it deletes the tree instead of handing it back, so the first
        # helper it reaches is the safe.directory de-registration. The clone kind does not reach a helper that can
        # refuse the command -- its one sudo is unreg_safedir's removal, which warns and carries on rather than failing
        # the verb -- so a target under the clone area is not probed on a plain run.
        "projects remove")
            [[ -z "${FOR_OPERATOR}" \
                && "$(realpath -m -- "$(handover_target "$@")" 2>/dev/null)" == "${SANDBOX_ROOT}/"* ]] && return 0
            bin="${SAFEDIR_BIN}"  what="removing a project"; delegable=true ;;
        "projects clone")     bin="${LOCKDOWN_BIN}" what="creating a sandbox clone" ;;
        # The enable/disable pair edits ONE line of the caller's own registry and does not reach a root helper at all,
        # so a plain run is not probed: refusing it for a missing grant would deny a no-sudo service account the one
        # pair of verbs it can run unaided. Only the --for form needs one, where ai-tools-allowlist performs the edit
        # on another operator's file.
        "projects enable"|"projects disable")
            [[ -n "${FOR_OPERATOR}" ]] || return 0
            bin="${ALLOWLIST_BIN}" what="enabling or disabling a project for ${FOR_OPERATOR}" ;;
        # A plain `projects list` reads the caller's own registry and does not reach a helper; under `--for` it reads
        # the target's through the registry reader.
        "projects list")
            [[ -n "${FOR_OPERATOR}" ]] || return 0
            what="reading ${FOR_OPERATOR}'s projects" ;;
        # `projects push` and the informational verbs reach no helper that can refuse the command on a plain run.
        #
        # `stop` is deliberately absent from this table: it is the privileged verb that WORKS for an account without
        # a general grant, since %ai-ops carries a dedicated NOPASSWD rule for its helper. Probing it is harmless --
        # `sudo -n -l <helper>` answers exit 0 for that rule even though the drop-in pins it to the helper's
        # zero-argument form (the trailing ""), because the probe does not pass an operand. An entry here would only
        # ever produce "grant present", so it stays out and the verb reaches sudo directly, which reports a missing
        # drop-in itself. The pin also means `stop`'s FLAGGED forms fall outside the rule and meet sudo's ordinary
        # prompt (deliberate; stop.rule.md). This probe could not report that either: it asks about the helper, while
        # what a flag changes is whether the rule matches the command
        # line.
        *) [[ -n "${FOR_OPERATOR}" ]] || return 0
           what="${COMMAND} for ${FOR_OPERATOR}" ;;
    esac
    [[ -z "${FOR_OPERATOR}" ]] || bin="${ALLOWLIST_BIN}"
    sudo_grant_missing "${bin}" || return 0

    # The route out, printed PLAIN and ahead of die(): die() wraps its text through the error emitter, which would break
    # a command across lines (messaging.rule.md). WHAT THIS MESSAGE DOES NOT SAY. It names the account and the command,
    # and stops. Who that account belongs to is not knowable here -- a service account, a person with a restricted
    # login, an administrator working from one deliberately -- and neither is who runs the suggested command
    # or what they are to each other. So there is no advice to obtain a grant, and the account is not described
    # as anyone's: a message that guesses the arrangement is wrong in exactly the deployments this refusal exists for.
    local -a advice=("Ask an administrator or an ai-ops operator with sudo to run:" "")
    if [[ "${COMMAND}" == "projects list" ]]; then
        # The listing does not take a path, so handover_target's current-directory default is not appended.
        advice+=( "    atr ${COMMAND} --for ${FOR_OPERATOR}" )
    elif [[ -n "${FOR_OPERATOR}" ]]; then
        advice+=( "    atr ${COMMAND} --for ${FOR_OPERATOR} $(handover_target "$@")" )
    elif ${delegable}; then
        # --for is the whole answer here: the verb runs against ${INVOKING_USER}'s registry whoever performs it,
        # which is what the pre-configured no-sudo account needs.
        advice+=( "    atr ${COMMAND} --for ${INVOKING_USER} $(handover_target "$@")" )
    elif [[ "${COMMAND}" == "projects clone" ]]; then
        # The one verb --for is refused on: a clone is made with the git credentials of whoever runs it. The REGISTRY
        # half is delegable all the same, and the clone area is deliberately outside the protected-paths set
        # so that claim is allowed. Two commands, two acts.
        local source_dir clone_dir
        source_dir="$(handover_target "$@")"
        clone_dir="${SANDBOX_ROOT}/$(basename "${source_dir}")"
        # The chown is not optional bookkeeping: the clone is created by whoever runs the first command, and a claim
        # FOR another operator over a tree that operator does not own leaves the agent without access
        # (require_claimable_owner refuses it). Three commands, because the middle one is the only thing that makes
        # the third do anything.
        advice+=( "    atr projects clone ${source_dir}" \
                  "    sudo chown -R ${INVOKING_USER} ${clone_dir}" \
                  "    atr projects claim --for ${INVOKING_USER} ${clone_dir}" "" \
                  "projects clone takes no --for: the clone is made with the git credentials of" \
                  "whoever runs it, so it is born owned by them. The chown hands it to" \
                  "${INVOKING_USER}, and the claim registers it for ${INVOKING_USER}." )
    else
        local rest=""; (( $# )) && rest="$(printf ' %q' "$@")"
        advice+=( "    atr ${COMMAND}${rest}" )
    fi
    # The trail is written to journald as well, which many hosts let an ordinary account read -- a partial view (the
    # file sink is the authoritative one) but one the operator reads unaided.
    [[ "${COMMAND}" == audit ]] && advice+=( "" \
        "Some of the same events reach the journal, readable without root on many hosts:" "" \
        "    journalctl -p notice --since '7 days ago' | grep ai-tools" )

    printf '\n' >&2
    printf '  %s\n' "${advice[@]}" >&2
    printf '\n' >&2
    die MSG-R4J2 "this run needs root: ${what} goes through ${bin##*/}, and ${INVOKING_USER} holds no sudo grant for it." \
        "Membership of ai-ops does not carry a general sudo grant."
}

# require_runas_target -- refuse a `--for` run whose filesystem steps `sudo -n -l -u <target>` does not let the caller
# run AS the target. A no-op without --for, and a no-op for every verb that touches the filesystem only through a root
# helper.
#
# `projects create` and `projects remove` build and destroy a tree as an OWNER, through run_as_owner i.e.
# `sudo -u <target>`. That is a different sudoers question from the one require_sudo_access asks: a host can grant every
# ai-tools helper and still restrict Runas to root, and there each verb would fail at the worst moment -- the create
# after making the directory and before claiming it, the remove after unregistering the project and before deleting it.
# Probing first is what keeps "refused before anything changes" true on such a host.
#
# `projects claim` reads the tree as its owner by running this file as `projects inspect` through run_as_owner, and
# a create runs the claim, so each is probed on that file too: without the read the claim ends 5 with no step applied,
# and the probe answers that before the first prompt. The claim's other owner-run steps, the traverse ACL and the
# core.filemode pin, each warn and continue on their own.
#
# Each command the run executes is probed rather than one representative, for the reason require_sudo_access gives:
# a sudoers permitting some and not others is then answered accurately. `sudo -n -l -u <target> <cmd>` cannot prompt,
# so this costs the caller no password, and it runs before require_for_target's snapshot -- the run's first real sudo --
# like every other refusal here.
require_runas_target() {
    [[ -n "${FOR_OPERATOR}" ]] || return 0
    local -a needed=()
    case "${COMMAND}" in
        "projects create") needed=(mkdir git tee chmod chgrp setfacl "${SELF_PATH}") ;;
        "projects claim")  needed=("${SELF_PATH}") ;;
        "projects remove") needed=(stat find rm) ;;
        *) return 0 ;;
    esac
    local name resolved blocked=""
    for name in "${needed[@]}"; do
        [[ -n "${name}" ]] || continue
        resolved="$(command -v -- "${name}" 2>/dev/null)" || continue   # absent: its own call site reports it
        if sudo_grant_missing "${resolved}" "${FOR_OPERATOR}"; then blocked="${resolved}"; break; fi
    done
    [[ -n "${blocked}" ]] || return 0

    # Printed plain and ahead of die(), whose emitter would wrap a command across lines.
    local -a advice=("Run it as ${FOR_OPERATOR} instead.")
    if [[ "${COMMAND}" == "projects create" ]]; then
        advice=("Run it as ${FOR_OPERATOR}, or create the project without --for and hand it over:" "" \
                "    atr projects create DIRECTORY" \
                "    sudo chown -R ${FOR_OPERATOR} DIRECTORY" \
                "    atr projects claim --for ${FOR_OPERATOR} DIRECTORY")
    fi
    printf '\n' >&2
    printf '  %s\n' "${advice[@]}" >&2
    printf '\n' >&2
    die MSG-Z6Q6 "a --for run acts on the filesystem AS the target: ${COMMAND} --for ${FOR_OPERATOR} runs ${blocked##*/} as ${FOR_OPERATOR}, and ${INVOKING_USER} holds no sudo grant to do that." \
        "This is a separate sudoers question from the ai-tools helpers: a host can grant every one of those and still restrict which accounts you may act as."
}

# FN-K9Y2: refresh_target_allowlist_snapshot
#
# refresh_target_allowlist_snapshot -- point ALLOWLIST at a private copy of the --for target's registry, read
# through the root helper: first by load_target_registry_snapshot, once the verb's own checks have passed, and again
# after each write. The copy is read-only input for THIS run: every mutation goes back through the helper,
# which re-reads the real file, so a stale snapshot can never be what a write is based on -- and reg_allow/unreg_allow
# refresh it after theirs. The helper refuses a target with no allowlist file, and its refusal reaches the terminal
# on stderr, so a failed read ends the run with a line naming the target under the helper's own text and code. The file
# exists only once the helper has answered: mktemp creates it 0600, and the EXIT trap removes it, so another operator's
# project list does not outlive the command.
ALLOWLIST_SNAPSHOT=""
refresh_target_allowlist_snapshot() {
    local entries
    entries="$(sudo "${ALLOWLIST_BIN}" --operator "${FOR_OPERATOR}" --print)" \
        || die MSG-E8W4 "could not read ${FOR_OPERATOR}'s project registry -- nothing changed"
    if [[ -z "${ALLOWLIST_SNAPSHOT}" ]]; then
        ALLOWLIST_SNAPSHOT="$(mktemp)" || die "cannot create a temporary file for the allowlist snapshot"
        trap remove_run_files EXIT
    fi
    # The capture drops the final line feeds, so one is put back; an empty registry stays an empty file.
    printf '%s' "${entries}${entries:+$'\n'}" > "${ALLOWLIST_SNAPSHOT}"
    ALLOWLIST="${ALLOWLIST_SNAPSHOT}"
    # The helper reads the target's file only once it is migrated, so its name is project-registry; the tilde-user
    # form is the operator's shell's to expand, to the target's home.
    # shellcheck disable=SC2088  # the tilde is printed for the reader, not expanded here
    REGISTRY_DISPLAY_PATH="~${FOR_OPERATOR}/.config/ai-tools/project-registry"
    REGISTRY_STATE="$(ai_tools_conf__read_registry_state "${ALLOWLIST}")"
}

# remove_run_files -- the EXIT trap: remove the files a run made for itself, the allowlist snapshot and the claim's work
# directory, and on a non-zero exit name the directory a create made whose claim did not finish (CREATE_LEFT_TREE),
# with the claim that finishes it. One function, since a run has one EXIT trap and any of the three may come first.
remove_run_files() {
    local status=$?
    if (( status != 0 )) && [[ -n "${CREATE_LEFT_TREE}" ]]; then
        warn MSG-S3U5 "the directory $(ai_tools_log__sanitize "${CREATE_LEFT_TREE}") was created and its claim did not finish -- the directory stays; finish the claim with:" \
            "      $(format_projects_command claim "${CREATE_LEFT_TREE}")"
    fi
    [[ -z "${ALLOWLIST_SNAPSHOT}" ]] || rm -f -- "${ALLOWLIST_SNAPSHOT}"
    [[ -z "${CLAIM_WORK}" ]] || rm -rf -- "${CLAIM_WORK}"
    return 0
}

# require_for_target -- validate a `--for` run of COMMAND, resolve the target's group, and point ALLOWLIST at a name
# where no file can exist until the verb loads the target's registry (load_target_registry_snapshot). A no-op without
# the flag, so no other code changes for an ordinary run.
#
# Every refusal here, like every refusal a verb makes of its own command line, precedes the snapshot, which is
# the run's first sudo: a command that is going to be refused must not first prompt the operator for a password.
#
# --for is accepted only on the verbs whose whole effect is decided by WHICH operator's registry covers the path:
# the registry pair, the two per-project root helpers that gate on registry coverage, and the listing. Elsewhere it is
# REFUSED rather than ignored -- a `projects clone --for` that silently cloned as the invoker would leave the tree owned
# by the wrong operator with no output to show the flag was disregarded.
#
# The target must be ENROLLED in OPERATORS: ai-tools-setfacl and the handback helpers resolve a path's owner
# over that list, so an entry written for an unenrolled name would create a launch gate no ownership machinery can act
# on. Enrollment is checked before the group lookup, so an unknown name is refused with the enrolment command rather
# than a getent failure.
#
# The target must also be in ai-ops, the other fact `ai-tools-admin operators add` writes: the target's launch wrapper
# refuses a caller outside the group, and a create under the clone area writes through that area's `g:ai-ops` ACL.
# `id -nG` reads the group database without a password, so an account in OPERATORS and not in the group -- a name
# written into operator.conf by hand -- is refused before the snapshot, rather than after the run's first writes.
#
# A command path the dispatch does not have is refused as an unknown command first (MSG-S2H5), so `--for op bogus` names
# the typo rather than a flag. The gating tables together classify every dispatched path, which tests/unit/cli-verbs.sh
# holds them to, so their union is the set read here.
require_for_target() {
    [[ -n "${FOR_OPERATOR}" ]] || return 0
    verb_in "${COMMAND}" "${OPERATOR_VERBS[@]}" "${ROOT_ALLOWED_VERBS[@]}" "${BOOTSTRAP_EXEMPT_VERBS[@]}" \
        || unknown_command "${COMMAND}"
    verb_in "${COMMAND}" "${FOR_ALLOWED_VERBS[@]}" \
        || die MSG-U7R7 "--for is not accepted on ${COMMAND}" \
               "it applies to: $(join_paths "${FOR_ALLOWED_VERBS[@]}")"
    [[ "${FOR_OPERATOR}" != "${SANDBOX_USER}" ]] \
        || die MSG-M3Z3 "the sandbox account is not an operator and must not own projects"
    [[ "${FOR_OPERATOR}" != "root" ]] || die MSG-C4Y4 "root is not an operator"
    local conf="${AI_TOOLS_OPERATOR_CONF:-/etc/ai-tools/operator.conf}"
    local -a ops=(); local op found=false
    if ai_tools_conf__read_list ops "${conf}" OPERATORS 2>/dev/null; then
        for op in "${ops[@]}"; do
            [[ "${op}" == "${FOR_OPERATOR}" ]] && { found=true; break; }
        done
    fi
    ${found} || die MSG-E3D2 "not a configured ai-tools operator: ${FOR_OPERATOR} -- enrol it first with:" \
        "       atr admin operators add ${FOR_OPERATOR}"
    # An account whose groups id cannot read reads as outside the group, so it is refused here too.
    local target_groups=""
    target_groups="$(id -nG "${FOR_OPERATOR}" 2>/dev/null)" || target_groups=""
    [[ " ${target_groups} " == *" ai-ops "* ]] \
        || die MSG-B9Y4 "the --for target ${FOR_OPERATOR} is listed in OPERATORS and is not in the ai-ops group -- enrol it with:" \
               "       atr admin operators add ${FOR_OPERATOR}"
    OWNER_GROUP="$(id -gn "${FOR_OPERATOR}" 2>/dev/null)" \
        || die "cannot resolve the primary group of ${FOR_OPERATOR}"
    # Until the verb loads the snapshot, every reader finds no registry there: /dev/null is not a directory, so no file
    # can exist under it and a write there fails.
    ALLOWLIST="${FOR_REGISTRY_NOT_LOADED}"
    REGISTRY_STATE=none
    # shellcheck disable=SC2088  # the tilde is printed for the reader, not expanded here
    REGISTRY_DISPLAY_PATH="~${FOR_OPERATOR}/.config/ai-tools/project-registry"
}

# FN-R7N4: load_target_registry_snapshot
#
# load_target_registry_snapshot  -- read a --for target's registry into this run's snapshot and validate it,
# the run's first sudo; a no-op without --for and once the snapshot exists. Each verb FOR_ALLOWED_VERBS names calls it
# after its own command-line checks and before its first registry read, after its path resolution too where the path
# does not come from discovery (`projects lockdown` and `projects handback` load first, since discovery reads
# the registry), so a command line, and a path the verb refuses, are refused before the password prompt. A verb that
# forgot the call would read ALLOWLIST at FOR_REGISTRY_NOT_LOADED, which reads as no registry: every path unregistered,
# the less-access answer for each verb but the claim, whose first-claim path then writes through the helper, which
# re-reads the real file and is idempotent. tests/unit/cli-verbs.sh holds every such verb to the call. The snapshot
# carries the grammar the helper validated against the target's own file, so the run reads it under that grammar
# alone; the link check is the helper's, made over the file whose owner the links belong to.
readonly FOR_REGISTRY_NOT_LOADED="/dev/null/ai-tools-for-registry-not-loaded"
load_target_registry_snapshot() {
    [[ -n "${FOR_OPERATOR}" && -z "${ALLOWLIST_SNAPSHOT}" ]] || return 0
    refresh_target_allowlist_snapshot
    ai_tools_conf__validate_registry "${ALLOWLIST}" "$(ai_tools_conf__read_registry_grammar "${ALLOWLIST}")" \
        || die_registry_refused
}

# Gate the operator-acting commands up front; the informational ones (`--help`, `--version`, `projects list`,
# `providers list`) stay open so an unenrolled user can still read usage and inspect the host.
if verb_in "${COMMAND}" "${OPERATOR_VERBS[@]}"; then require_operator; fi

# Refuse a verb whose root helper this caller has no sudo grant for, ahead of a --for run's snapshot, its first sudo.
# Every gate keeps the same ordering rule: a command that is going to be refused must not prompt for a password first.
require_sudo_access "$@"

# And refuse a --for run that cannot perform its filesystem steps AS the target -- a separate sudoers question
# from the helper grants, and one that would otherwise surface partway through building a tree. Same ordering rule:
# ahead of the snapshot, which is the first sudo that can prompt.
require_runas_target "$@"

# Validate a --for run, after require_operator: acting for another operator is an operator action, so the invoker must
# be enrolled before the target is even looked up. The target's registry is read by the verb, once its own command
# line has passed (load_target_registry_snapshot).
require_for_target

# One dispatch per collection, nested under the top-level one on the first token of COMMAND -- the shape ai-tools-admin
# takes, and the one tests/unit/man.sh and tests/integration/cli-flags.sh read the arms from.
projects_dispatch() {
    local verb="$1"; shift
    case "${verb}" in
        list|create|claim|unclaim|remove|enable|disable|clone|push|lockdown|handback) registry_gate "${verb}" ;;
    esac
    case "${verb}" in
        list)     cmd_project_list     "$@" ;;
        create)   cmd_project_create   "$@" ;;
        claim)    cmd_project_claim    "$@" ;;
        unclaim)  cmd_project_unclaim  "$@" ;;
        remove)   cmd_project_remove   "$@" ;;
        enable)   cmd_project_enable   "$@" ;;
        disable)  cmd_project_disable  "$@" ;;
        clone)    cmd_project_clone    "$@" ;;
        push)     cmd_project_push     "$@" ;;
        lockdown) cmd_project_lockdown "$@" ;;
        handback) cmd_project_handback "$@" ;;
        # The claim's owner-side read, run as the project's owner (cmd_project_inspect): its calling contract is
        # the claim's and it prints a record stream the claim parses, so the help, the man page and the completion leave
        # it out.
        inspect)  cmd_project_inspect  "$@" ;;
        *)        unknown_command "projects ${verb}" ;;
    esac
}
providers_dispatch() {
    local verb="$1"; shift
    case "${verb}" in
        list) cmd_providers "$@" ;;
        *)    unknown_command "providers ${verb}" ;;
    esac
}

# ── Dispatch ─────────────────────────────────────────────────────────────────────
case "${COMMAND%% *}" in
    projects)  projects_dispatch  "${COMMAND#* }" "$@" ;;
    providers) providers_dispatch "${COMMAND#* }" "$@" ;;
    status)    cmd_status "$@" ;;
    audit)     cmd_audit "$@" ;;
    stop)      cmd_stop  "$@" ;;
    --version) printf 'ai-tools %s\n' "${AI_TOOLS_VERSION}" ;;
    *)         unknown_command "${COMMAND}" ;;
esac
