#!/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.
# LAYERING CAVEAT: GIT_EXPERT=1 only clears THIS hook's blocks (bare-SHA/branch
# reset forms, stash, checkout-file, restore-worktree). It does NOT clear the two
# patterns `git reset --hard HEAD~*` and `git reset --hard HEAD^*` — those are
# hard-denied at the Claude Code permission layer (.claude/settings.json deny
# list), which fires BEFORE this hook runs. If an override appears to "fail" on a
# HEAD~/HEAD^ reset, debug the settings.json deny list, not this script.
#
# 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.
#
# 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`).
# KNOWN FALSE-POSITIVE CLASS (accepted friction, fails safe, GIT_EXPERT=1 clears
# it): a blocked git verb quoted on a command line — e.g. `echo "run git reset
# --hard"`, or a `gh issue create --body "... git reset --hard && git stash drop
# ..."` filing whose body mentions destructive git after a separator — still
# matches and still blocks. Quoted content is scanned as-is: fixing this without
# opening a bypass proved harder than the fix was worth. A quote-stripping pass
# (item 4, #3934) was built and CUT after two adversarial security reviews found
# fail-open regressions (executor-payload blanking; per-line sink scoping that
# allowed `echo "hi" && ssh h 'git reset --hard'`); the redesign is tracked
# separately. WORKAROUND for the issue/PR-body case: pass the prose via
# `--body-file <path>` instead of an inline `--body "..."`. 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.

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.
if echo "$COMMAND" | grep -qE '(^|[^A-Za-z_])GIT_EXPERT=1(\s|$)'; then
  exit 0
fi

# --- 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 '
    !inbody {
      line = $0
      if (match(line, /<<[-]?[[:space:]]*["'"'"']?[A-Za-z_][A-Za-z0-9_]*["'"'"']?/)) {
        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
      }
      print line
      next
    }
    {
      t = $0
      gsub(/^[[:space:]]+/, "", t)
      if (t == delim) { inbody = 0; keepbody = 0; next }
      if (keepbody) print $0
      # else: inert-consumer body — data, not a command; drop it from the scan
    }
  '
}

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

refuse() {
  local what="$1"
  {
    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 ""
  } >&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)\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)\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) ---------------------------------
# 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. Split SCAN on the shell separators
# `;` `&` `|` and newlines (heredoc bodies were already emitted line-per-line by
# strip_heredocs) and run the full verb logic on each segment independently.
# `&&`/`||` collapse to empty segments (skipped). refuse() exits, so the first
# offending segment blocks. NOTE: a separator inside a quoted string still
# splits here (quoted content is scanned as-is — see the KNOWN FALSE-POSITIVE
# CLASS note in the header); that fails safe (blocks), which is intentional.
check_segment() {
  local seg="$1"

  # 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" | tr ';&|' '\n')

exit 0
