#!/usr/bin/env bash
# charter-applock — arm or disarm Kintrinsic's app lock on a ward laptop.
#
# The app lock is fapolicyd with Kintrinsic's rules: a ward (a member of the
# charter-managed group) can only execute programs, and load shared
# libraries, from the trees only root can write to, the charterd-approved
# store and what fapolicyd trusts. A downloaded binary in $HOME, /tmp or on a
# USB stick does not run. Nobody else — root, the guardian, the login screen,
# system services — is restricted.
#
#   charter-applock arm [--no-install] [--accept-blocked] [--trial MINUTES]
#   charter-applock disarm
#   charter-applock status
#   charter-applock keep            (end a --trial: keep the lock armed)
#   charter-applock refresh         (cover wards added since arming)
#   charter-applock trial-check     (run by the trial timer)
#   charter-applock post-dpkg       (run by apt after every dpkg run)
#
# Arming is opt-in and never happens on install or upgrade. It is built so it
# cannot lock the session out:
#   1. fapolicyd is installed if missing (or arming refuses, with the command).
#   2. It refuses if a ward can write anywhere inside an allowed tree, if
#      someone else's fapolicyd policy is present, or if a binary the desktop
#      session needs lives somewhere the lock would deny.
#   3. Trust is seeded (by sha256; `integrity = sha256` while armed) for the
#      ward's session binaries that live outside the root-only trees, that no
#      ward owns or can write, and that are not under a home, a temp dir or
#      removable media.
#   4. fapolicyd is set to watch whole filesystems (`allow_filesystem_mark`)
#      and unprivileged user namespaces are restricted to AppArmor-profiled
#      programs (the sysctl Ubuntu ships on and Mint turns off). With
#      fapolicyd still stopped, a ward must fail to run its canary inside a
#      new user + mount namespace (`unshare -rm`), and the library canaries
#      must still load, so a refusal later is fapolicyd's doing.
#   5. fapolicyd runs in PERMISSIVE mode first while every session binary is
#      exec-probed as each ward, together with three canaries in the ward's
#      own files: a copy of `true` (execution), a copy of a system shared
#      library, and a copy of a shared object fapolicyd types as an
#      executable (libc.so.6; library loading). The lock is only switched to
#      enforcing if every canary WAS denied (the rules are live) and nothing
#      needed was.
#   6. After switching to enforcing it probes again, for real, and disarms
#      itself on the spot if anything needed is refused. Any failure or
#      interruption part-way through puts everything back.
#
# Disarm is one command and reverts everything; it is safe to run at any time,
# repeatedly, from a TTY or a recovery shell:
#   sudo charter-applock disarm
# If even that is unavailable: `systemctl disable --now fapolicyd` alone lifts
# the lock (fapolicyd not running = nothing enforced), and booting with
# `systemd.mask=fapolicyd.service` on the kernel command line stops it from
# starting at all.
set -uo pipefail

PROG=charter-applock
RULES_TEMPLATE="${CHARTER_APPLOCK_TEMPLATE:-/usr/share/charter/fapolicyd/72-charter.rules}"
FAPO_DIR=/etc/fapolicyd
RULES_DST="$FAPO_DIR/rules.d/72-kintrinsic-applock.rules"
# The file older packages shipped straight into rules.d (a default-deny for
# EVERYONE). Never ours to keep: arm and disarm both remove it.
STALE_RULES="$FAPO_DIR/rules.d/72-charter.rules"
COMPILED="$FAPO_DIR/compiled.rules"
FAPO_CONF="$FAPO_DIR/fapolicyd.conf"
TRUST_DIR="$FAPO_DIR/trust.d"
SESSION_TRUST=charter-session
STATE_DIR=/var/lib/charter/app-lock
MARKER="$STATE_DIR/armed"
CONF_BACKUP="$STATE_DIR/fapolicyd.conf.orig"
COMPILED_BACKUP="$STATE_DIR/compiled.rules.orig"
# fapolicyd's enabled/active state before the first arm, restored by disarm.
PRIOR_STATE="$STATE_DIR/prior-state"
CANARIES="$STATE_DIR/canaries"
# --trial: a deadline (unix seconds) that survives a reboot, checked by a
# persistent timer at boot and every 30 s.
TRIAL_DEADLINE="$STATE_DIR/trial-deadline"
TRIAL_UNIT=kintrinsic-applock-trial
UNIT_DIR=/etc/systemd/system
# Restarts an armed fapolicyd after dpkg replaces its binary.
APT_HOOK=/etc/apt/apt.conf.d/80kintrinsic-applock
# Removable media mount noexec while armed (defence in depth for the FUSE
# drivers fapolicyd does not watch: ntfs-3g, exfat-fuse).
UDISKS_CONF=/etc/udisks2/mount_options.conf
OURS_TAG="# kintrinsic-applock:"
MANAGED_GROUP=charter-managed
# First line of the rendered rules file. charterd keys STATUS `appLockArmed`
# off it (linux/crates/charterd/src/app_lock.rs) — change both together.
GENERATED_TAG="# generated by charter-applock"
# The default-deny line every rendered file carries; its presence in
# compiled.rules is the "these rules are what fapolicyd loads" check.
DENY_MARK="deny_log perm=execute"
# Trees a ward may execute from (mirror the dir= rules in the template, and
# ALLOWED_TREES in charterd's app_lock.rs).
ALLOWED_TREES=(/usr /etc /opt /snap /var/lib/flatpak /var/lib/charter/approved)
# Never trusted by path or hash, whoever owns the file: homes, temp dirs and
# removable media are where a ward's own content lives.
SEED_FORBIDDEN=(/home /root /media /mnt /run/media /run/user /tmp /var/tmp /dev/shm)
# Denied to wards on purpose by the template (not a self-test failure).
INTENDED_DENY=(/usr/bin/fusermount3 /usr/bin/fusermount /usr/bin/aa-exec)
# Filesystems added to watch_fs: the kernel exFAT/NTFS drivers USB sticks use.
# FUSE is deliberately NOT added — fapolicyd reading a file through a FUSE
# daemon that is itself waiting on a permission decision can hang the box.
EXTRA_WATCH_FS=(exfat ntfs3)
PROBE_AS_BYTES=65536
# Unprivileged user namespaces let a ward make its own mount namespace and
# mount a fresh filesystem (a tmpfs) that fapolicyd never marked. AppArmor's
# switch keeps them to programs whose profile allows it. Armed sets it to 1
# live and in a sysctl.d drop-in sorting after Mint's 20-apparmor-mint.conf;
# disarm removes the drop-in and puts the recorded live values back.
USERNS_SYSCTL=kernel.apparmor_restrict_unprivileged_userns
USERNS_PROC=/proc/sys/kernel/apparmor_restrict_unprivileged_userns
# Its companion: an unconfined process may not switch itself into a profile
# that allows user namespaces (aa-exec, or writing /proc/self/attr/exec).
UNCONFINED_SYSCTL=kernel.apparmor_restrict_unprivileged_unconfined
UNCONFINED_PROC=/proc/sys/kernel/apparmor_restrict_unprivileged_unconfined
SYSCTL_DROPIN=/etc/sysctl.d/99-kintrinsic-applock.conf
# Where each switch's live value before the first arm is kept.
sysctl_prior() { printf '%s/sysctl-%s.orig\n' "$STATE_DIR" "$1"; }
# Ubuntu's AppArmor profiles that let a desktop program (a browser sandbox,
# flatpak's bwrap, an Electron app) keep making user namespaces while the
# switch is on. Arm lists which are present; a program whose profile is
# missing cannot start its sandbox for a ward while armed.
USERNS_PROFILES=(chrome mint-chromium brave msedge opera vivaldi-bin firefox epiphany flatpak steam
    element-desktop signal-desktop slack Discord code)

log() { echo "$PROG: $*" >&2; }
die() { log "$*"; exit 1; }

# ---------------------------------------------------------------------------
# Pure helpers (exercised by tests/applock.sh without root).
# ---------------------------------------------------------------------------

# render_rules <template> <uid>...
# The live rules file: every @WARD@ line once per `uid=N` and `auid=N`
# selector, then the ward-free lines once.
render_rules() {
    local template="$1"; shift
    local uid sel line
    printf '%s — do not edit; `charter-applock disarm` removes it.\n' "$GENERATED_TAG"
    printf '# wards (uid): %s\n' "$*"
    for uid in "$@"; do
        for sel in "uid=$uid" "auid=$uid"; do
            while IFS= read -r line || [ -n "$line" ]; do
                case "$line" in
                    '#'*|'') continue ;;
                    *@WARD@*) printf '%s\n' "${line//@WARD@/$sel}" ;;
                esac
            done < "$template"
        done
    done
    while IFS= read -r line || [ -n "$line" ]; do
        case "$line" in
            '#'*|''|*@WARD@*) continue ;;
            *[![:space:]]*) printf '%s\n' "$line" ;;
        esac
    done < "$template"
}

# rules_cover_uid <rules file> <uid> — the file holds that ward's default-deny.
rules_cover_uid() {
    grep -qxF "$DENY_MARK uid=$2 : all" "$1" 2>/dev/null
}

# conf_get <file> <key> — the value of the LAST `key = value` line, or empty.
conf_get() {
    sed -n -E "s/^[[:space:]]*$2[[:space:]]*=[[:space:]]*(.*[^[:space:]])[[:space:]]*$/\1/p" "$1" 2>/dev/null | tail -n1
}

# conf_set <file> <key> <value> — replace every `key =` line, or append one.
conf_set() {
    local f="$1" k="$2" v="$3"
    if grep -qE "^[[:space:]]*$k[[:space:]]*=" "$f"; then
        sed -i -E "s|^[[:space:]]*$k[[:space:]]*=.*|$k = $v|" "$f"
    else
        printf '%s = %s\n' "$k" "$v" >> "$f"
    fi
}

# csv_merge <csv> <item>... — <csv> with each missing item appended.
csv_merge() {
    local out="$1" item; shift
    for item in "$@"; do
        case ",$out," in
            *",$item,"*) ;;
            *) out="${out:+$out,}$item" ;;
        esac
    done
    printf '%s\n' "$out"
}

# denied_paths <fapolicyd debug log> — each distinct object path fapolicyd
# decided to deny (in permissive mode: would have denied). Handles paths with
# spaces when the line carries the usual ` ftype=` field after the path.
denied_paths() {
    grep -E 'dec=deny' "$1" 2>/dev/null | while IFS= read -r line; do
        case "$line" in
            *' : path='*' ftype='*)
                line="${line#* : path=}"
                printf '%s\n' "${line%% ftype=*}"
                ;;
            *'path='*)
                line="${line#*path=}"
                printf '%s\n' "${line%% *}"
                ;;
        esac
    done | sort -u
}

# desktop_exec_prog <file.desktop> — the program an Exec= line runs: the
# first token that is not `env`, an env assignment or an env flag.
desktop_exec_prog() {
    local line tok
    line="$(grep -m1 -E '^Exec=' "$1" 2>/dev/null)" || return 1
    line="${line#Exec=}"
    local -a toks
    set -f
    read -r -a toks <<< "$line"
    set +f
    for tok in "${toks[@]}"; do
        tok="${tok#\"}"; tok="${tok%\"}"
        case "$tok" in
            ''|env|/usr/bin/env|-*|*=*) continue ;;
        esac
        printf '%s\n' "$tok"
        return 0
    done
    return 1
}

# under_allowed_tree <path> — 0 if <path> is inside one of ALLOWED_TREES.
under_allowed_tree() {
    local t
    for t in "${ALLOWED_TREES[@]}"; do
        case "$1" in "$t"/*) return 0 ;; esac
    done
    return 1
}

# seed_forbidden <path> — 0 if <path> must never be trusted (a home, a temp
# dir, removable media), whoever owns it.
seed_forbidden() {
    local t
    for t in "${SEED_FORBIDDEN[@]}"; do
        case "$1" in "$t"|"$t"/*) return 0 ;; esac
    done
    return 1
}

# intended_deny <path> — 0 if the template denies <path> to wards on purpose.
intended_deny() {
    local t
    for t in "${INTENDED_DENY[@]}"; do [ "$1" = "$t" ] && return 0; done
    return 1
}

# rules_file_is_ours <file> — the rendered file's first line is our tag.
rules_file_is_ours() {
    local first
    IFS= read -r first < "$1" 2>/dev/null || return 1
    case "$first" in "$GENERATED_TAG"*) return 0 ;; esac
    return 1
}

# file_is_ours <file> — a file this script wrote (tagged first line).
file_is_ours() {
    local first
    IFS= read -r first < "$1" 2>/dev/null || return 1
    case "$first" in "$OURS_TAG"*|"// ${OURS_TAG#\# }"*) return 0 ;; esac
    return 1
}

# Let a test harness `source` this script for the helpers above.
[[ "${BASH_SOURCE[0]}" == "$0" ]] || return 0

# ---------------------------------------------------------------------------
# System helpers.
# ---------------------------------------------------------------------------

need_root() { [ "$(id -u)" -eq 0 ] || die "run as root (sudo $PROG $*)"; }

# as_ward <uid> <gid> <cmd>... — run <cmd> as the ward, with their groups and
# a clean environment. The caller's auid is kept, so this exercises the
# `uid=` half of the rules.
as_ward() {
    local uid="$1" gid="$2"; shift 2
    setpriv --reuid="$uid" --regid="$gid" --init-groups --reset-env -- "$@"
}

# ward_list — "user uid gid home" for each member of charter-managed.
ward_list() {
    local members m ent
    members="$(getent group "$MANAGED_GROUP" | awk -F: '{print $4}')"
    for m in ${members//,/ }; do
        ent="$(getent passwd "$m")" || continue
        printf '%s\n' "$ent" | awk -F: '{print $1, $3, $4, $6}'
    done
}

# ward_can_alter <uid> <gid> <path> — 0 if the ward owns <path> or any
# directory above it (an owner can chmod its way to writing), or can write
# any of them (i.e. could replace it).
ward_can_alter() {
    local p="$3"
    while :; do
        [ "$(stat -c %u "$p" 2>/dev/null)" = "$1" ] && return 0
        [ "$p" = / ] && break
        p="$(dirname "$p")"
    done
    as_ward "$1" "$2" /bin/sh -c '
        p="$1"
        while :; do
            [ -w "$p" ] && exit 0
            [ "$p" = / ] && exit 1
            p="$(dirname "$p")"
        done' _ "$3"
}

# tree_audit <uid> <gid> [maxdepth] — up to 10 entries inside ALLOWED_TREES
# the ward can write (as the ward: access(2), so ACLs count). Symlinks are
# skipped: fapolicyd judges the resolved path.
tree_audit() {
    local -a trees=() depth=()
    local t
    for t in "${ALLOWED_TREES[@]}"; do [ -d "$t" ] && trees+=("$t"); done
    [ "${#trees[@]}" -gt 0 ] || return 0
    [ -n "${3:-}" ] && depth=(-maxdepth "$3")
    as_ward "$1" "$2" find "${trees[@]}" "${depth[@]}" ! -type l -writable -print 2>/dev/null | head -n 10
}

# resolve_prog <prog> <ward home> — absolute, symlink-resolved path of <prog>
# as the ward's session would find it.
resolve_prog() {
    local prog="$1" home="$2" d
    case "$prog" in
        '~/'*) prog="$home/${prog#\~/}" ;;
    esac
    if [[ "$prog" == */* ]]; then
        [ -f "$prog" ] && realpath -e "$prog" 2>/dev/null
        return
    fi
    for d in "$home/.local/bin" "$home/bin" /usr/local/sbin /usr/local/bin /usr/sbin /usr/bin /sbin /bin; do
        if [ -f "$d/$prog" ] && [ -x "$d/$prog" ]; then
            realpath -e "$d/$prog" 2>/dev/null
            return
        fi
    done
}

# probe_exec <uid> <gid> <path> — exec <path> as the ward under a tiny
# address-space limit: fapolicyd decides at open-for-exec, before the image
# loads, and the load then fails, so the program's own code never runs.
# Prints "denied" when the exec was refused with EPERM (fapolicyd), else "ok".
probe_exec() {
    local err
    err="$(LC_ALL=C timeout -s KILL 10 setpriv --reuid="$1" --regid="$2" --init-groups --reset-env -- \
        prlimit --as="$PROBE_AS_BYTES" -- "$3" </dev/null 2>&1 >/dev/null)"
    case "$err" in
        *'Operation not permitted'*) echo denied ;;
        *) echo ok ;;
    esac
}

# probe_open <uid> <gid> <path> — open <path> for reading as the ward (the
# loader's first step with a library). Prints "denied" on EPERM, else "ok".
probe_open() {
    local err
    err="$(LC_ALL=C timeout -s KILL 10 setpriv --reuid="$1" --regid="$2" --init-groups --reset-env -- \
        head -c 1 -- "$3" 2>&1 >/dev/null)"
    case "$err" in
        *'Operation not permitted'*) echo denied ;;
        *) echo ok ;;
    esac
}

# run_as_ward_real <uid> <gid> <path> — actually run it (only ever `true` or a
# copy of it). Prints the exit status.
run_as_ward_real() {
    timeout -s KILL 10 setpriv --reuid="$1" --regid="$2" --init-groups --reset-env -- "$3" </dev/null >/dev/null 2>&1
    echo $?
}

# preload_refused <uid> <gid> <lib> — 0 if the loader could not preload
# <lib> into /usr/bin/true for the ward (glibc then says "cannot be
# preloaded" and carries on without it).
preload_refused() {
    local err
    err="$(LC_ALL=C timeout -s KILL 10 setpriv --reuid="$1" --regid="$2" --init-groups --reset-env -- \
        env LD_PRELOAD="$3" /usr/bin/true 2>&1 >/dev/null)"
    case "$err" in *'cannot be preloaded'*) return 0 ;; esac
    return 1
}

# userns_open <uid> <gid> <canary> — 0 if the ward could make a new user +
# mount namespace and run <canary> (a copy of `true`) inside it. Run while
# fapolicyd is stopped, it measures the namespace restriction alone.
userns_open() {
    LC_ALL=C timeout -s KILL 10 setpriv --reuid="$1" --regid="$2" --init-groups --reset-env -- \
        unshare -rm -- "$3" </dev/null >/dev/null 2>&1
}

# restrict_userns — both AppArmor userns switches on, live and across
# reboots; a switch this kernel lacks is skipped. The live value before the
# first arm is recorded once, for disarm.
restrict_userns() {
    local key proc prior present=()
    for key in "$USERNS_SYSCTL" "$UNCONFINED_SYSCTL"; do
        proc="/proc/sys/${key//.//}"
        if ! [ -e "$proc" ]; then
            log "note: this kernel has no $key; the self-test alone decides what it would have stopped"
            continue
        fi
        prior="$(sysctl_prior "$key")"
        [ -f "$prior" ] || cat "$proc" > "$prior" || return 1
        present+=("$key")
    done
    [ "${#present[@]}" -gt 0 ] || return 0
    install -d -m 0755 "$(dirname "$SYSCTL_DROPIN")"
    {
        printf '%s unprivileged user namespaces stay restricted while the app lock is armed; removed by '"'"'charter-applock disarm'"'"'.\n' "$OURS_TAG"
        for key in "${present[@]}"; do printf '%s = 1\n' "$key"; done
    } > "$SYSCTL_DROPIN" || return 1
    chmod 0644 "$SYSCTL_DROPIN"
    for key in "${present[@]}"; do
        proc="/proc/sys/${key//.//}"
        printf '1\n' > "$proc" 2>/dev/null || return 1
        [ "$(cat "$proc" 2>/dev/null)" = 1 ] || return 1
    done
}

# restore_userns — the drop-in gone and each recorded live value back.
restore_userns() {
    [ -f "$SYSCTL_DROPIN" ] && file_is_ours "$SYSCTL_DROPIN" && rm -f "$SYSCTL_DROPIN"
    local key proc prior value
    for key in "$USERNS_SYSCTL" "$UNCONFINED_SYSCTL"; do
        proc="/proc/sys/${key//.//}"
        prior="$(sysctl_prior "$key")"
        [ -f "$prior" ] || continue
        value="$(cat "$prior" 2>/dev/null)"
        [[ "$value" =~ ^[0-9]+$ ]] && [ -e "$proc" ] && printf '%s\n' "$value" > "$proc" 2>/dev/null
        rm -f "$prior"
    done
}

# userns_probe_profile — the name of a LOADED AppArmor profile that grants
# user namespaces (trinity first, else the first such file in
# /etc/apparmor.d), for the aa-exec check. Fails if there is none.
userns_probe_profile() {
    local loaded=/sys/kernel/security/apparmor/profiles f name
    [ -r "$loaded" ] || return 1
    for f in /etc/apparmor.d/trinity /etc/apparmor.d/*; do
        [ -f "$f" ] || continue
        grep -qE '^[[:space:]]*userns,' "$f" || continue
        name="$(awk '$1 == "profile" { print $2; exit }' "$f")"
        [ -n "$name" ] || continue
        if cut -d' ' -f1 "$loaded" | grep -qxF "$name"; then
            printf '%s\n' "$name"
            return 0
        fi
    done
    return 1
}

# userns_open_via_profile <uid> <gid> <profile> <canary> — 0 if the ward
# could switch into <profile> with aa-exec and run <canary> inside a new
# user + mount namespace from there.
userns_open_via_profile() {
    LC_ALL=C timeout -s KILL 10 setpriv --reuid="$1" --regid="$2" --init-groups --reset-env -- \
        /usr/bin/aa-exec -p "$3" -- unshare -rm -- "$4" </dev/null >/dev/null 2>&1
}

# userns_profiles_report — which of USERNS_PROFILES are present.
userns_profiles_report() {
    local p have="" missing=""
    for p in "${USERNS_PROFILES[@]}"; do
        if [ -f "/etc/apparmor.d/$p" ]; then have="$have $p"; else missing="$missing $p"; fi
    done
    echo "==> AppArmor profiles that may still make user namespaces:${have:- none}"
    [ -n "$missing" ] && echo "==> not present (a program of that name cannot start its sandbox for a ward while armed):$missing"
    return 0
}

fapolicyd_active() { systemctl is-active --quiet fapolicyd 2>/dev/null; }

fapolicyd_pid() {
    local pid comm
    pid="$(cat /run/fapolicyd.pid 2>/dev/null || true)"
    comm="$(cat "/proc/${pid:-0}/comm" 2>/dev/null || true)"
    [ "$comm" = fapolicyd ] && printf '%s\n' "$pid"
}

# system_shlib — a small system shared library to copy as the .so canary.
system_shlib() {
    local l
    for l in libz.so.1 libattr.so.1 libacl.so.1 libm.so.6; do
        l="$(ldconfig -p 2>/dev/null | awk -v n="$l" '$1 == n { print $NF; exit }')"
        [ -n "$l" ] && [ -f "$l" ] && { realpath -e "$l"; return 0; }
    done
    return 1
}

# system_exec_shlib — a shared object `true` itself loads (so it is the
# right architecture) that fapolicyd types as application/x-executable, to
# copy as the second library canary. libc.so.6 is the usual one.
system_exec_shlib() {
    local l
    for l in $(ldd /usr/bin/true 2>/dev/null | awk '$2 == "=>" && $3 ~ /^\// { print $3 }'); do
        l="$(realpath -e "$l" 2>/dev/null)" || continue
        [ "$(fapolicyd-cli --ftype "$l" 2>/dev/null)" = application/x-executable ] && { printf '%s\n' "$l"; return 0; }
    done
    return 1
}

# make_canaries <user> <uid> <gid> <home> — in a fresh directory created BY
# the ward (so a planted symlink can't redirect a root write), somewhere the
# ward can write and that is not mounted noexec: `canary` (a copy of `true`),
# `libcanary.so` (a copy of a system library) and `libexeccanary.so` (a
# copy of a system shared object fapolicyd types as x-executable). Prints
# the directory.
make_canaries() {
    local user="$1" uid="$2" gid="$3" home="$4" base out lib xlib
    lib="$(system_shlib)" || return 1
    xlib="$(system_exec_shlib)" || return 1
    for base in "$home/.cache" /var/tmp; do
        out="$(as_ward "$uid" "$gid" /bin/sh -c '
            base="$1"; lib="$2"; xlib="$3"
            mkdir -p "$base" 2>/dev/null || exit 1
            d="$(mktemp -d "$base/kintrinsic-applock-canary.XXXXXX")" || exit 1
            cp /usr/bin/true "$d/canary" && chmod 0755 "$d/canary" \
                && cp "$lib" "$d/libcanary.so" && cp "$xlib" "$d/libexeccanary.so" \
                && echo "$d"' _ "$base" "$lib" "$xlib" 2>/dev/null)" || continue
        [ -n "$out" ] || continue
        printf '%s %s %s %s\n' "$user" "$uid" "$gid" "$out/canary" >> "$CANARIES"
        if findmnt -no OPTIONS -T "$out" 2>/dev/null | tr ',' '\n' | grep -qx noexec; then
            continue
        fi
        printf '%s\n' "$out"
        return 0
    done
    return 1
}

remove_canaries() {
    [ -f "$CANARIES" ] || return 0
    local user uid gid path
    while read -r user uid gid path; do
        [ -n "$path" ] || continue
        case "$path" in */kintrinsic-applock-canary.*/canary) ;; *) continue ;; esac
        as_ward "$uid" "$gid" rm -rf "$(dirname "$path")" 2>/dev/null || true
    done < "$CANARIES"
    rm -f "$CANARIES"
}

# ---------------------------------------------------------------------------
# Trial timer (persistent: a deadline file + a real timer unit).
# ---------------------------------------------------------------------------
install_trial() {
    local minutes="$1"
    echo "$(( $(date +%s) + minutes * 60 ))" > "$TRIAL_DEADLINE"
    cat > "$UNIT_DIR/$TRIAL_UNIT.service" <<EOF
$OURS_TAG trial auto-disarm; removed by 'charter-applock keep' or 'disarm'.
[Unit]
Description=Kintrinsic app lock trial: disarm once the deadline has passed

[Service]
Type=oneshot
ExecStart=/usr/sbin/charter-applock trial-check
EOF
    cat > "$UNIT_DIR/$TRIAL_UNIT.timer" <<EOF
$OURS_TAG trial auto-disarm; removed by 'charter-applock keep' or 'disarm'.
[Unit]
Description=Kintrinsic app lock trial deadline check

[Timer]
OnBootSec=15s
OnUnitActiveSec=30s
AccuracySec=5s

[Install]
WantedBy=timers.target
EOF
    systemctl daemon-reload >/dev/null 2>&1
    systemctl enable --now "$TRIAL_UNIT.timer" >/dev/null 2>&1
}

# The TIMER only is stopped, never the service: when the trial expires, the
# disarm it runs IS that service, and stopping it would kill the disarm half
# way. Removing the unit files leaves a running service alone.
remove_trial() {
    rm -f "$TRIAL_DEADLINE"
    if [ -e "$UNIT_DIR/$TRIAL_UNIT.timer" ] || [ -e "$UNIT_DIR/$TRIAL_UNIT.service" ]; then
        systemctl disable --now "$TRIAL_UNIT.timer" >/dev/null 2>&1 || true
        rm -f "$UNIT_DIR/$TRIAL_UNIT.timer" "$UNIT_DIR/$TRIAL_UNIT.service"
        systemctl daemon-reload >/dev/null 2>&1 || true
    fi
}

cmd_trial_check() {
    need_root trial-check
    local deadline
    deadline="$(cat "$TRIAL_DEADLINE" 2>/dev/null || true)"
    [[ "$deadline" =~ ^[0-9]+$ ]] || return 0
    if [ "$(date +%s)" -ge "$deadline" ]; then
        log "trial deadline passed without 'keep' — disarming"
        cmd_disarm
    fi
}

# ---------------------------------------------------------------------------
# The other files arm installs.
# ---------------------------------------------------------------------------
install_apt_hook() {
    [ -d "$(dirname "$APT_HOOK")" ] || return 0
    cat > "$APT_HOOK" <<EOF
// ${OURS_TAG#\# } restart an armed fapolicyd after dpkg replaces it; removed by 'charter-applock disarm'.
DPkg::Post-Invoke { "if [ -x /usr/sbin/charter-applock ]; then /usr/sbin/charter-applock post-dpkg || true; fi"; };
EOF
    chmod 0644 "$APT_HOOK"
}

# udisks2 reads this file at each mount. Only written when there is no
# admin-made one (never clobbered), only removed when it is ours.
install_udisks_noexec() {
    [ -d "$(dirname "$UDISKS_CONF")" ] || { log "note: no udisks2 here; removable media keep their mount options"; return 0; }
    if [ -e "$UDISKS_CONF" ] && ! file_is_ours "$UDISKS_CONF"; then
        log "note: $UDISKS_CONF already exists and is not Kintrinsic's; leaving it — removable media are NOT forced noexec"
        return 0
    fi
    cat > "$UDISKS_CONF" <<'EOF'
# kintrinsic-applock: removable media mount noexec while the app lock is armed; removed by 'charter-applock disarm'.
# `exec` is dropped from what a caller may ask for, so `udisksctl mount -o exec` is refused.
[defaults]
defaults=noexec
allow=noexec,nodev,nosuid,atime,noatime,nodiratime,relatime,strictatime,lazytime,ro,rw,sync,dirsync,noload,acl,nosymfollow
EOF
    chmod 0644 "$UDISKS_CONF"
}

remove_owned_files() {
    [ -f "$APT_HOOK" ] && file_is_ours "$APT_HOOK" && rm -f "$APT_HOOK"
    [ -f "$UDISKS_CONF" ] && file_is_ours "$UDISKS_CONF" && rm -f "$UDISKS_CONF"
    restore_userns
    remove_trial
}

record_prior_state() {
    [ -f "$PRIOR_STATE" ] && return 0
    {
        echo "enabled=$(systemctl is-enabled fapolicyd 2>/dev/null || echo disabled)"
        echo "active=$(systemctl is-active fapolicyd 2>/dev/null || echo inactive)"
    } > "$PRIOR_STATE"
}

# ---------------------------------------------------------------------------
# disarm — best effort at every step, never stops half way.
# ---------------------------------------------------------------------------
cmd_disarm() {
    need_root disarm
    local quiet=0
    [ "${1:-}" = --quiet ] && quiet=1
    # First and most important: with fapolicyd stopped nothing is enforced.
    systemctl disable --now fapolicyd >/dev/null 2>&1 || true
    local was_armed=0
    [ -f "$RULES_DST" ] && was_armed=1
    rm -f "$RULES_DST" "$STALE_RULES"
    rm -f "$TRUST_DIR/$SESSION_TRUST"
    if [ -f "$CONF_BACKUP" ]; then
        cp -p "$CONF_BACKUP" "$FAPO_CONF" && rm -f "$CONF_BACKUP"
    elif [ "$was_armed" = 1 ] && [ -f "$FAPO_CONF" ]; then
        # Armed, but the backup is gone: at least never leave it enforcing.
        conf_set "$FAPO_CONF" permissive 1 || true
    fi
    if [ -f "$COMPILED_BACKUP" ]; then
        cp -p "$COMPILED_BACKUP" "$COMPILED" && rm -f "$COMPILED_BACKUP"
    elif [ -f "$COMPILED" ] && grep -qF "$DENY_MARK" "$COMPILED" 2>/dev/null; then
        rm -f "$COMPILED"
    fi
    remove_owned_files
    remove_canaries
    rm -f "$MARKER"
    # Back to how fapolicyd was before Kintrinsic first armed it. Its rules
    # are gone by now, so re-enabling it cannot bring the lock back.
    local prior_enabled="" prior_active=""
    if [ -f "$PRIOR_STATE" ]; then
        prior_enabled="$(sed -n 's/^enabled=//p' "$PRIOR_STATE")"
        prior_active="$(sed -n 's/^active=//p' "$PRIOR_STATE")"
        rm -f "$PRIOR_STATE"
    fi
    [ "$prior_enabled" = enabled ] && systemctl enable fapolicyd >/dev/null 2>&1
    [ "$prior_active" = active ] && systemctl start fapolicyd >/dev/null 2>&1
    if [ "$prior_active" != active ] && fapolicyd_active; then
        log "WARNING: fapolicyd is still running. Run: systemctl disable --now fapolicyd"
        return 1
    fi
    if [ "$quiet" != 1 ]; then
        if [ "$prior_enabled" = enabled ] || [ "$prior_active" = active ]; then
            echo "$PROG: app lock disarmed — Kintrinsic's rules, trust entries and settings are removed; fapolicyd is back to how it was before (enabled=$prior_enabled, active=$prior_active)."
        else
            echo "$PROG: app lock disarmed — fapolicyd is stopped and disabled, Kintrinsic's rules, trust entries and settings are removed."
        fi
    fi
    return 0
}

# ---------------------------------------------------------------------------
# status — the same judgement charterd reports as STATUS `appLockArmed`.
# ---------------------------------------------------------------------------
cmd_status() {
    need_root status
    local ok=1 pid u uid gid home hits
    if [ -f "$RULES_DST" ] && rules_file_is_ours "$RULES_DST"; then
        echo "rules:      $RULES_DST (Kintrinsic)"
        local nwards=0
        while read -r u uid gid home; do
            [ -n "$u" ] || continue
            nwards=$((nwards + 1))
            if ! rules_cover_uid "$RULES_DST" "$uid"; then
                echo "coverage:   ward '$u' (uid $uid) is NOT covered — run: sudo $PROG refresh"; ok=0
            fi
            hits="$(tree_audit "$uid" "$gid" 2)"
            if [ -n "$hits" ]; then
                echo "trees:      ward '$u' can write inside a trusted tree: $(printf '%s' "$hits" | head -n1)"; ok=0
            fi
        done < <(ward_list)
        [ "$nwards" -gt 0 ] || { echo "coverage:   no wards in $MANAGED_GROUP"; ok=0; }
    else
        echo "rules:      not installed"; ok=0
    fi
    if [ -f "$COMPILED" ] && grep -qF "$DENY_MARK" "$COMPILED"; then
        echo "compiled:   loaded into $COMPILED"
    else
        echo "compiled:   Kintrinsic rules not compiled"; ok=0
    fi
    if [ "$(conf_get "$FAPO_CONF" permissive)" = 0 ] || { [ -f "$FAPO_CONF" ] && [ -z "$(conf_get "$FAPO_CONF" permissive)" ]; }; then
        echo "mode:       enforcing (integrity=$(conf_get "$FAPO_CONF" integrity))"
    else
        echo "mode:       permissive or not configured"; ok=0
    fi
    pid="$(fapolicyd_pid || true)"
    if [ -n "$pid" ]; then
        echo "fapolicyd:  running (pid $pid)"
    else
        echo "fapolicyd:  not running"; ok=0
    fi
    if [ -f "$TRIAL_DEADLINE" ]; then
        echo "trial:      running — disarms itself at $(date -d "@$(cat "$TRIAL_DEADLINE")" 2>/dev/null) unless '$PROG keep' is run"
    fi
    [ -f "$MARKER" ] && sed 's/^/marker:     /' "$MARKER"
    if [ "$ok" = 1 ]; then
        echo "app lock:   ARMED"
        return 0
    fi
    echo "app lock:   not armed"
    return 3
}

cmd_keep() {
    need_root keep
    if [ -f "$TRIAL_DEADLINE" ]; then
        remove_trial
        echo "$PROG: trial ended — the app lock stays armed."
    else
        remove_trial
        echo "$PROG: no trial is running."
    fi
}

# post-dpkg — run by apt after every dpkg invocation. Silent, never fails.
cmd_post_dpkg() {
    [ "$(id -u)" -eq 0 ] || return 0
    [ -f "$MARKER" ] && [ -f "$RULES_DST" ] || return 0
    # Someone stopped the lock on purpose (`systemctl disable --now
    # fapolicyd`, the emergency stop): never bring it back from here.
    systemctl is-enabled --quiet fapolicyd 2>/dev/null || return 0
    local pid exe
    pid="$(fapolicyd_pid || true)"
    if [ -z "$pid" ]; then
        # Armed but not running (an upgrade stopped it): bring it back.
        systemctl restart fapolicyd >/dev/null 2>&1 || true
        return 0
    fi
    exe="$(readlink "/proc/$pid/exe" 2>/dev/null || true)"
    case "$exe" in
        *' (deleted)')
            log "fapolicyd was upgraded — restarting it so the new binary enforces"
            systemctl restart fapolicyd >/dev/null 2>&1 || true
            ;;
    esac
    return 0
}

# ---------------------------------------------------------------------------
# arm (and refresh)
# ---------------------------------------------------------------------------

# Set while arm is changing the system; the EXIT trap reverts a partial arm.
ARMING=0
# The permissive self-test's foreground fapolicyd, while it runs.
SELFTEST_PID=""

on_exit() {
    local rc=$?
    if [ -n "$SELFTEST_PID" ]; then
        kill "$SELFTEST_PID" 2>/dev/null
        wait "$SELFTEST_PID" 2>/dev/null
        SELFTEST_PID=""
    fi
    if [ "$ARMING" = 1 ]; then
        ARMING=0
        cmd_disarm --quiet >/dev/null 2>&1 || true
        log "everything arm changed has been put back; the ward's session is unaffected."
        [ "$rc" = 0 ] && rc=1
    fi
    exit "$rc"
}

# Undo a failed arm (via the EXIT trap) and exit with the reason.
abort_arm() {
    log "NOT ARMED: $*"
    exit 1
}

# verify_ward_enforced <uid> <gid> <canary dir> <user> — live, enforcing
# checks for one ward; prints the failure and returns 1 on any miss.
verify_ward_enforced() {
    local uid="$1" gid="$2" dir="$3" user="$4" st
    st="$(run_as_ward_real "$uid" "$gid" "$dir/canary")"
    [ "$st" != 0 ] || { echo "enforcing fapolicyd still let '$user' run the canary"; return 1; }
    [ "$(probe_open "$uid" "$gid" "$dir/libcanary.so")" = denied ] \
        || { echo "enforcing fapolicyd still let '$user' open the library canary"; return 1; }
    preload_refused "$uid" "$gid" "$dir/libcanary.so" \
        || { echo "enforcing fapolicyd still let '$user' LD_PRELOAD the library canary"; return 1; }
    preload_refused "$uid" "$gid" "$dir/libexeccanary.so" \
        || { echo "enforcing fapolicyd still let '$user' LD_PRELOAD the x-executable library canary"; return 1; }
    ! userns_open "$uid" "$gid" "$dir/canary" \
        || { echo "'$user' ran the canary inside a new user namespace"; return 1; }
    local profile
    if [ -x /usr/bin/aa-exec ] && profile="$(userns_probe_profile)"; then
        ! userns_open_via_profile "$uid" "$gid" "$profile" "$dir/canary" \
            || { echo "'$user' ran the canary in a new user namespace through aa-exec -p $profile"; return 1; }
    fi
    st="$(run_as_ward_real "$uid" "$gid" /usr/bin/true)"
    [ "$st" = 0 ] || { echo "enforcing fapolicyd refused /usr/bin/true for '$user'"; return 1; }
    return 0
}

cmd_arm() {
    need_root arm
    local no_install=0 accept_blocked=0 trial=""
    while [ $# -gt 0 ]; do
        case "$1" in
            --no-install) no_install=1 ;;
            --accept-blocked) accept_blocked=1 ;;
            --trial) shift; trial="${1:-}"
                [[ "$trial" =~ ^[0-9]+$ ]] && [ "$trial" -ge 1 ] || die "--trial takes a number of minutes" ;;
            *) die "unknown option to arm: $1" ;;
        esac
        shift
    done

    # --- 1. fapolicyd present ------------------------------------------------
    if ! [ -x /usr/sbin/fapolicyd ]; then
        [ "$no_install" = 1 ] && die "fapolicyd is not installed. Install it with 'sudo apt install fapolicyd', then run '$PROG arm' again."
        echo "==> installing fapolicyd"
        DEBIAN_FRONTEND=noninteractive apt-get install -y fapolicyd \
            || die "could not install fapolicyd (offline?). Install it with 'sudo apt install fapolicyd', then run '$PROG arm' again."
        [ -x /usr/sbin/fapolicyd ] || die "fapolicyd still missing after install."
        # charterd's unit only carves /etc/fapolicyd/trust.d out of
        # ProtectSystem if it existed when the unit started (R3-7).
        install -d -m 0755 "$TRUST_DIR"
        systemctl try-restart charterd.service >/dev/null 2>&1 || true
    fi
    install -d -m 0755 "$TRUST_DIR"
    local tool
    for tool in setpriv prlimit timeout findmnt realpath ldconfig ldd unshare fagenrules fapolicyd-cli; do
        command -v "$tool" >/dev/null 2>&1 || die "missing required tool: $tool"
    done
    [ -f "$RULES_TEMPLATE" ] || die "rule template missing: $RULES_TEMPLATE (reinstall kintrinsic)."
    system_shlib >/dev/null || die "no system shared library found to build the library canary from."
    system_exec_shlib >/dev/null || die "no system shared object that fapolicyd types as application/x-executable (libc.so.6) found to build the second library canary from."
    local kconf="/boot/config-$(uname -r)"
    if [ -f "$kconf" ] && ! grep -q '^CONFIG_FANOTIFY_ACCESS_PERMISSIONS=y' "$kconf"; then
        die "this kernel has no fanotify permission events (CONFIG_FANOTIFY_ACCESS_PERMISSIONS); fapolicyd cannot run on it."
    fi

    # --- 2. wards --------------------------------------------------------------
    local -a W_USER=() W_UID=() W_GID=() W_HOME=()
    local u uid gid home
    while read -r u uid gid home; do
        [ -n "$u" ] || continue
        [ "$uid" -ge 1000 ] 2>/dev/null || die "ward '$u' has uid $uid; refusing to scope a lock to a system account."
        W_USER+=("$u"); W_UID+=("$uid"); W_GID+=("$gid"); W_HOME+=("$home")
    done < <(ward_list)
    [ "${#W_USER[@]}" -gt 0 ] || die "no ward accounts (group $MANAGED_GROUP is empty). Run 'charter-setup <child>' first."
    echo "==> wards: ${W_USER[*]}"

    # --- 3. nobody else's policy -------------------------------------------------
    [ -e "$FAPO_DIR/fapolicyd.rules" ] && die "$FAPO_DIR/fapolicyd.rules exists (an old-style fapolicyd policy). Kintrinsic will not merge with another policy; remove it first."
    local f foreign=""
    for f in "$FAPO_DIR"/rules.d/*; do
        [ -e "$f" ] || continue
        case "$f" in "$RULES_DST"|"$STALE_RULES") continue ;; esac
        foreign="$foreign $f"
    done
    [ -n "$foreign" ] && die "other fapolicyd rules are present:$foreign — Kintrinsic will not merge with another policy. Remove them, or keep your own policy and do not use the app lock."

    # --- 4. the allowed trees must be root-only -----------------------------------
    echo "==> checking that no ward can write inside the trees it may run programs from"
    local i hits
    for i in "${!W_USER[@]}"; do
        hits="$(tree_audit "${W_UID[$i]}" "${W_GID[$i]}")"
        if [ -n "$hits" ]; then
            die "ward '${W_USER[$i]}' can write inside a tree the lock trusts by path, so the lock would not hold. Fix the ownership/permissions of:
$hits"
        fi
    done

    # --- 5. discover the session's binaries ----------------------------------------
    # essential: the desktop cannot start or the lock cannot show without them.
    # session:   what this ward's session runs today (autostart, menu, live).
    echo "==> collecting the desktop session's programs"
    local -a PROBE_WARD=() PROBE_PATH=() PROBE_CLASS=()
    local -a SEED=() BLOCKED=()
    add_probe() { # <ward index> <class> <path>
        local j
        for j in "${!PROBE_PATH[@]}"; do
            [ "${PROBE_WARD[$j]}" = "$1" ] && [ "${PROBE_PATH[$j]}" = "$3" ] && {
                [ "$2" = essential ] && PROBE_CLASS[$j]=essential
                return 0; }
        done
        PROBE_WARD+=("$1"); PROBE_CLASS+=("$2"); PROBE_PATH+=("$3")
    }
    local prog p d exe shell
    for i in "${!W_USER[@]}"; do
        home="${W_HOME[$i]}"
        # The session starter, the ward's shell, and Kintrinsic's own ward-side programs.
        for p in /etc/X11/Xsession /usr/bin/sh /usr/bin/bash /usr/bin/dbus-daemon /usr/bin/dbus-run-session \
                 /usr/bin/charter-tray /usr/bin/charter /usr/bin/charter-lock /usr/bin/charter-console \
                 /usr/sbin/charterd /usr/lib/charter/charter-lock; do
            [ -e "$p" ] && add_probe "$i" essential "$(realpath -e "$p")"
        done
        shell="$(getent passwd "${W_USER[$i]}" | awk -F: '{print $7}')"
        [ -n "$shell" ] && [ -e "$shell" ] && add_probe "$i" essential "$(realpath -e "$shell")"
        for f in /usr/share/xsessions/*.desktop /etc/xdg/autostart/*.desktop; do
            [ -f "$f" ] || continue
            prog="$(desktop_exec_prog "$f")" || continue
            p="$(resolve_prog "$prog" "$home")"
            [ -n "$p" ] && add_probe "$i" essential "$p"
        done
        for f in "$home"/.config/autostart/*.desktop "$home"/.local/share/applications/*.desktop \
                 /usr/share/applications/*.desktop /usr/local/share/applications/*.desktop \
                 /var/lib/flatpak/exports/share/applications/*.desktop; do
            [ -f "$f" ] || continue
            prog="$(desktop_exec_prog "$f")" || continue
            p="$(resolve_prog "$prog" "$home")"
            [ -n "$p" ] && add_probe "$i" session "$p"
        done
        # What the ward is running right now, if logged in.
        for d in /proc/[0-9]*; do
            [ "$(stat -c %u "$d" 2>/dev/null)" = "${W_UID[$i]}" ] || continue
            exe="$(readlink "$d/exe" 2>/dev/null)" || continue
            case "$exe" in *' (deleted)'|'') continue ;; esac
            [ -f "$exe" ] && add_probe "$i" session "$exe"
        done
    done

    # Classify: covered by a tree / seed trust / blocked. A ward's own
    # content is never trusted: not if ANY ward owns or can write it or a
    # directory above it, and never from a home, temp dir or removable media.
    local -a SEEDED_SET=()
    local j cls k owned
    for j in "${!PROBE_PATH[@]}"; do
        p="${PROBE_PATH[$j]}"; i="${PROBE_WARD[$j]}"; cls="${PROBE_CLASS[$j]}"
        under_allowed_tree "$p" && continue
        owned=0
        seed_forbidden "$p" && owned=1
        for k in "${!W_USER[@]}"; do
            [ "$owned" = 1 ] && break
            ward_can_alter "${W_UID[$k]}" "${W_GID[$k]}" "$p" && owned=1
        done
        if [ "$owned" = 1 ]; then
            if [ "$cls" = essential ]; then
                die "the desktop session of '${W_USER[$i]}' needs $p, which is ward content (a ward can change it, or it lives in a home, temp dir or on removable media) — the lock would have to deny it. Install it under /usr/local (root-owned) or remove it from the session, then arm again."
            fi
            BLOCKED+=("${W_USER[$i]}: $p")
        else
            local seen=0 s
            for s in "${SEEDED_SET[@]}"; do [ "$s" = "$p" ] && seen=1; done
            [ "$seen" = 1 ] || SEEDED_SET+=("$p")
        fi
    done
    SEED=("${SEEDED_SET[@]}")
    echo "==> ${#PROBE_PATH[@]} program(s) to probe; ${#SEED[@]} to trust outside the root-only trees; ${#BLOCKED[@]} the lock will block"

    # --- 6. stage: backups, config, rules, trust -------------------------------------
    # From here on any exit that is not success — a failed check, Ctrl+C, a
    # closed terminal — reverts through the EXIT trap.
    install -d -m 0700 "$STATE_DIR"
    record_prior_state
    ARMING=1
    trap on_exit EXIT
    trap 'exit 130' INT TERM HUP
    # Back up only once: a re-arm must not overwrite the ORIGINAL with ours.
    [ -f "$CONF_BACKUP" ] || cp -p "$FAPO_CONF" "$CONF_BACKUP" || abort_arm "cannot back up $FAPO_CONF"
    if [ -f "$COMPILED" ] && [ ! -f "$COMPILED_BACKUP" ] && ! grep -qF "$DENY_MARK" "$COMPILED"; then
        cp -p "$COMPILED" "$COMPILED_BACKUP" || abort_arm "cannot back up $COMPILED"
    fi
    remove_trial
    systemctl stop fapolicyd >/dev/null 2>&1 || true
    remove_canaries
    rm -f "$MARKER"
    conf_set "$FAPO_CONF" permissive 1
    # Path rules over root-only trees replace the package-DB trust backend:
    # the Debian filter leaves /usr/share out of debdb (Cinnamon runs scripts
    # from there), and loading debdb hashes every packaged file at start.
    conf_set "$FAPO_CONF" trust file
    # A trusted entry is only honoured while its bytes still hash to what was
    # recorded; disarm restores the previous value with the whole file.
    conf_set "$FAPO_CONF" integrity sha256
    conf_set "$FAPO_CONF" watch_fs "$(csv_merge "$(conf_get "$FAPO_CONF" watch_fs)" ext4 tmpfs xfs vfat btrfs "${EXTRA_WATCH_FS[@]}")"
    # Mark whole filesystems, not mounts: a bind mount, or a watched
    # filesystem seen from another mount namespace, is then watched too.
    conf_set "$FAPO_CONF" allow_filesystem_mark 1
    restrict_userns || abort_arm "could not set $USERNS_SYSCTL=1 and $UNCONFINED_SYSCTL=1 (live and in $SYSCTL_DROPIN)"
    userns_profiles_report
    rm -f "$STALE_RULES"
    install -d -m 0755 "$FAPO_DIR/rules.d"
    render_rules "$RULES_TEMPLATE" "${W_UID[@]}" > "$RULES_DST.tmp" && mv "$RULES_DST.tmp" "$RULES_DST" \
        || abort_arm "could not write $RULES_DST"
    chmod 0644 "$RULES_DST"
    rm -f "$TRUST_DIR/$SESSION_TRUST"
    for p in "${SEED[@]}"; do
        # Already trusted (e.g. a charterd approval): adding again is an error.
        cat "$FAPO_DIR/fapolicyd.trust" "$TRUST_DIR"/* 2>/dev/null | grep -qF "$p " && continue
        fapolicyd-cli --file add "$p" --trust-file "$SESSION_TRUST" >/dev/null 2>&1 \
            || abort_arm "could not add $p to fapolicyd's trust"
    done
    fagenrules >/dev/null 2>&1 || abort_arm "fagenrules could not assemble the rules"
    grep -qF "$DENY_MARK" "$COMPILED" 2>/dev/null || abort_arm "the compiled rules do not contain Kintrinsic's policy"
    fapolicyd-cli --check-config >/dev/null 2>&1 || abort_arm "fapolicyd rejects its configuration: $(fapolicyd-cli --check-config 2>&1 | tail -n 3)"

    # --- 7. canaries ------------------------------------------------------------------
    local -a CANARY=()
    for i in "${!W_USER[@]}"; do
        p="$(make_canaries "${W_USER[$i]}" "${W_UID[$i]}" "${W_GID[$i]}" "${W_HOME[$i]}")" \
            || abort_arm "no place '${W_USER[$i]}' can write that is not mounted noexec — nothing for the lock to prove itself on"
        CANARY+=("$p")
    done

    # --- 7b. user namespaces, and canaries that work without the lock --------------------
    # fapolicyd is stopped here, so these measure the namespace restriction
    # and the canaries themselves, not the rules.
    echo "==> checking that no ward can make a user namespace"
    local c profile=""
    if [ -x /usr/bin/aa-exec ]; then
        profile="$(userns_probe_profile)" \
            || log "note: no loaded AppArmor profile grants user namespaces; the aa-exec check is skipped"
    fi
    for i in "${!W_USER[@]}"; do
        if userns_open "${W_UID[$i]}" "${W_GID[$i]}" "${CANARY[$i]}/canary"; then
            abort_arm "'${W_USER[$i]}' ran a program inside a new user + mount namespace (unshare -rm), so they could mount a filesystem fapolicyd does not watch. $USERNS_SYSCTL is $(cat "$USERNS_PROC" 2>/dev/null || echo 'not available on this kernel')."
        fi
        if [ -n "$profile" ] && userns_open_via_profile "${W_UID[$i]}" "${W_GID[$i]}" "$profile" "${CANARY[$i]}/canary"; then
            abort_arm "'${W_USER[$i]}' switched into the AppArmor profile '$profile' (aa-exec) and ran a program inside a new user namespace from there. $UNCONFINED_SYSCTL is $(cat "$UNCONFINED_PROC" 2>/dev/null || echo 'not available on this kernel')."
        fi
        for c in libcanary.so libexeccanary.so; do
            ! preload_refused "${W_UID[$i]}" "${W_GID[$i]}" "${CANARY[$i]}/$c" \
                || abort_arm "the library canary ${CANARY[$i]}/$c does not load even without the lock, so its refusal would prove nothing"
        done
    done

    # --- 8. permissive self-test ------------------------------------------------------
    echo "==> self-test: fapolicyd in permissive mode (nothing is blocked yet)"
    local stlog="$STATE_DIR/selftest.log"
    : > "$stlog"
    install -d -m 0770 -g fapolicyd /run/fapolicyd 2>/dev/null || install -d -m 0770 /run/fapolicyd
    local t0=$SECONDS
    /usr/sbin/fapolicyd --permissive --debug-deny >"$stlog" 2>&1 &
    SELFTEST_PID=$!
    local ready=0 n
    for n in $(seq 1 120); do
        if ! kill -0 "$SELFTEST_PID" 2>/dev/null; then
            abort_arm "fapolicyd would not start with these rules — see $stlog:
$(tail -n 15 "$stlog")"
        fi
        probe_exec "${W_UID[0]}" "${W_GID[0]}" "${CANARY[0]}/canary" >/dev/null
        if grep -qF "path=${CANARY[0]}/canary" "$stlog"; then ready=1; break; fi
        sleep 1
    done
    [ "$ready" = 1 ] || abort_arm "the self-test never saw fapolicyd deny the canary ${CANARY[0]}/canary — the rules are not taking effect, so arming would give false confidence. Log: $stlog"
    for i in "${!W_USER[@]}"; do
        probe_exec "${W_UID[$i]}" "${W_GID[$i]}" "${CANARY[$i]}/canary" >/dev/null
        probe_open "${W_UID[$i]}" "${W_GID[$i]}" "${CANARY[$i]}/libcanary.so" >/dev/null
        probe_open "${W_UID[$i]}" "${W_GID[$i]}" "${CANARY[$i]}/libexeccanary.so" >/dev/null
    done
    for j in "${!PROBE_PATH[@]}"; do
        i="${PROBE_WARD[$j]}"
        probe_exec "${W_UID[$i]}" "${W_GID[$i]}" "${PROBE_PATH[$j]}" >/dev/null
    done
    sleep 2
    kill "$SELFTEST_PID" 2>/dev/null; wait "$SELFTEST_PID" 2>/dev/null
    SELFTEST_PID=""
    echo "==> self-test ran in $((SECONDS - t0)) s (integrity=sha256)"

    # --- 9. judge the self-test ----------------------------------------------------------
    local denied
    denied="$(denied_paths "$stlog")"
    for i in "${!W_USER[@]}"; do
        grep -qxF "${CANARY[$i]}/canary" <<< "$denied" \
            || abort_arm "the self-test did not see the canary for '${W_USER[$i]}' denied (${CANARY[$i]}/canary). Log: $stlog"
        grep -qxF "${CANARY[$i]}/libcanary.so" <<< "$denied" \
            || abort_arm "the self-test did not see the library canary for '${W_USER[$i]}' denied (${CANARY[$i]}/libcanary.so). Log: $stlog"
        grep -qxF "${CANARY[$i]}/libexeccanary.so" <<< "$denied" \
            || abort_arm "the self-test did not see the x-executable library canary for '${W_USER[$i]}' denied (${CANARY[$i]}/libexeccanary.so). Log: $stlog"
    done
    local bad="" unexpected=""
    while IFS= read -r p; do
        [ -n "$p" ] || continue
        local is_canary=0
        for c in "${CANARY[@]}"; do case "$p" in "$c"/*) is_canary=1 ;; esac; done
        [ "$is_canary" = 1 ] && continue
        intended_deny "$p" && continue
        local known=0
        for j in "${!PROBE_PATH[@]}"; do
            [ "${PROBE_PATH[$j]}" = "$p" ] || continue
            known=1
            if [ "${PROBE_CLASS[$j]}" = essential ] || under_allowed_tree "$p"; then
                bad="$bad
  $p"
            elif ! printf '%s\n' "${BLOCKED[@]}" | grep -qF ": $p"; then
                # Seeded, yet denied: the trust did not take.
                bad="$bad
  $p (trusted at arm time, still denied)"
            fi
        done
        if [ "$known" = 0 ]; then
            # Not probed: something the live session did during the test.
            if under_allowed_tree "$p"; then
                bad="$bad
  $p (seen live)"
            else
                unexpected="$unexpected
  $p"
            fi
        fi
    done <<< "$denied"
    [ -n "$bad" ] && abort_arm "the self-test would have denied files the session needs:$bad
Log: $stlog"
    if [ "${#BLOCKED[@]}" -gt 0 ] || [ -n "$unexpected" ]; then
        {
            echo "==> once armed, these will be blocked (they are ward content):"
            [ "${#BLOCKED[@]}" -gt 0 ] && printf '  %s\n' "${BLOCKED[@]}"
            [ -n "$unexpected" ] && printf '%s\n' "${unexpected#?}"
        } >&2
        [ "$accept_blocked" = 1 ] \
            || abort_arm "programs or libraries the ward uses today would be blocked (listed above). Re-run with --accept-blocked to arm anyway."
    fi
    echo "==> self-test passed: every canary was denied and nothing the session needs was"

    # --- 10. enforce, then verify for real --------------------------------------------------
    conf_set "$FAPO_CONF" permissive 0
    systemctl enable fapolicyd >/dev/null 2>&1 || abort_arm "systemctl enable fapolicyd failed"
    systemctl restart fapolicyd >/dev/null 2>&1 || abort_arm "fapolicyd would not start enforcing: $(journalctl -u fapolicyd -n 10 --no-pager 2>/dev/null)"
    for n in $(seq 1 60); do fapolicyd_active && break; sleep 1; done
    fapolicyd_active || abort_arm "fapolicyd is not active after start"
    local st why
    for n in $(seq 1 30); do
        st="$(run_as_ward_real "${W_UID[0]}" "${W_GID[0]}" "${CANARY[0]}/canary")"
        [ "$st" != 0 ] && break
        sleep 1
    done
    for i in "${!W_USER[@]}"; do
        why="$(verify_ward_enforced "${W_UID[$i]}" "${W_GID[$i]}" "${CANARY[$i]}" "${W_USER[$i]}")" \
            || abort_arm "$why — disarmed at once"
    done
    for j in "${!PROBE_PATH[@]}"; do
        [ "${PROBE_CLASS[$j]}" = essential ] || continue
        i="${PROBE_WARD[$j]}"
        [ "$(probe_exec "${W_UID[$i]}" "${W_GID[$i]}" "${PROBE_PATH[$j]}")" = ok ] \
            || abort_arm "enforcing fapolicyd refused ${PROBE_PATH[$j]} for '${W_USER[$i]}' — disarmed at once"
    done

    install_apt_hook
    install_udisks_noexec
    {
        echo "armed_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
        echo "wards=${W_USER[*]}"
        echo "seeded=${#SEED[@]}"
        echo "blocked=${#BLOCKED[@]}"
    } > "$MARKER"
    if [ -n "$trial" ]; then
        install_trial "$trial" || abort_arm "could not schedule the --trial auto-disarm"
        systemctl is-active --quiet "$TRIAL_UNIT.timer" 2>/dev/null \
            || abort_arm "the --trial timer did not start"
        echo "==> trial: the lock disarms itself in $trial minute(s), across reboots, unless you run: sudo $PROG keep"
    fi
    ARMING=0
    trap - EXIT INT TERM HUP
    echo "$PROG: app lock ARMED for: ${W_USER[*]}"
    echo "$PROG: to undo (desktop, TTY or recovery shell): sudo $PROG disarm"
}

# refresh — an armed lock extended to the wards added since (charter-setup
# runs this after putting a new account in charter-managed). A new ward that
# fails the tree audit leaves the rules untouched (STATUS then reads
# not-armed); a new ward the reloaded rules do not enforce disarms the lock.
cmd_refresh() {
    need_root refresh
    if ! { [ -f "$RULES_DST" ] && rules_file_is_ours "$RULES_DST"; }; then
        echo "$PROG: the app lock is not armed; nothing to refresh."
        return 0
    fi
    local -a W_USER=() W_UID=() W_GID=() W_HOME=() NEW=()
    local u uid gid home i hits
    while read -r u uid gid home; do
        [ -n "$u" ] || continue
        [ "$uid" -ge 1000 ] 2>/dev/null || die "ward '$u' has uid $uid; refusing to scope a lock to a system account."
        W_USER+=("$u"); W_UID+=("$uid"); W_GID+=("$gid"); W_HOME+=("$home")
        rules_cover_uid "$RULES_DST" "$uid" || NEW+=("$((${#W_USER[@]} - 1))")
    done < <(ward_list)
    if [ "${#NEW[@]}" = 0 ]; then
        echo "$PROG: every ward is already covered."
        return 0
    fi
    for i in "${NEW[@]}"; do
        hits="$(tree_audit "${W_UID[$i]}" "${W_GID[$i]}")"
        [ -z "$hits" ] || die "new ward '${W_USER[$i]}' can write inside a tree the lock trusts by path; NOT extending the lock to them (status will say not armed). Fix:
$hits"
    done
    echo "==> extending the app lock to:$(for i in "${NEW[@]}"; do printf ' %s' "${W_USER[$i]}"; done)"
    render_rules "$RULES_TEMPLATE" "${W_UID[@]}" > "$RULES_DST.tmp" && mv "$RULES_DST.tmp" "$RULES_DST" \
        || die "could not write $RULES_DST"
    chmod 0644 "$RULES_DST"
    fagenrules >/dev/null 2>&1 && grep -qF "$DENY_MARK uid=${W_UID[${NEW[0]}]} : all" "$COMPILED" \
        || { cmd_disarm --quiet; die "the rules would not recompile for the new ward — the app lock was disarmed rather than left half-applied."; }
    fapolicyd-cli --reload-rules >/dev/null 2>&1 || systemctl restart fapolicyd >/dev/null 2>&1
    local dir why n st
    for i in "${NEW[@]}"; do
        dir="$(make_canaries "${W_USER[$i]}" "${W_UID[$i]}" "${W_GID[$i]}" "${W_HOME[$i]}")" \
            || { log "WARN no canary place for '${W_USER[$i]}'; covered but not verified"; continue; }
        for n in $(seq 1 15); do
            st="$(run_as_ward_real "${W_UID[$i]}" "${W_GID[$i]}" "$dir/canary")"
            [ "$st" != 0 ] && break
            sleep 1
        done
        if ! why="$(verify_ward_enforced "${W_UID[$i]}" "${W_GID[$i]}" "$dir" "${W_USER[$i]}")"; then
            cmd_disarm --quiet
            die "$why after refresh — the app lock was disarmed. Run '$PROG arm' to redo it with the full self-test."
        fi
    done
    echo "$PROG: app lock now covers: ${W_USER[*]}"
}

case "${1:-}" in
    arm) shift; cmd_arm "$@" ;;
    disarm) shift; cmd_disarm "$@" ;;
    status) cmd_status ;;
    keep) cmd_keep ;;
    refresh) cmd_refresh ;;
    trial-check) cmd_trial_check ;;
    post-dpkg) cmd_post_dpkg ;;
    *)
        echo "usage: $PROG arm [--no-install] [--accept-blocked] [--trial MINUTES] | disarm | status | keep | refresh" >&2
        exit 2
        ;;
esac
