#!/bin/bash
# Host-side exit-time audit for bin/dejima. Snapshots the workspace's
# host-persistence surfaces before the container runs, and after it exits
# reports (advisory only - never blocks) anything that changed during that
# window and would execute or load OUTSIDE the sandbox: git config keys that
# run a command on the next `git`, and content under .claude/ / .mcp.json /
# .envrc / .vscode/ / .git/hooks/. Read the report before running `git` or
# `claude` in that repo again. See docs/security.md.
set -uo pipefail

# --help before anything else runs: no libraries sourced, no config read. The
# moment someone reaches for it is often the moment the config is wrong.
# stdout and exit 0, where usage() stays on stderr and exits nonzero.
case "${1:-}" in
    -h|--help)
        cat <<'DEJIMA_AUDIT_HELP'
dejima-audit - report what a session changed outside its workspace

Usage: dejima-audit <snapshot|compare|show|ack> [workspace] [state-file]

  snapshot         record the current state of everything watched
  compare          compare against the last snapshot and file a report
  show [--full]    index of reports waiting to be read; --full prints one
  ack              walk a report finding by finding and record a decision
                   ([o] at the prompt opens the file in $VISUAL/$EDITOR)
  --print-paths    the resolved store paths, one per line, for scripts

  workspace defaults to the enclosing git repository, else $PWD.

snapshot and compare are run by bin/dejima around a session, not usually by
hand. show and ack are for reading what they found.

What is watched is the part a container boundary cannot protect: dejima's own
trusted code, the harness configuration a later host session would execute,
and anything else on the host a session could have reached. A finding is
CRITICAL when it names something that runs unsandboxed on the host.

ack records a decision per finding, so an approved change stops being reported
until the thing it approved changes again. Acknowledging is per value, not per
path: the same file with different content is a new finding.

Reports and baselines live under ~/.local/share/dejima, outside every mount, so
a session cannot read what was concluded about it or edit what is pending.

See docs/security.md.
DEJIMA_AUDIT_HELP
        exit 0 ;;
esac

# shellcheck source=dejima-provider-lib.sh
source "$(dirname "$(readlink -f "$0")")/dejima-provider-lib.sh"
# shellcheck source=dejima-color-lib.sh
source "$(dirname "$(readlink -f "$0")")/dejima-color-lib.sh"
# shellcheck source=dejima-slug-lib.sh
source "$(dirname "$(readlink -f "$0")")/dejima-slug-lib.sh"
# shellcheck source=dejima-harness-lib.sh
source "$(dirname "$(readlink -f "$0")")/dejima-harness-lib.sh"
# Defined empty here so every interpolation below is safe before a surface
# picks its stream; compare (stderr) and ack (stdout) each re-init for theirs.
color_init 1
config_validate || exit 1

cmd="${1:-}"; ws_arg="${2:-}"; state="${3:-}"

PROJECTS_DIR="$(harness_config_get projects_dir)"

# The paths the selected harness causes to run ON THE HOST, unsandboxed,
# from configuration a session can reach - Claude Code's statusline is the
# instance that exists. Each is watched on its own rather than folded into
# hash_paths, because bin/dejima's read-only mount does not reliably protect
# one that is a symlink (the common case - runc resolves the mount
# destination through the link; confirmed against a live container, see
# test/runtime.bats), so detection here is the compensating control.
#
# Declared by the harness's plugin, not by its manifest: the labels below
# are the fixed prefixes `ack` strips to recover an id, which makes them
# part of a parser's grammar rather than data. See bin/dejima-harness-lib.sh.
harness_plugin_load "$(config_get core harness)"
host_exec_load

# With no harness selected there is no statusline path to watch, and the
# compensating control above quietly covers nothing. bin/dejima refuses to
# start without [core] harness, so a snapshot in this state means the audit
# was driven by hand against a config dejima itself would reject - say so
# rather than reporting a clean run over a surface that was never examined.
# A silently narrower audit is worse than a noisy one.
if [ "${#DEJIMA_HOST_EXEC[@]}" -eq 0 ] && [ -z "$(config_get core harness)" ]; then
    case "$cmd" in
        snapshot|compare)
            echo "dejima audit: no [core] harness is set, so harness-specific surfaces" >&2
            echo "              (whatever this harness runs on the host) are NOT checked." >&2
            ;;
    esac
fi

# This script's own repo root - the sandbox's trusted computing base (see
# tcb_snapshot below) is only monitored when the workspace being audited IS
# this repo (a self-mounted session). Overridable for tests.
AUDIT_REPO_ROOT="${DEJIMA_AUDIT_REPO_ROOT:-$(cd "$(dirname "$(readlink -f "$0")")/.." && pwd)}"

# Fixed, not configurable: a predictable report shape is worth more than
# tunability, and >10 CRITICAL findings is an anomaly rather than a display
# problem (an ordinary session produces zero).
CRIT_CAP=10
# WARNING is where volume actually lands: the INFO path in path_severity()
# below is currently unreachable, so every high-volume workspace-file change
# classifies as WARNING instead of collapsing into an INFO count. Capped
# moderately - overflow here is ordinary, not an alarm.
WARN_CAP=5

AUDIT_DIR="$(config_get audit dir)"
# Retention is a security property, not housekeeping. With one overwritten
# report, a session that plants something needs only to talk the operator
# into a restart for the next clean exit to erase the evidence - and
# suggesting a restart is an entirely ordinary thing to suggest.
AUDIT_KEEP="$(config_get audit keep)"

# ws_slug and workspace_slugs come from dejima-slug-lib.sh, shared with
# bin/dejima so the set of slugs it mounts and the set treated as this
# workspace's own here cannot drift apart.

# The one report both `show` and `ack` operate on: the OLDEST unacknowledged
# CRITICAL report if there is one, else the newest report overall.
#
# Oldest-CRITICAL-first, rather than a plain `sort | tail -1` over every
# *.txt, because that always prefers the newer file - so a stale
# *-CRITICAL.txt sitting behind a newer clean report would be unreachable
# through `ack` entirely, while bin/dejima's startup warning kept firing on
# it forever with no remedy but to press past it. That is the alert fatigue
# this feature exists to end, so it must not be reachable by this route.
#
# Shared by both verbs so they cannot disagree: an operator following that
# startup warning may well reach for `show` first, and showing them a
# different report than the one being warned about sends them looking for a
# finding that is not in it.
# How many reports are waiting, and how many of those are CRITICAL. `ack`
# walks one report at a time by design - each is a separate session's worth of
# findings - but saying nothing about the rest is what made clearing a backlog
# feel like the tool was stuck on one.
# A report's stable identity: its basename without the -CRITICAL marker, which
# `ack` removes once every CRITICAL in it has been dealt with.
report_key() {
    local b; b="$(basename "$1")"
    printf '%s' "${b%-CRITICAL.txt}.txt" | sed 's/\.txt\.txt$/.txt/'
}

# How many reports are on disk regardless of whether anything is outstanding.
# The two numbers answer different questions and both get asked: an operator
# told "no reports" while looking at a directory of them is being told
# something false, even when nothing there wants a decision.
count_reports_total() {
    find "$1" -maxdepth 1 -name '*.txt' 2>/dev/null | wc -l
}

# A report walked to its end is done with, and stays on disk as the record of
# what that session did - rotation evicts it, not `ack`. Everything below
# therefore has to distinguish "on disk" from "still wants a decision", or the
# operator is told work is waiting that no keystroke can clear. That was #195:
# five reports waiting, "nothing new - 32 already dealt with", forever, with
# the other four unreachable because selection kept returning the same one.
report_dealt_with() {
    baseline_reviewed "$(report_key "$1")"
}

# Reports that still want a decision, oldest first. The caller gets a list
# rather than one path so counting and choosing cannot disagree about what is
# outstanding.
outstanding_reports() {
    local dir="$1" r
    while IFS= read -r r; do
        [ -n "$r" ] || continue
        report_dealt_with "$r" || printf '%s\n' "$r"
    done < <(find "$dir" -maxdepth 1 -name '*.txt' 2>/dev/null | sort)
}

count_reports() {
    local dir="$1" all crit r
    all=0; crit=0
    while IFS= read -r r; do
        [ -n "$r" ] || continue
        all=$((all + 1))
        case "$r" in *-CRITICAL.txt) crit=$((crit + 1)) ;; esac
    done < <(outstanding_reports "$dir")
    printf '%s %s\n' "$all" "$crit"
}

# Like select_report, but skipping whatever this run has already been
# through. A report can be left outstanding on purpose - the operator skipped
# a finding in it, and skipping means ask again next time - and without this
# the run would be handed that same report back, decide it is not a new one,
# and stop, leaving every report behind it unread. That is the shape of #195
# again, one step along: not "cannot retire" but "cannot move on".
#
# Reads the walked_this_run array the ack loop maintains; harmless where that
# array does not exist, which is every other caller.
select_next_report() {
    local dir="$1" r out unwalked=""
    out="$(outstanding_reports "$dir")"
    [ -n "$out" ] || return 1
    while IFS= read -r r; do
        [ -n "$r" ] || continue
        [ -n "${walked_this_run[$r]:-}" ] && continue
        unwalked="${unwalked}${unwalked:+$'\n'}$r"
    done <<< "$out"
    [ -n "$unwalked" ] || return 1
    r="$(printf '%s\n' "$unwalked" | grep -- '-CRITICAL\.txt$' | head -1)"
    [ -n "$r" ] || r="$(printf '%s\n' "$unwalked" | tail -1)"
    printf '%s\n' "$r"
}

select_report() {
    local dir="$1" r out
    out="$(outstanding_reports "$dir")"
    [ -n "$out" ] || return 1
    # An unacknowledged CRITICAL first, whatever its age, then the newest of
    # the rest - the same order as before, over the outstanding ones only.
    r="$(printf '%s\n' "$out" | grep -- '-CRITICAL\.txt$' | head -1)"
    [ -n "$r" ] || r="$(printf '%s\n' "$out" | tail -1)"
    [ -n "$r" ] || return 1
    printf '%s\n' "$r"
}

BASELINE_DIR="$(config_get audit baselines_dir)"

# The workspace argument is optional: `dejima-audit show` inside a repo
# should mean that repo. bin/dejima always passes an explicit path, so this
# only ever fires for a hand-run show/ack.
#
# Candidates in order, first one that already HAS reports wins - a report
# directory is the only evidence of which path bin/dejima was invoked with,
# and the three can genuinely differ:
#   1. the enclosing repo's toplevel (ordinary case, and from a subdir);
#   2. the MAIN worktree root, when cwd is a linked worktree - the
#      .worktrees/<branch> workflow keys reports to the parent repo;
#   3. $PWD, for a non-git directory, or when the logical path bin/dejima was
#      given differs from git's physical toplevel through a symlink.
# Falling back to 1 when none has reports is so "no reports for X" can name a
# concrete path: a wrong guess must be visible rather than silent.
resolve_ws() {
    local given="$1" top common main c
    if [ -n "$given" ]; then
        ( cd "$given" 2>/dev/null && pwd ) || printf '%s' "$given"
        return
    fi
    top="$(git rev-parse --show-toplevel 2>/dev/null)" || top=""
    main=""
    common="$(git rev-parse --git-common-dir 2>/dev/null)" || common=""
    if [ -n "$common" ]; then
        common="$(readlink -f "$common" 2>/dev/null)" || common=""
        [ -n "$common" ] && main="$(dirname "$common")"
    fi
    for c in "$top" "$main" "$PWD"; do
        [ -n "$c" ] || continue
        # A bare slug directory is not proof of a report: `snapshot` creates
        # one's .staging/ subdir for every workspace it runs against, report
        # or not. Only an actual report is evidence of which path bin/dejima
        # was invoked with.
        select_report "$AUDIT_DIR/$(ws_slug "$c")" >/dev/null 2>&1 || continue
        printf '%s' "$c"
        return
    done
    printf '%s' "${top:-$PWD}"
}
ws="$(resolve_ws "$ws_arg")"

# Retention of pre-session CONTENT (as bounded diffs) is separable from the
# audit itself: an operator who wants zero content at rest sets this off and
# still gets every finding, just without before/after.
DIFFS_ENABLED="$(config_get_bool audit diffs)"

# Per-file cap on what is staged. Bounded because staging is the one place
# WHOLE files (rather than diffs) exist on disk, so its size is the size of
# the exposure if a session is SIGKILLed before compare can clear it.
STAGE_FILE_CAP=1048576

# Set only by `snapshot`, deliberately. `compare` calls hash_paths() too, to
# build the after-side; if this were set there, that call would overwrite the
# staged BEFORE content with the AFTER content and every diff would come out
# empty.
STAGE_DIR=""

# Filename for a raw namespaced id in the staging and sidecar directories.
# Hashed rather than encoded because these are real filenames: an id can be
# any length and contain '/', and enc_id's output for an ordinary path still
# does.
id_key() { printf '%s' "$1" | sha256sum | cut -d' ' -f1; }

# Surfaces whose CONTENT is never retained, matched on the workspace-relative
# path. These hold credentials by convention, and a retained diff of one
# would put them in a store that outlives the session.
#
# .claude/settings.json is deliberately NOT here: it is the shared, commonly
# committed file, and it is also a CRITICAL surface whose diffs are worth
# having. The `.local` convention is what marks the personal one.
#
# This is the reliable half of the secret defence; looks_secret() (used at
# promote time) is the best-effort half.
diff_denied() {
    local id="${1#tcb:}"
    case "$id" in
        .envrc|.mcp.json|.claude/settings.local.json) return 0 ;;
    esac
    # Basename match, not exact-path: hash_paths() walks .claude/, .vscode/
    # and .git/hooks/ recursively, so a dotenv file can appear nested (e.g.
    # .claude/.env) - the exact-path arm above only ever matches the id in
    # its ORIGINAL, un-nested form, and never would.
    case "${id##*/}" in
        .env|.env.*) return 0 ;;
    esac
    return 1
}

# The audit store is only beyond the sandbox's reach while it sits outside
# the mounted workspace - inside it, it lands in the read-write
# ${WORKSPACE_DIR}:${WORKSPACE_DIR} mount, where a session can read the
# retained content of every earlier run and delete the evidence of its own.
# bin/dejima already WARNS about this; for retained content it is a hard gate,
# because the whole premise of storing bytes here is that the container
# cannot read them.
store_reachable() {
    local d rws rd
    # Canonicalised, not raw: symlinks and `..` in either side would otherwise
    # never match the "$rws"/* pattern below and would stage straight into the
    # container's read-write mount. `-m`, not
    # `-f`: `$AUDIT_DIR` routinely doesn't exist yet on a fresh install (its
    # parent tree is only ever mkdir -p'd on a real finding or by `ack`), and
    # `-f` requires every component but the last to exist - it would fail
    # there on every clean-history host, permanently disabling staging via
    # the fail-closed branch below. `-m` resolves what exists and treats the
    # rest literally, which is exactly this comparison's semantics. A failed
    # canonicalisation (now essentially just a malformed/empty path) is
    # treated as reachable so this still fails closed.
    rws="$(readlink -m "$ws" 2>/dev/null)" || return 0
    for d in "$AUDIT_DIR" "$BASELINE_DIR"; do
        rd="$(readlink -m "$d" 2>/dev/null)" || return 0
        case "$rd/" in "$rws"/*) return 0 ;; esac
    done
    return 1
}

# Field 22 of /proc/<pid>/stat is the process start time in clock ticks.
# Fields 1 and 2 are the pid and the comm, and comm is parenthesised and may
# itself contain spaces and ')' - so everything up to and including the LAST
# ') ' is stripped first, after which starttime is field 20.
proc_starttime() {
    local st rest
    [ -r "/proc/$1/stat" ] || return 1
    st="$(cat "/proc/$1/stat" 2>/dev/null)" || return 1
    rest="${st##*) }"
    set -- $rest
    [ "$#" -ge 20 ] || return 1
    printf '%s' "${20}"
}

# pid:starttime of the process that owns a staging directory - bin/dejima,
# which lives for the whole session (this script exits immediately). The
# start time is what makes it exact: a bare pid could be reused by an
# unrelated process and make a dead owner look alive forever.
owner_token() {
    local st
    st="$(proc_starttime "$PPID")" || return 1
    printf '%s:%s' "$PPID" "$st"
}

# Remove staging directories left behind by sessions that died without
# running `compare` - SIGKILL, an OOM kill, or power loss, none of which run
# bin/dejima's EXIT trap. Confined to a directory inside our own 0700 store,
# so unlike a /tmp sweep it can never touch a path another local user
# controls.
reap_staging() {
    local root="$1" d owner pid start
    for d in "$root"/*/; do
        [ -d "$d" ] || continue
        owner="$(cat "$d/.owner" 2>/dev/null)" || owner=""
        pid="${owner%%:*}"; start="${owner#*:}"
        if [ -n "$owner" ] && [ "$pid" != "$owner" ] && [ -n "$pid" ]; then
            # Owner recorded: reap only if it is provably gone. A live owner
            # is a concurrent session whose staging must survive.
            [ "$(proc_starttime "$pid" 2>/dev/null)" = "$start" ] && continue
            rm -rf "$d"
            continue
        fi
        # No usable owner record: liveness is undecidable, so fall back to
        # age rather than guessing. 24h is far longer than any session.
        [ -n "$(find "$d" -maxdepth 0 -mmin +1440 2>/dev/null)" ] && rm -rf "$d"
    done
}

# Copy one surface's current bytes into the staging directory, to become the
# BEFORE side of a diff after the session ends. No-op unless `snapshot` set
# STAGE_DIR. Symlinks stage "-> <target>", matching what
# current_content_for_id() renders for them - the staged BEFORE bytes must
# line up with the after-side content renderer, not the hasher.
stage_entry() {
    [ -n "$STAGE_DIR" ] || return 0
    local id="$1" src="$2" key sz
    diff_denied "$id" && return 0
    key="$(id_key "$id")"
    if [ -L "$src" ]; then
        printf '%s\n' "-> $(readlink "$src")" > "$STAGE_DIR/$key" 2>/dev/null || return 0
        return 0
    fi
    [ -f "$src" ] || return 0
    sz="$(stat -c %s "$src" 2>/dev/null)" || return 0
    [ "$sz" -le "$STAGE_FILE_CAP" ] || return 0
    cp -- "$src" "$STAGE_DIR/$key" 2>/dev/null || rm -f "$STAGE_DIR/$key"
    return 0
}

# Create this session's staging directory and return its path, or nothing if
# retention is off, the store is sandbox-reachable, or the store cannot be
# written. Every one of those degrades to "no diffs", never to an error.
open_staging() {
    local root token d
    [ "$DIFFS_ENABLED" = "1" ] || return 0
    store_reachable && return 0
    root="$AUDIT_DIR/$(ws_slug "$ws")/.staging"
    ( umask 077; mkdir -p "$root" ) 2>/dev/null || return 0
    reap_staging "$root"
    d="$( umask 077; mktemp -d "$root/XXXXXX" 2>/dev/null )" || return 0
    [ -n "$d" ] || return 0
    token="$(owner_token)" && printf '%s\n' "$token" > "$d/.owner" 2>/dev/null
    printf '%s' "$d"
}

# Caps on a RETAINED diff. Separate from DISPLAY_LINE_CAP (what the operator
# sees): this one bounds what sits on disk after the session.
DIFF_LINE_CAP=200
DIFF_BYTE_CAP=65536

# Total retained-diff bytes one report may hold. Fixed, not configurable, in
# the same spirit as CRIT_CAP/WARN_CAP - and like CRIT_CAP's overflow,
# spending it is a finding rather than a tuning problem: more changed
# host-persistence surfaces in one session than there is room to keep the
# evidence for is itself abnormal, so both the report and the exit-time
# summary say so.
RETAIN_BUDGET=1048576

# Set only by `compare`, from the state file's ### staging line: where this
# session's BEFORE content was staged. Distinct from STAGE_DIR (write side)
# so that compare's own call to hash_paths() cannot overwrite the before
# content with the after content.
BEFORE_DIR=""

# The retained-diff directory for a report. Derived with the -CRITICAL suffix
# stripped first, so `ack`'s rename of a fully-approved report (which drops
# that suffix) does not orphan the sidecar it was using a moment earlier.
sidecar_dir() {
    local r="$1"
    r="${r%-CRITICAL.txt}"; r="${r%.txt}"
    printf '%s.d' "$r"
}

# Best-effort credential detection over a generated diff, run before it is
# promoted. This is the SECOND net - diff_denied() is the reliable one - and
# it has real false negatives. False positives only cost a withheld diff, so
# it is deliberately broad.
#
# Case-INSENSITIVE, and that is load-bearing rather than cosmetic: the
# canonical shape of a credential in the one high-value surface this is the
# only net for is UPPERCASE. .claude/settings.json is deliberately outside
# diff_denied() (it is the shared, committed file - see there), and its `env`
# block is exactly where GITLAB_TOKEN/NPM_TOKEN/... live, so a case-sensitive
# keyword match put live tokens in the store in cleartext.
looks_secret() {
    LC_ALL=C grep -qiE \
        'sk-ant-|sk-proj-|ghp_|gho_|github_pat_|glpat-|xox[baprs]-|AIza|AKIA[0-9A-Z]{16}|_authToken|Authorization:[[:space:]]*Bearer|-----BEGIN [A-Z ]*PRIVATE KEY-----|(api[_-]?key|apikey|secret|token|passwd|password)[[:space:]"'"'"']*[:=][[:space:]"'"'"']*[^[:space:]"'"'"']{8,}|[A-Za-z0-9+/]{40,}={0,2}' \
        "$1"
}

# A file is binary if it contains a NUL. diff's own output for such a file is
# useless ("Binary files differ"), and retaining it would be retaining bytes
# nobody can read at the prompt.
is_binary() {
    local total stripped
    total="$(wc -c < "$1" 2>/dev/null)" || return 1
    stripped="$(tr -d '\000' < "$1" 2>/dev/null | wc -c)" || return 1
    [ "$total" != "$stripped" ]
}

withhold() { printf '%s\n' "$2" > "$1" 2>/dev/null || true; }

# Was this surface present in the pre-session snapshot? Answered from the
# state file, NOT from "is there a staged before-copy": staging is also empty
# for a denylisted surface (never staged at all) and for one over
# STAGE_FILE_CAP, so the staging test would label a MODIFIED .envrc as newly
# created - the one label that promises there is no earlier version to
# compare against.
existed_before() {
    local rid="$1" range enc
    if host_exec_is_id "$rid"; then
        range="/^$(_hostexec_boundary "$state")/,\$p"; enc="$(enc_id "${rid#*:}")"
    else
    case "$rid" in
        tcb:*)        range="/^### tcb/,/^$(_hostexec_boundary "$state")/p"; enc="$(enc_id "${rid#tcb:}")" ;;
        *)            range='/^### paths/,/^### projects/p';   enc="$(enc_id "$rid")" ;;
    esac
    fi
    sed -n "$range" "$state" | grep -v '^### ' | cut -f1 | grep -qxF -- "$enc"
}

# Bound, screen and retain one diff. Shared by both promote paths - they
# differ only in where the BEFORE side comes from, and a second copy of these
# caps would be a second place for them to drift.
write_diff() {
    local before="$1" cur="$2" sc="$3" key="$4" d sz
    d="$(mktemp)" || return 0
    # Bounded AT generation, not after it: everything over the cap is
    # discarded anyway, and `compare` runs from bin/dejima's EXIT trap, so
    # letting diff write the whole thing first would put an unbounded,
    # session-chosen number of bytes into $TMPDIR (frequently a tmpfs, i.e.
    # host RAM) on every session exit just to produce a marker. One byte past
    # the cap, so the over-cap test below still trips.
    diff -U2 --label 'before (pre-session)' --label 'current' "$before" "$cur" 2>/dev/null \
        | head -c "$(( DIFF_BYTE_CAP + 1 ))" > "$d"
    sz="$(wc -c < "$d")" || sz=0
    if [ "$sz" -eq 0 ]; then
        # An empty diff is worse than none: Task 6 prefers the retained diff
        # over live content, so a 0-byte file would show the operator a blank
        # before/after for a real finding. It happens for a git config key
        # whose config-file TEXT changed while its parsed value did not - a
        # re-quoted value, say - which the raw-line comparison in `compare`
        # reports as a change and unquote_value() then flattens back to
        # equal. Say the comparison came out empty instead of silently
        # showing nothing.
        withhold "$sc/$key.withheld" "before and after compared equal - the change is not visible in a line diff; compare by hand"
    elif [ "$(wc -l < "$d")" -gt "$DIFF_LINE_CAP" ] || [ "$sz" -gt "$DIFF_BYTE_CAP" ]; then
        withhold "$sc/$key.withheld" "change too large to retain - compare by hand"
    elif looks_secret "$d"; then
        withhold "$sc/$key.withheld" "change appears to contain a credential - diff not retained"
    elif [ "$(( retain_spent + sz ))" -gt "$RETAIN_BUDGET" ]; then
        withhold "$sc/$key.withheld" "retention budget for this report is spent - compare by hand"
        retain_squeezed=$(( retain_squeezed + 1 ))
    else
        if cp -- "$d" "$sc/$key.diff" 2>/dev/null; then
            retain_spent=$(( retain_spent + sz ))
        else
            rm -f "$sc/$key.diff"
        fi
    fi
    rm -f "$d"
    return 0
}

# A git config key is not staged: its pre-session key=value is already in the
# state file, so the before side is read straight out of it.
promote_gitconfig() {
    local rid="$1" sc="$2" key="$3" ckey="${1#gitconfig:}" before cur l
    before="$(mktemp)" || return 0
    { while IFS= read -r l; do
          [ "${l%%=*}" = "$ckey" ] || continue
          printf '%s\n' "$(unquote_value "${l#*=}")"
      done < <(sed -n '/^### gitconfig/,/^### paths/p' "$state" | grep -v '^### '); } > "$before"
    if [ ! -s "$before" ]; then
        : > "$sc/$key.new" 2>/dev/null || true
        rm -f "$before"; return 0
    fi
    # git resolves a multi-valued key to its LAST value in FILE order, and
    # nothing here can say which that is: git_config_snapshot sorts, so last
    # in this list is lexicographically last, not the value git will run.
    # Neither side of such a comparison is therefore trustworthy, and the
    # loop above collects EVERY pre-session value - so with two of them the
    # diff read as "the second one was removed" when nothing was removed at
    # all, while the value the session appended (the one git now executes)
    # appeared nowhere in it. Refuse to compare rather than show the operator
    # a diff over values git does not use.
    if [ "$(wc -l < "$before")" -gt 1 ] \
       || [ "$(gitconfig_count_for_key "$ws" "$ckey")" -gt 1 ]; then
        withhold "$sc/$key.withheld" "this key has multiple values and git uses the last - compare by hand"
        rm -f "$before"; return 0
    fi
    cur="$(mktemp)" || { rm -f "$before"; return 0; }
    if current_content_for_id "$rid" > "$cur" 2>/dev/null; then
        write_diff "$before" "$cur" "$sc" "$key"
    else
        # Defensive duplicate of promote_finding's existence check, which
        # already ran for this id: only a key unset in the microseconds since
        # reaches here. Without it the diff below would retain the whole
        # former value, which is the one thing a removed surface must not do.
        : > "$sc/$key.removed" 2>/dev/null || true
    fi
    rm -f "$before" "$cur"
    return 0
}

# Write exactly one sidecar entry for one finding. Every path here degrades
# to a marker rather than failing: this runs from bin/dejima's EXIT trap and
# must never change the session's exit status.
promote_finding() {
    local rid="$1" sc="$2" key before cur src csz
    key="$(id_key "$rid")"

    if store_reachable; then
        withhold "$sc/$key.withheld" "audit store is inside the mounted workspace - retention disabled"
        return 0
    fi
    # Existence is settled first, for EVERY namespace (current_sha_for_id
    # dispatches, including to the git config keys promote_gitconfig handles
    # below) - one tested branch rather than one per namespace.
    if [ -z "$(current_sha_for_id "$rid")" ]; then
        # Gone now. A deletion diff is the entire former file, which is the
        # whole-file dump this design avoids - and a surface that no longer
        # exists no longer runs.
        : > "$sc/$key.removed" 2>/dev/null || true
        return 0
    fi
    case "$rid" in
        gitconfig:*) promote_gitconfig "$rid" "$sc" "$key"; return 0 ;;
    esac

    # Novelty is settled before the denylist, because its answer is a marker
    # with no content in it - a denied surface still gets an honest "this is
    # new".
    if ! existed_before "$rid"; then
        # New this session: there is no before, and the after is live on disk
        # for `ack` to read. Nothing to store.
        : > "$sc/$key.new" 2>/dev/null || true
        return 0
    fi
    if diff_denied "$rid"; then
        withhold "$sc/$key.withheld" "secret-bearing surface - diff not retained"
        return 0
    fi

    before="$BEFORE_DIR/$key"
    if [ ! -f "$before" ]; then
        # It existed before but was not staged: over STAGE_FILE_CAP, staging
        # unavailable this run, or a copy that failed. Say so rather than
        # claiming it is new.
        withhold "$sc/$key.withheld" "no pre-session copy - compare by hand"
        return 0
    fi
    # The after side gets the same cap the before side got at staging time,
    # and - like there - it is checked BEFORE any copy is taken. `compare`
    # runs from bin/dejima's EXIT trap and this size is the session's own
    # choice, so copying first would let any session turn "I filled my own
    # workspace" into that many bytes written to $TMPDIR (frequently a tmpfs,
    # i.e. host RAM) at every exit, and into a whole copy of the file left
    # sitting there if that exit was the SIGKILL case rm -f never survives.
    # Mirrors current_content_for_id's dispatch; gitconfig ids never reach
    # here (promote_gitconfig returned above), and stat does not follow a
    # symlink, whose rendering is its target string rather than its content.
    # A host-execution surface's path comes from the harness plugin that
    # declared it, not from the workspace.
    local _hp
    if _hp="$(host_exec_path_for_id "$rid")"; then src="$_hp"; else
    case "$rid" in
        tcb:*)        src="$ws/${rid#tcb:}" ;;
        *)            src="$ws/$rid" ;;
    esac
    fi
    csz="$(stat -c %s "$src" 2>/dev/null)" || csz=0
    if [ "$csz" -gt "$STAGE_FILE_CAP" ]; then
        withhold "$sc/$key.withheld" "change too large to retain - compare by hand"
        return 0
    fi
    cur="$(mktemp)" || return 0
    current_content_for_id "$rid" > "$cur" 2>/dev/null
    if is_binary "$before" || is_binary "$cur"; then
        withhold "$sc/$key.withheld" "binary content - diff not retained"
    else
        write_diff "$before" "$cur" "$sc" "$key"
    fi
    rm -f "$cur"
    return 0
}

# Two acknowledgement forms, deliberately distinct:
#   spam <id>         - this changes routinely; count it, never list it.
#                       Refused for CRITICAL surfaces (see `ack`).
#   safe <id> <sha>   - this exact content is approved; anything else
#                       re-fires at full severity. The only form permitted
#                       for CRITICAL, so approving a hook today does not
#                       approve a different hook tomorrow.
baseline_suppresses() {
    local id="$1" sha="$2" bf verb rest bid bsha eid
    bf="$BASELINE_DIR/$(ws_slug "$ws")"
    [ -f "$bf" ] || return 1
    # Callers pass the RAW id; the baseline file stores the encoded form (see
    # enc_id). Encoded once here, outside the loop - it does not vary per
    # line, and this runs for every finding. For an ordinary id the encoding
    # is the identity, so baseline files written before this existed keep
    # matching unchanged.
    eid="$(enc_id "$id")"
    while read -r verb rest; do
        case "$verb" in ''|'#'*) continue ;; esac
        # An id may itself contain spaces (e.g. "file 2.md"), so it can't be
        # split off with a fixed field count - a naive `read verb id sha`
        # would truncate such an id at its first space and could then
        # collide with a shorter, unrelated real path of that truncated
        # name, silently suppressing a finding no one acknowledged. spam has
        # no trailing sha field, so its id is everything after the verb;
        # safe's sha is fixed-position-last, so its id is recovered by
        # stripping the last whitespace-separated field from the right.
        case "$verb" in
            spam) bid="$rest"; bsha="" ;;
            safe) case "$rest" in
                      *' '*) bsha="${rest##* }"; bid="${rest% *}" ;;
                      *)     continue ;;   # malformed: no sha field
                  esac ;;
            *)    continue ;;              # unknown verb
        esac
        [ "$bid" = "$eid" ] || continue
        case "$verb" in
            # `ack` refusing to write `spam` for a CRITICAL surface is
            # convenience; this is the actual boundary - it holds even
            # against a hand-edited or corrupted baseline file.
            spam) [ "$(id_severity "$id")" = CRITICAL ] && return 1; return 0 ;;
            safe) [ -n "$sha" ] && [ "$bsha" = "$sha" ] && return 0 ;;
        esac
    done < "$bf"
    return 1
}

# Whether `ack` has already walked this report to its end. Recorded in the
# Recorded against the report's name with any -CRITICAL suffix stripped,
# because a report that sheds that suffix later in the same run would
# otherwise carry a marker naming a file that no longer exists - and re-prompt
# forever, which is the failure the marker exists to prevent.
#
# baseline file, alongside the approvals, as `reviewed <report-basename>`:
# the file is already the per-workspace record of what the operator has
# decided, already parsed line-by-line, and already tolerant of verbs it does
# not know (baseline_suppresses skips them), so a report written by an older
# version reads exactly as it did.
#
# Only findings that CANNOT be acknowledged are governed by this. An ackable
# one is governed by its own entry, so `skip` still means "ask me again" for
# it, which is the whole difference between skip and ignore.
baseline_reviewed() {
    local want="$1" bf verb rest
    bf="$BASELINE_DIR/$(ws_slug "$ws")"
    [ -f "$bf" ] || return 1
    while read -r verb rest; do
        case "$verb" in
            reviewed) [ "$rest" = "$want" ] && return 0 ;;
        esac
    done < "$bf"
    return 1
}

# git config keys whose values run a command on the next git invocation, or
# (include.path/includeif.*.path) indirect to another config file that could
# set one - key names are case-insensitive in git, so match on a lowercased
# copy.
is_exec_key() {
    local key; key="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')"
    case "$key" in
        core.hookspath|core.fsmonitor|core.sshcommand|credential.helper) return 0 ;;
        core.pager|core.editor|sequence.editor|gpg.program|diff.external) return 0 ;;
        pager.*) return 0 ;;
        filter.*.smudge|filter.*.clean|filter.*.process) return 0 ;;
        diff.*.textconv|diff.*.command) return 0 ;;
        merge.*.driver) return 0 ;;
        include.path|includeif.*.path) return 0 ;;
    esac
    return 1
}

# Severity of a monitored workspace-relative path, derived from the surface
# alone - never from content. CRITICAL means "runs unsandboxed host code the
# next time the operator uses this repo", which is decidable from a fixed
# list. .vscode/tasks.json and launch.json are CRITICAL wholesale rather than
# parsed for runOn:folderOpen: a JSON parser on the trust boundary costs more
# than the false positives do.
path_severity() {
    case "$1" in
        .envrc|.mcp.json)                                    echo CRITICAL ;;
        .git/hooks/*)                                        echo CRITICAL ;;
        .claude/settings.json|.claude/settings.local.json)   echo CRITICAL ;;
        .vscode/tasks.json|.vscode/launch.json)              echo CRITICAL ;;
        .claude/*|.vscode/*)                                 echo WARNING ;;
        *)                                                   echo INFO ;;
    esac
}

# Severity of any acknowledgeable finding id, namespaced or bare-path (see
# baseline_suppresses' CRITICAL guard below). Bare ids (no known namespace
# prefix) are workspace-relative paths, handled by path_severity. The
# alias.* case is deliberately conservative: the audit only classifies a
# `!`-prefixed alias value as CRITICAL, but an id alone can't say whether the
# value started with `!`, so any alias is treated as CRITICAL for
# suppression purposes.
id_severity() {
    local k
    case "$1" in
        gitconfig:*) k="${1#gitconfig:}"; k="${k#worktree:}"
                     is_exec_key "$k" && { echo CRITICAL; return; }
                     case "$k" in alias.*) echo CRITICAL ;; *) echo WARNING ;; esac ;;
        tcb:*) echo CRITICAL ;;
        *) path_severity "$1" ;;
    esac
}

# sanitize() and sanitize_body() come from dejima-color-lib.sh - every finding
# here interpolates workspace-controlled text (paths, config values) into a
# terminal, and so does every epitaph, so the rule has one definition.

# sanitize() is lossy, so an ID must never pass through it. `ack` derives the
# baseline id from the report text and the next `compare` matches it against
# the RAW bytes on disk, so a stripped id cannot round-trip: the surface
# becomes permanently unsuppressible and the startup warning fires forever.
#
# Ids are encoded instead, canonically, in the report AND the baseline file.
# An id sanitize() leaves alone is written verbatim (every ordinary one, so
# existing baselines are unchanged); anything else is hex-encoded behind a
# marker, which is ASCII and reverses exactly.
#
# An id that merely LOOKS encoded is encoded too, so the marker's presence
# always means this encoder put it there. Otherwise a workspace could present
# an id `ack` decodes into one of the attacker's choosing - baselining
# something other than the finding while counting it acknowledged. Only the
# START matters, and no monitored path begins with the marker, but a git
# config section name is free text the session can write.
#
# Encoding is also the honest rendering: sanitize() DELETES those bytes, so a
# planted ".git/hooks/pre<U+202E>commit" shown as "precommit" sends an
# operator grepping for a name no file has.
HEX_MARK='{hex}'
enc_id() {
    local v="$1"
    # Fast path, and the only one an ordinary id ever takes: decide with
    # pattern matching alone, no subprocesses. hash_paths() calls this once
    # per monitored file on both the snapshot and the compare side, so the
    # exact check below - a subshell plus tr plus sed - is far too expensive
    # to run unconditionally (a bare `git init` alone ships ~14 sample
    # hooks).
    #
    # `local LC_ALL=C` puts bash's own matching into byte semantics for the
    # duration, so [[:cntrl:]] is exactly C0+DEL. Any C1 or bidi character
    # must contain the UTF-8 lead byte \302 or \342 respectively, so testing
    # for those catches every one. The test is deliberately conservative
    # rather than exact - ordinary text carrying one of those lead bytes (an
    # em dash, say) falls through to the exact check and is then found
    # clean and printed verbatim, which costs a little time and changes
    # nothing.
    local LC_ALL=C
    case "$v" in
        "$HEX_MARK"*) ;;
        *[[:cntrl:]]*|*$'\302'*|*$'\342'*) ;;
        *) printf '%s' "$v"; return ;;
    esac
    if [ "$(sanitize "$v")" = "$v" ]; then
        case "$v" in "$HEX_MARK"*) ;; *) printf '%s' "$v"; return ;; esac
    fi
    printf '%s%s' "$HEX_MARK" "$(printf '%s' "$v" | od -An -tx1 | tr -d ' \n')"
}
# Inverse of enc_id. Built with parameter expansion and one printf rather
# than a pipeline, so no command substitution inside can eat a trailing
# byte. printf '%b' consumes the escapes left to right in a single pass,
# which is what makes "\\x41" decode to a literal backslash followed by x41
# instead of to "A".
dec_id() {
    local h="$1" esc=""
    case "$h" in
        "$HEX_MARK"*) h="${h#"$HEX_MARK"}" ;;
        *) printf '%s' "$h"; return ;;
    esac
    while [ -n "$h" ]; do esc="$esc\\x${h:0:2}"; h="${h:2}"; done
    printf '%b' "$esc"
}

# Undo git's config-file quoting of a value (git wraps values containing
# special chars in "..." and backslash-escapes embedded \ and ") so exec
# detection sees the real value instead of a quoted literal.
unquote_value() {
    local v="$1"
    case "$v" in
        \"*\")
            v="${v#\"}"; v="${v%\"}"
            v="${v//\\\"/\"}"
            v="${v//\\\\/\\}"
            ;;
    esac
    printf '%s' "$v"
}

# Hash a regular file's content, or a symlink's target path string (so a
# repointed symlink is caught even though the link's own inode doesn't
# change and its target may not exist to read content from).
hash_entry() {
    local f="$1"
    if [ -L "$f" ]; then
        printf '%s' "$(readlink "$f")" | sha256sum | cut -d' ' -f1
    else
        sha256sum "$f" | cut -d' ' -f1
    fi
}

hash_value() {
    printf '%s' "$1" | sha256sum | cut -d' ' -f1
}

hash_paths() {
    # encoded-path<TAB>sha for each existing monitored path, sorted by path.
    #
    # Encoded (see enc_id) rather than raw, because this record is itself
    # TAB-delimited and line-based: a filename containing a TAB would
    # otherwise be silently truncated at it by the `cut -f1` that reads this
    # back, and reported under a name no file on disk has - unackable for
    # exactly the reason enc_id exists, and CRITICAL if the truncation still
    # lands under .git/hooks/. TAB and newline are both in the set enc_id
    # encodes, so an encoded path can never contain either. Ordinary paths
    # encode to themselves, so this record is unchanged for them.
    local ws="$1" p
    {
        for p in .mcp.json .envrc; do
            [ -f "$ws/$p" ] && { stage_entry "$p" "$ws/$p"; printf '%s\t%s\n' "$(enc_id "$p")" "$(hash_entry "$ws/$p")"; }
        done
        for dir in .claude .vscode .git/hooks; do
            if [ -d "$ws/$dir" ]; then
                while IFS= read -r -d '' f; do
                    stage_entry "${f#"$ws"/}" "$f"
                    printf '%s\t%s\n' "$(enc_id "${f#"$ws"/}")" "$(hash_entry "$f")"
                done < <(find "$ws/$dir" \( -type f -o -type l \) -print0)
            fi
        done
    } | sort
}

# Whether `git status` would show a change to $2 inside repo $1. One call per
# candidate path rather than one listing compared as a set: the candidate list
# is what changed this session, which is small, and --porcelain on a path is
# cheap. A path git does not know about at all (never added, and ignored)
# prints nothing, which is exactly the invisible case.
#
# A repo git refuses to read (not a repository, or a dubious-ownership
# refusal) makes this return false, so every change is reported rather than
# silently downgraded - failing towards the noisier answer, not the quieter
# one.
git_shows_change() {
    local repo="$1" path="$2" out
    out="$(git -C "$repo" status --porcelain --untracked-files=all -- "$path" 2>/dev/null)" || return 1
    [ -n "$out" ]
}

tcb_snapshot() {
    # encoded-path<TAB>sha for the sandbox's own trusted computing base, but
    # only when $ws IS the sandbox's repo (self-mount) - see AUDIT_REPO_ROOT.
    # Empty otherwise, so a normal (non-self) workspace is unaffected.
    # Encoded for the same reason as hash_paths above.
    local ws="$1" p
    [ "$(readlink -f "$ws" 2>/dev/null)" = "$AUDIT_REPO_ROOT" ] || return 0
    {
        # A one-row table, not a loop that happens to run once: the shape is
        # what lets a second path be added as a line rather than a branch.
        # shellcheck disable=SC2043
        for p in install.sh; do
            [ -f "$ws/$p" ] && { stage_entry "tcb:$p" "$ws/$p"; printf '%s\t%s\n' "$(enc_id "$p")" "$(hash_entry "$ws/$p")"; }
        done
        # The provider directories are here for two reasons that are easy to
        # miss because they are data, not code.
        #
        # A forge's `fixed_hosts` and a harness's paths are resolved on the
        # host and handed to the container: fixed_hosts reaches
        # DEJIMA_FORGE_HOSTS, which firewall-up.sh puts into the permitted set
        # the egress policy is given. So a line added to a manifest widens the
        # egress allowlist on the next launch - the boundary that bounds where
        # a compromised session can send anything.
        #
        # And a briefing fragment is what the next session is told about where
        # it is and what it may do, which docs/security.md already names as
        # reachable from a session with write access to this checkout.
        #
        # Neither is code and neither is executed, which is why they were not
        # here. Both decide what the next launch permits, which is what the
        # TCB list is for. A visible edit stays a normal reviewable diff - the
        # split above only reports what git cannot see.
        # tools/ for the same reason as the provider directories, and it was
        # missed when that layer landed: a tool.conf names a tarball URL and
        # the sha256 checked against it, and the host's image build installs
        # what it points at as root.
        #
        # backends/ likewise: a backend.conf names the upstream every request
        # is forwarded to and the host path the credential is read from, so an
        # edit here redirects where a real credential is sent.
        #
        # mirror/ runs on the host from a timer, holding a key that can push
        # to every repository in the GitHub organisation.
        for dir in bin hooks docker test forges workflows harnesses tools backends mirror; do
            if [ -d "$ws/$dir" ]; then
                while IFS= read -r -d '' f; do
                    stage_entry "tcb:${f#"$ws"/}" "$f"
                    printf '%s\t%s\n' "$(enc_id "${f#"$ws"/}")" "$(hash_entry "$f")"
                done < <(find "$ws/$dir" \( -type f -o -type l \) -print0)
            fi
        done
    } | sort
}

git_config_snapshot() {
    # Parse the raw config file(s) instead of `git config --list`, which
    # lowercases key names (core.hooksPath -> core.hookspath) and would hide
    # the exact key a session set.
    local ws="$1" f pfx
    for f in "$ws/.git/config" "$ws"/.git/worktrees/*/config.worktree; do
        [ -f "$f" ] || continue
        case "$f" in
            "$ws"/.git/worktrees/*) pfx="worktree:" ;;
            *) pfx="" ;;
        esac
        awk -v pfx="$pfx" '
            function trim(s) { gsub(/^[ \t]+|[ \t]+$/, "", s); return s }
            /^[ \t]*[#;]/ { next }
            /^[ \t]*\[/ {
                line = trim($0)
                sub(/^\[/, "", line); sub(/\][ \t]*$/, "", line)
                if (match(line, /"/)) {
                    sect = trim(substr(line, 1, RSTART - 1))
                    rest = line
                    sub(/^[^"]*"/, "", rest)
                    sub(/"[ \t]*$/, "", rest)
                    section = sect "." rest
                } else {
                    section = line
                }
                next
            }
            /=/ {
                line = trim($0)
                eq = index(line, "=")
                key = trim(substr(line, 1, eq - 1))
                val = trim(substr(line, eq + 1))
                print pfx section "." key "=" val
            }
        ' "$f"
    done | sort
}

# The sha ack/baseline_suppresses use for a gitconfig:<key> id: the value
# currently set for that exact key (worktree: prefix included, as parsed by
# git_config_snapshot), hashed the same way compare does. Empty (and a
# failing return) if the key no longer exists.
gitconfig_sha_for_key() {
    local ws="$1" key="$2" line lkey
    while IFS= read -r line; do
        lkey="${line%%=*}"
        [ "$lkey" = "$key" ] || continue
        hash_value "$(unquote_value "${line#*=}")"
        return 0
    done < <(git_config_snapshot "$ws")
    return 1
}

# The sha ack writes for any acknowledgeable id, dispatched by namespace -
# mirrors id_severity's dispatch and the hash each compare loop computes for
# that namespace, so the id/sha pair ack records is exactly what
# baseline_suppresses will later compare against.
current_sha_for_id() {
    local id="$1" f _hp
    if _hp="$(host_exec_path_for_id "$id")"; then f="$_hp"; else
    case "$id" in
        gitconfig:*)  gitconfig_sha_for_key "$ws" "${id#gitconfig:}"; return ;;
        tcb:*)        f="$ws/${id#tcb:}" ;;
        *)            f="$ws/$id" ;;
    esac
    fi
    # -e alone follows a symlink and misses one whose target is gone - but a
    # dangling symlink is exactly what hash_entry() below can still hash (the
    # link's target string, never the target's content), so excluding it here
    # would make this function and current_content_for_id disagree about
    # whether the surface still exists.
    { [ -e "$f" ] || [ -L "$f" ]; } && hash_entry "$f"
}

# How many lines of content or diff `ack` prints before truncating. Fixed,
# like CRIT_CAP/WARN_CAP: a predictable prompt is worth more than tunability,
# and the truncation notice names the real paths so the full text is one
# command away.
DISPLAY_LINE_CAP=40

# Live content of a raw namespaced id, mirroring current_sha_for_id's
# dispatch arm for arm so that what is DISPLAYED and what gets PINNED can
# never disagree about the meaning of "this exact content". Symlinks render
# as their target, because that is what hash_entry() hashes for them - the
# link's own content is not what changed.
current_content_for_id() {
    local id="$1" f _hp
    if _hp="$(host_exec_path_for_id "$id")"; then f="$_hp"; else
    case "$id" in
        gitconfig:*)  gitconfig_value_for_key "$ws" "${id#gitconfig:}" ;;
        tcb:*)        f="$ws/${id#tcb:}" ;;
        *)            f="$ws/$id" ;;
    esac
    fi
    case "$id" in gitconfig:*) return ;; esac
    [ -e "$f" ] || [ -L "$f" ] || return 1
    if [ -L "$f" ]; then
        printf '%s\n' "-> $(readlink "$f")"
    else
        cat -- "$f" 2>/dev/null || return 1
    fi
}

# The raw (unquoted) value currently set for a git config key, or a failing
# return if the key is gone. Same parse as gitconfig_sha_for_key, which
# hashes this exact string.
gitconfig_value_for_key() {
    local ws="$1" key="$2" line lkey
    while IFS= read -r line; do
        lkey="${line%%=*}"
        [ "$lkey" = "$key" ] || continue
        printf '%s\n' "$(unquote_value "${line#*=}")"
        return 0
    done < <(git_config_snapshot "$ws")
    return 1
}

# How many values a git config key currently holds. Only ever compared
# against 1: more than one and neither gitconfig_value_for_key above nor the
# state file's pre-session lines can say which value git will actually use
# (see promote_gitconfig).
gitconfig_count_for_key() {
    local ws="$1" key="$2" line n=0
    while IFS= read -r line; do
        [ "${line%%=*}" = "$key" ] && n=$(( n + 1 ))
    done < <(git_config_snapshot "$ws")
    printf '%s' "$n"
}

# Print a file for the operator: sanitized per line (this is
# workspace-controlled text reaching a terminal - see sanitize), indented so
# it cannot be mistaken for the audit's own output, and capped. Sanitizing
# line by line rather than whole-file keeps a planted NUL or newline run from
# collapsing separate lines into one.
# $2 = "diff" to colour +/- lines. Colour is applied to the audit's own
# rendering of a line, never mixed into the line's text: the content here is
# workspace-controlled, and sanitize() has already removed the bytes that
# could forge one of these sequences.
show_capped() {
    local f="$1" mode="${2:-plain}" n=0 total line clean
    total="$(wc -l < "$f" 2>/dev/null)" || total=0
    while IFS= read -r line; do
        n=$((n + 1))
        [ "$n" -le "$DISPLAY_LINE_CAP" ] || break
        clean="$(sanitize "$line")"
        if [ "$mode" = diff ]; then
            case "$clean" in
                +*) printf '    %s%s%s\n' "$C_ADD" "$clean" "$C_RESET"; continue ;;
                -*) printf '    %s%s%s\n' "$C_DEL" "$clean" "$C_RESET"; continue ;;
            esac
        fi
        printf '    %s\n' "$clean"
    done < "$f"
    if [ "$total" -gt "$DISPLAY_LINE_CAP" ]; then
        printf '    %s... +%s more line(s)%s\n' "$C_DIM" "$(( total - DISPLAY_LINE_CAP ))" "$C_RESET"
    fi
}

# The file a finding is about, as it is on disk now, or empty when the finding
# is not about a file at all - a git config key has no path, and a surface that
# has since been removed has nothing to open.
#
# Deliberately the current file rather than the text the report captured: the
# question `ack` asks is about what is on the host now, and an approval pins
# the content that is there at the moment it is recorded. Reading a stale copy
# and approving would be approving something nobody read.
path_for_id() {
    local id="$1" f _hp
    if _hp="$(host_exec_path_for_id "$id")"; then
        f="$_hp"
    else
        case "$id" in
            gitconfig:*) return 1 ;;
            tcb:*)       f="$ws/${id#tcb:}" ;;
            *)           f="$ws/$id" ;;
        esac
    fi
    [ -f "$f" ] || return 1
    printf '%s\n' "$f"
}

# The operator's own editor, read-only where the editor understands how.
#
# Read-only is the default because the file being opened is the thing under
# audit: editing it mid-decision changes what an approval would then pin, and
# an approval recorded against content the session did not write is a worse
# record than no approval. -R is only added for the vi family, whose spelling
# of it is known; anything else is run as the operator configured it.
open_in_editor() {
    local file="$1" ed base
    ed="${VISUAL:-${EDITOR:-vi}}"
    base="$(basename "${ed%% *}")"
    case "$base" in
        vi|vim|nvim|view) "$ed" -R "$file" <&3 || true ;;
        *)                "$ed" "$file" <&3 || true ;;
    esac
}

# Where the operator can read the whole thing, when the display was capped.
# Never sanitized through a path that would make it unfindable: it is the
# audit's OWN text about a location, not workspace-controlled content.
source_hint() {
    local id="$1" _hp
    if _hp="$(host_exec_path_for_id "$id")"; then
        printf '%s\n' "    (full text: $(sanitize "$_hp"))"
        return
    fi
    case "$id" in
        gitconfig:*)  printf '%s\n' "    (full value: git -C $ws config --get $(sanitize "${id#gitconfig:}"))" ;;
        tcb:*)        printf '%s\n' "    (full text: $ws/$(sanitize "${id#tcb:}"))" ;;
        *)            printf '%s\n' "    (full text: $ws/$(sanitize "$id"))" ;;
    esac
}

# The display block for one finding, preferring the diff retained beside this
# report and degrading, in order, to: the full content of a surface that is
# new; the live content with the reason no diff was kept; and - for a report
# written before retention existed at all - the live content alone.
render_finding() {
    local id="$1" sc="$2" key tmp
    key="$(id_key "$id")"

    # Dim on the audit's own framing, plain on the content between it: the
    # complaint this answers is that the two ran together in one colour with
    # nothing to say where the tool stopped speaking and the workspace
    # started (#65).
    if [ -n "$sc" ] && [ -f "$sc/$key.diff" ]; then
        printf '  %s--- what changed while the session ran ---%s\n' "$C_DIM" "$C_RESET"
        show_capped "$sc/$key.diff" diff
        source_hint "$id"
        return 0
    fi
    if [ -n "$sc" ] && [ -f "$sc/$key.removed" ]; then
        printf '  %s--- this surface was removed; its pre-session content was not retained ---%s\n' "$C_DIM" "$C_RESET"
        return 0
    fi
    if [ -n "$sc" ] && [ -f "$sc/$key.withheld" ]; then
        printf '  %s--- no diff retained: %s ---%s\n' "$C_DIM" \
            "$(sanitize "$(head -n1 "$sc/$key.withheld" 2>/dev/null)")" "$C_RESET"
    elif [ -n "$sc" ] && [ -f "$sc/$key.new" ]; then
        printf '  %s--- new surface; full current content ---%s\n' "$C_DIM" "$C_RESET"
    else
        printf '  %s--- no pre-session record; current content ---%s\n' "$C_DIM" "$C_RESET"
    fi

    tmp="$(mktemp)" || return 0
    if current_content_for_id "$id" > "$tmp" 2>/dev/null; then
        show_capped "$tmp"
        source_hint "$id"
    else
        echo "    (this surface no longer exists)"
    fi
    rm -f "$tmp"
    return 0
}

# The boundary that ends the tcb section and begins host-execution entries.
# A state file written before that section was renamed says "### statusline";
# reading it with "### hostexec" alone would run the tcb range to the end of
# the file, turning that old entry into a spurious "TCB modified, INVISIBLE
# to git" CRITICAL and firing a host-exec finding for a surface nothing
# changed. Only a comparison spanning a checkout update sees this, which is
# exactly the case the section boundaries exist to survive.
_hostexec_boundary() {
    grep -q '^### hostexec$' "$1" 2>/dev/null && { printf '### hostexec'; return; }
    printf '### statusline'
}

host_exec_snapshot() {
    # leaf<TAB>sha for every host-execution surface the harness declared, or
    # nothing where the host has none. One line per surface, keyed by the
    # leaf so the id a baseline stores ("<namespace>:<leaf>") is unchanged
    # for a harness already in use.
    local rec ns leaf path label
    for rec in ${DEJIMA_HOST_EXEC[@]+"${DEJIMA_HOST_EXEC[@]}"}; do
        IFS=$'\t' read -r ns leaf path label <<< "$rec"
        [ -n "$path" ] && [ -e "$path" ] || continue
        stage_entry "$ns:$leaf" "$path"
        printf '%s\t%s\n' "$leaf" "$(hash_entry "$path")"
    done
}

case "$cmd" in
    --print-paths)
        # statusline_path is still named, because it is the surface this
        # harness declares and existing callers read it by that name. It
        # comes from the declaration now rather than from a global of its
        # own, so a harness declaring none prints an empty value and one
        # declaring several prints a line each.
        printf 'projects_dir=%s\naudit_dir=%s\nbaselines_dir=%s\nkeep=%s\n' \
            "$PROJECTS_DIR" "$AUDIT_DIR" "$BASELINE_DIR" "$AUDIT_KEEP"
        for _rec in ${DEJIMA_HOST_EXEC[@]+"${DEJIMA_HOST_EXEC[@]}"}; do
            IFS=$'\t' read -r _ns _leaf _path _label <<< "$_rec"
            printf '%s_path=%s\n' "$_ns" "$_path"
        done
        exit 0
        ;;
    snapshot)
        STAGE_DIR="$(open_staging)"
        # First section, so the existing ranges (anchored at ### gitconfig
        # and ### statusline) are unaffected. Written even when empty, so
        # `compare` can tell "retention was off" from "state file predates
        # this" without guessing.
        { echo "### staging $STAGE_DIR";
          echo "### gitconfig"; git_config_snapshot "$ws";
          echo "### paths";     hash_paths "$ws";
          # Empty since the cross-project finding was retired, and kept
          # rather than dropped: it is a range boundary for the paths section
          # above, and removing it would make a state file written by one
          # version unparseable by the other across an upgrade mid-session.
          echo "### projects";
          echo "### tcb";       tcb_snapshot "$ws";
          echo "### hostexec"; host_exec_snapshot; } > "$state" 2>/dev/null || true
        exit 0
        ;;
    compare)
        # cleanup() (bin/dejima) now runs this from the EXIT trap, which
        # fires on early exits too - before the workspace's mktemp'd state
        # file has ever been written to by `snapshot`. Such a file exists
        # but is empty, and treating "exists" as "has a snapshot" would diff
        # the real current state against nothing, manufacturing a finding
        # for every monitored surface already present. Empty is the same as
        # absent for that purpose, so both skip - but only a genuinely
        # missing path (not the routine mktemp'd-but-unwritten case that
        # follows every pre-snapshot fatal exit) is worth a message: that
        # case is the operator's last line on a fatal error, and printing
        # "skipping" over it would bury the real failure.
        [ -e "$state" ] || { echo "dejima audit: no snapshot to compare against, skipping." >&2; exit 0; }
        [ -s "$state" ] || exit 0

        BEFORE_DIR="$(sed -n 's/^### staging //p' "$state" | head -1)"
        # An rm -rf on a path read out of a file gets one sanity check: it
        # must sit under our own store, in the shape open_staging() produces.
        # `..` is rejected separately because `*` in a case pattern matches
        # `/` too, so the shape alone would accept a path that climbs back
        # out of the store again.
        case "$BEFORE_DIR" in
            *..*) BEFORE_DIR="" ;;
            "$AUDIT_DIR/"*/.staging/*) ;;
            *) BEFORE_DIR="" ;;
        esac
        # Owned from here on regardless of how this script leaves: an early
        # exit (no findings, or an unwritable report dir) must not leave whole
        # pre-session files staged on disk.
        cleanup_staging() { [ -n "$BEFORE_DIR" ] && rm -rf -- "$BEFORE_DIR"; return 0; }
        trap cleanup_staging EXIT

        new="$(mktemp)"; { echo "### gitconfig"; git_config_snapshot "$ws";
                           echo "### paths";     hash_paths "$ws";
                           echo "### projects";
                           echo "### tcb";       tcb_snapshot "$ws";
                           echo "### hostexec"; host_exec_snapshot; } > "$new" 2>/dev/null || {
            echo "dejima audit: could not complete, skipping." >&2; rm -f "$new"; exit 0; }

        # One raw namespaced id per finding whose surface has retainable
        # content, in report order - what promote_finding() works from below.
        crit=(); warn=(); info=(); finding_ids=()
        # Retention budget accounting: bytes of diff already kept for this
        # report, how many findings were refused for want of room, and how
        # many had been dealt with when the budget ran out. budget_note is
        # the two lines both the report and the summary print when it did.
        retain_spent=0; retain_squeezed=0; retain_at=0; budget_note=()
        # config diffs: added/changed lines present in new but not old
        while IFS= read -r line; do
            key="${line%%=*}"
            key_nopfx="${key#worktree:}"
            # The key is the id-bearing half and is encoded; the value is
            # display-only and is sanitized. Splitting them keeps a value
            # full of control bytes from dragging its key into the opaque
            # hex form, where the operator could no longer read which
            # setting had changed.
            body="$(enc_id "$key")=$(sanitize "${line#*=}")"
            if is_exec_key "$key_nopfx"; then
                gid="gitconfig:$key"; gsha="$(hash_value "$(unquote_value "${line#*=}")")"
                baseline_suppresses "$gid" "$gsha" && continue
                crit+=("COMMAND-EXECUTION git config: $body")
                finding_ids+=("gitconfig:$key")
            elif [ "$key_nopfx" = "alias" ] || [[ "$key_nopfx" == alias.* ]]; then
                # alias.<name>=!cmd runs a shell command
                case "$(unquote_value "${line#*=}")" in
                    '!'*) gid="gitconfig:$key"; gsha="$(hash_value "$(unquote_value "${line#*=}")")"
                          baseline_suppresses "$gid" "$gsha" && continue
                          crit+=("COMMAND-EXECUTION git alias: $body")
                          finding_ids+=("gitconfig:$key") ;;
                    *)    gid="gitconfig:$key"; gsha="$(hash_value "$(unquote_value "${line#*=}")")"
                          baseline_suppresses "$gid" "$gsha" && continue
                          warn+=("changed git config: $body")
                          finding_ids+=("gitconfig:$key") ;;
                esac
            else
                # Suppressible and id-bearing like the CRITICAL arms above:
                # without an id, keys some tools rewrite on every run
                # (branch.<name>.merge on each push, submodule.*) re-fire
                # every session and push real WARNINGs out of the capped
                # list (#45).
                #
                # Safe by construction rather than by care taken here:
                # id_severity already calls an exec key or any alias CRITICAL
                # whatever wrote the entry, and baseline_suppresses refuses a
                # `spam` entry against a CRITICAL surface at READ time - so a
                # key that later joins is_exec_key stops being suppressed by
                # an existing entry, with nothing to revisit.
                gid="gitconfig:$key"; gsha="$(hash_value "$(unquote_value "${line#*=}")")"
                baseline_suppresses "$gid" "$gsha" && continue
                warn+=("changed git config: $body")
                finding_ids+=("gitconfig:$key")
            fi
        done < <(comm -13 <(sed -n '/^### gitconfig/,/^### paths/p' "$state" | grep -vE '^### |^worktree:$') \
                          <(sed -n '/^### gitconfig/,/^### paths/p' "$new"   | grep -vE '^### |^worktree:$') )

        # path diffs: any monitored file added/changed/removed (a content
        # change emits both an old-hash and new-hash line; dedupe by path
        # so each changed path is reported once)
        while IFS= read -r line; do
            # $line arrives encoded (see hash_paths). Everything that has to
            # reach the real file, classify it, or match a baseline entry
            # needs the raw name; only the report body keeps the encoded
            # form, which is what enc_id would produce for it anyway.
            rline="$(dec_id "$line")"
            cur_sha=""
            [ -e "$ws/$rline" ] && cur_sha="$(hash_entry "$ws/$rline")"
            baseline_suppresses "$rline" "$cur_sha" && continue
            # Recorded once for all three arms below: severity decides how the
            # finding is REPORTED, not whether its content can be retained.
            finding_ids+=("$rline")
            case "$(path_severity "$rline")" in
                CRITICAL) crit+=("HOST-CODE-EXECUTION workspace file: $line") ;;
                WARNING)  warn+=("changed workspace file: $line") ;;
                # Currently unreachable: hash_paths() only monitors
                # .mcp.json/.envrc/.claude/.vscode/.git/hooks, and every path
                # under those matches an earlier CRITICAL/WARNING pattern in
                # path_severity(). Kept as the defensive default for
                # surfaces monitored by a future task.
                *)        info+=("changed workspace file: $line") ;;
            esac
        done < <(comm -3 <(sed -n '/^### paths/,/^### projects/p' "$state" | grep -v '^### ') \
                         <(sed -n '/^### paths/,/^### projects/p' "$new"   | grep -v '^### ') \
                 | sed 's/^\t//' | cut -f1 | sort -u )

        # Deliberately no cross-project finding: since #104 a session gets
        # its own ~/.claude/projects slugs and nothing else, so the only
        # writes visible here are other host or dejima sessions doing their
        # own legitimate work - and with several running it would report all
        # of them, on every report, drowning the findings that mattered.
        #
        # Detection that cannot observe what it exists for, and fires
        # constantly on what it does not, is worse than no detection: it
        # trains the operator to skim. The mount is the control, and
        # static.bats pins it.

        # tcb diffs: any change under the sandbox's own trusted computing
        # base, only populated when this session's workspace IS the
        # sandbox's own repo (see tcb_snapshot) - empty/no-op otherwise.
        # Bounded at ### hostexec (not $p, the last section now) so this
        # doesn't also swallow that section's lines.
        # Split on whether git can see the change, because that decides
        # whether this finding is telling the operator anything.
        #
        # tcb_snapshot only populates when the workspace IS dejima's own repo,
        # so every change here is also a change to a git working tree the
        # operator is about to read in `git status` and in the pull request.
        # Reporting each one as a CRITICAL to acknowledge duplicated a
        # stronger control and made ordinary development unusable.
        #
        # What git cannot show is the case worth keeping: a file listed in
        # .gitignore or .git/info/exclude, or marked assume-unchanged or
        # skip-worktree, is live code that runs on the next launch and appears
        # nowhere in `git status`. Nothing but this hash sees it, so it stays
        # a CRITICAL finding.
        tcb_visible=0
        while IFS= read -r line; do
            rline="$(dec_id "$line")"
            tid="tcb:$rline"; tsha=""; [ -e "$ws/$rline" ] && tsha="$(hash_entry "$ws/$rline")"
            baseline_suppresses "$tid" "$tsha" && continue
            if git_shows_change "$ws" "$rline"; then
                tcb_visible=$((tcb_visible + 1))
                continue
            fi
            crit+=("TCB (sandbox self) modified, INVISIBLE to git: $line")
            finding_ids+=("$tid")
        done < <(comm -3 <(sed -n "/^### tcb/,/^$(_hostexec_boundary "$state")/p" "$state" | grep -v '^### ') \
                         <(sed -n "/^### tcb/,/^$(_hostexec_boundary "$new")/p"   "$new"   | grep -v '^### ') \
                 | sed 's/^\t//' | cut -f1 | sort -u )
        if [ "$tcb_visible" -gt 0 ]; then
            info+=("$tcb_visible change(s) under the sandbox's own bin/, hooks/, docker/, test/ - this workspace IS dejima; review them with git")
        fi

        # host-execution diff: a path the harness runs on the HOST added,
        # changed, removed or repointed. Its own section rather than folded
        # into hash_paths for the reason given where the surfaces are loaded,
        # and worded at least as strongly as COMMAND-EXECUTION/TCB above:
        # this is unsandboxed host code execution, not just a config change.
        #
        # The label is the plugin's, because it is the fixed prefix `ack`
        # strips to recover the id and so belongs in trusted code rather than
        # in manifest data. A leaf with no declaring surface cannot happen
        # from a snapshot this run wrote, but it can from a state file
        # written before the harness changed - such a line is skipped rather
        # than reported under a label nothing owns.
        while IFS= read -r line; do
            _hl=""; _hns=""; _hpath=""
            for _rec in ${DEJIMA_HOST_EXEC[@]+"${DEJIMA_HOST_EXEC[@]}"}; do
                IFS=$'\t' read -r _ns _leaf _path _label <<< "$_rec"
                [ "$_leaf" = "$line" ] || continue
                _hns="$_ns"; _hpath="$_path"; _hl="$_label"; break
            done
            [ -n "$_hl" ] || continue
            sid="$_hns:$line"; ssha=""; [ -e "$_hpath" ] && ssha="$(hash_entry "$_hpath")"
            baseline_suppresses "$sid" "$ssha" && continue
            crit+=("$_hl$(enc_id "$line")")
            finding_ids+=("$sid")
        done < <(comm -3 <(sed -n "/^$(_hostexec_boundary "$state")/,\$p" "$state" | grep -v '^### ') \
                         <(sed -n "/^$(_hostexec_boundary "$new")/,\$p" "$new"   | grep -v '^### ') \
                 | sed 's/^\t//' | cut -f1 | sort -u )

        rm -f "$new"
        total=$(( ${#crit[@]} + ${#warn[@]} + ${#info[@]} ))
        if [ "$total" -eq 0 ]; then
            exit 0
        fi

        report_dir="$AUDIT_DIR/$(ws_slug "$ws")"
        if mkdir -p "$report_dir" 2>/dev/null; then
            if [ "${#crit[@]}" -gt 0 ]; then
                report="$report_dir/$(date +%Y-%m-%d-%H%M%S)-CRITICAL.txt"
            else
                report="$report_dir/$(date +%Y-%m-%d-%H%M%S).txt"
            fi
            {
                echo "dejima audit report for $(sanitize "$ws")"
                echo "Changes below happened while this session ran - not necessarily because"
                echo "of it. A concurrent dejima/Claude Code session, or your own edits during"
                echo "the run, land in this same diff; see docs/security.md."
                # Named, never hashed. Writing to a writable mount is the entire
                # point of configuring one, so a finding per write would fire on
                # every legitimate use and train the reader to skim - the exact
                # failure the audit's severity tiers exist to avoid. What the
                # operator needs instead is the blast radius: reading any finding
                # below, they should know what else this session could reach.
                if [ -n "${DEJIMA_RW_MOUNTS:-}" ]; then
                    echo "also writable by this session, not hashed: $(sanitize "$DEJIMA_RW_MOUNTS")"
                fi
                [ "${#crit[@]}" -gt 0 ] && printf 'CRITICAL %s\n' "${crit[@]}"
                [ "${#warn[@]}" -gt 0 ] && printf 'WARNING  %s\n' "${warn[@]}"
                [ "${#info[@]}" -gt 0 ] && printf 'INFO     %s\n' "${info[@]}"
            } > "$report" 2>/dev/null || true

            # The one place pre-session CONTENT becomes something that
            # outlives the session, and only ever as a bounded diff or a
            # marker. 0700, since that content is the thing the sandbox was
            # not allowed to see. rmdir at the end leaves nothing behind when
            # every finding declined to retain anything.
            if [ "$DIFFS_ENABLED" = "1" ] && [ "${#finding_ids[@]}" -gt 0 ]; then
                sc="$(sidecar_dir "$report")"
                if ( umask 077; mkdir -p "$sc" ) 2>/dev/null; then
                    # umask set and restored around the loop rather than a
                    # ( umask 077; ... ) subshell, because the budget counters
                    # promote_finding updates have to survive it. Every file
                    # the loop writes is pre-session content or a statement
                    # about it, so the 0700 directory must not be the only
                    # thing keeping it off other local accounts.
                    old_umask="$(umask)"; umask 077
                    n=0
                    for rid in "${finding_ids[@]}"; do
                        n=$(( n + 1 ))
                        promote_finding "$rid" "$sc"
                        if [ "$retain_squeezed" -gt 0 ] && [ "$retain_at" -eq 0 ]; then
                            retain_at=$(( n - 1 ))
                        fi
                    done
                    umask "$old_umask"
                    rmdir "$sc" 2>/dev/null || true
                    if [ "$retain_squeezed" -gt 0 ]; then
                        budget_note=(
                            "  !! retention budget spent after $retain_at of ${#finding_ids[@]} findings."
                            "  !! that many changed host-persistence surfaces in one session is ABNORMAL."
                        )
                        printf '%s\n' "${budget_note[@]}" >> "$report" 2>/dev/null || true
                    fi
                fi
            fi

            # Oldest-first, but only clean reports are evictable: a report
            # still carrying an unacknowledged CRITICAL is retained past the
            # cap, so flushing one out cannot be achieved by inducing runs.
            # `ack` renames it to drop the suffix, which makes it evictable.
            while [ "$(find "$report_dir" -maxdepth 1 -name '*.txt' ! -name '*-CRITICAL.txt' | wc -l)" -gt "$AUDIT_KEEP" ]; do
                oldest="$(find "$report_dir" -maxdepth 1 -name '*.txt' ! -name '*-CRITICAL.txt' | sort | head -1)"
                [ -n "$oldest" ] || break
                # The report's retained diffs go with it: they are content at
                # rest whose only reason to exist is the report that cites it.
                rm -rf "$(sidecar_dir "$oldest")"
                rm -f "$oldest" || break
            done
        fi

        # Ascending severity: the operator reads bottom-up from their shell
        # prompt, so whatever prints LAST is what actually gets seen. INFO
        # collapses to a count - it exists to be counted, not read.
        # stderr is this block's stream, so the palette is decided for that
        # one: redirecting the summary to a file must not put escapes in it.
        color_init 2
        {
            echo ""
            printf '%s================================================================%s\n' "$C_DIM" "$C_RESET"
            echo " dejima: host-persistence surfaces changed in"
            printf ' %s%s%s while this session ran\n' "$C_PATH" "$(sanitize "$ws")" "$C_RESET"
            printf '%s----------------------------------------------------------------%s\n' "$C_DIM" "$C_RESET"
            # Named, never hashed. Writing to a writable mount is the entire
            # point of configuring one, so a finding per write would fire on
            # every legitimate use and train the reader to skim - the exact
            # failure the audit's severity tiers exist to avoid. What the
            # operator needs instead is the blast radius: reading any finding
            # below, they should know what else this session could reach.
            if [ -n "${DEJIMA_RW_MOUNTS:-}" ]; then
                echo " also writable by this session, not hashed: $(sanitize "$DEJIMA_RW_MOUNTS")"
            fi
            if [ "${#info[@]}" -gt 0 ]; then
                echo "  ${#info[@]} routine file change(s) - not listed"
            fi
            if [ "${#warn[@]}" -gt 0 ]; then
                printf "  $C_WARN-$C_RESET %s\n" "${warn[@]:0:$WARN_CAP}"
                if [ "${#warn[@]}" -gt "$WARN_CAP" ]; then
                    echo "  + $(( ${#warn[@]} - WARN_CAP )) further change(s) not shown."
                fi
            fi
            if [ "${#crit[@]}" -gt 0 ]; then
                printf "  $C_CRIT!$C_RESET %s\n" "${crit[@]:0:$CRIT_CAP}"
                if [ "${#crit[@]}" -gt "$CRIT_CAP" ]; then
                    echo "  !! $(( ${#crit[@]} - CRIT_CAP )) FURTHER CRITICAL FINDINGS NOT SHOWN."
                    echo "  !! ${#crit[@]} critical findings in one session is ABNORMAL."
                fi
            fi
            # Last before the footer, where it is read first: running out of
            # room to keep evidence is a finding about the session, not a
            # note about the store.
            [ "${#budget_note[@]}" -gt 0 ] && printf '%s\n' "${budget_note[@]}"
            printf '%s----------------------------------------------------------------%s\n' "$C_DIM" "$C_RESET"
            if [ "${#crit[@]}" -gt 0 ]; then
                echo "         Review BEFORE the next 'git' or 'claude' in that repo."
                printf ' dejima: %s%s CRITICAL%s - code that will run on your host.\n' \
                    "$C_CRIT" "${#crit[@]}" "$C_RESET"
            else
                printf ' dejima: %sno critical findings%s; %s change(s) to review.\n' \
                    "$C_OK" "$C_RESET" "${#warn[@]}"
            fi
        } >&2
        exit 0
        ;;
    show)
        color_init 1
        report_dir="$AUDIT_DIR/$(ws_slug "$ws")"
        report="$(select_report "$report_dir")" || {
            _total="$(count_reports_total "$report_dir")"
            if [ "$_total" -gt 0 ]; then
                printf 'dejima audit: nothing waiting for %s.\n' "$ws" >&2
                printf '  %s report(s) kept in %s as the record of what those sessions did.\n' \
                    "$_total" "$report_dir" >&2
            else
                echo "dejima audit: no reports for $ws" >&2
            fi
            exit 0
        }
        # Default is an index, not a dump. `cat` of one report was a wall of
        # text that answered neither question an operator actually has - how
        # much is waiting, and which of it still needs a decision - and it
        # printed findings already acknowledged, so the wall did not even
        # shrink as they worked through it.
        if [ "${2:-}" = --full ] || [ "${3:-}" = --full ] || [ "${4:-}" = --full ]; then
            echo "dejima audit: $report" >&2
            cat "$report"
            exit 0
        fi
        read -r n_all n_crit <<< "$(count_reports "$report_dir")"
        printf '%s report(s) for %s' "$n_all" "$ws"
        [ "$n_crit" -gt 0 ] && printf ', %s%s with unacknowledged CRITICAL%s' \
            "$C_CRIT" "$n_crit" "$C_RESET"
        printf '\n\n'
        # Newest last, so the one `ack` will offer first is the one nearest
        # the prompt - and mark it, since select_report prefers CRITICAL over
        # recency and "the newest" is the wrong guess.
        r=""; line=""; pending=""
        while IFS= read -r r; do
            pending=$(grep -cE '^(CRITICAL|WARNING|INFO)' "$r" 2>/dev/null || true)
            line="$(printf '  %-44s %s finding(s)' "$(basename "$r")" "${pending:-0}")"
            case "$r" in
                *-CRITICAL.txt) printf '%s%s%s' "$C_CRIT" "$line" "$C_RESET" ;;
                *)              printf '%s' "$line" ;;
            esac
            [ "$r" = "$report" ] && printf '   %s<- ack starts here%s' "$C_KEY" "$C_RESET"
            printf '\n'
        done < <(find "$report_dir" -maxdepth 1 -name '*.txt' 2>/dev/null | sort)
        printf '\n%sdejima-audit ack%s walks them; %sdejima-audit show --full%s prints one.\n' \
            "$C_KEY" "$C_RESET" "$C_KEY" "$C_RESET"
        exit 0
        ;;
    ack)
        color_init 1
        report_dir="$AUDIT_DIR/$(ws_slug "$ws")"
        bf="$BASELINE_DIR/$(ws_slug "$ws")"
        report="$(select_report "$report_dir")" || {
            _total="$(count_reports_total "$report_dir")"
            if [ "$_total" -gt 0 ]; then
                printf 'dejima audit: nothing waiting - every finding in %s report(s) has been dealt with.\n' \
                    "$_total" >&2
            else
                echo "dejima audit: no reports for $ws" >&2
            fi
            exit 0
        }
        read -r n_all n_crit <<< "$(count_reports "$report_dir")"
        if [ "$n_all" -gt 1 ]; then
            printf '%s reports waiting' "$n_all"
            [ "$n_crit" -gt 0 ] && printf ', %s%s with unacknowledged CRITICAL%s' \
                "$C_CRIT" "$n_crit" "$C_RESET"
            printf '.\n\n'
        fi
        # What has already been put in front of the operator during THIS
        # invocation, keyed by surface and the content that is on disk now.
        #
        # Reports overlap: the same file can appear in several, and `ack`
        # walks them in one run. Once its current content has been shown, an
        # older report asking about the same file is asking about a version
        # that no longer exists - the operator has seen what is there and
        # decided, or declined to. Approving covers this through the baseline
        # already; skipping does not, and that is the case this handles.
        declare -A shown_this_run=()
        # Reports this invocation has already walked, so it moves on rather
        # than being handed one of them again.
        declare -A walked_this_run=()

        # One report per pass, every report in one invocation. Walking one and
        # exiting meant clearing a backlog took as many runs as there were
        # reports, with nothing on screen to say another was behind it.
        while :; do
        walked_this_run["$report"]=1
        sc="$(sidecar_dir "$report")"
        # Removed only at the end, and only for findings actually
        # acknowledged - an abort must leave the operator exactly where they
        # were, retained diffs included.
        sc_done=()

        # Accumulate in memory and write once at the end: an ack interrupted
        # partway (q/Esc/EOF/Ctrl-C) must not leave a CRITICAL surface
        # silently approved, so every abort path discards everything rather
        # than half-writing.
        pending=(); aborted=0
        # crit_seen/crit_acked gate the -CRITICAL.txt rename below: it must
        # fire only when every CRITICAL finding in THIS report was actually
        # approved, not merely when the operator approved something. A
        # report mixing an unacknowledged CRITICAL with an approved WARNING
        # (or a non-path CRITICAL, which can never be "approved" - see
        # ackable below) must keep its suffix and stay eviction-protected.
        crit_seen=0; crit_acked=0
        # Set when a finding was cleared because its surface no longer exists.
        # Like unackable_shown, it means the report has been dealt with as far
        # as it can be, so it earns a `reviewed` marker.
        removed_cleared=0
        # Findings the operator chose to be asked about again, and un-ackable
        # ones that were shown but could not be recorded either way. The first
        # keeps the report outstanding; the second must not, or it re-prompts
        # forever - which is the distinction the reviewed marker turns on.
        left_undecided=0
        # Findings passed over because the baseline already covers them, or
        # because this report has been walked before - counted so a run that
        # displays nothing can say why instead of looking broken.
        already=0
        # Whether this report carries any finding that cannot be
        # acknowledged, and whether a previous run already walked it to the
        # end. Together they decide if a `reviewed` line gets written below.
        unackable_shown=0
        report_reviewed=0
        if baseline_reviewed "$(report_key "$report")"; then report_reviewed=1; fi
        # The loop below reads report lines on its own stdin (`< "$report"`),
        # so operator keystrokes are read from fd 3 - a copy of the script's
        # original stdin taken before that redirection - rather than
        # /dev/tty (which has nothing to open under piped test input; a
        # bare `read` on fd 0 would otherwise be consuming the very report
        # lines the loop is iterating). In real use fd 3 is still the
        # operator's terminal, since that's what an interactive shell hands
        # a foreground command as fd 0. Piping into `ack` therefore drives
        # approvals in both test and real use through the same code path -
        # safe, because the read-time CRITICAL guard in baseline_suppresses()
        # (a `spam` entry can never silence a CRITICAL surface) holds no
        # matter how a baseline entry got written, piped or typed.
        { exec 3<&0; } 2>/dev/null
        while IFS= read -r rline; do
            tier="${rline%% *}"
            # Report lines are column-padded by the printf that wrote them
            # (`'CRITICAL %s\n'` vs `'WARNING  %s\n'` - one vs two spaces),
            # so stripping only the first space would leave WARNING bodies
            # with a leading space that a since-anchored case match (see
            # below) would silently fail to match. Strip the exact,
            # known-fixed prefix per tier instead of a single space.
            case "$tier" in
                CRITICAL) body="${rline#CRITICAL }" ;;
                WARNING)  body="${rline#WARNING  }" ;;
                *) continue ;;
            esac
            [ "$tier" = CRITICAL ] && crit_seen=$((crit_seen + 1))
            # Derive the namespaced id baseline_suppresses() compares
            # against - one arm per finding-producing loop in `compare`.
            #
            # Both steps must resist workspace-controlled text in $body. The
            # patterns anchor to the fixed label at the START of body: a git
            # config value or path can contain "workspace file: ", so an
            # unanchored match derives an attacker-chosen id in the wrong
            # namespace, and approving it baselines an id that never matches
            # while counting the real finding as acknowledged. The id is then
            # recovered with `${body#LABEL}`, never `${body##*: }` - the
            # latter strips to the LAST ": ", and a path can contain one.
            # A body matching no arm (currently only CROSS-PROJECT) gets no
            # id: shown, but not offered a/s.
            #
            # $id is the ENCODED form the report and baseline file carry;
            # $rid is it decoded, for current_sha_for_id to stat and hash and
            # id_severity to classify. Only the id-bearing portion decodes,
            # never the namespace prefix added here. $lbl and $surface are
            # display-only, split so the surface can be coloured without the
            # audit's own words around it, and recomputed rather than derived
            # from $id because a gitconfig body carries `key=value` while its
            # id is the key alone.
            ackable=0; id=""; rid=""; lbl=""; surface=""
            # Host-execution surfaces first, because their labels come from
            # the harness plugin rather than from a literal here. Matched by
            # an exact prefix strip - "$body" with the label removed differs
            # from "$body" only when the label really is at position 0 -
            # rather than by a `case` pattern, which would treat a label's
            # own characters as globs. Anchoring is the whole point (#45):
            # an unanchored match lets chosen text derive an id in the wrong
            # namespace, and approving it baselines an id that never matches
            # while the real finding counts as acknowledged.
            for _rec in ${DEJIMA_HOST_EXEC[@]+"${DEJIMA_HOST_EXEC[@]}"}; do
                IFS=$'\t' read -r _ns _leaf _path _label <<< "$_rec"
                _rest="${body#"$_label"}"
                [ "$_rest" != "$body" ] || continue
                ackable=1; lbl="$_label"; surface="$_rest"
                id="$_ns:$_rest"; rid="$_ns:$(dec_id "$_rest")"
                break
            done
            # Only if none of them claimed it: a host-exec label that
            # matched has already derived the id, and the arms below must
            # not get a second go at the same text.
            if [ -z "$id" ]; then
            case "$body" in
                "HOST-CODE-EXECUTION workspace file: "*)
                    ackable=1; lbl="HOST-CODE-EXECUTION workspace file: "
                    id="${body#HOST-CODE-EXECUTION workspace file: }"
                    surface="$id"; rid="$(dec_id "$id")" ;;
                "changed workspace file: "*)
                    ackable=1; lbl="changed workspace file: "
                    id="${body#changed workspace file: }"
                    surface="$id"; rid="$(dec_id "$id")" ;;
                "COMMAND-EXECUTION git config: "*)
                    ackable=1; lbl="COMMAND-EXECUTION git config: "
                    cfgline="${body#COMMAND-EXECUTION git config: }"
                    ekey="${cfgline%%=*}"; surface="$ekey"
                    id="gitconfig:$ekey"; rid="gitconfig:$(dec_id "$ekey")" ;;
                "COMMAND-EXECUTION git alias: "*)
                    ackable=1; lbl="COMMAND-EXECUTION git alias: "
                    cfgline="${body#COMMAND-EXECUTION git alias: }"
                    ekey="${cfgline%%=*}"; surface="$ekey"
                    id="gitconfig:$ekey"; rid="gitconfig:$(dec_id "$ekey")" ;;
                # The WARNING form of the two above. Its own arm rather than
                # another alternative on theirs, because the label it strips
                # differs and the strip must be of that exact label (#45).
                "changed git config: "*)
                    ackable=1; lbl="changed git config: "
                    cfgline="${body#changed git config: }"
                    ekey="${cfgline%%=*}"; surface="$ekey"
                    id="gitconfig:$ekey"; rid="gitconfig:$(dec_id "$ekey")" ;;
                "TCB (sandbox self) modified, INVISIBLE to git: "*)
                    ackable=1; lbl="TCB (sandbox self) modified, INVISIBLE to git: "
                    etcb="${body#TCB (sandbox self) modified, INVISIBLE to git: }"
                    surface="$etcb"
                    id="tcb:$etcb"; rid="tcb:$(dec_id "$etcb")" ;;
                *) ackable=0 ;;
            esac
            fi
            # Defence in depth: even an anchored arm above should never
            # leave id empty while claiming ackable, but don't trust that -
            # crit_acked (and thus whether a report can shed -CRITICAL.txt)
            # must only ever advance when a real, non-empty id was derived.
            [ -n "$id" ] || ackable=0
            [ -n "$rid" ] || ackable=0
            # Already decided: the baseline holds an approval pinning this
            # exact content, or an ignore for the surface outright. `compare`
            # filters these out when it WRITES a report, but a report written
            # before that entry existed still lists them - and two reports
            # can list the same finding, so approving it in the first left
            # the second re-presenting it on every later run, with the
            # approval sitting in the baseline the whole time. That is what
            # made repeated `ack` runs show the same CRITICALs forever (#65).
            #
            # Counted as covered rather than merely hidden: a CRITICAL whose
            # content is already approved must let its report shed
            # -CRITICAL.txt, or the startup warning nags about a report whose
            # every finding has been dealt with.
            if [ "$ackable" -eq 1 ] && baseline_suppresses "$rid" "$(current_sha_for_id "$rid")"; then
                already=$((already + 1))
                if [ "$tier" = CRITICAL ]; then crit_acked=$((crit_acked + 1)); fi
                continue
            fi
            # Already shown this run, for the content that is on disk now.
            # Counted as dealt with rather than silently dropped, so a report
            # that displays nothing still says why.
            #
            # crit_acked is deliberately NOT advanced: nothing was approved,
            # so a report whose CRITICAL was only superseded must keep its
            # -CRITICAL.txt. And left_undecided is set, so this report is not
            # retired on the strength of a question that was never answered -
            # it comes back on the next run, where the operator meets it once.
            if [ "$ackable" -eq 1 ]; then
                _seen_key="$rid"$'\t'"$(current_sha_for_id "$rid")"
                if [ -n "${shown_this_run[$_seen_key]:-}" ]; then
                    already=$((already + 1))
                    left_undecided=1
                    continue
                fi
            fi
            # A finding cleared on an earlier pass because its surface was
            # gone. Nothing was recorded for it - there is no content to pin -
            # so baseline_suppresses cannot cover it, and it is ackable, so
            # the un-ackable branch below does not either. Without this it
            # re-presents on every run: the same forever-loop the clear was
            # meant to end, one step further along.
            if [ "$ackable" -eq 1 ] && [ "$report_reviewed" -eq 1 ] \
               && [ -z "$(current_sha_for_id "$rid")" ]; then
                already=$((already + 1))
                if [ "$tier" = CRITICAL ]; then crit_acked=$((crit_acked + 1)); fi
                continue
            fi
            # An un-ackable finding in a report already walked to the end.
            # There is nothing to record for one, so without this it would
            # re-prompt on every run forever, gating the exit from `ack` on a
            # keystroke that can never change anything (#65).
            if [ "$ackable" -ne 1 ] && [ "$report_reviewed" -eq 1 ]; then
                already=$((already + 1))
                continue
            fi
            [ "$ackable" -eq 1 ] || unackable_shown=1
            echo ""
            # The tier carries the colour, and the surface the finding is
            # about is picked out from the label around it - those are the
            # two things an operator scans a wall of these for.
            if [ -n "$surface" ]; then
                tail_rest="${body#"$lbl$surface"}"
                printf '  %s[%s]%s %s%s%s%s%s\n' "$(tier_color "$tier")" "$tier" "$C_RESET" \
                    "$lbl" "$C_PATH" "$surface" "$C_RESET" "$tail_rest"
            else
                printf '  %s[%s]%s %s\n' "$(tier_color "$tier")" "$tier" "$C_RESET" "$body"
            fi
            if [ "$ackable" -eq 1 ]; then
                render_finding "$rid" "$sc"
                shown_this_run["$rid"$'\t'"$(current_sha_for_id "$rid")"]=1
            fi
            # Each option says what it does to FUTURE findings, because that
            # is the whole difference between the two that record something
            # and it was not visible: approve pins this exact content and
            # re-fires on any other, ignore blankets the surface whatever it
            # later contains. A bare "approve" reads as the same thing as
            # "always ignore", which is the confusion these words avoid.
            #
            # "file" is wrong for a git config key, and naming the wrong kind
            # of thing in the sentence that offers a permanent mute is worth
            # one case.
            case "$rid" in
                gitconfig:*) ign_noun="key" ;;
                *)           ign_noun="file" ;;
            esac
            k_a="${C_KEY}[a]$C_RESET"; k_i="${C_KEY}[i]$C_RESET"
            k_s="${C_KEY}[s]$C_RESET"; k_q="${C_KEY}[q]$C_RESET"
            k_o="${C_KEY}[o]$C_RESET"
            # Offered only where there is a file to open. The report shows the
            # part that changed, which is chosen by what changed and not by
            # what a person needs in order to judge it - three changed lines in
            # a hook script are only judgeable by reading the script.
            open_path="$(path_for_id "$rid")" || open_path=""
            [ "$ackable" -eq 1 ] || open_path=""
            open_hint=""
            [ -n "$open_path" ] && open_hint="  $k_o open the file"
            # Looped, so opening the file returns to the same decision rather
            # than counting as one.
            key=""
            while :; do
            if [ "$ackable" -ne 1 ]; then
                printf '  %s skip  %s quit (this finding cannot be acknowledged): ' "$k_s" "$k_q"
            elif [ "$tier" = CRITICAL ]; then
                printf '  %s approve this exact content%s  %s skip (ask again next time)  %s quit: ' \
                    "$k_a" "$open_hint" "$k_s" "$k_q"
            else
                printf '  %s approve this exact content%s  %s ignore this %s from now on  %s skip (ask again next time)  %s quit: ' \
                    "$k_a" "$open_hint" "$k_i" "$ign_noun" "$k_s" "$k_q"
            fi
            { read -r key <&3; } 2>/dev/null || { aborted=1; break 2; }
            case "$key" in
                o)
                    if [ -z "$open_path" ]; then
                        echo "  nothing to open: this finding is not about a file that exists."
                        continue
                    fi
                    open_in_editor "$open_path"
                    # Re-read after the editor: the content may have moved on,
                    # and what gets approved below is whatever is there when
                    # the key is pressed, not what was rendered above.
                    render_finding "$rid" "$sc"
                    shown_this_run["$rid"$'\t'"$(current_sha_for_id "$rid")"]=1
                    continue ;;
            esac
            break
            done
            [ "$aborted" -eq 1 ] && break
            case "$key" in
                q|$'\e'|'') aborted=1; break ;;
                i)
                    if [ "$ackable" -ne 1 ]; then
                        echo "  cannot acknowledge: this finding cannot be acknowledged."
                    elif [ "$tier" = CRITICAL ]; then
                        echo "  cannot ignore a CRITICAL surface from now on - approve the exact content or skip."
                    else
                        pending+=("spam $id")
                        sc_done+=("$rid")
                    fi
                    ;;
                s) [ "$ackable" -eq 1 ] && left_undecided=1 ;;
                a)
                    if [ "$ackable" -ne 1 ]; then
                        echo "  cannot acknowledge: this finding cannot be acknowledged."
                    else
                        sha="$(current_sha_for_id "$rid")"
                        if [ -z "$sha" ]; then
                            # The surface is gone - deleted since compare, or a
                            # gitconfig key unset. There is nothing to pin an
                            # approval to, and `safe <id> ` with an empty sha would
                            # record a baseline line baseline_suppresses() can never
                            # match, leaving the surface permanently unsuppressible.
                            #
                            # So nothing is recorded - but the finding is CLEARED,
                            # because a surface that does not exist does not run.
                            # Refusing outright is what made one deleted file pin
                            # the audit forever: crit_acked never reached crit_seen,
                            # the report kept -CRITICAL.txt, and select_report picks
                            # CRITICAL first, so every other report stayed hidden
                            # behind a finding that could not be resolved by any
                            # means. If the surface comes back, it comes back as a
                            # new finding, which is the honest outcome.
                            echo "  ${C_OK}cleared${C_RESET}: $(sanitize "$id") no longer exists"
                            removed_cleared=1
                            [ "$tier" = CRITICAL ] && crit_acked=$((crit_acked + 1))
                            sc_done+=("$rid")
                        else
                            pending+=("safe $id $sha")
                            sc_done+=("$rid")
                            [ "$tier" = CRITICAL ] && crit_acked=$((crit_acked + 1))
                        fi
                    fi
                    ;;
                *) ;;
            esac
        done < "$report"

        # An abort discards everything, the reviewed marker included: the
        # operator did not finish the report, so it has not been reviewed.
        if [ "$aborted" -eq 1 ]; then
            echo ""; echo "dejima audit: nothing recorded."
            exit 0
        fi
        # Written once the report has been walked to its end with nothing
        # left undecided. It used to be written only for a report carrying
        # something that cannot be acknowledged, which left a report whose
        # findings were all approved with no record that it had been finished
        # - so selection kept returning it and the ones behind it were never
        # reached (#195).
        #
        # The undecided check is what keeps "skip (ask again next time)"
        # honest. Retiring the report retires every finding in it, so a marker
        # written over a skipped finding would turn that promise into "never
        # ask again" - silently, and for a finding the operator had looked at
        # and declined to approve.
        if [ "$report_reviewed" -eq 0 ] && [ "$left_undecided" -eq 0 ]; then
            pending+=("reviewed $(report_key "$report")")
        fi
        if [ "${#pending[@]}" -gt 0 ]; then
            mkdir -p "$BASELINE_DIR"
            printf '%s\n' "${pending[@]}" >> "$bf"
        fi
        for rid in ${sc_done[@]+"${sc_done[@]}"}; do
            rm -f "$sc/$(id_key "$rid")".diff "$sc/$(id_key "$rid")".new \
                  "$sc/$(id_key "$rid")".removed "$sc/$(id_key "$rid")".withheld
        done
        rmdir "$sc" 2>/dev/null || true
        # Only rename off -CRITICAL.txt (making the report evictable by
        # rotation) when every CRITICAL finding this report carried was
        # actually approved - not just when something was approved.
        # Reached even when this run recorded nothing new: a report whose
        # CRITICALs were all approved through some EARLIER report listing the
        # same finding is fully dealt with, and has to stop being counted as
        # unacknowledged or the startup warning nags about it forever.
        if [ "$crit_seen" -gt 0 ] && [ "$crit_acked" -eq "$crit_seen" ]; then
            case "$report" in
                *-CRITICAL.txt) mv "$report" "${report%-CRITICAL.txt}.txt" ;;
            esac
        fi
        echo ""
        # Distinguish the three ways a run can end with nothing new, because
        # "nothing recorded" for all of them is what made an already-handled
        # report look like a broken tool.
        acks=0
        for p in ${pending[@]+"${pending[@]}"}; do
            case "$p" in reviewed\ *) ;; *) acks=$((acks + 1)) ;; esac
        done
        if [ "$acks" -gt 0 ]; then
            echo "dejima audit: recorded $acks acknowledgement(s) in $bf"
        elif [ "$already" -gt 0 ]; then
            echo "dejima audit: nothing new - $already finding(s) already dealt with."
        else
            echo "dejima audit: nothing recorded."
        fi

        # Next report, if there is one. Asked rather than assumed: an operator
        # who has decided enough for now should not have to abort out of a
        # report they have not read to stop.
        next_report="$(select_next_report "$report_dir")" || next_report=""
        if [ -n "$next_report" ] && [ "$next_report" != "$report" ]; then
            read -r n_all n_crit <<< "$(count_reports "$report_dir")"
            printf '\n%s report(s) still waiting' "$n_all"
            [ "$n_crit" -gt 0 ] && printf ', %s%s with unacknowledged CRITICAL%s' \
                "$C_CRIT" "$n_crit" "$C_RESET"
            printf '. Continue? [%sy%s/%sn%s] ' "$C_KEY" "$C_RESET" "$C_KEY" "$C_RESET"
            # fd 3, the same duplicate of the original stdin the finding
            # prompt reads: the report is this loop's stdin, and /dev/tty is
            # not the caller's input when `ack` is driven by a pipe.
            { read -r answer <&3; } 2>/dev/null || answer=n
            printf '\n'
            case "$answer" in
                y|Y|"") report="$next_report"; sc="$(sidecar_dir "$report")"; continue ;;
            esac
        fi
        break
        done
        exit 0
        ;;
    *)
        echo "usage: dejima-audit {snapshot|compare|show|ack} [workspace] [state-file]" >&2
        echo "  show [--full]   index of waiting reports; --full prints one" >&2
        echo "  workspace defaults to the enclosing git repository, else the current directory" >&2
        exit 2
        ;;
esac
