#!/bin/bash
# Pre-commit secret detection for multi-agent pipeline
# Scans staged git changes for accidentally committed secrets
# Exit 0 = clean, Exit 1 = secrets found (blocks commit)

set -uo pipefail

# 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
if [ ! -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
    if [ -n "$HOOK_COMMAND" ]; then
      if ! printf '%s\n' "$HOOK_COMMAND" \
        | grep -qE '(^|[^[:alnum:]_./-])git[[:space:]]+([^|&;]*[[:space:]])?commit([^[:alnum:]_-]|$)'; then
        exit 0
      fi
      # 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' "$HOOK_COMMAND" \
        | grep -qE '(^|[^[:alnum:]_./-])git[[:space:]]+add([^[:alnum:]_-]|$)'; then
        SCAN_UNSTAGED=1
      fi
    fi
  fi
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"

  # .env files
  if echo "$file" | grep -qE '\.env($|\.)'; then
    echo "BLOCKED: .env file staged: $file"
    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"
    FOUND=1
    return 0
  fi

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

  # 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"
    FOUND=1
  fi

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

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

  # High-signal provider token prefixes (low false-positive rate)
  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"
    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"
    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
    candidates=$(echo "$content" | grep -oE '[A-Za-z0-9+/=_-]{40,}' || true)
    if [ -n "$candidates" ]; then
      entropy_hit=$(printf '%s\n' "$candidates" | awk '
        {
          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 was
          # blocked as a possible credential. 47 of the 400 committed markdown
          # files in this repo carried such a path, so each was un-editable  -  and
          # the gate only fired once such a file was staged, which is why it
          # stayed invisible.
          #
          # The reduction is applied ONLY to path-shaped candidates. An earlier
          # version of this fix scored the longest slash-free segment for every
          # candidate containing a slash, which structurally exempted any secret
          # with a slash in it: the canonical 40-char AWS documentation-example
          # secret access key (two slashes, full-match H=4.663, blocked before)
          # reduced to an 18-char segment and sailed through, and roughly half
          # of `openssl rand -base64 32` outputs contain a "/". It 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
          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"
        FOUND=1
      fi
    fi
  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)
  scan_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)
    scan_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)
    scan_file "$file" "$content"
  done < <(git ls-files --others --exclude-standard -z 2>/dev/null)
fi

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

exit 0
