#!/usr/bin/env bash
# kb-enforce-hook - data-olympus enforcement hook dispatcher.
#
# Invoked by a coding agent's hooks. Reads the hook payload as JSON on stdin and
# talks to the data-olympus REST endpoints. Modes:
#   session-start  warm-up note (stdout -> context)
#   user-prompt    POST /consult, print governing rules (stdout -> context)
#   pre-tool       POST /gate/check, block (exit 2) on consult_required
#   stop           no-op
#
# Environment:
#   KB_ENDPOINT            REST endpoint (default http://localhost:8080)
#   KB_ENFORCE_FAIL_MODE   open|closed (default open) behaviour when unreachable
#   KB_AUTH_TOKEN          when set, sent as Authorization: Bearer on POSTs
set -uo pipefail

KB_ENDPOINT="${KB_ENDPOINT:-http://localhost:8080}"
KB_ENFORCE_FAIL_MODE="${KB_ENFORCE_FAIL_MODE:-open}"
KB_AUTH_TOKEN="${KB_AUTH_TOKEN:-}"
MODE="${1:-}"
HERE="$(cd "$(dirname "$0")" && pwd)"

# Optional --dialect after the mode. Default "claude" keeps the current
# (claude == codex) output contract byte-for-byte; "gemini" switches deny/inject
# to its JSON-on-stdout contract.
shift || true
DIALECT="claude"
# AGENT is reported as agent_identity to the consult audit so the per-agent
# compliance view is correct. The same dispatcher serves claude/codex/gemini, so
# a hardcoded identity would mis-attribute every non-claude consultation.
AGENT="unknown"
POSITIONAL=()
while [[ $# -gt 0 ]]; do
  case "$1" in
    --dialect) DIALECT="${2:-claude}"; shift; shift || true ;;
    --agent) AGENT="${2:-unknown}"; shift; shift || true ;;
    *) POSITIONAL+=("$1"); shift ;;
  esac
done

# Read entire stdin payload. Skip for resolve-workspace: it is a stdin-free
# diagnostic (resolve and print a workspace label), so reading stdin would hang.
PAYLOAD=""
if [[ "$MODE" != "resolve-workspace" ]]; then
  PAYLOAD="$(cat || true)"
fi

json_field() {
  # json_field <dotted.path> ; reads $PAYLOAD, prints value or empty.
  # JSON null and missing keys BOTH emit an empty string (never the literal
  # "None"): a null session_id must not become the ledger key "None".
  python3 -c 'import json,sys
p=sys.argv[1].split(".")
try:
    d=json.loads(sys.stdin.read() or "{}")
    for k in p:
        d=d.get(k, "") if isinstance(d, dict) else ""
    if d is None:
        print("")
    else:
        print(d if isinstance(d,str) else json.dumps(d))
except Exception:
    print("")' "$1" <<<"$PAYLOAD"
}

json_field_from() {
  # json_field_from <json-string> <key> ; null/missing -> empty string.
  python3 -c 'import json,sys
try:
    d=json.loads(sys.argv[1] or "{}")
    v=d.get(sys.argv[2], "")
    if v is None:
        print("")
    else:
        print(v if isinstance(v,str) else json.dumps(v))
except Exception:
    print("")' "$1" "$2"
}

resolve_workspace() {
  # Echo a stable workspace label for cwd. Preference order:
  #   1. the MAIN git worktree basename, which is identical from the main
  #      checkout and any linked worktree (so one consult clears both the
  #      pre-tool and pre-commit gates, and matches `report`'s default);
  #   2. the workspaces-root detector (KB_WORKSPACES_ROOT), for non-git dirs;
  #   3. the raw cwd, as a last resort.
  # Git resolution is tried FIRST (not the detector) so the two gates never
  # disagree on a linked worktree, even when KB_WORKSPACES_ROOT resolves that
  # worktree to its own basename. `git worktree list` reports the main worktree
  # as its first entry and is correct for separate-git-dir layouts.
  local cwd="$1"
  local main_wt
  # First NON-bare worktree record. The main worktree is listed first, but a bare
  # repo's first record is the bare git dir (marked `bare`), not a real checkout;
  # skip it so the key is a worktree basename, not `repo.git`.
  main_wt="$(git -C "$cwd" worktree list --porcelain 2>/dev/null | awk '
    /^worktree /{p=substr($0,10); b=0}
    /^bare$/{b=1}
    /^$/{ if(p!="" && b==0){print p; exit} p=""; b=0 }
    END{ if(p!="" && b==0) print p }')"
  if [[ -n "$main_wt" ]]; then
    basename "$main_wt"; return 0
  fi
  if [[ -r "$HERE/_kb_detect_workspace.sh" ]]; then
    # shellcheck disable=SC1091
    . "$HERE/_kb_detect_workspace.sh"
    if detect_workspace_and_component "$cwd" 2>/dev/null; then
      echo "$WORKSPACE"; return 0
    fi
  fi
  echo "$cwd"
}

post_json() {
  # post_json <url> <json-body>
  # On a successful HTTP round-trip, sets globals RESP_BODY (response body) and
  # RESP_CODE (numeric HTTP status) and returns 0. Returns non-zero ONLY when the
  # curl CONNECTION itself fails (DNS/connect/timeout); callers must additionally
  # check RESP_CODE for 2xx, since curl --silent exits 0 on 4xx/5xx.
  #
  # When KB_AUTH_TOKEN is set, pass the Authorization header via a curl config on
  # a process-substitution FD so the token is NOT exposed in process arguments
  # (visible via `ps`). Mirrors bin/kb's do_curl_post.
  local url="$1"
  local body="$2"
  local tmpfile
  tmpfile="$(mktemp)"
  local auth_k_arg=()
  if [[ -n "$KB_AUTH_TOKEN" ]]; then
    auth_k_arg=(-K <(printf 'header = "Authorization: Bearer %s"\n' "$KB_AUTH_TOKEN"))
  fi
  # Timeouts: the pre-tool gate is on the agent's critical path (it blocks every
  # governed edit), so it sets tight CURL_MAX_TIME/CURL_CONNECT_TIMEOUT to fail
  # fast to the fail-mode switch rather than stalling the tool call. Other callers
  # (user-prompt injection) keep the looser default.
  local max_time="${CURL_MAX_TIME:-5}"
  local connect_arg=()
  if [[ -n "${CURL_CONNECT_TIMEOUT:-}" ]]; then
    connect_arg=(--connect-timeout "$CURL_CONNECT_TIMEOUT")
  fi
  # Guard the array expansion: under `set -u`, "${arr[@]}" on an empty array is
  # an unbound-variable error on bash 3.2 (macOS default).
  if RESP_CODE=$(curl --silent --max-time "$max_time" \
      ${connect_arg[@]+"${connect_arg[@]}"} \
      -H "Content-Type: application/json" \
      ${auth_k_arg[@]+"${auth_k_arg[@]}"} \
      --data "$body" \
      --output "$tmpfile" --write-out "%{http_code}" \
      -X POST "$url" 2>/dev/null); then
    RESP_BODY="$(cat "$tmpfile")"
    rm -f "$tmpfile"
    return 0
  fi
  rm -f "$tmpfile"
  RESP_BODY=""
  RESP_CODE=""
  return 1
}

is_2xx() {
  # is_2xx <http-code> ; true when the code is 200-299.
  [[ "$1" =~ ^2[0-9][0-9]$ ]]
}

emit_context() {
  # emit_context <text> ; print injected context per dialect.
  local text="$1"
  if [[ "$DIALECT" == "gemini" ]]; then
    python3 -c 'import json,sys; print(json.dumps({"hookSpecificOutput":{"additionalContext":sys.argv[1]}}))' "$text"
  else
    printf '%s\n' "$text"
  fi
}

emit_deny() {
  # emit_deny <reason> ; signal a blocked tool call per dialect, then exit.
  local reason="$1"
  if [[ "$DIALECT" == "gemini" ]]; then
    python3 -c 'import json,sys; print(json.dumps({"decision":"deny","reason":sys.argv[1]}))' "$reason"
    exit 0
  fi
  echo "$reason" >&2
  exit 2
}

emit_fail_closed() {
  # emit_fail_closed <reason> ; block due to gate-unavailable under fail-mode=closed.
  emit_deny "$1"
}

record_degraded() {
  # record_degraded <workspace> <session> <reason> ; best-effort, ignores failure.
  # Records a gate_degraded audit event when the gate was REACHED but degraded.
  # Never changes the gate's allow/block/fail decision.
  local body
  body="$(python3 -c 'import json,sys
print(json.dumps({"event_type":"gate_degraded","workspace":sys.argv[1],
"agent_identity":sys.argv[2],"source_session":sys.argv[3],"reason":sys.argv[4]}))' \
    "$1" "$AGENT" "$2" "$3")"
  post_json "$KB_ENDPOINT/api/v1/audit/event" "$body" >/dev/null 2>&1 || true
}

case "$MODE" in
  session-start)
    emit_context "[KB] data-olympus enforcement active (endpoint: $KB_ENDPOINT)."
    exit 0
    ;;
  user-prompt)
    SESSION="$(json_field session_id)"
    CWD="$(json_field cwd)"
    PROMPT="$(json_field prompt)"
    WS="$(resolve_workspace "$CWD")"
    # trigger=prompt_hook: this consult is the installer's per-prompt auto-consult
    # (UserPromptSubmit / Gemini BeforeAgent), NOT a deliberate agent kb_consult.
    # It is recorded for audit/compliance and its rules are still injected, but it
    # does NOT clear the gate: only an explicit kb_consult call does. Without this
    # marker every user prompt would refresh the ledger and the gate would fire
    # only during autonomous stretches longer than the TTL.
    BODY="$(python3 -c 'import json,sys
print(json.dumps({"workspace":sys.argv[1],"intent":sys.argv[2],
"source_session":sys.argv[3],"agent_identity":sys.argv[4],"trigger":"prompt_hook"}))' "$WS" "$PROMPT" "$SESSION" "$AGENT")"
    # Prompt-rule injection is best-effort and never blocks: any failure
    # (connection error OR non-2xx) just means no injected rules this turn.
    if ! post_json "$KB_ENDPOINT/api/v1/consult" "$BODY" || ! is_2xx "$RESP_CODE"; then
      echo "[KB] warn: consult endpoint unreachable; proceeding without injected rules." >&2
      exit 0
    fi
    GOV="$(json_field_from "$RESP_BODY" is_governed_decision)"
    if [[ "$GOV" == "true" ]]; then
      RULES="$(echo "$RESP_BODY" | python3 -c 'import json,sys
d=json.load(sys.stdin)
lines=["=== GOVERNING RULES (data-olympus) ==="]
for r in d.get("rules",[]):
    lines.append(f"- {r.get('"'"'id'"'"')}: {r.get('"'"'title'"'"')}")
lines.append("=== consult these before deciding ===")
print("\n".join(lines))')"
      emit_context "$RULES"
    fi
    exit 0
    ;;
  pre-tool)
    # Single parse of the hook payload: emit session_id, cwd, tool_name, file_path
    # and the capped action_diff, each base64-encoded on its own line (base64 is
    # newline/tab-safe, so a multi-line diff or command survives the read). This
    # replaces four separate json_field spawns plus the ADIFF spawn with one
    # python3 invocation on the pre-tool critical path.
    #   action_diff: the change content (Write.content, Edit.new_string,
    #   Bash.command), capped at 4000 chars so the gate body stays bounded, so the
    #   classifier can use diff signals and classify shell commands.
    {
      IFS= read -r SESSION_B64
      IFS= read -r CWD_B64
      IFS= read -r TOOL_B64
      IFS= read -r FPATH_B64
      IFS= read -r ADIFF_B64
    } < <(printf '%s' "$PAYLOAD" | python3 -c 'import base64,json,sys
def enc(v):
    return base64.b64encode((v or "").encode("utf-8","replace")).decode("ascii")
try:
    d=json.loads(sys.stdin.read() or "{}")
    if not isinstance(d,dict): d={}
except Exception:
    d={}
ti=d.get("tool_input",{}) if isinstance(d.get("tool_input"),dict) else {}
diff=ti.get("content") or ti.get("new_string") or ti.get("command") or ""
if not isinstance(diff,str): diff=""
for v in (d.get("session_id"), d.get("cwd"), d.get("tool_name"),
          ti.get("file_path"), diff[:4000]):
    print(enc(v if isinstance(v,str) else ""))')
    b64d() { printf '%s' "$1" | base64 --decode 2>/dev/null || true; }
    SESSION="$(b64d "$SESSION_B64")"
    CWD="$(b64d "$CWD_B64")"
    TOOL="$(b64d "$TOOL_B64")"
    FPATH="$(b64d "$FPATH_B64")"
    ADIFF="$(b64d "$ADIFF_B64")"
    WS="$(resolve_workspace "$CWD")"
    BODY="$(python3 -c 'import json,sys
print(json.dumps({"workspace":sys.argv[1],"session_id":sys.argv[2],
"tool_name":sys.argv[3],"action_path":sys.argv[4],"action_diff":sys.argv[5]}))' "$WS" "$SESSION" "$TOOL" "$FPATH" "$ADIFF")"
    # Gate is "unavailable" when the connection fails OR the HTTP status is not
    # 2xx (curl --silent exits 0 on 4xx/5xx). Unavailable routes through the
    # fail-mode switch; it must NEVER implicitly allow. REACHED distinguishes a
    # full connection failure (cannot record) from a reachable-but-degraded gate.
    GATE_AVAILABLE=1
    REACHED=1
    # Tight timeouts on the critical-path gate: fail fast to the fail-mode switch
    # instead of stalling every governed edit for up to 5s on a slow/down server.
    CURL_MAX_TIME=2 CURL_CONNECT_TIMEOUT=1
    export CURL_MAX_TIME CURL_CONNECT_TIMEOUT
    if ! post_json "$KB_ENDPOINT/api/v1/gate/check" "$BODY"; then
      GATE_AVAILABLE=0; REACHED=0
    elif ! is_2xx "$RESP_CODE"; then
      GATE_AVAILABLE=0
    fi
    VERDICT=""
    if [[ "$GATE_AVAILABLE" == "1" ]]; then
      VERDICT="$(json_field_from "$RESP_BODY" verdict)"
    fi
    # A reachable 2xx with no parseable verdict is also "unavailable": do not
    # silently allow on an unexpected/degraded response.
    if [[ "$GATE_AVAILABLE" == "0" || ( "$VERDICT" != "allow" && "$VERDICT" != "consult_required" ) ]]; then
      # Reachable-but-degraded (server up, non-2xx or unparseable) is recorded;
      # a full connection failure cannot be recorded (phone is down).
      if [[ "$REACHED" == "1" ]]; then
        record_degraded "$WS" "$SESSION" "gate degraded (code=$RESP_CODE)"
      fi
      if [[ "$KB_ENFORCE_FAIL_MODE" == "closed" ]]; then
        emit_fail_closed "[KB] gate unavailable and fail-mode=closed; blocking."
      fi
      echo "[KB] warn: gate endpoint unavailable; failing open (enforcement gap)." >&2
      exit 0
    fi
    if [[ "$VERDICT" == "consult_required" ]]; then
      # Actionable deny: include the exact workspace key AND the session id (the
      # one value the agent cannot guess) plus a copy-pasteable kb_consult call.
      # A null/empty session_id echoes as empty (json_field already emits "" for
      # JSON null), never the literal "None".
      emit_deny "[KB] BLOCKED: this is a governed change and no explicit consultation is on record. Call kb_consult(workspace='$WS', source_session='$SESSION', intent='<what you are doing>') then retry."
    fi
    # Only reached when 2xx AND verdict == allow.
    exit 0
    ;;
  stop)
    exit 0
    ;;
  resolve-workspace)
    # Diagnostic: print the workspace label this hook resolves for a directory
    # (default: $PWD). Same resolution the pre-tool gate applies, so operators
    # can see exactly which key a consult must target.
    resolve_workspace "${POSITIONAL[0]:-$PWD}"
    exit 0
    ;;
  *)
    echo "kb-enforce-hook: unknown mode '$MODE' (want session-start|user-prompt|pre-tool|stop|resolve-workspace)" >&2
    exit 64
    ;;
esac
