#!/usr/bin/env bash
# PreToolUse guard for commands that reach a live host.
#
# Policy: reads stay friction-free, mutations ask first.
#
# The estate's widest privilege is that `agent` has passwordless sudo on prod and
# beta, and the global settings allow `ssh prod:*` outright — so before this hook
# existed, any session in any directory could roll, delete or restart production
# with no prompt. CI cannot help here: nothing about `ssh prod 'docker service rm'`
# goes through a pull request.
#
# Classification is an ALLOWLIST of read-only commands, not a blocklist of
# dangerous ones. A blocklist is wrong by construction: the first destructive verb
# nobody thought of sails straight through. Anything this script does not
# positively recognise as a read becomes an "ask", which is the safe direction to
# be wrong in.
#
# Exit 0 with no output = "no opinion", and the normal permission flow proceeds.
# That is also what happens if this script errors or jq is missing, so a bug here
# fails toward the prompt rather than toward silent execution.
#
# Beta is guarded as strictly as prod -- more strictly than the names alone
# suggest, now: vps2 (169.58.46.181) runs prod AND beta as two Swarm stacks on
# the SAME manager, sharing the same TimescaleDB instance, so a "beta" command
# there has PROD's blast radius (shared Docker host, shared Postgres server --
# see infra/deploy/env-topology.sh). vps1 (75.119.132.91) is the other guarded
# host: Forgejo (git + CI + registry), Grafana/Loki, and the dev environment --
# a destructive command there can take out the git server, CI and the container
# registry at once. Mesh IPs did NOT move in the vps1/vps2 split (vps1 is still
# 10.10.0.2, vps2 is still 10.10.0.1); what moved is which environment runs where.
set -uo pipefail

payload=$(cat)
command -v jq >/dev/null 2>&1 || exit 0

tool=$(printf '%s' "$payload" | jq -r '.tool_name // ""')

# An ssh target naming a live host, matched as a whole token after any `user@`.
# Backslashes are doubled because awk -v processes escape sequences in the value
# before the regex ever sees it; a single \. arrives as a bare dot (and warns).
# vps2 (169.58.46.181) carries BOTH prod and beta; vps1 (75.119.132.91) carries
# Forgejo/Grafana/dev -- "vps1"/"vps2" are recognised alongside the environment
# names and raw IPs so a command naming the host either way still gets caught.
readonly HOST_TOKEN_RE='^(prod|beta|vps1|vps2|169\\.58\\.46\\.181|75\\.119\\.132\\.91|10\\.10\\.0\\.[12])$'

emit() { # $1=allow|ask|deny  $2=reason
  jq -n --arg d "$1" --arg r "$2" \
    '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:$d,permissionDecisionReason:$r}}'
  exit 0
}

# --- is this shell command read-only? ----------------------------------------
# Returns 0 only when every segment leads with a recognised read-only command.
is_readonly() {
  local cmd="$1" seg w1 w2 stripped

  # Discard the harmless redirections that appear in ordinary read commands,
  # then treat any surviving redirect as a write.
  stripped=${cmd//2>&1/}
  stripped=${stripped//&>\/dev\/null/}
  stripped=${stripped//2>\/dev\/null/}
  stripped=${stripped//>\/dev\/null/}
  stripped=${stripped//> \/dev\/null/}
  case "$stripped" in *'>'*) return 1 ;; esac

  # Command substitution can hide anything; refuse to reason about it.
  # shellcheck disable=SC2016  # single quotes are the point: these are literal
  # `$(` and backtick characters being searched for inside the command text, not
  # expansions to perform. Expanding them here would defeat the check entirely.
  case "$cmd" in *'$('*|*'`'*) return 1 ;; esac

  while IFS= read -r seg; do
    seg="${seg#"${seg%%[![:space:]]*}"}"
    [ -z "$seg" ] && continue
    w1=$(printf '%s' "$seg" | awk '{print $1}')
    w2=$(printf '%s' "$seg" | awk '{print $2}')
    case "$w1" in
      cat|ls|head|tail|wc|grep|egrep|fgrep|rg|find|stat|df|du|free|uptime|whoami)   ;;
      id|hostname|date|ps|env|printenv|pwd|which|uname|lsblk|ip|ss|netstat)         ;;
      pg_isready|echo|true|sort|uniq|cut|tr|awk|jq|nproc|readlink|realpath|test)    ;;
      sed)  case "$seg" in *' -i'*) return 1 ;; esac ;;
      curl) case "$seg" in *' -X'*|*' -d'*|*'--data'*|*' -T'*|*' -F'*|*'--upload'*) return 1 ;; esac ;;
      journalctl) ;;
      systemctl)
        case "$w2" in status|show|is-active|is-enabled|is-failed|list-units|cat) ;; *) return 1 ;; esac ;;
      docker)
        case "$w2" in
          ps|logs|inspect|stats|images|version|info|top|port|events|history|diff) ;;
          service) case "$seg" in *'service ls'*|*'service ps'*|*'service inspect'*|*'service logs'*) ;; *) return 1 ;; esac ;;
          stack)   case "$seg" in *'stack ls'*|*'stack ps'*|*'stack services'*) ;; *) return 1 ;; esac ;;
          compose) case "$seg" in *'compose ps'*|*'compose logs'*|*'compose config'*|*'compose top'*|*'compose images'*) ;; *) return 1 ;; esac ;;
          node)    case "$seg" in *'node ls'*|*'node inspect'*) ;; *) return 1 ;; esac ;;
          volume)  case "$seg" in *'volume ls'*|*'volume inspect'*) ;; *) return 1 ;; esac ;;
          *) return 1 ;;
        esac ;;
      git)
        case "$w2" in status|log|diff|show|branch|remote|rev-parse|describe|blame|ls-files|config) ;; *) return 1 ;; esac ;;
      *) return 1 ;;
    esac
    # NOTE: the trailing newline matters. Without it `read` returns non-zero on the
    # final (only) line, the loop body never runs, and every command classifies as
    # read-only — a silent false negative that allows anything.
  done < <(printf '%s\n' "$cmd" | sed 's/&&/\n/g; s/||/\n/g; s/;/\n/g; s/|/\n/g')
  return 0
}

# --- find an ssh target and return what would run on it -----------------------
# Prints "<host>\t<remote command>" when a live host is addressed, nothing
# otherwise. The remote command is empty for a bare login shell.
ssh_target_of() {
  printf '%s' "$1" | awk -v re="$HOST_TOKEN_RE" '
    {
      for (i = 1; i <= NF; i++) {
        t = $i; sub(/^[^@]*@/, "", t)
        if (t ~ re) {
          out = ""
          for (j = i + 1; j <= NF; j++) out = out (out == "" ? "" : " ") $j
          printf "%s\t%s\n", t, out
          exit
        }
      }
    }'
}

case "$tool" in
  Bash)
    cmd=$(printf '%s' "$payload" | jq -r '.tool_input.command // ""')
    case "$cmd" in *ssh*) ;; *) exit 0 ;; esac
    found=$(ssh_target_of "$cmd")
    [ -n "$found" ] || exit 0                              # no live host addressed
    host=${found%%$'\t'*}
    remote=${found#*$'\t'}
    remote=$(printf '%s' "$remote" | sed -E "s/^'(.*)'$/\1/; s/^\"(.*)\"$/\1/")
    [ -z "$remote" ] && exit 0                             # bare login shell
    if is_readonly "$remote"; then
      emit allow "read-only command on ${host}"
    fi
    emit ask "This mutates ${host}, a live host where the agent user has passwordless sudo. Command: ${remote}"
    ;;

  mcp__centralis__run_on_host)
    env=$(printf '%s' "$payload" | jq -r '.tool_input.env // ""')
    cmd=$(printf '%s' "$payload" | jq -r '.tool_input.command // ""')
    case "$env" in prod|beta) ;; *) exit 0 ;; esac
    if is_readonly "$cmd"; then
      emit allow "read-only command on ${env}"
    fi
    emit ask "run_on_host would mutate ${env}. Command: ${cmd}"
    ;;

  mcp__centralis__sql_query)
    env=$(printf '%s' "$payload" | jq -r '.tool_input.env // ""')
    write=$(printf '%s' "$payload" | jq -r '.tool_input.write // false')
    [ "$write" = "true" ] || exit 0
    case "$env" in prod|beta) ;; *) exit 0 ;; esac
    sql=$(printf '%s' "$payload" | jq -r '.tool_input.sql // ""' | tr '\n' ' ' | cut -c1-300)
    emit ask "Write query against the ${env} database. sql_query escalates to the app role and has no confirmation of its own. SQL: ${sql}"
    ;;

  mcp__centralis__rollback_to|mcp__centralis__promote|mcp__centralis__secrets_rotate)
    emit ask "${tool#mcp__centralis__} changes production state directly."
    ;;

  *) exit 0 ;;
esac
