#!/usr/bin/env bash
# destructive-command-guard.sh -- PreToolUse hook
# Blocks dangerous Bash commands unless explicitly authorized.
# Exit code 2 = block. Exit code 0 = allow.
#
# Part of @connexum/ai-governance

set -euo pipefail

INPUT=""; IFS= read -r -d '' -t 5 INPUT || true

# AIAG-018: enforce 1MB input size limit
if [ "${#INPUT}" -gt 1048576 ]; then
  echo "BLOCKED: Hook input exceeds 1MB. Possible injection attempt." >&2
  exit 2
fi

TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null)

# Only gate Bash tool
if [ "$TOOL_NAME" != "Bash" ]; then
  exit 0
fi

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

if [ -z "$COMMAND" ]; then
  exit 0
fi

# REMOVED: CXNI_MAINTENANCE_MODE bypass (security review 2026-04-17). Maintenance mode must not bypass enforcement.

# Destructive patterns that require explicit authorization
# Each pattern: regex | human-readable description
DESTRUCTIVE_PATTERNS=(
  "rm\s+(-[rRf]+\s+|--recursive|--force).*[/~]|rm\s+-rf\b:Recursive force delete"
  "git\s+push\s+.*--force\b|git\s+push\s+-f\b:Force push (can overwrite remote history)"
  "git\s+reset\s+--hard\b:Hard reset (discards all uncommitted changes)"
  "git\s+clean\s+-[fdx]+\b:Git clean (removes untracked files permanently)"
  "git\s+checkout\s+--\s+\.\b|git\s+restore\s+\.\b:Restore all files (discards all changes)"
  "DROP\s+(TABLE|DATABASE|INDEX|SCHEMA)\b:SQL DROP statement"
  "TRUNCATE\s+TABLE\b:SQL TRUNCATE statement"
  "DELETE\s+FROM\s+\w+\s*(;|\s*$|WHERE\s+1\s*=\s*1):SQL DELETE without specific WHERE"
  "npm\s+publish\b:npm publish (releases package to registry)"
  "docker\s+system\s+prune\b:Docker system prune"
  "kill\s+-9\s+(-1|0)\b|killall\b:Kill all processes"
  "chmod\s+-R\s+777\b:Recursive world-writable permissions"
  "mkfs\b|fdisk\b|dd\s+if=:Filesystem/disk operations"
  ">\s*/dev/sd|>\s*/etc/:Write to system paths"
  # F-NEW-MED-20: redirect into governance/audit directories. Without
  # this pattern a malicious bash command can overwrite or append to
  # the audit chain file (e.g. `> .claude/governance/audit/audit-X.jsonl`).
  # The chain-hash verifier detects tamper on next verify-chain run, but
  # only if the operator runs it. Block at the source.
  ">>?\s*\.?/?(\.claude/governance|GOVERNANCE)/(audit|[^[:space:]]+\.conf|[^[:space:]]+\.json):Redirect into governance/audit directory (audit chain tamper)"
  "curl.*\|\s*(bash|sh)\b|wget.*\|\s*(bash|sh)\b:Pipe remote script to shell"
  "eval\s+.*\\\$(curl|wget):Eval remote content"
  "base64\s+(-d|--decode).*\|\s*(bash|sh|eval)\b:Decode and execute base64 payload"
  "echo\s+.*\|\s*base64\s+(-d|--decode)\s*\|\s*(bash|sh)\b:Pipe decoded base64 to shell"
  "eval\s+.*base64:Eval base64-decoded content"
  "python[23]?\s+-c\s+.*exec\(.*base64:Python exec with base64 decode"
  "node\s+-e\s+.*Buffer\.from\(.*base64:Node eval with base64 decode"
)

# F-NEW-MED-21: strip heredoc bodies before scanning. Without this,
# a destructive pattern that appears ONLY inside a heredoc body (a
# git commit message describing "rm -rf", a docs commit explaining
# "npm publish", etc.) is matched and blocked. The body is text input
# to a parent command, not an executable instruction.
#
# Heredoc forms handled (per Bash 3.2+):
#   <<MARKER      ... MARKER
#   <<-MARKER     ... MARKER     (leading tabs stripped from body lines)
#   <<'MARKER'    ... MARKER     (no parameter expansion)
#   <<"MARKER"    ... MARKER     (no parameter expansion)
#
# The closing marker line itself is also dropped. This is safe because
# (a) the marker by definition is just an identifier with no destructive
# content and (b) any real command after the closing marker survives
# because awk only suppresses lines while in_heredoc=1.
#
# Real destructive commands NOT inside a heredoc body are unaffected
# (proven by tests/hooks/destructive-guard-heredoc-false-positive.test.ts
# regression cases: bare `rm -rf /tmp/foo`, bare `npm publish`, and
# `... && rm -rf /tmp/evil` after a heredoc still BLOCK).
COMMAND=$(printf '%s' "$COMMAND" | awk '
  BEGIN { in_heredoc = 0; marker = ""; }
  {
    if (in_heredoc) {
      stripped = $0;
      sub(/^[[:space:]]+/, "", stripped);
      sub(/[[:space:]]+$/, "", stripped);
      if (stripped == marker) {
        in_heredoc = 0;
        marker = "";
      }
      next;
    }
    line = $0;
    if (match(line, /<<-?[\047"]?[A-Za-z_][A-Za-z0-9_]*[\047"]?/)) {
      tok = substr(line, RSTART, RLENGTH);
      sub(/^<<-?/, "", tok);
      gsub(/[\047"]/, "", tok);
      marker = tok;
      in_heredoc = 1;
    }
    print $0;
  }
')

# C6 fix: split the command on shell separators before pattern matching.
# Without this, a command like "echo safe; rm -rf /" would pass because the
# entire string does not match any single pattern -- only the sub-command
# after the semicolon does.
#
# Strategy: replace separator tokens with newlines so the resulting stream
# can be scanned line-by-line. We cover:
#   ;   -- sequential execution
#   &&  -- conditional execution (AND)
#   ||  -- conditional execution (OR)
#   $(  -- command substitution (sub-command runs before parent)
#   `   -- backtick command substitution
#
# sed replacement order matters: && and || before & and | to avoid partial
# matches. The $(  form is treated by extracting everything after $( up to
# a closing paren; for simplicity we just split on $( so both the outer and
# inner commands are scanned as separate segments.

# Build a normalized multi-line representation of the command where every
# sub-command appears on its own line.
SPLIT_CMD=$(
  printf '%s' "$COMMAND" \
  | sed -E \
      -e 's/;/\n/g' \
      -e 's/&&/\n/g' \
      -e 's/\|\|/\n/g' \
      -e 's/\$\(/\n/g' \
      -e 's/`/\n/g'
)

# F-NEW-PERF-DCG-FASTPATH (2026-05-16): the per-segment per-pattern loop
# below forks `grep` once per pattern per segment. With 22 patterns and
# typically 1 segment, that is 22 subprocess invocations on the common
# allow-path -- the dominant cost (~50ms p99 on a workstation, breaching
# the 50ms enforcement cap under any concurrent load).
#
# Fast-path: build ONE combined alternation regex from all pattern bodies
# and run ONE `grep -qEi` against the whole normalised SPLIT_CMD. If
# nothing matches, exit 0 immediately. This collapses the common allow
# case from 22 grep forks to 1.
#
# When the combined grep DOES match, fall through to the slow path so
# the per-segment per-pattern loop can identify WHICH pattern matched
# and emit the accurate user-facing description + segment. Match path
# behaviour is unchanged -- same patterns, same description format,
# same segment context, same exit codes.
COMBINED_PATTERN=""
for entry in "${DESTRUCTIVE_PATTERNS[@]}"; do
  PATTERN="${entry%:*}"
  if [ -z "$COMBINED_PATTERN" ]; then
    COMBINED_PATTERN="$PATTERN"
  else
    COMBINED_PATTERN="$COMBINED_PATTERN|$PATTERN"
  fi
done

# Fast-path early-exit: single grep against the entire split command.
# No alternative-discrimination here -- this is purely a hit/miss test.
if ! printf '%s' "$SPLIT_CMD" | grep -qEi -- "$COMBINED_PATTERN"; then
  exit 0
fi

# Slow path: at least one pattern matched somewhere in SPLIT_CMD. Run the
# per-segment per-pattern loop to identify and report the specific match.
while IFS= read -r SEGMENT; do
  # Trim leading/trailing whitespace
  SEGMENT="${SEGMENT#"${SEGMENT%%[![:space:]]*}"}"
  SEGMENT="${SEGMENT%"${SEGMENT##*[![:space:]]}"}"
  [ -z "$SEGMENT" ] && continue

  for entry in "${DESTRUCTIVE_PATTERNS[@]}"; do
    # F-NEW-MED-20: use shortest-from-right (%:*) instead of longest-from-
    # left (%%:*) so patterns containing colons (e.g. POSIX classes
    # like [:space:]) survive the split intact. Same fix family as
    # CRIT-36 (secret-leak-scanner). Assumption: description text after
    # the last colon contains no further colons.
    PATTERN="${entry%:*}"
    DESCRIPTION="${entry##*:}"

    if echo "$SEGMENT" | grep -qEi "$PATTERN"; then
      echo "BLOCKED: Destructive command detected -- $DESCRIPTION" >&2
      echo "Segment: $SEGMENT" >&2
      echo "Full command: $COMMAND" >&2
      echo "This command requires explicit human authorization." >&2
      exit 2
    fi
  done
done <<< "$SPLIT_CMD"

exit 0
