#!/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`).
# 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.

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
    }
  '
}

# --- 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 '
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 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" }
        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
    for (s = 1; s <= num; s++) { snk[s] = check_sink(segs[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(segs[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(segs[s]) }
        else {
            print segs[s]
            cmd = get_lead_cmd(segs[s])
            if (is_exec(cmd) || pxe[s]) {
                ex = extract_quotes(segs[s])
                if (ex != "") print ex
            }
        }
    }
}
  '
}

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|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. refuse() exits, so the first offending
# segment blocks.
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" | strip_sink_quotes)

exit 0
