#!/bin/bash
# git-mutation-guard — PreToolUse(Bash) guard against destructive git operations
# that discard uncommitted work in a worktree shared with background agents.
#
# Contract: reads the tool-call JSON on stdin, extracts .tool_input.command.
#   exit 0 = allow, exit 2 = block (non-zero exit blocks the Bash command).
# Fails OPEN (exit 0) on any parse ambiguity so it never wedges legitimate work.
#
# OUTPUT ROUTING (KI-HOOKS-STDERR-SYSTEMIC): the harness surfaces a blocking
# hook's STDERR, not its stdout. All refuse() output goes to stderr (>&2) so the
# agent actually sees WHY a command was blocked. The allow paths stay silent.
#
# WHY (KI-SHARED-WORKTREE-GIT-MUTATION): impl agents and the leader share one
# worktree. `git stash`, `git reset --hard`, `git checkout -- <path>`, and
# `git restore` (worktree) silently delete another agent's uncommitted edits —
# three real work-loss incidents on 2026-07-03. Commit at gate points; never
# discard. This guard gates v3.84 Step 0.
#
# ESCAPE HATCH: prefix the command with `GIT_EXPERT=1` to run a genuinely-needed
# destructive git op deliberately (e.g. GIT_EXPERT=1 git reset --hard <sha>).
# The escape is intentional friction, not a lock. PREFIX means prefix (#4804):
# the token must sit in the environment-assignment position at the start of the
# SAME command segment as the operation it clears, which is where a shell would
# apply it. The token appearing anywhere else in the command — inside quoted
# prose, in a comment, in a file being written, in a different segment — clears
# nothing. Before #4804 it cleared everything, which made `echo 'see
# GIT_EXPERT=1 docs' && git stash` a working bypass and made the audit log
# unreadable as evidence.
# OVERRIDE AUDIT (2026-07-31 false alarm): an override that clears a real block is
# appended to ~/.mx-agent/logs/git-guard-override.jsonl before the command runs,
# so "the guard never fired" and "the guard fired and was overridden" are
# distinguishable after the fact. Motivating incident: a git-expert was blocked on
# a hard reset, re-ran it four seconds later with GIT_EXPERT=1, and reported only
# the reset to its leader; that leader then reported a broken guard in good faith
# and an investigation confirmed the guard was healthy in all eight worktrees. The
# override itself was sanctioned — its invisibility was the defect. Self-reporting
# alone is a rung-5 control (it depends on the overriding agent choosing to
# mention it), so the log exists as the independent record. Logging is FAIL-OPEN
# and side-effect-only: on any problem it is skipped, not retried and not
# escalated, so an override is neither withheld nor delayed because of it. See
# the log_override() header for the failure modes this had to be hardened
# against (a FIFO that blocked the append forever, a symlink that redirected it)
# and for the residual TOCTOU that is knowingly accepted.
# LAYERING CAVEAT (corrected 2026-08-25, issue #144): GIT_EXPERT=1 clears THIS
# hook's blocks (bare-SHA/branch reset forms, stash, checkout-file,
# restore-worktree). The ONLY patterns it cannot clear are the two
# `git reset --hard HEAD~*` and `git reset --hard HEAD^*` forms, which stay
# hard-denied at the Claude Code permission layer (.claude/settings.json deny
# list). That layer decides BEFORE this hook runs, so the override never reaches
# it. If an override appears to "fail" on a HEAD~/HEAD^ reset, debug the
# settings.json deny list, not this script.
# Checkout-file and restore-worktree are HOOK-ONLY as of 2026-08-25. Their two
# settings entries, `Bash(git checkout -- *)` and `Bash(git restore --worktree*)`,
# were removed because they denied `GIT_EXPERT=1 git checkout -- <path>` before
# this hook saw it, so the override this script prints in its own block message
# could not work. A git-expert spent a full task discovering that dead end (#144,
# reported by the surface team 2026-08-24), and the same denial closed every exit
# from the post-squash-merge sync cycle (#156). This guard is now the sole
# enforcer for those two verbs, which is what its own header already described:
# instrumented friction with an audit line per override, not a lock.
#
# This guard ONLY inspects git mutation patterns; all non-git and read-only git
# commands (status, log, diff, stash list, stash show, restore --staged) pass.
#
# SANCTIONED IN-PROCESS EXCEPTION (issue #172, product owner approval given in
# the bootstrap lead's session 2026-08-25): `mx-agent worktree sync` runs git
# from Node via execFile/spawn with argv arrays, so no Bash tool call carries
# the mutation and this hook never sees it. That is a reviewed, deliberate
# carve-out with its own controls (leader-lock gate, checkpoint to
# refs/mx/checkpoint/<worktree>/<epoch-ms>-<rand6> before any destructive
# step, quiescence check, and an audit record in
# ~/.mx-agent/logs/worktree-sync.jsonl on every path). WORDING CORRECTED
# 2026-08-25 (Should Fix 1, security review of PR #182, comment 5415821643):
# this said "one audit line per run", which no longer describes the command. A
# run that reaches the destructive step writes TWO records, an `in-progress`
# record before that step and a terminal record after it, correlated by
# `runId`. The invariant is one TERMINAL record per run, and an `in-progress`
# record with no matching terminal record is the signal that a run was killed
# mid-step. It is NOT evidence that any hook can
# be routed around by moving an operation into a Node child process: the same
# technique applied to workflow-dispatch-guard.sh or gh-pr-edit-guard.sh
# without an equivalent review is a bypass, and AGENTS.md's rule stands that a
# blocked operation routes through the git-expert teammate, not around the
# guard. Plan and review record: issue #172, revisions 1 to 4.
#
# SCOPE (SF-2, QA probe audit 2026-07-06 — this is a guardrail, not a security
# boundary). INERT-CONSUMER HEREDOC BODIES ARE EXCLUDED FROM MATCHING: a git verb
# inside a heredoc whose consuming command does NOT execute the body (e.g. a
# status report written via `cat <<EOF ... git reset --hard ... EOF`, or a body
# fed to tee / a redirect-to-file / a non-shell interpreter) is data, not a
# command, so it is stripped before matching. This removes the reported false-
# positive class that blocked report/heredoc writes (RT-3 / block-report
# 2026-07-06). GUARANTEE (corrected after security re-review): the strip is
# consumer-aware — a heredoc whose operator line names a SHELL interpreter
# (bash/sh/zsh/dash/ksh/eval/source/`.`) or pipes the body into one (`| ...sh`)
# is KEPT and still scanned, because those bodies ARE executed and a destructive
# git verb in them is real (e.g. `bash <<EOF ... git reset --hard ... EOF`).
# QUOTED-CONTENT HANDLING (per-segment sink scoping, #3938): commands whose
# quoted text merely MENTIONS destructive git verbs (e.g. `echo "run git reset
# --hard"`, `gh issue create --body "... git stash ..."`) are correctly allowed.
# Sink commands (echo, printf, gh --body, git commit -m, etc.) have their quoted
# content blanked before scanning. Executor commands (bash, ssh, su, etc.) have
# their quoted content extracted and scanned as separate segments — their args
# are commands, not data. Pipe chains propagate executor status backwards, so
# `echo "git reset --hard" | bash` is still blocked. Unknown commands keep
# quotes intact (fail safe = blocked if a pattern matches). Known bypass classes
# (out of scope, NOT defended): indirection via a script file, shell
# variable/`eval` assembly, or an alias can run a blocked op without a literal
# match. The guard deters accidental discards; it does not stop a determined
# caller.

MXHT_HOOK=git-mutation-guard MXHT_EVENT=PreToolUse MXHT_TOOL=Bash
# >>> SHARED hook-timing probe v1
# Diagnostic instrumentation: records how long THIS hook ran, so hook time can be
# separated from time spent waiting on the API when a session stalls mid-stream.
# Appends one line to a local, gitignored log. Read it with
# mx-agent-system/scripts/hook-timing-report.sh. Nothing is transmitted anywhere.
#
# FAIL SILENT IS DELIBERATE HERE, and it is the opposite of this repo's usual
# fail-loud direction. Do not "correct" it to fail-loud by applying the fleet
# default. A diagnostic that can break the gate it observes is worse than no
# diagnostic: an unwritable path or a full disk would otherwise take out every
# guard on the PreToolUse path at once. Every step below is guarded and every
# failure path is a silent no-op that leaves the hook's behaviour untouched.
#
# NEAR-ZERO OVERHEAD: bash builtins only on the hot path ($EPOCHREALTIME, printf
# -T, one read, one append). No subprocess, no network, no JSON parsing, and the
# hook's stdin is never touched (the hook owns it). mkdir runs at most once per
# worktree. Kill switch: MX_HOOK_TIMING=0.
MXHT_T0="${EPOCHREALTIME:-}"
_mxht_emit() {
  [ -z "${MXHT_DONE:-}" ] || return 0        # an ERR trap that exits fires EXIT too
  MXHT_DONE=1
  [ "${MX_HOOK_TIMING:-1}" != "0" ] || return 0
  [ -n "${MXHT_T0:-}" ] && [ -n "${EPOCHREALTIME:-}" ] || return 0
  local root="${CLAUDE_PROJECT_DIR:-${CLAUDE_WORKTREE_PATH:-}}"
  [ -n "$root" ] && [ -d "$root" ] || return 0
  local dir="$root/.mx-hook-timing"
  [ -d "$dir" ] || mkdir -p "$dir" 2>/dev/null || return 0
  # EPOCHREALTIME is seconds.microseconds; the separator is locale-dependent, so
  # strip both forms and subtract as plain integer microseconds.
  local dur=-1
  { dur=$(( ${EPOCHREALTIME//[.,]/} - ${MXHT_T0//[.,]/} )); } 2>/dev/null
  [ "$dur" -ge 0 ] 2>/dev/null || return 0
  local stamp hour
  printf -v stamp '%(%Y-%m-%d %H:%M:%S)T' -1
  printf -v hour '%(%H)T' -1
  # BOUNDED BY CONSTRUCTION: a 24-slot ring, one file per hour-of-day, so the log
  # is at most 24 files each holding one hour of this machine's hook calls. The
  # first writer into a slot on a new day truncates it instead of appending —
  # detected by reading the slot's first line, which is a builtin read (no stat,
  # no rm, no find, no cron). Two writers can race that truncation and lose a
  # line or two at an hour boundary; that is acceptable for a sampling diagnostic.
  local f="$dir/h$hour.log" first="" line
  # THE SLOT MUST BE AN ORDINARY FILE, OR NOT EXIST YET (security review, #4848).
  # Anything else is a silent no-op. Two concrete failures if this check is
  # dropped: a FIFO at the slot makes the `read` below block until the hook's
  # settings.json timeout kills it, and a PreToolUse guard killed that way loses
  # its exit 2 — the command it meant to block then runs; a symlink at the slot
  # sends the day-rollover truncation onto the symlink's target. The `-e || -L`
  # wrapper is what lets a not-yet-created slot through (-e is false for a
  # dangling symlink, so -L is needed to catch that case too).
  if [ -e "$f" ] || [ -L "$f" ]; then
    [ -f "$f" ] && [ ! -L "$f" ] || return 0
  fi
  [ -r "$f" ] && { read -r first < "$f"; } 2>/dev/null
  printf -v line '%s dur_us=%s hook=%s event=%s ppid=%s tool=%s\n' \
    "$stamp" "$dur" "${MXHT_HOOK:--}" "${MXHT_EVENT:--}" "$PPID" "${MXHT_TOOL:--}"
  # One printf of ~90 bytes to an O_APPEND fd is atomic under PIPE_BUF, so
  # concurrent hooks cannot interleave partial lines.
  # The 2>/dev/null wraps the GROUP, not the printf: a failing redirection is
  # reported by the shell before an inner 2>/dev/null would be in effect, and a
  # "Permission denied" on stderr from a PreToolUse hook is surfaced to the model.
  if [ "${first%% *}" = "${stamp%% *}" ]; then
    { printf '%s' "$line" >>"$f"; } 2>/dev/null
  else
    { printf '%s' "$line" >"$f"; } 2>/dev/null
  fi
}
# `|| :` IS LOAD-BEARING — do not simplify to `trap _mxht_emit EXIT`. The hooks
# this block is inlined into run under `set -e`, and the EXIT trap runs with
# errexit still in force. Without `|| :`, a non-zero return from the probe — the
# final printf failing on an unwritable slot is the reachable case — replaces the
# hook's own exit status: a PreToolUse guard's exit 2 becomes exit 1, which the
# harness reads as "error", not "block", so the command it meant to block runs.
# Pinned verbatim by hook-timing-parity.test.sh.
trap '_mxht_emit || :' EXIT
# <<< SHARED hook-timing probe v1


set -euo pipefail

INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // .command // empty' 2>/dev/null || true)

# Can't parse — fail open.
if [[ -z "$COMMAND" ]]; then
  exit 0
fi

# Deliberate override. NOTE: this no longer short-circuits. The scan still runs so
# we can name WHICH pattern the override cleared; refuse() then logs and allows
# instead of blocking. A GIT_EXPERT=1 command that matches nothing is not an
# override of anything and is not logged.
#
# OVERRIDE DETECTION IS SEGMENT-ANCHORED AND SEGMENT-SCOPED (#4804). Two rules,
# both required:
#   1. the token must sit where a shell would actually apply it — at the start of
#      a command segment, in the environment-assignment position, the same way
#      GITSEG anchors `git`; and
#   2. it must be in the SAME segment as the pattern it is claimed to clear.
# Before this, OVERRIDE was an unanchored, unscoped grep of the whole command,
# which meant any command containing the eleven characters `GIT_EXPERT=1`
# anywhere — inside quoted prose, in a comment, in a file being written, in a
# later segment — turned off the guard for the entire command. Measured:
#   echo 'see GIT_EXPERT=1 docs' && git stash      -> was ALLOWED, now BLOCKED
#   # a comment naming GIT_EXPERT=1 \n git reset --hard abc -> was ALLOWED, now BLOCKED
# That was the cheapest bypass in the file, cheaper than the three bypass classes
# the SCOPE header documents as knowingly out of scope, and unlike those it could
# be tripped by accident — an agent writing a report about the guard disabled it
# for the same command. It also destroyed the audit log's meaning; see part 1.
#
# OVSEG IS ANCHORED AT `^` ONLY — it is deliberately NOT a copy of GITSEG's
# boundary class (MF-1, security review 2026-08-01). Every string OVSEG is tested
# against is already exactly one command segment on its own line (strip_heredocs
# then strip_sink_quotes emit one segment per line, and grep -E is line-oriented),
# so `^` IS the segment boundary. The earlier revision reused GITSEG's
# `(^|[;&|({`]|...)` prefix, which named FIVE boundaries the segmenter never
# splits on — a single `&`, `(`, `{`, a backtick, and `$(`. A token after any of
# them sat mid-line inside one segment and still matched, so all of these
# overrode the guard: `git stash & GIT_EXPERT=1 true`,
# `git stash $(GIT_EXPERT=1 true)`, `git stash # note (GIT_EXPERT=1 ...)`, and a
# `--body "run (GIT_EXPERT=1 git stash) now"` escalation. Measured, all four
# ALLOWED before this change, all four BLOCK after it.
#
# ASYMMETRY — this is the rule that decides which regex gets which treatment.
# GITSEG (block detection) MAY over-match: a false positive costs one blocked
# command that GIT_EXPERT=1 clears, so it stays permissive about boundaries.
# OVSEG (override detection) MUST under-match: a false positive turns the guard
# OFF, which is the failure the guard exists to prevent, so it recognises only
# the position a shell would actually treat as an assignment. Never "fix" a
# missed override by widening OVSEG toward GITSEG; widen the SEGMENTER instead,
# so the token and the verb land in different segments.
#
# Preceding leading redirections, other env assignments, and launcher words that
# exec their argv are tolerated, so `FOO=1 GIT_EXPERT=1 git ...` and
# `GIT_EXPERT=1 sudo git ...` still override. `echo GIT_EXPERT=1 ...` does not:
# `echo` is not a launcher, so the token is an argument, not an assignment.
#
# PROPAGATION TO DERIVED SEGMENTS: a segment that a shell WILL execute inherits
# its parent's override — the quoted payload of an executor (`GIT_EXPERT=1 bash
# -c "git reset --hard"`) and the kept body of a shell heredoc (`GIT_EXPERT=1
# bash <<EOF ... EOF`). Those bodies are emitted as separate scan segments, so
# without propagation a genuine, correctly-anchored override would stop working
# on them. The token still has to be anchored on the parent, so this is not a
# way back in for prose.
OVSEG='^[[:space:]]*([0-9]*[<>][^[:space:]]*[[:space:]]+|[A-Za-z_][A-Za-z0-9_]*=[^[:space:];&|]*[[:space:]]+|(sudo|env|nice|nohup|time|timeout|ionice|stdbuf|command|xargs|setsid|doas|nsenter)[[:space:]]+([0-9]+[[:space:]]+)?)*GIT_EXPERT=1([[:space:]]|$)'
# Set per segment by check_segment; consumed by refuse() for the decision AND the
# audit record. There is no whole-command override state any more.
SEG_OVERRIDE=0

# --- override audit log (fail-open, side-effect only) -------------------------
# One JSONL line per cleared block, alongside idle-gate.jsonl / session-pause.jsonl.
# Fields: ts, worktree, guard, pattern (what was blocked), command (first line,
# sanitized + truncated). The command is UNTRUSTED text, so it is reduced to a
# conservative allowlist — no quotes, backslashes, control characters or non-ASCII
# reach the file, and nothing that renders as markup survives.
#
# PATH IS HARD-CODED, deliberately. There is no env var to redirect or disable
# this log: the party whose action is being recorded is the party who benefits
# from suppressing it, so a single variable in the environment must not be able
# to turn the record off. Same shape as session-idle-gate.sh, which hard-codes
# its own path. The probe isolates itself by overriding HOME, not by asking the
# product for a test hook.
#
# WRITE TARGET CHECKS (security review, 2026-08-01). Three defects were measured
# against earlier revisions of this file, each reproduced before it was fixed:
#   - a FIFO at the log path with no reader made the append block forever on
#     open(); `timeout 8` returned 124. Every override in that home directory
#     hung the Bash call with no error output. `set +e` catches failures; it does
#     nothing about blocking, so the claim that no logging failure could convert
#     an allowed command into a blocked one was false — the command never ran.
#   - a symlink at the log FILE path was followed, appending the record to the
#     link target (201 bytes into a file outside the log directory).
#   - after the leaf check landed, the same redirect survived one level up:
#     `mkdir -p` and `>>` both follow DIRECTORY symlinks, so a symlinked
#     `~/.mx-agent/logs` still took 185 bytes into an attacker-chosen directory.
# So there are two tests: the leaf must be absent or a plain regular file and not
# a symlink (`-f` alone follows links), and the containing directory must be a
# real directory and not a symlink. Either test failing skips the log entirely
# and lets the override proceed.
#
# WHAT THIS DOES NOT CLOSE — read this before describing the log as tamper-proof,
# because that description has now been wrong three times. Symlink redirection is
# closed AT THE LEAF AND ONE DIRECTORY UP. It is NOT closed for a symlinked
# `~/.mx-agent`, a symlinked `$HOME`, or any higher path component: those are
# resolved by the kernel before either test sees them, and walking every
# component is not something this hook should be doing on a hot path. Also not
# closed: the checks are TOCTOU, not atomic — a writer that swaps the path
# between the test and the append still wins the race, and closing that needs
# O_NOFOLLOW|O_NONBLOCK, which bash cannot express. Both residuals are accepted
# on the same grounds: anyone able to plant a component symlink in the agent's
# own home or to win that race already executes as the agent user, and this file
# is a guardrail, not an access control (same posture as the guard itself).
#
# The whole body runs in a subshell with errexit disabled, so an error inside it
# cannot change the caller's exit status. Combined with the two target tests, the
# intended property is that logging is skipped rather than allowed to stall or
# misdirect — an override is never withheld, and never delayed, because of the
# log.
log_override() {
  (
    set +e +u +o pipefail
    log_dir="${HOME:-/tmp}/.mx-agent/logs"
    log_file="${log_dir}/git-guard-override.jsonl"

    # Leaf: refuse anything that is not an absent path or a plain regular file.
    # FIFOs block, symlinks redirect, devices do neither usefully.
    if [ -e "$log_file" ] || [ -L "$log_file" ]; then
      [ -f "$log_file" ] && [ ! -L "$log_file" ] || exit 0
    fi

    ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
    wt_path="${CLAUDE_WORKTREE_PATH:-${CLAUDE_PROJECT_DIR:-}}"
    wt="$(basename "$wt_path" 2>/dev/null)"
    wt="${wt//[^a-zA-Z0-9_.-]/}"
    wt="${wt:-unknown}"
    cmd="$(printf '%s' "$COMMAND" \
      | head -n 1 \
      | LC_ALL=C tr -d '\000-\037\177' \
      | LC_ALL=C tr -c 'A-Za-z0-9 _.,:/=@+~^-' '?' \
      | cut -c1-200)"
    pat="$(printf '%s' "$1" | LC_ALL=C tr -c 'A-Za-z0-9 _.<>-' '?' | cut -c1-60)"
    mkdir -p "$log_dir" || exit 0

    # Containing directory: `mkdir -p` succeeds on a symlink-to-directory and
    # `>>` follows it, so the leaf check alone left the redirect intact one level
    # up. Test after mkdir, since the directory may not have existed before it.
    [ -d "$log_dir" ] && [ ! -L "$log_dir" ] || exit 0

    printf '{"ts":"%s","worktree":"%s","guard":"git-mutation-guard","pattern":"%s","override":"GIT_EXPERT=1","command":"%s"}\n' \
      "$ts" "$wt" "$pat" "$cmd" >> "$log_file"
  ) >/dev/null 2>&1 || true
  return 0
}

# --- heredoc-body stripping (consumer-aware) --------------------------------
# Produce a "scannable" copy of the command. A heredoc body is dropped ONLY when
# its consuming command is inert (cat/tee/redirect-to-file/non-shell language) —
# such a body is data, never executed. When the operator line names a SHELL
# interpreter (bash/sh/zsh/dash/ksh/eval/source/`.`) or pipes the body into one
# (`| ...sh`), the body IS executed, so it is KEPT and still scanned (a
# destructive git verb in it is a real command, not prose). The operator line
# itself is always preserved (it may hold a real git verb before the `<<`).
# Covers the common forms: `<<EOF`, `<<-EOF`, `<<"EOF"`, `<<'EOF'`, `<< EOF`.
strip_heredocs() {
  awk -v ovre="$OVSEG" '
    # The heredoc OPERATOR LINE may hold several command segments
    # (`echo "..."; bash <<EOF`). Only the segment that actually contains the
    # `<<` is the command that executes the body, so only that segment can carry
    # an override for it (MF-3). Returns the operator line from just after the
    # last separator preceding the `<<` to end of line. Separators are the same
    # set the segmenter in strip_sink_quotes uses (`;` `&` `&&` `|` `||`) — for
    # the two-character forms the second character simply wins, which lands the
    # start in the same place.
    #
    # The scan is QUOTE-AWARE, character for character the same way the segmenter
    # in strip_sink_quotes is (MF-4). Only an UNQUOTED separator ends a segment;
    # a `;`, `&` or `|` inside a quoted ARGUMENT is an ordinary character that no
    # shell treats as a boundary. Scanning without quote awareness was a bypass:
    # the separator inside the quotes moved the derived segment start forward and
    # parked an inert positional argument at position 1, where the ^-anchored
    # OVSEG matched it as if it were an environment assignment —
    #   bash -s "x; GIT_EXPERT=1 note" <<EOF ... git reset --hard origin/main ... EOF
    # `bash -s` reads its script from STDIN, so the heredoc body executes while
    # the token is never an assignment at all. ALLOWED before this change (and it
    # wrote an audit record claiming a sanctioned override), BLOCKS after it.
    # The two functions must agree on what counts as a separator: divergence
    # between them is what made MF-1 exploitable, so keep this loop and the one
    # at the top of strip_sink_quotes in step.
    function op_segment(line, pos,   i, c, st, isq, idq) {
      st = 1; isq = 0; idq = 0
      for (i = 1; i < pos; i++) {
        c = substr(line, i, 1)
        if (isq) { if (c == "'"'"'") isq = 0; continue }
        if (idq) { if (c == "\\") { i++; continue }; if (c == "\"") idq = 0; continue }
        if (c == "'"'"'") { isq = 1; continue }
        if (c == "\"") { idq = 1; continue }
        if (c == ";" || c == "&" || c == "|") st = i + 1
      }
      return substr(line, st)
    }
    !inbody {
      line = $0
      if (match(line, /<<[-]?[[:space:]]*["'"'"']?[A-Za-z_][A-Za-z0-9_]*["'"'"']?/)) {
        hpos = RSTART
        d = substr(line, RSTART, RLENGTH)
        gsub(/<<[-]?[[:space:]]*["'"'"']?/, "", d)
        gsub(/["'"'"'].*/, "", d)
        delim = d
        inbody = 1
        # keepbody: does a SHELL execute this body? If so, still scan it.
        keepbody = 0
        if (line ~ /(^|[^[:alnum:]_])(bash|sh|zsh|dash|ksh|eval|source)([^[:alnum:]_]|$)/) keepbody = 1
        if (line ~ /(^|[[:space:]])\.[[:space:]]/) keepbody = 1
        if (line ~ /\|[^|]*(bash|sh|zsh|dash|ksh|eval)([^[:alnum:]_]|$)/) keepbody = 1
        # A kept body is executed BY the operator-line SEGMENT that holds the
        # `<<`, so only an anchored override on THAT segment applies to it
        # (#4804 / MF-3). Testing the whole line was a bypass: an unrelated
        # earlier segment supplied the token, e.g.
        #   echo "(GIT_EXPERT=1 note)"; bash <<EOF ... git reset --hard ... EOF
        # ALLOWED before this change, BLOCKS after it. This runs before
        # segmentation and before sink-quote blanking, which is why the operator
        # line has to be segmented here rather than trusted whole.
        ovbody = (op_segment(line, hpos) ~ ovre) ? 1 : 0
      }
      print line
      next
    }
    {
      t = $0
      gsub(/^[[:space:]]+/, "", t)
      if (t == delim) { inbody = 0; keepbody = 0; ovbody = 0; next }
      if (keepbody) { if (ovbody) print "GIT_EXPERT=1 " $0; else print $0 }
      # else: inert-consumer body — data, not a command; drop it from the scan
    }
  '
}

# --- per-segment sink-scoped quote handling (#3938) ---------------------------
# Sink commands (echo, printf, gh --body, git commit -m, etc.) have their
# quoted content blanked so prose mentioning destructive git verbs doesn't
# false-positive. Executor commands (bash, ssh, su, etc.) have their quoted
# content EXTRACTED and emitted as additional segments for scanning (their
# quoted args are commands, not data). Per-segment scoping prevents the
# round-2 bypass where a sink on one segment blanked an executor's payload
# on the next. Pipe chains propagate executor status backwards: if stage N
# is an executor and connected by pipe to stage N-1, stage N-1's quotes
# are not blanked (its output feeds the executor). Fail-safe: unknown
# commands keep quotes intact (no blanking, no extraction).
strip_sink_quotes() {
  awk -v ovre="$OVSEG" '
function get_lead_cmd(seg,    s) {
    s = seg; gsub(/^[[:space:]]+/, "", s)
    while (match(s, /^[A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*[[:space:]]+/))
        s = substr(s, RSTART + RLENGTH)
    while (match(s, /^[0-9]*[<>][>&]?[^[:space:]]*[[:space:]]+/))
        s = substr(s, RSTART + RLENGTH)
    if (match(s, /^[^[:space:]]+/)) return substr(s, RSTART, RLENGTH)
    return ""
}
function peel_keywords(seg,    s, i) {
    # #225 MF-2 route (a): strip leading shell keywords so the segment starts at
    # the command the keyword introduces. The segmenter splits only on `;` `&`
    # `&&` `|` `||`, so `if git reset --hard; then` arrived as ONE segment
    # beginning `if `. GITSEG matched nothing there (its boundary class named
    # then/do/else and not if/elif/while/until) and OVSEG matched nothing either,
    # because `if` is neither an assignment nor a launcher word. The verb was
    # invisible AND the override was unusable in the same position. Peeling puts
    # the verb at ^ where both patterns already work, which is the repair lines
    # 241-243 name: widen the SEGMENTER, never widen OVSEG toward GITSEG.
    # BOUNDED AT 8 ITERATIONS ON PURPOSE. Each pass is O(segment length), so the
    # bound keeps this linear; an unbounded `while` with a per-pass substr is
    # quadratic on an adversarial `if if if ...` prefix, and a hook killed by its
    # 60s timeout loses its exit 2 (header, lines 155-161). Stacking more than 8
    # keywords does not evade: the GITSEG alternative (^|\s)(if|elif|while|until|
    # then|do|else)\s still matches the 9th, which is why both changes ship.
    # NO APOSTROPHE MAY APPEAR IN THIS COMMENT. It sits inside the single-quoted
    # awk program, so one apostrophe closes the quote and breaks the whole hook.
    # `!` is deliberately NOT peeled — `! V`, `if ! V` and `while ! V` stay open
    # and are recorded as such (#228).
    s = seg
    for (i = 0; i < 8; i++) {
        if (!match(s, /^[[:space:]]*(if|elif|while|until|then|do|else)[[:space:]]+/)) break
        s = substr(s, RSTART + RLENGTH)
    }
    return s
}
function is_exec(cmd) {
    return (cmd == "bash" || cmd == "sh" || cmd == "zsh" || cmd == "dash" || \
            cmd == "ksh" || cmd == "eval" || cmd == "source" || cmd == "ssh" || \
            cmd == "su" || cmd == "doas" || cmd == "nsenter" || \
            cmd == "sudo" || cmd == "xargs")
}
function check_sink(seg,    cmd, s, sc) {
    cmd = get_lead_cmd(seg)
    if (is_exec(cmd)) return 0
    if (cmd == "echo" || cmd == "printf" || cmd == "cat" || cmd == "tee" || cmd == "write")
        return 1
    if (cmd == "gh" && (seg ~ /--body/ || seg ~ /--title/ || seg ~ /--comment/))
        return 1
    if (cmd == "git") {
        s = seg; gsub(/^[[:space:]]+/, "", s); sub(/^git[[:space:]]+/, "", s)
        while (match(s, /^(-C[[:space:]]+[^[:space:]]+|-c[[:space:]]+[^[:space:]]+|--[a-z][-a-z0-9]*(=[^[:space:]]*)?)[[:space:]]+/))
            s = substr(s, RSTART + RLENGTH)
        if (match(s, /^[^[:space:]]+/)) {
            sc = substr(s, RSTART, RLENGTH)
            if ((sc == "commit" || sc == "tag" || sc == "notes") && \
                (seg ~ /-[a-zA-Z]*m[[:space:]]/ || seg ~ /--message/))
                return 1
        }
    }
    return 0
}
function blank_quotes(seg,    n, i, c, out, isq, idq) {
    n = length(seg); out = ""; isq = 0; idq = 0
    for (i = 1; i <= n; i++) {
        c = substr(seg, i, 1)
        if (isq) {
            if (c == "'"'"'") { isq = 0; out = out c } else out = out " "
            continue
        }
        if (idq) {
            if (c == "\\") { out = out "  "; i++; continue }
            if (c == "\"") { idq = 0; out = out c } else out = out " "
            continue
        }
        if (c == "'"'"'") { isq = 1; out = out c; continue }
        if (c == "\"") { idq = 1; out = out c; continue }
        out = out c
    }
    return out
}
function extract_quotes(seg,    n, i, c, ct, isq, idq, res) {
    n = length(seg); isq = 0; idq = 0; ct = ""; res = ""
    for (i = 1; i <= n; i++) {
        c = substr(seg, i, 1)
        if (isq) {
            if (c == "'"'"'") {
                isq = 0
                if (ct != "") { if (res != "") res = res "\n"; res = res ct }
                ct = ""
            } else ct = ct c
            continue
        }
        if (idq) {
            if (c == "\\") { ct = ct substr(seg, i+1, 1); i++; continue }
            if (c == "\"") {
                idq = 0
                if (ct != "") { if (res != "") res = res "\n"; res = res ct }
                ct = ""
            } else ct = ct c
            continue
        }
        if (c == "'"'"'") { isq = 1; continue }
        if (c == "\"") { idq = 1; continue }
    }
    return res
}
{
    line = $0; n = length(line)
    isq = 0; idq = 0; num = 0; seg_s = 1
    for (i = 1; i <= n; i++) {
        c = substr(line, i, 1)
        if (isq) { if (c == "'"'"'") isq = 0; continue }
        if (idq) { if (c == "\\") { i++; continue }; if (c == "\"") idq = 0; continue }
        if (c == "'"'"'") { isq = 1; continue }
        if (c == "\"") { idq = 1; continue }
        sp = 0; sl = 1; st = ""
        if (c == ";") { sp = 1; st = "seq" }
        else if (c == "&" && i < n && substr(line, i+1, 1) == "&") { sp = 1; sl = 2; st = "seq" }
        # Single `&` backgrounds the command to its LEFT and starts a new one to
        # its right — two separate shell commands, exactly like `;` (MF-1). It was
        # missing here while GITSEG and the old OVSEG both named `&` a boundary,
        # so `git stash & GIT_EXPERT=1 true` arrived as ONE segment carrying both
        # the verb and the token and the override applied to a command the token
        # was never part of. Splitting here puts the backgrounded verb in its own
        # segment with no token, which blocks.
        else if (c == "&") { sp = 1; st = "seq" }
        else if (c == "|" && i < n && substr(line, i+1, 1) == "|") { sp = 1; sl = 2; st = "seq" }
        else if (c == "|") { sp = 1; st = "pipe" }
        if (sp) {
            num++; segs[num] = substr(line, seg_s, i - seg_s); stypes[num] = st
            seg_s = i + sl; if (sl == 2) i++
        }
    }
    num++; segs[num] = substr(line, seg_s); stypes[num] = ""
    # classify each segment
    # psegs[] is segs[] with leading shell keywords peeled (#225 MF-2). Every
    # consumer below reads psegs, so classification, executor detection, the
    # override stamp and the emitted text all see the same string. Peeling for
    # the emit alone would leave check_sink deciding on `if` as the lead command,
    # which is the kind of half-applied transform that becomes its own defect.
    for (s = 1; s <= num; s++) { psegs[s] = peel_keywords(segs[s]); snk[s] = check_sink(psegs[s]); pxe[s] = 0 }
    # backward-propagate executor status through pipes
    for (s = num; s >= 2; s--) {
        if (stypes[s-1] == "pipe") {
            cmd = get_lead_cmd(psegs[s])
            if (is_exec(cmd) || pxe[s]) { snk[s-1] = 0; pxe[s-1] = 1 }
        }
    }
    # emit processed segments
    for (s = 1; s <= num; s++) {
        if (snk[s]) { print blank_quotes(psegs[s]) }
        else {
            print psegs[s]
            cmd = get_lead_cmd(psegs[s])
            if (is_exec(cmd) || pxe[s]) {
                ex = extract_quotes(psegs[s])
                if (ex != "") {
                    # An executor payload is executed BY this segment, so an
                    # anchored override on the segment applies to it (#4804).
                    # Stamp each extracted line, which is its own scan segment.
                    if (psegs[s] ~ ovre) {
                        nx = split(ex, xa, "\n")
                        for (k = 1; k <= nx; k++) print "GIT_EXPERT=1 " xa[k]
                    } else print ex
                }
            }
        }
    }
}
  '
}

SCAN=$(printf '%s' "$COMMAND" | strip_heredocs)

# Set when an override clears a real match. It records that the scan CONTINUED
# past a cleared pattern rather than exiting at it (MF-2) — the exit status is 0
# either way, so this exists to make the cleared-but-continued state readable in
# a trace and greppable by a future check. Assigned in refuse(), read at the
# final exit.
CLEARED=0

refuse() {
  local what="$1"

  # Override in effect: record what it cleared, then RETURN — do not exit.
  # Silent on stdout and stderr; the allow contract is "exit 0, no output".
  #
  # RETURN, NOT EXIT (MF-2, security review 2026-08-01). This used to
  # `exit 0` here, which ended the whole scan at the FIRST cleared segment.
  # An override anchored on segment 1 therefore let every later segment through
  # unscanned and unlogged, which is precisely the whole-command override #4804
  # was opened to remove — reintroduced by the exit rather than by the regex.
  # Measured: `GIT_EXPERT=1 git stash && git reset --hard origin/main` ALLOWED
  # the reset, with one audit line naming only the stash. Now each pattern is
  # judged against its own segment: the stash is cleared and logged, the reset
  # has no token in its segment and blocks. A command with a genuine override on
  # every segment produces one audit line PER cleared pattern, so the log stays
  # a complete record of what was cleared rather than a record of the first
  # thing cleared.
  #
  # Both the allow and the record come from the same per-segment condition
  # (#4804): the token is anchored at the start of the segment, in the
  # environment-assignment position, AND is in the segment whose pattern
  # matched. A token elsewhere in the command clears nothing and is not
  # written down.
  if [[ "$SEG_OVERRIDE" -eq 1 ]]; then
    log_override "$what"
    CLEARED=1
    return 0
  fi

  {
    echo ""
    echo "BLOCKED: destructive git operation ($what)"
    echo "  Command: $COMMAND"
    echo ""
    echo "This discards uncommitted work in a worktree shared with background"
    echo "agents (KI-SHARED-WORKTREE-GIT-MUTATION — three work-loss incidents"
    echo "2026-07-03). Commit at gate points instead of discarding."
    echo ""
    echo "If you truly must run this, prefix it with GIT_EXPERT=1 to override,"
    echo "e.g.  GIT_EXPERT=1 $what ..."
    echo ""
    echo "In a compound command, put the token on the command itself, NOT on the"
    echo "keyword (#225). Bash rejects a token before if/while/until with a"
    echo "syntax error, so these are the forms that work:"
    echo "  if GIT_EXPERT=1 $what ...; then ...; fi"
    echo "  while GIT_EXPERT=1 $what ...; do ...; done"
    echo "  if true; then GIT_EXPERT=1 $what ...; fi"
    echo "  GIT_EXPERT=1 bash -c '<the whole compound command>'"
    echo ""
    echo "IF YOU OVERRIDE, YOU MUST REPORT IT. Your report back to whoever asked"
    echo "for this work has to state: the command that was blocked, that"
    echo "git-mutation-guard blocked it, that you used GIT_EXPERT=1 to override,"
    echo "and why the override was justified. Omitting this is a failure to"
    echo "complete the task — a silent override on 2026-07-31 was reported up the"
    echo "chain as a broken guard and cost an investigation across eight"
    echo "worktrees. The override is recorded either way."
    echo ""
  } >&2
  exit 2
}

# --- command-segment anchoring (M3, KI-HOOKS-PROSE-FALSE-POSITIVE) ----------
# A `git` verb only counts as a real invocation when it STARTS a command
# segment. Matching the bare substring `git reset --hard` anywhere in the
# command blocked legitimate commit messages and prose, e.g.
#   git commit -m "reverted the git reset --hard"
#   echo "run git reset --hard to undo"
# GITSEG requires `git` at a segment boundary — start-of-line (grep is
# line-oriented, so embedded newlines are segment starts too), after a
# separator (`;` `&` `|` `(` `{`), or after a shell keyword (then/do/else) —
# optionally preceded by any run of: leading redirections (`>out `,
# `2>/dev/null `), env-var assignments (`VAR=1 `), and known launcher/wrapper
# words that exec their argv (sudo/env/nice/nohup/time/timeout/ionice/stdbuf/
# command/xargs/setsid, with an optional numeric arg like `timeout 5`). Without
# the wrapper/redirection tolerance, `nice git reset --hard`,
# `2>/dev/null git reset --hard`, etc. slipped past the anchor (security
# re-review, #3912).
#
# GIT GLOBAL OPTIONS between `git` and the verb are consumed (High, #3934). git
# accepts global options before the subcommand — `-C <path>`, `-c <key>=<val>`,
# `--git-dir[=| ]<p>`, `--work-tree[=| ]<p>`, `--namespace[=| ]<p>`,
# `--no-pager`, and other single/double-dash forms with or without values. Only
# consuming `-C` (the pre-#3934 behavior) let `git --no-pager reset --hard`,
# `git -c safe.directory=* reset --hard`, `git --git-dir=... reset --hard`
# bypass the guard entirely. The repeated GOPT group below consumes the whole
# global-option run so the blocked verb is still anchored. `-C`/`-c`/the
# enumerated value-taking long options consume their following value; a generic
# `--long`/`--long=val`/`-x` handles the no-value forms (like --no-pager). This
# mirrors the PUSH_RE global-option handling in ci-validate-hook.sh /
# mx-guard-hook.sh. Applied per-segment to the heredoc- and quote-stripped SCAN.
# GIT_EXPERT=1 clears any accepted false positive. `echo` is deliberately NOT a
# wrapper word, so `echo "use timeout 5 git reset --hard"` stays allowed.
GITSEG='(^|[;&|({`]|(^|\s)(then|do|else|if|elif|while|until)\s)\s*([0-9]*[<>][^[:space:]]*\s+|[A-Za-z_][A-Za-z0-9_]*=[^[:space:];&|]*\s+|(sudo|env|nice|nohup|time|timeout|ionice|stdbuf|command|xargs|setsid|doas|nsenter)\s+([0-9]+\s+)?)*git(\s+(-C\s+[^[:space:];&|]+|-c\s+[^[:space:];&|]+|--(git-dir|work-tree|namespace|super-prefix)(=[^[:space:];&|]*|\s+[^[:space:];&|]+)|--[A-Za-z][A-Za-z0-9-]*=[^[:space:];&|]*|--[A-Za-z][A-Za-z0-9-]*|-[A-Za-z]))*\s+'

# --- per-segment evaluation (item 3, #3934; sink scoping, #3938) --------------
# Each allow-check (stash list|show; restore --staged-without-worktree) must be
# scoped to the SAME command segment as the mutating verb it whitelists.
# Whole-command allow-checks let a read-only first segment green-light a
# destructive second one: `git stash list && git stash drop` or
# `git restore --staged f && git restore f` slipped through because the
# allow-form matched elsewhere on the line. strip_sink_quotes splits SCAN on
# unquoted shell separators (`;` `&&` `||` `|`), blanks quoted content in sink
# segments, extracts quoted content from executor segments as additional lines,
# and emits one segment per line.
#
# TERMINATION (MF-2): refuse() exits only on a BLOCK, so the first offending
# segment with no override in it still ends the scan at exit 2. A CLEARED
# override terminates nothing — the remaining pattern checks in that segment and
# every remaining segment are still evaluated, so a later un-overridden segment
# can still block, and each cleared pattern gets its own audit line.
check_segment() {
  local seg="$1"

  # Is there an anchored override token in THIS segment (#4804)? This decides
  # both whether a match is refused and whether it is recorded.
  SEG_OVERRIDE=0
  if printf '%s' "$seg" | grep -qE "$OVSEG"; then
    SEG_OVERRIDE=1
  fi

  # git stash (mutating forms). Allow read-only `stash list` / `stash show`;
  # block bare stash, push, pop, apply, drop, clear, save, branch, create, store.
  if printf '%s' "$seg" | grep -qE "${GITSEG}stash\b"; then
    if ! printf '%s' "$seg" | grep -qE "${GITSEG}stash\s+(list|show)\b"; then
      refuse "git stash"
    fi
  fi

  # git reset --hard
  if printf '%s' "$seg" | grep -qE "${GITSEG}reset\s+([^&|;]*\s)?--hard\b"; then
    refuse "git reset --hard"
  fi

  # git checkout <ref?> -- <path> (pathspec form discards working changes).
  # The trailing `(\s|$)` after `--` means `--help`/`--force`/branch switches do
  # NOT match (only the bare `--` pathspec separator does).
  if printf '%s' "$seg" | grep -qE "${GITSEG}checkout\s+([^&|;]*\s)?--(\s|$)"; then
    refuse "git checkout -- <path>"
  fi

  # git restore (worktree). `git restore` defaults to --worktree (destructive).
  # Safe ONLY when --staged is present AND --worktree is absent (a pure unstage).
  # Block if --worktree is explicit, or if --staged is missing.
  if printf '%s' "$seg" | grep -qE "${GITSEG}restore\b"; then
    if printf '%s' "$seg" | grep -qE "${GITSEG}restore\b[^&|;]*--worktree\b"; then
      refuse "git restore --worktree"
    elif ! printf '%s' "$seg" | grep -qE "${GITSEG}restore\b[^&|;]*--staged\b"; then
      refuse "git restore <path> (defaults to --worktree)"
    fi
  fi
}

while IFS= read -r _seg; do
  [[ -n "$_seg" ]] || continue
  check_segment "$_seg"
done < <(printf '%s\n' "$SCAN" | strip_sink_quotes)

# Reached the end of the scan: either nothing matched, or every match was cleared
# by an anchored, same-segment override (CLEARED=1, one audit line per cleared
# pattern). Both are allow. Under `set -u` this also fails loudly if a future
# edit drops the CLEARED initialisation, rather than allowing silently.
[[ "$CLEARED" -eq 1 ]] || true
exit 0
