#!/usr/bin/bash
# SPDX-License-Identifier: AGPL-3.0-only
# /usr/local/libexec/ai-tools/ai-tools-unclaim
# Reverses the filesystem side of a project claim: hands an approved project tree back to a target group and revokes
# the agent's access. For every eligible path it:
#   1. clears every extended ACL entry and the default ACL (`setfacl -b`): the group:ai-tools
#      and user:<operator> entries claim seeded, and the default that made new files inherit them;
#   2. changes the group owner to <target-group> (the operator's own group by default), moving
#      the tree out of ai-tools;
#   3. removes group WRITE: 660 -> 640, 770 -> 750, 400 stays 400; group read and traverse stay. Group
#      execute comes off a data file alone, keyed on OWNER-execute (the bit git records): `setfacl -b`
#      promotes the tree's `group::r-x` base into the mode, so a plain file the agent wrote can surface
#      as 0650. On a DIRECTORY the setgid bit claim added is cleared too (`chmod g-w,g-s`; a numeric chmod
#      leaves a directory's setgid standing). A file keeps its setuid/setgid bits.
# Net effect: the agent (group ai-tools) loses access through the group owner and the named ACL entry alike,
# and the tree carries plain Unix permissions under the new group. `.git`, which the main walk skips with the other
# heavy trees, is reverted by a dedicated pass, so git history is revoked with the rest.
#
# Invoked as root via sudo by `atr projects unclaim`: root is what a chgrp to an arbitrary group, and a change to a file
# the projects user does not own, need. The path and the target group the CLI passes are re-validated here, and the path
# must resolve at or under a registered project (the operator's project registry) or the helper is a no-op. A registry
# the loader refuses, a line the grammar refuses among the reasons, does not list any project.
#
# Two modes, differing ONLY in which paths they accept -- never in what they do to a path they accept, so one reversal
# is described once and tested once:
#   default      the whole tree is authorized by its allowlist entry, and every eligible path in it is reverted.
#   `--unlisted` the tree is in NO allowlist (a claimed project copied or moved elsewhere and never unclaimed), so it
#                has no authorization of its own; a path is touched only while it still bears the ai-tools
#                fingerprint (_is_residue: owned by the sandbox account, grouped to it, or carrying its named ACL
#                entry), so a path that was never part of a claim is left byte for byte as it is. This mode also
#                hands a sandbox-OWNED inode back to the invoking operator (ai-tools-reclaim declines an unlisted
#                path -- MSG-K9H2, exit 0) and resets a leftover ai_tools_project_t label. `--full` extends the walk
#                into the skip-listed heavy trees, where residue survives a copy as it does elsewhere.
#
# Owner guard: only the projects user's and the sandbox account's own files are touched, the rule the claim walks hold.
# Under `--unlisted` the "projects user" is the operator who invoked sudo, validated against OPERATORS, since no
# allowlist entry names the owner of an unlisted tree.
#
# Hardlink guard: a regular file with more than one name is refused in BOTH modes, the boundary ai-tools-chown holds too
# -- chgrp and chmod act on the inode, which a second name reaches from outside the tree, so acting would change a path
# the walk never authorized. This is the one refusal here that leaves MORE access than acting would: the inode keeps its
# group, so after the project is deregistered the agent still holds those files through it. That is accepted, since
# the second name is outside the tree, where this pass does not authorize a change, and for `git clone --local`,
# which hardlinks .git/objects to the source repo, acting would silently rewrite the ORIGIN's objects. What the guard
# owes the operator is disclosure: refusals are counted apart from the other skips, reported with what they leave
# behind, and handed the `find -links +1` that lists them.
#
# NOT a round trip. The reversal normalizes; it does not restore. `setfacl -b` clears every extended ACL -- entries
# that predate the claim included -- and group write comes off, so a path the claim opened lands on 640 (750 when
# the owner has execute) under the target group. The world bits are not put back: the claim's ACL walk set other::---
# on every path it touched, so 644 and 664 both arrive here as 660 and leave as 640. An owner-only path (0600/0700)
# passes through unchanged at both ends, the claim having skipped it. No prior state is recorded anywhere, so no pass
# can restore it; the CLI says so before it asks and tells the operator to back up first.
#
# Idempotent: a re-run finds no ACL to clear, regroups to the same group, and removes an absent write bit.
#
# Installed 750 root:root. Its domain rule is cli.rule.md.

set -euo pipefail

# Every refusal and every disclosure this helper prints goes through this pair, so the component prefix is stated once
# here instead of at each site. A leading message code (msg.lib.sh states the form) is printed on its own line ahead
# of the message, the shape tests/lib/harness.sh's assert_msg reads. Matched inline, since this helper reports
# before msg.lib.sh is loaded. The code it printed is left in _warn_code, for a site that also records the situation
# through log.lib.sh: the log call passes the variable, so the code literal stays at the emit call the reference index
# reads as its definition (messaging.rule.md).
_warn_code=""
warn() {
    local IFS=' ' code=""
    if [[ "${1-}" =~ ^MSG-[A-Z][0-9][A-Z][0-9]$ ]]; then code="$1"; shift; printf '%s\n' "${code}" >&2; fi
    _warn_code="${code}"
    printf 'ai-tools-unclaim: %s\n' "$*" >&2
}
# die records the refusal as well as reporting it, so a run the CLI only sees fail leaves the reason in the trail. It
# therefore runs only after log.lib.sh has loaded, which every refusal that uses it does; the option refusal
# in the argument parse reports through warn and exits 2 at its own site.
die() {
    local IFS=' '
    warn "$@"
    if [[ "${1-}" =~ ^MSG-[A-Z][0-9][A-Z][0-9]$ ]]; then shift; fi
    ai_tools_log__error "$*"
    exit 1
}

readonly USAGE="usage: ai-tools-unclaim <absolute-project-path> <target-group> [--unlisted] [--full]"
TARGET="${1:?${USAGE}}"
TARGET_GROUP="${2:?${USAGE}}"
shift 2

# `--unlisted`: act on a tree that is NOT in any project registry (a claimed project copied or moved elsewhere and never
# unclaimed). It swaps one gate for another rather than removing one: the allowlist-membership check is skipped,
# and every path must instead carry the ai-tools residue fingerprint (_is_residue) to be touched at all.
# The protected-paths backstop, the owner guard, the hardlink guard, and the secret/'!' skips all still apply,
# so the mode is strictly NARROWER per path than a listed unclaim and identical in what it does to a path it accepts.
# `--full` additionally walks the skip-listed heavy trees (node_modules, .venv, caches), where residue would otherwise
# survive a copy.
UNLISTED=false
FULL=false
for _arg in "$@"; do
    case "${_arg}" in
        --unlisted) UNLISTED=true ;;
        --full)     FULL=true ;;
        # The usage line follows the refusal unprefixed, as orientation rather than a situation.
        *) warn MSG-T6H6 "unknown option: ${_arg}"; printf '%s\n' "${USAGE}" >&2; exit 2 ;;
    esac
done
unset _arg
readonly TARGET TARGET_GROUP UNLISTED FULL

# Operator-identity resolver (operator.lib.sh): resolves the operator that owns the project. A missing lib leaves
# ai_tools_operator__resolve_owner a fail-closed stub, so the tree is left untouched.
readonly OPERATOR_LIB="/usr/local/lib/ai-tools/operator.lib.sh"
# shellcheck source=SCRIPTDIR/../../lib/ai-tools/operator.lib.sh
source "${OPERATOR_LIB}" 2>/dev/null || ai_tools_operator__resolve_owner() { return 1; }
# Two identities may legitimately hold a project tree (see ai-tools-setfacl); a file belonging to a third party is left
# untouched. Matched by numeric UID; PROJECTS_UID is the resolved operator.
SANDBOX_UID="$(id -u "ai-tools" 2>/dev/null || echo -1)"
# The sandbox GID is the residue fingerprint's cheapest arm: a claim chgrp's the tree to it, and every copy method
# that carries residue at all (`cp -a`, `rsync -a`, `mv`, `tar -p`) preserves it.
SANDBOX_GID="$(getent group "ai-tools" 2>/dev/null | cut -d: -f3)"
[[ -n "${SANDBOX_GID}" ]] || SANDBOX_GID=-1
readonly SANDBOX_UID SANDBOX_GID

# Shared leveled logger: journald (always) + the root-only file /var/log/ai-tools/unclaim.log.
AI_TOOLS_LOG_TAG="ai-tools-unclaim"
AI_TOOLS_LOG_FILE="unclaim.log"
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() { :; }
fi

# Directory-skip selector (shared single source of truth). A missing lib leaves a stub that descends into every
# directory -- a slower but correct walk.
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__FIND_EXPRESSION=(); return 0; }

# Validate the target group exists before touching anything (fail-closed).
getent group "${TARGET_GROUP}" >/dev/null 2>&1 \
    || die MSG-R3C7 "unknown target group '${TARGET_GROUP}' -- nothing changed"

# Protected-paths backstop (safe-paths.lib.sh): refuse to act on a system directory even when the allowlist includes it.
# See safe-paths.rule.md.
readonly SAFE_PATHS_LIB="/usr/local/lib/ai-tools/safe-paths.lib.sh"
# shellcheck source=SCRIPTDIR/../../lib/ai-tools/safe-paths.lib.sh
source "${SAFE_PATHS_LIB}"

# Canonicalise the argument; block symlink traversal of the path itself.
canonical="$(realpath -e "${TARGET}" 2>/dev/null)" || exit 0
[[ -d "${canonical}" ]] || exit 0
# Refuse the whole pass if the project root is a protected system directory.
ai_tools_safe_paths__assert_safe_target "${canonical}" "unclaim" || exit 3

if ${UNLISTED}; then
    # No allowlist entry names this tree, so its owner cannot be resolved from one. The identity bounding the walk is
    # instead the operator who INVOKED sudo, validated against OPERATORS in operator.conf: operator A can never rewrite
    # operator B's files, and a direct root call with no sudo context is refused rather than defaulting to some
    # operator. Fails closed at every step -- an unresolvable caller, an unconfigured one, or an unloadable operator.lib
    # (whose functions are then undefined, so the `||` fires) all stop the pass before any mutation.
    caller_uid="${SUDO_UID:-}"
    [[ -n "${caller_uid}" ]] \
        || die MSG-N6X6 "--unlisted needs an invoking operator (no SUDO_UID) -- nothing changed"
    caller="$(id -un "${caller_uid}" 2>/dev/null)" \
        || die MSG-V9Q6 "--unlisted: unknown invoking uid ${caller_uid} -- nothing changed"
    ai_tools_operator__load_operators 2>/dev/null \
        || die MSG-J2Q8 "--unlisted: no operators configured -- nothing changed"
    _is_operator=false
    for op in "${AI_TOOLS_OPERATOR__OPERATORS[@]}"; do
        [[ "${op}" == "${caller}" ]] && { _is_operator=true; break; }
    done
    ${_is_operator} \
        || die MSG-H9D4 "--unlisted: ${caller} is not a configured operator -- nothing changed"
    PROJECTS_UID="${caller_uid}"
    # The caller is the one operator identity in play, so the secret matcher reads the caller's own pattern file (the
    # loader builds its path from PROJECTS_HOME), as it reads the resolved owner's on the registered path.
    PROJECTS_HOME="$(getent passwd "${caller}" 2>/dev/null | cut -d: -f6)"
    # The caller's own allowlist is still read, for its '!' exclusions and for the "already registered" refusal: a glob
    # rule the operator wrote to keep a path out of reach keeps it out of reach here too. Resolved
    # through operator.lib's own path helper so the AI_TOOLS_ALLOWLIST test hook applies here exactly as it does
    # on the resolve_owner path.
    is_primary=secondary
    [[ "${caller}" == "${AI_TOOLS_OPERATOR__OPERATORS[0]}" ]] && is_primary=primary
    ALLOWLIST="$(ai_tools_operator__get_allowlist_path "${caller}" "${is_primary}")"
else
    # Resolve the operator that owns this project (operator.lib.sh); no owner -> exit without acting. The guard then
    # acts only on paths the resolved operator or the sandbox account hold.
    ai_tools_operator__resolve_owner "${canonical}" || exit 0
    ALLOWLIST="${AI_TOOLS_OPERATOR__RESOLVED_ALLOWLIST}"
fi
readonly ALLOWLIST PROJECTS_UID

# Secret-name matcher: the walk skips a secret-named path, so a locked secret stays put. Loaded after the operator is
# known, which names the operator's file (ai_tools_secret_patterns__load states what an earlier load reads).
# Fail-closed: a reversal with no matcher would regroup a locked secret the operator named, so a library that does not
# load and a present file the loader cannot read each refuse before the first change (secret-handling.rule.md).
readonly SECRET_PATTERNS_LIB="/usr/local/lib/ai-tools/secret-patterns.lib.sh"
# shellcheck source=SCRIPTDIR/../../lib/ai-tools/secret-patterns.lib.sh
if ! source "${SECRET_PATTERNS_LIB}" 2>/dev/null || ! declare -F ai_tools_secret_patterns__load >/dev/null 2>&1; then
    die MSG-P5R2 "cannot load ${SECRET_PATTERNS_LIB}, which decides which paths are secrets -- nothing under ${canonical} was changed; reinstall the ai-tools package"
fi
ai_tools_secret_patterns__load \
    || die "the operator's secret-patterns file could not be read, so nothing under ${canonical} was changed"
_is_secret_name() {
    ai_tools_secret_patterns__is_secret_basename "$(basename -- "$1")"
}

# This run reverts one project for one operator, so the operator and the project ride as per-run log context
# (logging.rule.md).
AI_TOOLS_LOG_OPERATOR="${PROJECTS_USER:-}"
AI_TOOLS_LOG_PROJECT="${canonical}"

# Shared config grammar (ai_tools_conf__parse_path_entry; see conf.lib.sh), the ONE parser the allowlist is read with --
# end-of-line comments, and quotes for a path carrying a space or a literal '#'. REQUIRED like safe-paths.lib.sh:
# the bare source under `set -e` aborts when it is missing. A bare filter in its place mis-reads an entry every other
# reader of the file reads correctly. Include-guarded.
# shellcheck source=SCRIPTDIR/../../lib/ai-tools/conf.lib.sh
source /usr/local/lib/ai-tools/conf.lib.sh

declare -a allowed_directories=()
# shellcheck disable=SC2034  # filled and read through its name by the conf.lib.sh loader and matcher
declare -a exclusion_patterns=()
# The one read every reader of the registry makes (ai_tools_conf__load_allowlist, conf.lib.sh). A file that cannot be
# read, one whose exclusion the loader refuses (status 2), and one holding a line the grammar refuses (status 3) each
# leave both arrays empty, so the target is not listed.
ai_tools_conf__load_allowlist "${ALLOWLIST}" allowed_directories exclusion_patterns || true

# _is_excluded <abs-path>: 0 if covered by a '!' or '%' line (ai_tools_conf__is_path_excluded, conf.lib.sh -- the match
# every reader of the registry makes).
#   A match the library could not make (a status other than 0 or 1, MSG-V9S2 on stderr) ends the run with exit 1
#   before the path is unclaimed: read as "not excluded" it would act on a path an exclusion covers.
_is_excluded() {
    local -i excluded_rc=0
    ai_tools_conf__is_path_excluded "$1" exclusion_patterns || excluded_rc=$?
    (( excluded_rc <= 1 )) || exit 1
    return "${excluded_rc}"
}

# _is_allowed <abs-path>: 0 if at or under an allowed directory.
_is_allowed() {
    local path="$1" d
    [[ "${#allowed_directories[@]}" -gt 0 ]] || return 1
    for d in "${allowed_directories[@]}"; do
        [[ "${path}" == "${d}" || "${path}" == "${d}/"* ]] && return 0
    done
    return 1
}

# Refuse a '!'-excluded target outright, and in the default mode a target outside every registered project: unclaim must
# never modify permissions outside the project registry. Same gate as `ai-tools-{setgid,setfacl}` (a silent no-op
# on a foreign target). The management CLI runs the hand-back BEFORE it drops the allowlist entry, so a legitimate
# unclaim still resolves its owner and stays listed here. `--unlisted` swaps this whole-tree gate for the per-path
# residue gate in _safe_unclaim; it never runs with neither.
_is_excluded "${canonical}" && exit 0
if ${UNLISTED}; then
    # `--unlisted` is for a tree NO allowlist names. If the caller's own allowlist does name it, the caller chose
    # the wrong mode: refuse rather than run the narrower per-path gate over a registered project, where the full walk
    # is what the operator asked for.
    if _is_allowed "${canonical}"; then
        die MSG-R9H2 "--unlisted on a registered project ${canonical} -- use the listed mode; nothing changed"
    fi
else
    _is_allowed "${canonical}" || exit 0
fi

# _is_residue <fd> <uid> <gid>: 0 when the pinned inode still carries the ai-tools fingerprint -- owned by the sandbox
# account, grouped to it, or carrying a named ACL entry for it. The UNION of the three is what makes the gate safe
# to run outside the allowlist: a partially handed-back tree can retain any one of them alone, and testing only
# the group would leave an ai-tools-OWNED file untouched, where the agent keeps access through the user bits. Read
# from the pinned fd, so it describes the same inode the mutation acts on. The uid and gid checks read values the caller
# already holds; the ACL read (`getfacl`) runs only when both miss.
_is_residue() {
    local fd="$1" uid="$2" gid="$3"
    [[ "${uid}" == "${SANDBOX_UID}" ]] && return 0
    [[ "${gid}" == "${SANDBOX_GID}" ]] && return 0
    getfacl -c -- "/proc/self/fd/${fd}" 2>/dev/null \
        | grep -q "^\(default:\)\?group:ai-tools:"
}

# _safe_unclaim <path>: clear ACL, regroup, drop group write -- TOCTOU-safe via a pinned fd held at <path>
# (ai_tools_safe_paths__is_pinned_fd_at_path, safe-paths.lib.sh; see ai-tools-setfacl for the rationale). Owner-guarded
# on the pinned inode.
#
# Returns 0 when the path was changed, 2 when it was refused as a hardlink (the caller counts and reports those), 1
# for every other skip.
_safe_unclaim() {
    local path="$1" expect_ident fd got_ident got_uid got_gid got_nlink got_ftype
    expect_ident="$(stat -c '%d:%i' "${path}" 2>/dev/null)" || return 1
    { exec {fd}< "${path}"; } 2>/dev/null || return 1
    # %F ("regular empty file") is multi-word, so it must be the last field.
    read -r got_ident got_uid got_gid got_nlink got_ftype \
        < <(stat -L -c '%d:%i %u %g %h %F' "/proc/self/fd/${fd}" 2>/dev/null) \
        || { exec {fd}<&-; return 1; }
    if [[ "${got_ident}" != "${expect_ident}" ]]; then exec {fd}<&-; return 1; fi
    ai_tools_safe_paths__is_pinned_fd_at_path "${fd}" "${path}" || { exec {fd}<&-; return 1; }
    # Owner guard: only the projects user's or the sandbox account's own files.
    if [[ "${got_uid}" != "${PROJECTS_UID}" && "${got_uid}" != "${SANDBOX_UID}" ]]; then
        exec {fd}<&-; return 1
    fi
    case "${got_ftype}" in
        directory) ;;
        "regular file"|"regular empty file")
            # Hardlink guard, the same boundary ai-tools-chown enforces: an inode with more than one name can be reached
            # from OUTSIDE this tree, and chgrp/chmod act on the inode, so acting here would change a path the walk
            # never authorized. Directories legitimately have nlink >= 2 ('.' plus each child's '..'), so this applies
            # to regular files only.
            if [[ "${got_nlink}" -ne 1 ]]; then exec {fd}<&-; return 2; fi
            ;;
        *) exec {fd}<&-; return 1 ;;            # never touch symlinks/fifos/devices
    esac
    # Residue gate (`--unlisted` only): outside the allowlist the tree does not carry authorization of its own,
    # so a path is touched ONLY while it still bears the ai-tools fingerprint. A path that never belonged to a claim is
    # left byte-for-byte as it is.
    if ${UNLISTED} && ! _is_residue "${fd}" "${got_uid}" "${got_gid}"; then
        exec {fd}<&-; return 1
    fi
    local rc=0
    # Order: clear ACL (incl. default) -> regroup -> drop group write, so chmod acts on clean mode bits and no masked
    # ACL grant survives. On directories also clear the setgid bit (added by claim) so the tree returns to plain perms
    # (770 -> 750); a numeric chmod cannot clear a directory's setgid, only the symbolic g-s can. Files keep their
    # setuid/setgid bits untouched (an sgid binary must not be silently altered); only group write is removed there (660
    # -> 640).
    setfacl -b   "/proc/self/fd/${fd}" 2>/dev/null || rc=1
    chgrp -- "${TARGET_GROUP}" "/proc/self/fd/${fd}" 2>/dev/null || rc=1
    # `--unlisted` only: hand a sandbox-OWNED inode back to the invoking operator. Regrouping alone would leave
    # the agent its access through the USER bits, and the ownership sweep that covers this for a registered project
    # (ai-tools-reclaim) refuses an unlisted path, so this pass is the only one that can reach it. A path the operator
    # already owns is left alone -- this never changes ownership away from a third party, which the owner guard has
    # already excluded.
    if ${UNLISTED} && [[ "${got_uid}" == "${SANDBOX_UID}" ]]; then
        chown -- "${PROJECTS_UID}" "/proc/self/fd/${fd}" 2>/dev/null || rc=1
    fi
    if [[ "${got_ftype}" == "directory" ]]; then
        chmod g-w,g-s "/proc/self/fd/${fd}" 2>/dev/null || rc=1
    else
        # Drop group WRITE; also drop a stray group EXECUTE on a data file. `setfacl -b` promoted the ACL's group:: base
        # (r-x on a tree the agent wrote) into the mode, so a data file can land group-executable (0650); strip
        # that, keyed on OWNER-execute (the bit git records) so a genuine script (owner rwx) keeps group r-x (-> 750)
        # while a data file (owner rw) drops to 640. Relative g-w[,g-x] leaves any setuid/setgid bit on the file
        # untouched (an sgid binary is not altered).
        local fmode gxarg=""
        fmode="$(stat -L -c '%a' "/proc/self/fd/${fd}" 2>/dev/null)" || fmode=""
        if [[ -n "${fmode}" ]] && (( ( ( 8#${fmode} >> 6 ) & 1 ) == 0 )); then
            gxarg=",g-x"
        fi
        chmod "g-w${gxarg}" "/proc/self/fd/${fd}" 2>/dev/null || rc=1
    fi
    exec {fd}<&-
    return "${rc}"
}

# Walk the project's directories and files (one filesystem; heavy trees skipped unless `--full`). A '!'-excluded
# or secret-named directory has its whole subtree skipped; an excluded or secret regular file is skipped on its own.
# find runs WITHOUT `-L`, so a symlink is listed but never descended: a symlink loop inside the tree is unreachable
# by construction and needs neither cycle detection, and `-xdev` keeps the walk off other filesystems and bind mounts.
if ${FULL}; then
    # `--full`: the skip list is a walk-cost optimization, and residue hidden in a skipped tree (node_modules, .venv,
    # caches) survives a copy exactly like the rest.
    AI_TOOLS_SKIP_DIRS__FIND_EXPRESSION=()
else
    ai_tools_skip_dirs__build_find_expression unclaim '' "${canonical}"
fi
declare -a expr=( "${canonical}" -xdev "${AI_TOOLS_SKIP_DIRS__FIND_EXPRESSION[@]}" \
                  '(' -type d -o -type f ')' -print0 )

declare -i changed=0
find "${expr[@]}" 2>/dev/null \
    | { declare -a skip=()
        declare -i hardlinked=0 rc=0
        _under_skip() { local p; for p in "${skip[@]:-}"; do
            [[ -n "${p}" && ( "$1" == "${p}" || "$1" == "${p}/"* ) ]] && return 0; done; return 1; }
        while IFS= read -r -d '' p; do
            _under_skip "${p}" && continue
            if _is_excluded "${p}" || _is_secret_name "${p}"; then
                [[ -d "${p}" ]] && skip+=("${p}")
                continue
            fi
            rc=0; _safe_unclaim "${p}" || rc=$?
            case "${rc}" in
                0) changed=$(( changed + 1 )) ;;
                2) hardlinked=$(( hardlinked + 1 )) ;;
            esac
        done
        ai_tools_log__structured info \
            "unclaimed ${changed} path(s) under ${canonical} (group -> ${TARGET_GROUP}, group write removed)" \
            "AI_TOOLS_RESULT=ok"
        # Surfaced, never silent, and with its CONSEQUENCE: a refused hardlink is a path the operator asked to change
        # that keeps the group it has -- so after the project is deregistered those inodes still carry the agent's
        # group, which is the one thing an unclaim is for. The refusal is still right (the inode is reachable
        # from outside the tree, and this pass does not authorize a change out there), so what the operator needs is
        # to be told plainly and handed the command that lists them, not a silent difference between counts.
        if (( hardlinked )); then
            warn MSG-Z5S7 "left ${hardlinked} hardlinked file(s) untouched -- an inode with more than one name can be reached from outside this tree, so changing it here would change a path this pass never authorized. They KEEP the group they have, so the agent is not off them: list them with"
            # The command goes out unprefixed, on its own line, so it stays copy-pasteable.
            printf '  find %s -xdev -type f -links +1 -group ai-tools\n' "${canonical}" >&2
            ai_tools_log__coded warning "${_warn_code}" \
                "left ${hardlinked} hardlinked file(s) under ${canonical} untouched -- they keep group ai-tools"
        fi
      } || true

# .git reversal: the main walk skips .git (the shared heavy-tree list), but a claim grouped it to ai-tools (the
# recursive chgrp) and may have normalized it (ai-tools-setfacl `--with-git`: setgid + ACL), so a full unclaim must
# revert .git too -- otherwise the agent keeps git-history access through the group owner and the named ACL entry.
# Revert it here in one pass with the same per-entry reversal (clear ACL, regroup to <target-group>, drop group write,
# clear dir setgid) and the same secret/exclusion skips. Unconditional: it reverses the base claim's chgrp whether
# or not `--with-git` ran, and no-ops on an already- reverted tree. The loop runs in this shell (process substitution),
# so the counter survives.
gitdir="${canonical}/.git"
if [[ -d "${gitdir}" ]] && ! _is_excluded "${gitdir}"; then
    declare -i git_changed=0 git_hardlinked=0 grc=0
    declare -a gskip=()
    _under_gskip() { local q; for q in "${gskip[@]:-}"; do
        [[ -n "${q}" && ( "$1" == "${q}" || "$1" == "${q}/"* ) ]] && return 0; done; return 1; }
    while IFS= read -r -d '' p; do
        _under_gskip "${p}" && continue
        if _is_excluded "${p}" || _is_secret_name "${p}"; then
            [[ -d "${p}" ]] && gskip+=("${p}")
            continue
        fi
        grc=0; _safe_unclaim "${p}" || grc=$?
        case "${grc}" in
            0) git_changed=$(( git_changed + 1 )) ;;
            2) git_hardlinked=$(( git_hardlinked + 1 )) ;;
        esac
    done < <(find "${gitdir}" -xdev '(' -type d -o -type f ')' -print0 2>/dev/null)
    ai_tools_log__structured info \
        "unclaimed ${git_changed} path(s) under ${gitdir} (group -> ${TARGET_GROUP}, group write removed)" \
        "AI_TOOLS_PATH=${gitdir}" "AI_TOOLS_RESULT=ok"
    # `git clone --local` hardlinks .git/objects to the source repo, so a locally-cloned tree legitimately hits
    # the hardlink guard here in bulk. Refusing is the correct outcome -- those inodes are shared with the origin,
    # and changing one changes the origin's copy -- but it has to be said out loud, or the operator reads a partial
    # revert as a complete one.
    if (( git_hardlinked )); then
        warn MSG-H9D7 "left ${git_hardlinked} hardlinked file(s) in .git untouched -- a local git clone shares object files with the source repo, so changing them would change the origin. They KEEP the group they have: list them with"
        printf '  find %s -xdev -type f -links +1 -group ai-tools\n' "${gitdir}" >&2
        ai_tools_log__coded warning "${_warn_code}" \
            "left ${git_hardlinked} hardlinked file(s) under ${gitdir} untouched -- they keep group ai-tools" \
            "AI_TOOLS_PATH=${gitdir}"
    fi
fi

# SELinux label reset (`--unlisted` only): a tree that was MOVED rather than copied keeps the ai_tools_project_t label
# it was claimed with. No fcontext rule names the new path, so `ai-tools-relabel --remove` has no rule to remove;
# a forced restorecon resets the tree to the default its location resolves to. Gated on the root actually carrying
# the label, so a tree that never had it is not relabelled as a side effect of unclaiming. Best-effort: a label left
# behind is a defence-in-depth gap, not an access grant -- the DAC reversal has already removed the agent's reach.
# The tree was a claimed project, so the walk runs only while no process of the sandbox account is live, read
# through sessions.lib.sh, and a reading that cannot be made skips it as a live one does (cli.rule.md,
# ref-section-e6c6).
if ${UNLISTED} && [[ "$(getenforce 2>/dev/null || echo Disabled)" != "Disabled" ]] \
        && command -v restorecon >/dev/null 2>&1; then
    if [[ "$(stat -c '%C' "${canonical}" 2>/dev/null)" == *:ai_tools_project_t:* ]]; then
        sessions_state=unreadable
        # shellcheck source=SCRIPTDIR/../../lib/ai-tools/sessions.lib.sh
        if source /usr/local/lib/ai-tools/sessions.lib.sh 2>/dev/null \
                && declare -F ai_tools_sessions__read_live_session_state >/dev/null 2>&1; then
            sessions_state="$(ai_tools_sessions__read_live_session_state)"
        fi
        if [[ "${sessions_state}" != none ]]; then
            warn MSG-T6T2 "left the SELinux label under ${canonical} as it was: an agent session, the toolchain update, or a step run as the sandbox account is live, or this host's sessions could not be read; let each session end (/exit inside it) and the toolchain update finish, or end them all with 'atr stop', then run: sudo restorecon -RF ${canonical}"
            ai_tools_log__coded warning "${_warn_code}" \
                "left the SELinux label under ${canonical}: a session of the sandbox account was live" \
                "AI_TOOLS_RESULT=skipped"
        elif restorecon -RF -- "${canonical}" 2>/dev/null; then
            ai_tools_log__structured info \
                "reset SELinux label under ${canonical} (was ai_tools_project_t)" "AI_TOOLS_RESULT=ok"
        else
            warn MSG-T4S2 "could not reset the SELinux label -- run: sudo restorecon -RF ${canonical}"
            ai_tools_log__coded warning "${_warn_code}" \
                "could not reset the SELinux label under ${canonical}" "AI_TOOLS_RESULT=failed"
        fi
    fi
fi

exit 0
