#!/usr/bin/bash
# SPDX-License-Identifier: AGPL-3.0-only
# /usr/local/libexec/ai-tools/ai-tools-setfacl
# Applies the per-project POSIX ACL that lets the owning operator and the sandbox agent co-write an approved tree
# whatever either party's umask: an access + inherited-default ACL granting rwX to the ai-tools group (the
# agent's access to operator-written files) and to the resolved operator (the operator's access to agent-written files),
# others denied, built by project-permissions.lib.sh, the specification the claim's verifier reads too.
# Under `--with-git` a second pass normalizes `.git` the same way -- group, setgid on its directories and the same ACL
# -- so the operator's commits stay agent-readable.
#
# The walk skips secret-named, '!'-excluded, skip-list and foreign-owned paths, and never grants an owner-only path
# (0600/0700; `_safe_setfacl` returns 2 on ai_tools_owner_only__is_owner_only): it strips the sandbox residue such
# a path carries instead, and a skipped directory takes its subtree with it, no grant inside being reachable through it.
# What the seal is, what the strip removes and why `setfacl -m` on a sealed path would grant are owner-only.lib.sh's.
# Every skip is counted and reported: on the project ROOT it means the sandbox account has no way into the tree,
# and under `--with-git` it means the history the operator asked to share was not shared.
#
# Alongside the ACL, the walk normalizes the primary group of a DRIFTED path -- group-accessible yet not group
# ai-tools (it arrived by rename, inheriting neither the setgid group nor the default ACL) -- so a re-claim's
# drift scan (acl_drift_scan in the CLI) finds the tree settled.
#
# Runs as root via sudo under `ai-tools projects claim`; CAP_FOWNER lets it ACL files the operator does not own.
# Installed 750 root:root. Its domain rule is cli.rule.md.

set -euo pipefail

# Every refusal and every disclosure this helper prints goes through warn, 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. Each refusal exits at its own site: this helper's statuses are 2 (usage), 3 (an unusable
# library) and 0 (a completed walk, and a target it declines silently), so there is no one status for a die() to carry.
# The printed text is left in _warn_text, and the code it printed 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_text="" _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_text="$*" _warn_code="${code}"
    printf 'ai-tools-setfacl: %s\n' "${_warn_text}" >&2
}

# Args: an optional --with-git flag (anywhere) enables the one-shot .git normalization
# pass; the remaining argument is the absolute project path.
WITH_GIT=false
TARGET=""
for arg in "$@"; do
    case "${arg}" in
        --with-git) WITH_GIT=true ;;
        -*) warn MSG-C3V2 "unknown option: ${arg}"; exit 2 ;;
        *)  if [[ -z "${TARGET}" ]]; then
                TARGET="${arg}"
            else
                warn MSG-J9J3 "too many arguments"; exit 2
            fi ;;
    esac
done
[[ -n "${TARGET}" ]] \
    || { printf 'usage: ai-tools-setfacl [--with-git] <absolute-project-path>\n' >&2; exit 2; }
readonly TARGET WITH_GIT

# 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; }
readonly GROUP="ai-tools"
# The ACL a claim grants (project-permissions.lib.sh), shared with the claim's verifier so the two cannot disagree
# about which entries a repaired path carries. Required: without it the helper has no specification to apply.
# shellcheck source=SCRIPTDIR/../../lib/ai-tools/project-permissions.lib.sh
source /usr/local/lib/ai-tools/project-permissions.lib.sh 2>/dev/null || true
if ! declare -F ai_tools_project_permissions__build_acl_specification >/dev/null 2>&1; then
    warn MSG-D3E3 "FATAL: project-permissions.lib.sh did not load -- no ACL specification to apply"
    exit 3
fi
# Two identities may legitimately hold a project tree's files: the resolved operator and the sandbox account. A file
# belonging to a third party (root, another developer) is left untouched -- claim must not pull a foreign file
# into the agent's group, even one the operator placed in the tree -- and COUNTED, so a walk that skipped every path is
# reported rather than silent; the project root hitting the guard is called out on its own, since it means the claim
# granted no access at all. Matched by numeric UID; PROJECTS_UID is the resolved operator (set by the owner resolution).
SANDBOX_UID="$(id -u "ai-tools" 2>/dev/null || echo -1)"
readonly SANDBOX_UID

# Shared leveled logger: journald (always) + the root-only file /var/log/ai-tools/setfacl.log. Best-effort -- a no-op
# fallback keeps the helper working if the lib is missing.
AI_TOOLS_LOG_TAG="ai-tools-setfacl"
AI_TOOLS_LOG_FILE="setfacl.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 from the shared library (single source of truth, also used by session-hook.sh
# and ai-tools-setgid). A missing lib (broken install) leaves a stub that descends everywhere -- 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; }

# Without setfacl (or on a filesystem without ACL support) there is no ACL to apply -- warn once and exit cleanly
# (best-effort, mirrors the other helpers' fail-soft). The claim reports the step as applied either way, so the operator
# is told on stderr as well as in the log: a tree with no ACL is one the agent reaches only through the group it was
# chgrp'd to.
command -v setfacl >/dev/null 2>&1 \
    || { warn MSG-V3W8 "setfacl not found -- skipping ACL normalization for ${TARGET}"
         ai_tools_log__coded warning "${_warn_code}" "${_warn_text}" "AI_TOOLS_RESULT=failed"
         exit 0; }

# Which paths the operator sealed, and what may be stripped from one (owner-only.lib.sh, the reference for both).
# Required and fail-closed like safe-paths.lib.sh: an unusable library must not leave this walk unable to recognize
# a sealed path.
# shellcheck source=SCRIPTDIR/../../lib/ai-tools/owner-only.lib.sh
source /usr/local/lib/ai-tools/owner-only.lib.sh
if ! declare -F ai_tools_owner_only__is_owner_only >/dev/null 2>&1 \
        || ! declare -F ai_tools_owner_only__strip_sandbox_residue >/dev/null 2>&1; then
    # One library, one defect, one remedy, so the three helpers that make this check share one code (messaging.rule.md's
    # twin rule): it is DEFINED here and cited from a printf format string in ai-tools-setgid and ai-tools-lockdown.
    warn MSG-G4P4 "FATAL: owner-only.lib.sh defines no owner-only guard"
    exit 3
fi

# 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}" "ACL grant" || exit 3

# 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
readonly ALLOWLIST="${AI_TOOLS_OPERATOR__RESOLVED_ALLOWLIST}" PROJECTS_UID

# Secret-name matcher (defense in depth): the walk skips every path whose basename matches the secret patterns
# (_is_secret_name), so a private file such as .env is not re-exposed to the agent group even if the operator forgot
# to '!'-exclude it. Loaded after the owner resolve, which names the operator's file (ai_tools_secret_patterns__load
# states what an earlier load reads). Fail-closed: a walk with no matcher would grant the agent's group every path
# the operator named, so a library that does not load and a present file the loader cannot read each refuse
# before the first grant (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
    warn MSG-Q6N6 "cannot load ${SECRET_PATTERNS_LIB}, which decides which paths are secrets -- no ACL was granted under ${canonical}; reinstall the ai-tools package"
    exit 3
fi
if ! ai_tools_secret_patterns__load; then
    warn "the operator's secret-patterns file could not be read, so no ACL was granted under ${canonical}"
    exit 3
fi
_is_secret_name() {
    ai_tools_secret_patterns__is_secret_basename "$(basename -- "$1")"
}

# This run grants 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}"

ACL_SPEC=""
if ! ai_tools_project_permissions__build_acl_specification ACL_SPEC "${PROJECTS_USER}" "${GROUP}"; then
    warn MSG-F3U3 "FATAL: project-permissions.lib.sh built no ACL specification for ${PROJECTS_USER}"
    exit 3
fi
readonly ACL_SPEC

# 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 reads a commented exclusion
# as a pattern no path matches, and would grant the agent an ACL on a subtree the operator carved out and the launch
# wrapper refuses. 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 allowlist makes (ai_tools_conf__load_allowlist, conf.lib.sh). A file that cannot be
# read, or whose exclusion the loader refuses, leaves both arrays empty, so the project is not allowed.
ai_tools_conf__load_allowlist "${ALLOWLIST}" allowed_directories exclusion_patterns || true

# _is_excluded <abs-path>: 0 if covered by a '!' rule (ai_tools_conf__is_path_excluded, conf.lib.sh -- the match every
# reader of the allowlist makes).
_is_excluded() { ai_tools_conf__is_path_excluded "$1" exclusion_patterns; }

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

# The passed project root must itself be an allowed, non-excluded path.
_is_excluded "${canonical}" && exit 0
_is_allowed  "${canonical}" || exit 0

# _safe_setfacl <path>: apply the ACL to <path>, TOCTOU-safe. The agent is a group- writer on project dirs and could
# swap an entry for a symlink between the find that enumerates it and the setfacl that acts on it; setfacl would then
# follow the symlink and ACL an arbitrary target (e.g. /etc) as root. Pin the inode with an open fd and operate
# through /proc/self/fd, re-checking it is still the same inode, at <path> (ai_tools_safe_paths__is_pinned_fd_at_path,
# safe-paths.lib.sh). Directories get the access AND default ACL; regular files the access ACL only. Mirrors
# ai-tools-setgid's pinned-fd apply. Returns 0 on apply, 1 when skipped or on error.
_safe_setfacl() {
    local path="$1" normalize="${2:-}" expect_ident fd got_ident got_uid got_grp got_mode got_ftype
    expect_ident="$(stat -c '%d:%i' "${path}" 2>/dev/null)" || return 1
    { exec {fd}< "${path}"; } 2>/dev/null || return 1
    # %u/%G/%a BEFORE %F: %F ("regular empty file") is multi-word and must be the last field.
    read -r got_ident got_uid got_grp got_mode got_ftype \
        < <(stat -L -c '%d:%i %u %G %a %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 (checked on the pinned inode, TOCTOU-safe): only the projects user's or the sandbox account's own
    # files are eligible; anything else is left untouched. Returns 3, not 1, so the walk can tell a third-party owner
    # from a stat failure and report it. Without that split, a claim whose every path was foreign-owned closes
    # with the same clean report as one that had no path to grant.
    if [[ "${got_uid}" != "${PROJECTS_UID}" && "${got_uid}" != "${SANDBOX_UID}" ]]; then
        exec {fd}<&-
        return 3
    fi
    # Owner-only guard: the operator sealed this path and the claim honours it. Granting it would be worse than a no-op
    # -- `setfacl -m` RECALCULATES the mask, so the grant on a 0600 file
    # raises its mask from --- to rw- and hands the agent EFFECTIVE read/write while `ls -l` still
    # shows `-rw-------` and only the trailing `+` hints anything changed. Strip the residue the
    # path carries instead (owner-only.lib.sh), and report it: an owner-only .git under `--with-git` is a deliberate
    # no-op the operator has to be told about, not a share that quietly skipped.
    if ai_tools_owner_only__is_owner_only "${got_mode}"; then
        ai_tools_owner_only__strip_sandbox_residue "${fd}" "${got_ftype}" "${got_grp}" "${got_mode}" \
            "${PROJECTS_GROUP:-}" || true
        exec {fd}<&-
        return 2
    fi
    local rc=0
    # Group ownership (plus setgid on dirs) so future entries inherit group GROUP -- the ownership inheritance
    # ai-tools-setgid gives the work tree's directories. Applied unconditionally under 'normalize' (the .git pass),
    # and on the main walk to a DRIFTED path: group-accessible (any group/other bit) yet not group GROUP -- it arrived
    # by rename, inheriting neither the setgid group nor the default ACL; the same predicate the CLI's acl_drift_scan
    # reports. The chgrp is what settles the drift report: an ACL entry alone grants access but leaves the primary group
    # foreign, so the scan would re-flag the path on every claim. Operates on the pinned fd, TOCTOU-safe like the ACL.
    local fix_group=false
    if [[ "${normalize}" == "normalize" ]]; then
        fix_group=true
    elif [[ "${got_grp}" != "${GROUP}" ]] && (( 8#${got_mode} & 077 )); then
        fix_group=true
    fi
    if ${fix_group}; then
        chgrp -- "${GROUP}" "/proc/self/fd/${fd}" 2>/dev/null || rc=1
        [[ "${got_ftype}" == directory ]] \
            && { chmod -- g+s "/proc/self/fd/${fd}" 2>/dev/null || rc=1; }
    fi
    case "${got_ftype}" in
        directory)
            setfacl    -m "${ACL_SPEC}" "/proc/self/fd/${fd}" 2>/dev/null || rc=1
            setfacl -d -m "${ACL_SPEC}" "/proc/self/fd/${fd}" 2>/dev/null || rc=1
            ;;
        "regular file"|"regular empty file")
            setfacl    -m "${ACL_SPEC}" "/proc/self/fd/${fd}" 2>/dev/null || rc=1
            ;;
        *)  # symlink / fifo / device / socket -- never ACL these
            exec {fd}<&-
            return 1
            ;;
    esac
    exec {fd}<&-
    return "${rc}"
}

# Walk the project's directories and files (skipping heavy trees, one filesystem) and ACL each. find emits a dir
# before its contents (pre-order), so when a dir is '!'-excluded or secret-named we record it as a skip-prefix and skip
# its whole subtree; an excluded/secret regular file is skipped on its own.
ai_tools_skip_dirs__build_find_expression setfacl '' "${canonical}"
declare -a expr=( "${canonical}" -xdev "${AI_TOOLS_SKIP_DIRS__FIND_EXPRESSION[@]}" \
                  '(' -type d -o -type f ')' -print0 )

declare -i applied=0
find "${expr[@]}" 2>/dev/null \
    | { declare -a skip=()
        declare -i owneronly=0 thirdparty=0 rc=0
        declare root_thirdparty=false
        _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}")     # skip the whole subtree of a dir
                continue
            fi
            rc=0; _safe_setfacl "${p}" || rc=$?
            case "${rc}" in
                0) applied=$(( applied + 1 )) ;;
                2) owneronly=$(( owneronly + 1 ))
                   # A skipped DIRECTORY keeps its whole subtree out of the walk: an unreachable directory's contents
                   # cannot be granted through it, and descending would grant paths the operator sealed
                   # off at the parent.
                   [[ -d "${p}" ]] && skip+=("${p}") ;;
                3) thirdparty=$(( thirdparty + 1 ))
                   # The project root decides whether the ACL grant happened at all -- see the same split
                   # in ai-tools-setgid.
                   [[ "${p}" == "${canonical}" ]] && root_thirdparty=true ;;
            esac
        done
        # The counts are local to this subshell (pipe); log them here.
        ai_tools_log__structured info "ACL-normalized ${applied} path(s) under ${canonical}" \
            "AI_TOOLS_RESULT=ok"
        if (( owneronly )); then
            warn MSG-C9Z6 "left ${owneronly} owner-only path(s) (0600/0700) out of the sandbox account's reach"
            ai_tools_log__coded info "${_warn_code}" \
                "left ${owneronly} owner-only path(s) under ${canonical} out of the agent's reach"
        fi
        # Surfaced for the same reason as the setgid walk's: the owner guard is the one skip that can leave a claim
        # reporting success having granted no access.
        if (( thirdparty )); then
            if ${root_thirdparty}; then
                warn MSG-M6H3 "the project directory itself is owned by neither ${PROJECTS_USER} nor ai-tools -- no ACL was applied, and the agent gets no access to this tree"
            else
                warn MSG-K8M2 "left ${thirdparty} path(s) owned by neither ${PROJECTS_USER} nor ai-tools untouched -- the agent gets no access to them"
            fi
            ai_tools_log__coded warning "${_warn_code}" \
                "left ${thirdparty} path(s) under ${canonical} untouched: owned by neither ${PROJECTS_USER} nor ai-tools"
        fi
      } || true

# .git normalization (opt-in via `--with-git`): the main walk skips .git, but when the operator intends the agent
# to share git history, normalize it here in one pass -- group GROUP + setgid on its dirs and the same default+access
# group ACL, so commits the operator makes stay agent-accessible. Secret-named and '!'-excluded entries are still
# skipped (a stray credential committed into .git stays private). A `.git` FILE (submodule/worktree pointer) is not
# a tree to normalize, so the `-d` guard skips it. Idempotent. The loop runs in this shell (process substitution, not
# a pipe), so the counter survives.
gitdir="${canonical}/.git"
if ${WITH_GIT} && [[ -d "${gitdir}" ]] && ! _is_excluded "${gitdir}"; then
    declare -i git_applied=0
    declare -a gskip=()
    _under_gskip() { local q; for q in "${gskip[@]:-}"; do
        [[ -n "${q}" && ( "$1" == "${q}" || "$1" == "${q}/"* ) ]] && return 0; done; return 1; }
    declare -i git_owneronly=0 git_thirdparty=0 grc=0
    while IFS= read -r -d '' p; do
        _under_gskip "${p}" && continue
        if _is_excluded "${p}" || _is_secret_name "${p}"; then
            [[ -d "${p}" ]] && gskip+=("${p}")        # skip a secret/excluded subtree whole
            continue
        fi
        grc=0; _safe_setfacl "${p}" normalize || grc=$?
        case "${grc}" in
            0) git_applied=$(( git_applied + 1 )) ;;
            2) git_owneronly=$(( git_owneronly + 1 ))
               [[ -d "${p}" ]] && gskip+=("${p}") ;;
            3) git_thirdparty=$(( git_thirdparty + 1 )) ;;
        esac
    done < <(find "${gitdir}" -xdev '(' -type d -o -type f ')' -print0 2>/dev/null)
    ai_tools_log__structured info \
        "normalized ${git_applied} path(s) under ${gitdir} (group ${GROUP}, setgid dirs, ACL)" \
        "AI_TOOLS_PATH=${gitdir}" "AI_TOOLS_RESULT=ok"
    # `--with-git` is an explicit opt-in, so a .git the owner-only guard seals off is a share that did NOT happen.
    # Silence here would leave the operator believing history is shared.
    if (( git_owneronly )); then
        warn MSG-J8R8 "under .git, ${git_owneronly} owner-only path(s) were NOT shared (0600/0700) -- git history stays out of the sandbox account's reach"
        ai_tools_log__coded info "${_warn_code}" \
            "left ${git_owneronly} owner-only path(s) under ${gitdir} out of the agent's reach" \
            "AI_TOOLS_PATH=${gitdir}"
    fi
    # Same disclosure as the main walk, for the same reason the owner-only count is disclosed here: `--with-git` is
    # an explicit opt-in, so a share that did not happen must be said.
    if (( git_thirdparty )); then
        warn MSG-D8D7 "under .git, ${git_thirdparty} path(s) were NOT shared -- owned by neither ${PROJECTS_USER} nor ai-tools"
        ai_tools_log__coded warning "${_warn_code}" \
            "left ${git_thirdparty} path(s) under ${gitdir} untouched: owned by neither ${PROJECTS_USER} nor ai-tools" \
            "AI_TOOLS_PATH=${gitdir}"
    fi
fi

exit 0
