#!/bin/bash
# Pre-commit secret detection for multi-agent pipeline
# Scans staged git changes for accidentally committed secrets
# Exit 0 = clean, Exit 2 = secrets found or, on a commit, a path an automated
# account owns (owned_path_gate below). The PreToolUse hook contract
# (install/templates/claude-hooks.json, agent-guard.sh) blocks the tool call
# only on exit 2, with the reason on stderr.
#
# No `-e` here either, and for a sharper reason than in pre-push-check.sh: this
# file is a PreToolUse hook that fires on every Bash call, and it is built out
# of greps that are SUPPOSED to find nothing. Under `-e` the first clean
# detector would end the scan, and a hook that exits early reports "no secrets"
# for a file it never finished reading - a scanner failing open, on the path
# that exists to keep secrets out of commits.

set -uo pipefail

PRE_COMMIT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=../lib/unattended.sh
if [ -f "$PRE_COMMIT_DIR/../lib/unattended.sh" ]; then . "$PRE_COMMIT_DIR/../lib/unattended.sh"; fi
command -v ma_unattended >/dev/null 2>&1 || ma_unattended() { [ "${MULTI_AGENT_UNATTENDED:-}" = "1" ]; }

# Hook-mode fast exit. The Claude Code PreToolUse matcher is the tool name
# ("Bash"), so this script now fires for EVERY Bash call and receives JSON on
# stdin: {"tool_name":"Bash","tool_input":{"command":"..."}}. Extract the
# command and exit 0 immediately unless it actually invokes `git commit`.
# Direct invocations (empty/non-JSON stdin, e.g. smoke tests or manual runs)
# fall through to the full scan. Extraction failure also falls through - the
# only cost is an unnecessary scan, never a skipped one on a real commit.
SCAN_UNSTAGED=0
# Set only when this is the hook firing on a real `git commit`. A direct run
# (Phase 2 Gate 4, the smokes) is the secret scan and nothing else.
IS_COMMIT_HOOK=0
# --scan-file <path>: run the detectors over ONE file that git knows nothing
# about, then exit on the result. It exists so callers outside the commit path
# can reuse these patterns instead of copying them - learn-from-transcripts.mjs
# runs every candidate learning through here before it can reach the ledger,
# because a transcript is the least-redacted artefact on the machine and
# anything mined from one has to be proven clean first.
#
# Copying the regexes into the caller was the alternative, and it is the shape
# that rots: the copy stops matching a pattern this file later gains, and the
# gap stays invisible until something leaks.
#
# The flag is READ here, before the hook-mode stdin read below (which blocks on
# a stdin that never closes), and ACTED ON after scan_file is defined.
SCAN_FILE_TARGET=""
if [ "${1:-}" = "--scan-file" ]; then
  SCAN_FILE_TARGET="${2:-}"
  [ -f "$SCAN_FILE_TARGET" ] || { echo "ERR: --scan-file needs an existing path" >&2; exit 1; }
fi
# --scan-range <base>..<head>: run the detectors over the lines each commit in
# the range added, file by file, the way a commit of the whole range would have
# been scanned. The autopilot runner calls it on a branch before it pushes,
# outside the run that made the commits (autopilot-publish.mjs), so a commit
# that reached the branch without passing through this hook is still scanned.
SCAN_RANGE=""
if [ "${1:-}" = "--scan-range" ]; then
  SCAN_RANGE="${2:-}"
  case "$SCAN_RANGE" in
    ?*..?*) ;;
    *) echo "ERR: --scan-range needs <base>..<head>" >&2; exit 1 ;;
  esac
fi

if [ -z "$SCAN_FILE_TARGET" ] && [ -z "$SCAN_RANGE" ] && [ ! -t 0 ]; then
  HOOK_INPUT="$(cat 2>/dev/null || true)"
  if [ -n "$HOOK_INPUT" ]; then
    HOOK_COMMAND=""
    if command -v python3 >/dev/null 2>&1; then
      HOOK_COMMAND="$(printf '%s' "$HOOK_INPUT" | python3 -c '
import json, sys
try:
    print(json.load(sys.stdin).get("tool_input", {}).get("command", ""))
except Exception:
    pass
' 2>/dev/null || true)"
    elif command -v node >/dev/null 2>&1; then
      HOOK_COMMAND="$(printf '%s' "$HOOK_INPUT" | node -e '
let raw = "";
process.stdin.on("data", (c) => (raw += c));
process.stdin.on("end", () => {
  try {
    process.stdout.write(String(JSON.parse(raw)?.tool_input?.command ?? ""));
  } catch { /* fall through to full scan */ }
});
' 2>/dev/null || true)"
    fi
    # A command that never spells git, even with its quotes and backslashes
    # removed (g'i't, "git", \git), cannot run it through any path the helper
    # below would recognise, so it returns before paying for the parse.
    if [ -n "$HOOK_COMMAND" ] \
      && ! printf '%s' "$HOOK_COMMAND" | tr -d "\"'\\\\" | grep -qi 'git'; then
      exit 0
    fi
    if [ -n "$HOOK_COMMAND" ]; then
      # Commit detection is a tokenizer, not a regex: agent-guard.py shares its
      # shell parser through `--git-subcommand`, so an absolute git path, a
      # quoted or case-folded name, an alias and the other commands that write a
      # commit (commit-tree, merge, cherry-pick, revert, am) are all seen the
      # same way the guard sees them. A regex over the raw string missed every
      # one of those. If the helper is unavailable the scan runs anyway.
      GUARD_PY="$PRE_COMMIT_DIR/agent-guard.py"
      GIT_SUBS=""
      HELPER_RAN=0
      if command -v python3 >/dev/null 2>&1 && [ -f "$GUARD_PY" ]; then
        if GIT_SUBS="$(printf '%s' "$HOOK_INPUT" | python3 "$GUARD_PY" --git-subcommand 2>/dev/null)"; then
          HELPER_RAN=1
        fi
      fi
      if [ "$HELPER_RAN" = "1" ]; then
        if ! printf '%s\n' "$GIT_SUBS" \
          | grep -qxE 'commit|commit-tree|merge|cherry-pick|revert|am'; then
          exit 0
        fi
      elif ! printf '%s\n' "$HOOK_COMMAND" \
        | grep -qE '(^|[^[:alnum:]_./-])git[[:space:]]+([^|&;]*[[:space:]])?commit([^[:alnum:]_-]|$)'; then
        exit 0
      fi
      IS_COMMIT_HOOK=1
      # PreToolUse fires BEFORE the tool runs, so a compound command like
      # `git add -A && git commit -m ...` reaches this point with NOTHING
      # staged yet from either half - `git diff --cached` alone sees an
      # empty index and this scan silently no-ops right before the commit
      # it exists to gate. When the same command also runs `git add`, widen
      # the scan to the working tree (tracked-unstaged + untracked), not
      # just the index, so that case is still caught.
      if printf '%s\n' "$GIT_SUBS" | grep -qxF 'add' \
        || printf '%s\n' "$HOOK_COMMAND" \
          | grep -qE '(^|[^[:alnum:]_./-])git[[:space:]]+add([^[:alnum:]_-]|$)'; then
        SCAN_UNSTAGED=1
      fi
    fi
  fi
fi

# Staged paths from `git diff --cached --name-only` are repo-root-relative, so
# the `-f` tests below only see them from the repo root. Hooks fire with the
# caller's cwd, which is often a subdirectory.
# A `cd <dir>` or `git -C <dir>` earlier on the line moves the commit into
# another checkout; that checkout is the one scanned.
if [ "$IS_COMMIT_HOOK" = "1" ] && command -v python3 >/dev/null 2>&1 && [ -f "${GUARD_PY:-}" ]; then
  COMMIT_DIR="$(printf '%s' "$HOOK_INPUT" | python3 "$GUARD_PY" --commit-dir 2>/dev/null || true)"
  if [ -n "$COMMIT_DIR" ] && [ -d "$COMMIT_DIR" ]; then
    cd "$COMMIT_DIR" || exit 0
  fi
fi
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)"
if [ -n "$REPO_ROOT" ]; then
  cd "$REPO_ROOT" || exit 0
fi

FOUND=0

# `file "$f" | grep -q "binary"` never matches real `file` output (it names a
# concrete type - "ELF 64-bit", "Mach-O", "PNG image data" - not the word
# "binary"), so every binary was scanned as text. --mime's charset=binary is
# the actual signal libmagic gives for non-text content.
is_binary() {
  file --mime "$1" 2>/dev/null | grep -q 'charset=binary'
}

# All BLOCKED checks for one file, given the content to scan (added lines for
# a diff, or the whole file for something not yet tracked at all - every line
# of a brand-new file is an addition).
scan_file() {
  local file="$1" content="$2" folded="${3:-}"

  # .env files. A template (.env.example/.sample/.template) documents names,
  # not values, so it falls through to the content checks below instead.
  if echo "$file" | grep -qE '\.env($|\.)' &&
    ! echo "$file" | grep -qE '\.env\.(example|sample|template)$'; then
    echo "BLOCKED: .env file staged: $file" >&2
    FOUND=1
    return 0
  fi

  # Credentials files
  if echo "$file" | grep -qiE '(credentials|secrets|tokens)\.(json|yaml|yml|plist)$'; then
    echo "BLOCKED: Credentials file staged: $file" >&2
    FOUND=1
    return 0
  fi

  [ -z "$content" ] && return 0

  # A token written with an invisible character or a look-alike letter: the
  # folded copy (see fold_and_scan) is scanned together with the raw text.
  if [ -n "$folded" ] && [ "$folded" != "$content" ]; then
    content="$content
$folded"
  fi

  # API keys / tokens (key = "long_string" pattern)
  if echo "$content" | grep -qiE '(api[_-]?key|api[_-]?secret|access[_-]?token|auth[_-]?token|secret[_-]?key)\s*[:=]\s*["'"'"'][A-Za-z0-9+/=_-]{20,}'; then
    echo "BLOCKED: Possible API key/token in $file" >&2
    FOUND=1
  fi

  # AWS access keys
  if echo "$content" | grep -qE 'AKIA[0-9A-Z]{16}'; then
    echo "BLOCKED: AWS access key in $file" >&2
    FOUND=1
  fi

  # Private keys
  if echo "$content" | grep -qE 'BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY'; then
    echo "BLOCKED: Private key in $file" >&2
    FOUND=1
  fi

  # Firebase/GCP service account JSON
  if echo "$content" | grep -qE '"type"\s*:\s*"service_account"'; then
    echo "BLOCKED: Service account JSON in $file" >&2
    FOUND=1
  fi

  # High-signal provider token prefixes (low false-positive rate).
  #
  # This set and the one in lib/outbound-gate.mjs cover the same providers, and
  # smoke-secret-parity.sh holds them to it by running a fake token of each
  # shape through BOTH.
  if echo "$content" | grep -qE '(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{60,}|xox[baprs]-[A-Za-z0-9-]{12,}|sk_live_[A-Za-z0-9]{20,}|rk_live_[A-Za-z0-9]{20,}|AIza[0-9A-Za-z_-]{35}|npm_[A-Za-z0-9]{36}|glpat-[A-Za-z0-9_-]{20,}'; then
    echo "BLOCKED: Provider access token in $file" >&2
    FOUND=1
  fi

  # Model-provider and ML-hub keys. `sk-ant-` and `sk-proj-` are checked before
  # the bare `sk-` form so the message names the provider a reader can revoke.
  if echo "$content" | grep -qE 'sk-ant-[A-Za-z0-9_-]{32,}|sk-proj-[A-Za-z0-9_-]{32,}|pplx-[A-Za-z0-9]{32,}|hf_[A-Za-z0-9]{30,}'; then
    echo "BLOCKED: Model-provider API key in $file" >&2
    FOUND=1
  fi

  # Figma personal access token (figd_) and the MCP OAuth token (figu_). Same
  # shape, same blast radius: both read every file the account can reach.
  if echo "$content" | grep -qE 'fig[a-z]_[A-Za-z0-9_-]{20,}'; then
    echo "BLOCKED: Figma token in $file" >&2
    FOUND=1
  fi

  # A URL carrying its own credentials, which is how a `git remote -v` paste
  # leaks, and an Authorization header copied out of a curl trace.
  if echo "$content" | grep -qE '[a-z][a-z0-9+.-]*://[^[:space:]/:@]+:[^[:space:]/@]+@'; then
    echo "BLOCKED: URL with embedded credentials in $file" >&2
    FOUND=1
  fi
  if echo "$content" | grep -qiE 'Authorization:[[:space:]]*(Bearer|Basic)[[:space:]]+[A-Za-z0-9._~+/=-]{16,}'; then
    echo "BLOCKED: Authorization header with a token in $file" >&2
    FOUND=1
  fi

  # JWT (three base64url segments  -  header.payload.signature)
  if echo "$content" | grep -qE 'eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}'; then
    echo "BLOCKED: JWT in $file" >&2
    FOUND=1
  fi

  # High-entropy string scan  -  catches custom/unknown secrets the prefix
  # patterns miss. Skips lockfiles, integrity hashes, source maps, and snapshot
  # fixtures, which legitimately carry high-entropy non-secret content.
  #
  # `smoke-pre-commit.sh` is this gate's OWN test suite: it must contain
  # secret-shaped fixtures (a fake `ghp_` token, a fake JWT, a random 44-char
  # string) in order to prove the detector fires on them. Only ADDED lines are
  # scanned, so existing fixtures are invisible  -  but ADDING a new detector
  # test blocks the commit that adds it, which makes the detector untestable.
  # Entropy only: the provider-prefix, AWS, private-key, JWT and
  # service-account checks above still apply to this file, so a real token
  # pasted here is still caught. Scoped to this one path on purpose  -  widen
  # the list and the gate stops meaning anything.
  # The generic high-entropy exemptions stay case-insensitive (lockfile names
  # vary). The gate's own suite is matched separately and CASE-SENSITIVELY on its
  # exact repo-relative path: `(^|/)smoke-pre-commit\.sh$` under `grep -i` would
  # have exempted any file with that basename in any directory and in any case,
  # so staging `docs/SMOKE-PRE-COMMIT.SH` with a live credential in it would have
  # passed. Staged names come from `git diff --cached --name-only`, which is
  # repo-root-relative, so the full path can be anchored.
  if ! echo "$file" | grep -qiE '(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|Podfile\.lock|Cartfile\.resolved|Package\.resolved|\.lock$|\.map$|__snapshots__|\.snap$|\.min\.(js|css)$)' \
     && ! echo "$file" | grep -qE '^pipeline/scripts/smoke-pre-commit\.sh$'; then
    # Staged and working-tree content arrives as diff lines, so the leading "+"
    # is stripped first: it is in the candidate class and would otherwise glue
    # onto a token that starts at column 0 and defeat every shape check below.
    candidates=$(echo "$content" | sed 's/^+//' | grep -oE '[A-Za-z0-9+/=_-]{40,}' || true)
    if [ -n "$candidates" ]; then
      entropy_hit=$(printf '%s\n' "$candidates" | awk '
        # Word-shaped identifier: letters and _ or - only, split into segments at
        # separators and case boundaries (UIApplication -> UI, Application),
        # where the segments average 4+ letters, none is longer than 24, and the
        # letter pairs inside them read like English. PAIR scores every pair as
        # log2 of how often it occurs across a 235k-word English word list
        # relative to a uniform draw, rounded and clipped to -4..4 (stored +4,
        # one row per first letter). Identifiers made of words average above
        # +0.5 per pair even with an abbreviation or two (Qr, Ffp, Otp, Kg);
        # uniformly random letters average below +0.1, so the mean must reach
        # +0.25. Clipping keeps one unusual pair from outweighing the rest. An
        # all-caps segment of up to five letters inside a token that also has
        # lowercase letters is an acronym (URL, HTTP, JSON) and is not scored.
        # Version markers (v1, V2), digit runs and "/" separate segments too, so
        # a versioned API name
        # (ReportingV1beta1QueryCrashRateMetricSetRequest) or a short REST path
        # (/v1/inAppPurchaseOfferCodes) is read as the words it is made of; a
        # token that is mostly digits is never word-shaped.
        BEGIN {
          rows = "06655352403757150767433232 52015000500600400421400010 " \
            "60306006504400600515500040 50026021600313500420301030 " \
            "63564442401657452876433420 40004300400400400302400020 " \
            "50005033500423400510400030 60006000600322600403301050 " \
            "64764551103657651577350105 20002000000000200000200000 " \
            "30005001400201200010100010 71237220701622630024520060 " \
            "64006000600032650010400040 62667462613324631357422031 " \
            "44553352403667560766644321 60006006500501630644400030 " \
            "00000000000000000000400000 73447233703354740445432050 " \
            "51506105602453552067502040 61307106700421600624502050 " \
            "44443230401657240665000000 40006000400000300000100000 " \
            "40004002400102400100000000 20102000300000220002000020 " \
            "31332020200433340343000000 30004000200000300000000011";
          split(rows, row, " ");
          alpha = "abcdefghijklmnopqrstuvwxyz";
          for (r = 1; r <= 26; r++)
            for (k = 1; k <= 26; k++)
              PAIR[substr(alpha, r, 1) substr(alpha, k, 1)] = substr(row[r], k, 1) - 4;
        }
        function score_segment(seg, has_lower,    l, j) {
          if (has_lower && seg ~ /^[A-Z]+$/ && length(seg) <= 5) return;
          l = tolower(seg);
          for (j = 2; j <= length(l); j++) {
            pairs++;
            pair_score += PAIR[substr(l, j - 1, 2)];
          }
        }
        function wordlike(t,    n, i, c, p, nx, seg, nseg, total, maxlen, split_here, has_lower, digits) {
          if (t !~ /^[A-Za-z0-9_\/-]+$/) return 0;
          digits = t; gsub(/[^0-9]/, "", digits);
          if (length(digits) * 8 > length(t)) return 0;
          gsub(/[vV][0-9]+/, "_", t);
          has_lower = (t ~ /[a-z]/);
          pairs = 0; pair_score = 0;
          n = length(t); seg = ""; nseg = 0; total = 0; maxlen = 0;
          for (i = 1; i <= n + 1; i++) {
            c = (i <= n) ? substr(t, i, 1) : "_";
            if (c ~ /[0-9\/]/) c = "_";
            split_here = 0;
            if (c == "_" || c == "-") split_here = 1;
            else if (seg != "" && c ~ /[A-Z]/) {
              p = substr(seg, length(seg), 1);
              nx = (i < n) ? substr(t, i + 1, 1) : "";
              if (p ~ /[a-z]/ || (nx ~ /[a-z]/ && length(seg) > 1)) split_here = 1;
            }
            if (split_here && seg != "") {
              nseg++; total += length(seg);
              if (length(seg) > maxlen) maxlen = length(seg);
              score_segment(seg, has_lower);
              seg = "";
            }
            if (c != "_" && c != "-") seg = seg c;
          }
          if (nseg == 0 || pairs == 0) return 0;
          return (total / nseg >= 4 && maxlen <= 24 && pair_score * 4 >= pairs);
        }
        {
          s=$0;
          # Scoring the joined string measures the variety of a whole SENTENCE
          # rather than of any token in it, so an ordinary doc path like
          # "claude/commands/multi-agent/review-jira/SKILL" scores 4.35 and would
          # be blocked as a possible credential, making every doc that cites
          # such a path un-editable.
          #
          # The reduction is applied ONLY to path-shaped candidates. Scoring the
          # longest slash-free segment for every candidate containing a slash
          # would structurally exempt any secret with a slash in it: the
          # canonical 40-char AWS documentation-example secret access key (two
          # slashes, full-match H=4.663) reduces to an 18-char segment, and
          # roughly half of `openssl rand -base64 32` outputs contain a "/". The
          # key is not reproduced here on purpose  -  this gate flags
          # credential-shaped strings in comments too, and it is right to.
          # No other check here catches that key: the key-name regex above does
          # not match "secret_access_key", and the AKIA pattern only covers the
          # key ID, not the secret.
          #
          # Path-shaped means 3+ separators AND no segment long enough to be a
          # token on its own. A 2-slash base64 blob therefore still gets scored
          # whole, which is what keeps the AWS-key case blocked.
          nseg = split(s, seg, "/");
          longest = "";
          for (j = 1; j <= nseg; j++) if (length(seg[j]) > length(longest)) longest = seg[j];
          if (nseg >= 4 && length(longest) < 40) s = longest;
          n = length(s);
          if (n < 40) next;
          if (s ~ /^[0-9]+$/) next;        # pure digits
          if (s ~ /^[0-9a-f]+$/) next;     # lowercase hex (git sha / md5 / sha-*)
          if (s ~ /^[0-9A-F]+$/) next;     # uppercase hex
          if (wordlike(s)) next;           # identifier made of words (plist key / type name), never a credential
          delete freq;
          for (i = 1; i <= n; i++) { c = substr(s, i, 1); freq[c]++ }
          H = 0;
          for (c in freq) { p = freq[c] / n; H -= p * log(p) / log(2) }
          if (H >= 4.2) { print s; exit }
        }')
      if [ -n "$entropy_hit" ]; then
        echo "BLOCKED: High-entropy string (possible secret) in $file" >&2
        FOUND=1
      fi
    fi
  fi
}

# Files are queued and scanned together so that the look-alike fold runs in
# one node process for every non-ASCII file of the run rather than one per
# file. ASCII content folds to itself and is not sent.
Q_FILES=()
Q_CONTENTS=()
queue_file() {
  Q_FILES+=("$1")
  Q_CONTENTS+=("$2")
}

FOLD_WARNED=0
fold_warn() {
  [ "$FOLD_WARNED" = "1" ] && return 0
  FOLD_WARNED=1
  echo "pre-commit-check: $1 - non-ASCII content scanned without look-alike folding" >&2
}

fold_and_scan() {
  local i n=${#Q_FILES[@]} lib="$PRE_COMMIT_DIR/../lib/normalize-text.mjs" out rec
  local -a idx=() folded=()
  for ((i = 0; i < n; i++)); do
    folded[i]=""
    if printf '%s' "${Q_CONTENTS[i]}" | LC_ALL=C grep -q '[^ -~[:space:]]' 2>/dev/null; then
      idx+=("$i")
    fi
  done
  if [ "${#idx[@]}" -gt 0 ]; then
    if ! command -v node >/dev/null 2>&1 || [ ! -f "$lib" ]; then
      fold_warn "node or lib/normalize-text.mjs unavailable"
    else
      out=$(mktemp "${TMPDIR:-/tmp}/pre-commit-fold.XXXXXX") || out=""
      if [ -n "$out" ] && for i in "${idx[@]}"; do printf '%s\0' "${Q_CONTENTS[i]}"; done \
        | node "$lib" --nul >"$out" 2>/dev/null; then
        local -a recs=()
        while IFS= read -r -d '' rec; do recs+=("$rec"); done <"$out"
        if [ "${#recs[@]}" -eq "${#idx[@]}" ]; then
          for ((i = 0; i < ${#idx[@]}; i++)); do folded[idx[i]]="${recs[i]}"; done
        else
          fold_warn "the look-alike fold returned ${#recs[@]} of ${#idx[@]} records"
        fi
      else
        fold_warn "the look-alike fold (node lib/normalize-text.mjs) failed"
      fi
      [ -n "$out" ] && rm -f "$out"
    fi
  fi
  for ((i = 0; i < n; i++)); do
    scan_file "${Q_FILES[i]}" "${Q_CONTENTS[i]}" "${folded[i]}"
  done
  Q_FILES=()
  Q_CONTENTS=()
}

if [ -n "$SCAN_FILE_TARGET" ]; then
  is_binary "$SCAN_FILE_TARGET" && exit 0
  queue_file "$SCAN_FILE_TARGET" "$(cat "$SCAN_FILE_TARGET" 2>/dev/null)"
  fold_and_scan
  if [ "$FOUND" -eq 1 ]; then
    echo "" >&2
    echo "Secret check failed on $SCAN_FILE_TARGET." >&2
    exit 2
  fi
  exit 0
fi

if [ -n "$SCAN_RANGE" ]; then
  git rev-parse --verify --quiet "${SCAN_RANGE%%..*}^{commit}" >/dev/null \
    && git rev-parse --verify --quiet "${SCAN_RANGE##*..}^{commit}" >/dev/null \
    || { echo "ERR: --scan-range: $SCAN_RANGE does not name two commits" >&2; exit 1; }
  # Every commit in the range is pushed, so each one is scanned on its own,
  # from the commit object rather than the working tree: a key added in one
  # commit and removed in a later one is still in the pushed history.
  range_commits=$(git rev-list --reverse "$SCAN_RANGE" 2>/dev/null) \
    || { echo "ERR: --scan-range: cannot list commits in $SCAN_RANGE" >&2; exit 1; }
  # The repository's own .gitattributes can mark any path `-diff` or `binary`,
  # which would make the diff say "Binary files differ" and hide added lines
  # from the scan. Neutralise it: GIT_ATTR_NOSYSTEM drops the system file,
  # core.attributesFile=/dev/null drops the user file, and --attr-source with an
  # empty tree (the empty-tree hash) drops the in-tree .gitattributes. --text
  # forces a textual diff, and numstat's binary marker ('-') is scanned from the
  # blob rather than skipped. GIT_LITERAL_PATHSPECS stops a crafted filename
  # from being read as a pathspec magic prefix.
  EMPTY_TREE=$(git hash-object -t tree /dev/null 2>/dev/null)
  gitscan() {
    GIT_ATTR_NOSYSTEM=1 GIT_LITERAL_PATHSPECS=1 \
      git -c core.attributesFile=/dev/null -c core.quotePath=false \
      ${EMPTY_TREE:+-c "diff.attributesSource=$EMPTY_TREE"} "$@"
  }
  for commit in $range_commits; do
    while IFS= read -r -d '' file; do
      [ -z "$file" ] && continue
      content=$(gitscan diff-tree --no-commit-id -p --root -m --first-parent \
        --no-ext-diff --no-textconv --text ${EMPTY_TREE:+--attr-source="$EMPTY_TREE"} \
        "$commit" -- "$file" 2>/dev/null | grep "^+" | grep -v "^+++" || true)
      if [ -z "$content" ]; then
        # A file git still treats as binary yields no +lines; scan the whole
        # blob at this commit so a NUL-laden file cannot hide a key.
        content=$(gitscan show --text --no-textconv "${commit}:${file}" 2>/dev/null || true)
      fi
      queue_file "$file" "$content"
    done < <(gitscan diff-tree --no-commit-id -r --root -m --first-parent \
      --name-only -z --diff-filter=d "$commit" 2>/dev/null)
  done
  fold_and_scan
  if [ "$FOUND" -eq 1 ]; then
    echo "" >&2
    echo "Secret check failed on $SCAN_RANGE." >&2
    exit 2
  fi
  exit 0
fi

# NUL-delimited so filenames with spaces are scanned (a space-split loop would
# silently skip them - a secret false-negative).

# Already-staged content - the common case, and the only source when nothing
# in the triggering command itself runs `git add`.
while IFS= read -r -d '' file; do
  [ -z "$file" ] && continue
  [ ! -f "$file" ] && continue
  is_binary "$file" && continue
  content=$(git diff --cached -- "$file" 2>/dev/null | grep "^+" | grep -v "^+++" || true)
  queue_file "$file" "$content"
done < <(git diff --cached --name-only -z 2>/dev/null)

if [ "$SCAN_UNSTAGED" = "1" ]; then
  # Tracked and modified, but not staged yet (the `git add` half of the
  # compound command hasn't run at this point - see SCAN_UNSTAGED above).
  while IFS= read -r -d '' file; do
    [ -z "$file" ] && continue
    [ ! -f "$file" ] && continue
    is_binary "$file" && continue
    content=$(git diff HEAD -- "$file" 2>/dev/null | grep "^+" | grep -v "^+++" || true)
    queue_file "$file" "$content"
  done < <(git diff HEAD --name-only -z 2>/dev/null)

  # Untracked (brand-new) files - there is no diff to take yet, so every line
  # of the file is "added" content for scanning purposes.
  while IFS= read -r -d '' file; do
    [ -z "$file" ] && continue
    [ ! -f "$file" ] && continue
    is_binary "$file" && continue
    content=$(sed 's/^/+/' "$file" 2>/dev/null || true)
    queue_file "$file" "$content"
  done < <(git ls-files --others --exclude-standard -z 2>/dev/null)
fi
fold_and_scan

if [ $FOUND -eq 1 ]; then
  echo "" >&2
  echo "Secret check failed. Remove secrets from staged files before committing." >&2
  exit 2
fi

# Owned-path gate: a commit must not carry a path the repo profile says an
# automated account owns. Commit hook only; a direct run stays the secret scan.
# The profile's globs are read with sed, so a commit that touches no owned
# prefix never reaches the gate itself. Anything missing (profile, node, a
# readable result) lets the commit through with one line.
# The literal directory part of each glob on stdin, before its first wildcard
# (lib/owned-path-gate.mjs ownedPrefix), normalized the way the gate does:
# doubled slashes collapsed, leading `./` and `/` and trailing slashes trimmed. Each line
# carries a `P:` sentinel so an empty prefix (a glob that starts with a
# wildcard, which every path matches) survives command substitution.
owned_prefixes() {
  local glob
  while IFS= read -r glob; do
    while [ "${glob#*//}" != "$glob" ]; do glob="${glob%%//*}/${glob#*//}"; done
    while [ "${glob#./}" != "$glob" ]; do glob="${glob#./}"; done
    glob="${glob#/}"
    while [ "${glob%/}" != "$glob" ]; do glob="${glob%/}"; done
    case "$glob" in
      *[\*\?\[]*)
        glob="${glob%%[\*\?\[]*}"
        case "$glob" in
          */*) printf 'P:%s\n' "${glob%/*}/" ;;
          *) printf 'P:\n' ;;
        esac
        ;;
      *) printf 'P:%s\n' "$glob" ;;
    esac
  done
}
# The host's project slug for an absolute path (lib/repo-profile.mjs
# projectSlug): every UTF-16 code unit outside [A-Za-z0-9] becomes `-`. sed
# counts bytes or characters depending on the locale, so a path that is not
# plain ASCII goes through node, which counts the way the library does.
owned_slug() {
  if printf '%s' "$1" | LC_ALL=C grep -q '[^ -~]'; then
    command -v node >/dev/null 2>&1 || return 1
    node -e 'process.stdout.write(process.argv[1].replace(/[^A-Za-z0-9]/g, "-"))' "$1"
  else
    printf '%s' "$1" | LC_ALL=C sed 's/[^A-Za-z0-9]/-/g'
  fi
}
owned_path_gate() {
  local common main slug profile profiles prefixes list="" p hit=0 always=0 out rc glob top
  local scope item add_all=0 tracked_all=0 add_specs commit_specs
  top="$(git rev-parse --show-toplevel 2>/dev/null || true)"
  [ -n "$top" ] || top="$PWD"
  # The commit's own paths: the index, plus what the same command adds or
  # `commit -a` / `commit <pathspec>` takes (agent-guard.py --commit-scope).
  # A dirty file the commit leaves out is not judged. Without the helper the
  # widest set is scanned, as the secret scan above does.
  scope="ADD_ALL"$'\n'"COMMIT_ALL"
  if command -v python3 >/dev/null 2>&1 && [ -f "$PRE_COMMIT_DIR/agent-guard.py" ] && [ -n "${HOOK_INPUT:-}" ]; then
    scope="$(printf '%s' "$HOOK_INPUT" | python3 "$PRE_COMMIT_DIR/agent-guard.py" --commit-scope 2>/dev/null)" \
      || scope="ADD_ALL"$'\n'"COMMIT_ALL"
  fi
  add_specs=()
  commit_specs=()
  while IFS= read -r item; do
    case "$item" in
      ADD_ALL) add_all=1 ;;
      ADD_UPDATE | COMMIT_ALL) tracked_all=1 ;;
      "ADD "*) add_specs+=("${item#ADD }") ;;
      "COMMIT "*) commit_specs+=("${item#COMMIT }") ;;
    esac
  done <<<"$scope"
  while IFS= read -r -d '' p; do
    list="$list$p"$'\n'
  done < <(
    git -C "$top" diff --cached --name-only --no-renames -z 2>/dev/null
    if [ "$add_all" = "1" ] || [ "$tracked_all" = "1" ]; then
      git -C "$top" diff HEAD --name-only --no-renames -z 2>/dev/null
    fi
    if [ "$add_all" = "1" ]; then
      git -C "$top" ls-files --others --exclude-standard --full-name -z 2>/dev/null
    fi
    if [ "${#add_specs[@]}" -gt 0 ]; then
      git ls-files --modified --others --exclude-standard --full-name -z -- "${add_specs[@]}" 2>/dev/null
    fi
    if [ "${#commit_specs[@]}" -gt 0 ]; then
      git ls-files --modified --full-name -z -- "${commit_specs[@]}" 2>/dev/null
    fi
  )
  [ -n "$list" ] || return 0
  common="$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null || true)"
  if [ -n "$common" ] && [ "$(basename "$common")" = ".git" ]; then
    main="$(cd "$(dirname "$common")" 2>/dev/null && pwd -P)"
  else
    main="$(cd "$top" 2>/dev/null && pwd -P)"
  fi
  [ -n "$main" ] || return 0
  if ! slug="$(owned_slug "$main")" || [ -z "$slug" ]; then
    echo "pre-commit-check: node unavailable to locate the repo profile for $main - owned-path gate skipped" >&2
    return 0
  fi
  profiles=""
  for profile in "${HOME:-}/.claude/projects/$slug/repo-profile.json" \
    "${MA_UNATTENDED_RUN_ROOT:-${HOME:-}/.multi-agent-unattended}/repo-profiles/$slug/repo-profile.json"; do
    [ -f "$profile" ] && profiles="$profiles$profile"$'\n'
  done
  if [ -z "$profiles" ]; then
    echo "pre-commit-check: no repo profile for $main - owned-path gate skipped" >&2
    return 0
  fi
  prefixes="$(while IFS= read -r profile; do
    [ -n "$profile" ] && sed -n 's/^ *"glob": *"\(.*\)",\{0,1\} *$/\1/p' "$profile"
  done <<<"$profiles" | owned_prefixes)"
  if [ -z "$prefixes" ]; then
    echo "pre-commit-check: the repo profile for $main lists no owned path - owned-path gate skipped" >&2
    return 0
  fi
  while IFS= read -r glob; do
    [ "$glob" = "P:" ] && always=1
  done <<<"$prefixes"
  if [ "$always" = "1" ]; then
    hit=1
  else
    while IFS= read -r p; do
      [ -z "$p" ] && continue
      while IFS= read -r glob; do
        glob="${glob#P:}"
        case "$p" in "$glob"*) hit=1; break ;; esac
      done <<<"$prefixes"
      [ "$hit" = "1" ] && break
    done <<<"$list"
  fi
  [ "$hit" = "1" ] || return 0
  if ! command -v node >/dev/null 2>&1 || [ ! -f "$PRE_COMMIT_DIR/owned-path-gate.mjs" ]; then
    echo "pre-commit-check: node or owned-path-gate.mjs unavailable - owned-path gate skipped" >&2
    return 0
  fi
  # --state auto finds the live pipeline run working in this checkout, so an
  # autopilot run's commit gets the policy of a run that asks nothing.
  out="$(printf '%s' "$list" | node "$PRE_COMMIT_DIR/owned-path-gate.mjs" \
    --repo "$top" --paths - --state auto 2>&1 >/dev/null)"
  rc=$?
  if [ "$rc" = "1" ]; then
    printf '%s\n' "$out" >&2
    echo "BLOCKED: the commit edits paths an automated account owns (see above)." >&2
    return 2
  fi
  if [ "$rc" != "0" ]; then
    echo "pre-commit-check: owned-path gate could not run (exit $rc) - skipped" >&2
  fi
  return 0
}
if [ "$IS_COMMIT_HOOK" = "1" ]; then
  owned_path_gate || exit 2
fi

# Unattended commit: the verification gates must have passed for this HEAD.
# The environment is the hook's own, inherited from the process the launcher
# started, so the run cannot talk its way past it by editing its state. An
# attended commit skips this entirely.
if [ "$IS_COMMIT_HOOK" = "1" ] && ma_unattended; then
  if ! command -v node >/dev/null 2>&1; then
    echo "BLOCKED: unattended commit, and node is not available to check the gate ledger." >&2
    exit 2
  fi
  if ! node "$PRE_COMMIT_DIR/gate-ledger.mjs" check --repo "${REPO_ROOT:-$PWD}" >/dev/null; then
    echo "BLOCKED: unattended commit without a passing gate ledger for HEAD (see above)." >&2
    exit 2
  fi
fi

exit 0
