#!/usr/bin/env bash
# audit-logger.sh -- PostToolUse hook
# Logs every tool call to a structured audit log.
# Never blocks (always exits 0). Audit trail for compliance.
#
# Part of @connexum/ai-governance

# AIAG-019: use -e so silent failures in audit-pipeline commands surface
# as a nonzero exit rather than corrupting the log. PostToolUse hooks
# still should not block a tool that has already run, so the final
# `exit 0` at the bottom is preserved and failing sub-commands are
# guarded with `|| true` where best-effort is correct.
set -euo pipefail

INPUT=$(cat)

# AIAG-018: enforce 1MB input size limit (matches hook-utils.sh MAX_INPUT_BYTES)
if [ "${#INPUT}" -gt 1048576 ]; then
  echo "BLOCKED: Hook input exceeds 1MB (${#INPUT} bytes). Possible injection attempt." >&2
  exit 0  # PostToolUse hook: never block the tool itself, just skip logging.
fi

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

# F-NEW-CRIT-38b: Claude Code does not pass .agent_name in the hook
# payload. Read it from the payload first (legacy / synthesized callers
# still work), then fall back to CXNI_AGENT_NAME env var (operator
# pins identity per session), then to "unknown" as the safe default.
AGENT_NAME=$(echo "$INPUT" | jq -r '.agent_name // empty' 2>/dev/null || true)
if [ -z "$AGENT_NAME" ]; then
  AGENT_NAME="${CXNI_AGENT_NAME:-unknown}"
fi

# F-NEW-CRIT-39a: capture user identity for the regulator-grade audit
# trail. Same operator-knob pattern as CXNI_AGENT_NAME: payload field
# first (synthesized callers), then CXNI_USER_ID env var (operator
# pins identity per session), then "unknown" safe default. The
# regulator answer to "who did what when" requires this field.
USER_ID=$(echo "$INPUT" | jq -r '.user_id // empty' 2>/dev/null || true)
if [ -z "$USER_ID" ]; then
  USER_ID="${CXNI_USER_ID:-unknown}"
fi

# F-NEW-CRIT-39b: capture LLM model for the regulator-grade audit trail.
# Same operator-knob pattern: payload field first, then CXNI_LLM_MODEL
# env var (operator pins per session), then "unknown" safe default.
# The regulator answer to "which model was used" requires this field.
LLM_MODEL=$(echo "$INPUT" | jq -r '.llm_model // empty' 2>/dev/null || true)
if [ -z "$LLM_MODEL" ]; then
  LLM_MODEL="${CXNI_LLM_MODEL:-unknown}"
fi

TOOL_INPUT=$(echo "$INPUT" | jq -r '.tool_input // {}' 2>/dev/null || echo "{}")
TOOL_RESULT=$(echo "$INPUT" | jq -r '.tool_result // ""' 2>/dev/null | head -c 200 || echo "")

# F-NEW-CRIT-39d: capture truncated full tool input + tool response
# for the regulator-grade audit trail. The 100-char `target` summary
# alone is insufficient for "what data did the agent process."
#
# Truncation policy:
# - inputCapture / responseCapture: max 1024 bytes (UTF-8 safe via jq's
#   .[0:1024]). Truncated values are appended with "...truncated" so
#   readers know the value is partial.
# - inputBytes / responseBytes: original byte count of the JSON-stringified
#   value (so reviewers can see truncation occurred).
# - scrubbed: false. This hook does NOT scrub secrets inline. Customers
#   relying on regulator-grade non-leaked audit logs MUST configure
#   secret-leak-scanner (PreToolUse, blocks at the source) AND/OR a
#   downstream redaction layer. Documenting the scrubbed=false flag
#   makes the layered design explicit so a regulator review knows
#   what guarantee the field carries.
INPUT_JSON=$(echo "$INPUT" | jq -c '.tool_input // null' 2>/dev/null || echo "null")
RESPONSE_JSON=$(echo "$INPUT" | jq -c '.tool_response // null' 2>/dev/null || echo "null")

INPUT_BYTES=${#INPUT_JSON}
RESPONSE_BYTES=${#RESPONSE_JSON}

if [ "$INPUT_JSON" = "null" ]; then
  INPUT_CAPTURE_JSON='null'
  INPUT_BYTES=0
elif [ "$INPUT_BYTES" -gt 1024 ]; then
  TRUNCATED=$(printf '%s' "$INPUT_JSON" | head -c 1024)
  INPUT_CAPTURE_JSON=$(jq -nc --arg s "${TRUNCATED}...truncated" '$s' 2>/dev/null || echo '"...truncate-error"')
else
  INPUT_CAPTURE_JSON=$(jq -nc --arg s "$INPUT_JSON" '$s' 2>/dev/null || echo '"...stringify-error"')
fi

if [ "$RESPONSE_JSON" = "null" ]; then
  RESPONSE_CAPTURE_JSON='null'
  RESPONSE_BYTES=0
elif [ "$RESPONSE_BYTES" -gt 1024 ]; then
  TRUNCATED=$(printf '%s' "$RESPONSE_JSON" | head -c 1024)
  RESPONSE_CAPTURE_JSON=$(jq -nc --arg s "${TRUNCATED}...truncated" '$s' 2>/dev/null || echo '"...truncate-error"')
else
  RESPONSE_CAPTURE_JSON=$(jq -nc --arg s "$RESPONSE_JSON" '$s' 2>/dev/null || echo '"...stringify-error"')
fi

# F-NEW-CRIT-39d-2: inline secret scrubber for inputCapture / responseCapture.
# CRIT-39d shipped scrubbed=false flag as caller documentation. This block
# closes the leak surface: any matched secret span is replaced with
# [REDACTED:type] before the entry is written. When any redaction happens
# SCRUBBED is flipped to "true" and the entry's scrubbed key reflects it.
#
# Operator opt-out: CXNI_AUDIT_NO_SCRUB=true preserves verbatim capture
# (forensic / offline-redaction workflows).
#
# Pattern set mirrors src/hooks/secret-leak-scanner.sh:
#   AKIA[0-9A-Z]{16}                      -> [REDACTED:aws-key]
#   eyJ...eyJ...sig 3-segment JWT         -> [REDACTED:jwt]
#   -----BEGIN ... PRIVATE KEY----- block -> [REDACTED:private-key]
#
# `sed -E` for portability (POSIX ERE supported on GNU + BusyBox).
SCRUBBED="false"
# Scrub via direct in-shell parameter expansion + sed pipes. Function
# would run in a subshell ($()), losing the SCRUBBED state, so the
# logic stays inline here.
if [ "${CXNI_AUDIT_NO_SCRUB:-}" != "true" ]; then
  INPUT_BEFORE="$INPUT_CAPTURE_JSON"
  RESPONSE_BEFORE="$RESPONSE_CAPTURE_JSON"
  INPUT_CAPTURE_JSON=$(printf '%s' "$INPUT_CAPTURE_JSON" \
    | sed -E 's/AKIA[0-9A-Z]{16}/[REDACTED:aws-key]/g' \
    | sed -E 's/eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/[REDACTED:jwt]/g' \
    | sed -E 's/-----BEGIN[^-]{0,30}PRIVATE KEY-----[^"]*-----END[^-]{0,30}PRIVATE KEY-----/[REDACTED:private-key]/g' \
    | sed -E 's/-----BEGIN[^-]{0,30}PRIVATE KEY-----[^"]{0,2000}/[REDACTED:private-key]/g')
  RESPONSE_CAPTURE_JSON=$(printf '%s' "$RESPONSE_CAPTURE_JSON" \
    | sed -E 's/AKIA[0-9A-Z]{16}/[REDACTED:aws-key]/g' \
    | sed -E 's/eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/[REDACTED:jwt]/g' \
    | sed -E 's/-----BEGIN[^-]{0,30}PRIVATE KEY-----[^"]*-----END[^-]{0,30}PRIVATE KEY-----/[REDACTED:private-key]/g' \
    | sed -E 's/-----BEGIN[^-]{0,30}PRIVATE KEY-----[^"]{0,2000}/[REDACTED:private-key]/g')
  if [ "$INPUT_CAPTURE_JSON" != "$INPUT_BEFORE" ] || [ "$RESPONSE_CAPTURE_JSON" != "$RESPONSE_BEFORE" ]; then
    SCRUBBED="true"
  fi
fi

# F-NEW-CRIT-39c: capture tool outcome (success/exitCode/errorSummary)
# from .tool_response. Three shapes supported:
#   1. Bash-style: { stdout, stderr, exit_code }   -> success=(exit_code==0)
#   2. Edit/Write-style: { success: true|false, error?, ... }  -> success direct
#   3. Absent .tool_response (or string): success=null (cannot determine)
# The outcome object always exists in the entry so downstream consumers can
# rely on the key being present.
HAS_TOOL_RESPONSE=$(echo "$INPUT" | jq -r 'has("tool_response")' 2>/dev/null || echo "false")
if [ "$HAS_TOOL_RESPONSE" = "true" ]; then
  OUTCOME_JSON=$(echo "$INPUT" | jq -c '
    (.tool_response // null) as $tr |
    if ($tr | type) != "object" then
      { success: null, exitCode: null, errorSummary: null }
    elif ($tr | has("exit_code")) then
      ($tr.exit_code) as $ec |
      { success: ($ec == 0),
        exitCode: $ec,
        errorSummary: (($tr.stderr // "") | tostring | .[0:200] // null) }
    elif ($tr | has("success")) then
      { success: $tr.success,
        exitCode: null,
        errorSummary: (($tr.error // "") | tostring | .[0:200] // null) }
    else
      { success: null, exitCode: null, errorSummary: null }
    end
  ' 2>/dev/null || echo '{"success":null,"exitCode":null,"errorSummary":null}')
else
  OUTCOME_JSON='{"success":null,"exitCode":null,"errorSummary":null}'
fi

TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
SESSION_ID="${CXNI_SESSION_ID:-$(date +%s)}"

# F-NEW-CRIT-39g: vendor-neutral audit dir picker. Honors
# CXNI_AUDIT_DIR override, then detects active IDE marker (.claude,
# .cursor, .continue, .cody, .aider, .gemini), falls back to
# .governance/audit/. Old hardcoded ".claude/governance/audit" first-
# priority loop is gone.
SCRIPT_DIR_AUDIT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=audit-dir-picker.sh
source "$SCRIPT_DIR_AUDIT/audit-dir-picker.sh"
AUDIT_DIR="$(pick_audit_dir)"
mkdir -p "$AUDIT_DIR" 2>/dev/null || exit 0

AUDIT_FILE="$AUDIT_DIR/audit-$(date -u +%Y-%m-%d).jsonl"

# Extract relevant details based on tool type
case "$TOOL_NAME" in
  Edit|Write|NotebookEdit)
    TARGET=$(echo "$TOOL_INPUT" | jq -r '.file_path // .path // "unknown"' 2>/dev/null || echo "unknown")
    ;;
  Bash)
    TARGET=$(echo "$TOOL_INPUT" | jq -r '.command // "unknown"' 2>/dev/null | head -c 100 || echo "unknown")
    ;;
  Read)
    TARGET=$(echo "$TOOL_INPUT" | jq -r '.file_path // "unknown"' 2>/dev/null || echo "unknown")
    ;;
  Glob|Grep)
    TARGET=$(echo "$TOOL_INPUT" | jq -r '.pattern // "unknown"' 2>/dev/null || echo "unknown")
    ;;
  Agent)
    TARGET=$(echo "$TOOL_INPUT" | jq -r '.description // "unknown"' 2>/dev/null || echo "unknown")
    ;;
  WebSearch)
    TARGET=$(echo "$TOOL_INPUT" | jq -r '.query // "unknown"' 2>/dev/null || echo "unknown")
    ;;
  WebFetch)
    TARGET=$(echo "$TOOL_INPUT" | jq -r '.url // "unknown"' 2>/dev/null || echo "unknown")
    ;;
  *)
    TARGET="n/a"
    ;;
esac

# Write structured JSONL entry
# F-NEW-CRIT-38b: use `jq -c` (compact) so each entry is a single line.
# Without this, jq pretty-prints across multiple lines and the .jsonl
# file violates the JSON-lines spec (one record per line) - any
# downstream reader that splits on newlines breaks.
ENTRY=$(jq -nc \
  --arg ts "$TIMESTAMP" \
  --arg sid "$SESSION_ID" \
  --arg user "$USER_ID" \
  --arg agent "$AGENT_NAME" \
  --arg tool "$TOOL_NAME" \
  --arg target "$TARGET" \
  --arg llmModel "$LLM_MODEL" \
  --argjson outcome "$OUTCOME_JSON" \
  --argjson inputCapture "$INPUT_CAPTURE_JSON" \
  --argjson responseCapture "$RESPONSE_CAPTURE_JSON" \
  --argjson inputBytes "$INPUT_BYTES" \
  --argjson responseBytes "$RESPONSE_BYTES" \
  --argjson scrubbed "$SCRUBBED" \
  '{timestamp: $ts, session: $sid, user: $user, agent: $agent, tool: $tool, target: $target, llmModel: $llmModel, outcome: $outcome, inputCapture: $inputCapture, responseCapture: $responseCapture, inputBytes: $inputBytes, responseBytes: $responseBytes, scrubbed: $scrubbed}' 2>/dev/null || echo "")

if [ -n "$ENTRY" ]; then
  # Pack-driven SHA-256 chained append.
  # Thomas directive 2026-05-17 evening: "It must start up and report
  # findings to a local database on the compliance and company packs,
  # including what they are allowed and not allowed to do. It must have
  # SHA-256 installed."
  #
  # Try the Node-based audit-chain-append first — it merges in the
  # bound packs' verdict + triggeringPackIds and SHA-256-chains each
  # entry (previousHash + currentHash) so the local audit trail is
  # tamper-evident.
  #
  # If the helper is missing (e.g. .governance.json absent, runtime not
  # initialised, or Node not available) fall back to the legacy plain-
  # JSONL append so the hook never blocks an agent (Invariant 2 advisory).
  CHAIN_HELPER="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/cli/audit-chain-append.js"
  if [ -f "$CHAIN_HELPER" ] && command -v node >/dev/null 2>&1; then
    node "$CHAIN_HELPER" \
      --tool="$TOOL_NAME" \
      --input="${TOOL_INPUT:-{\}}" \
      --base-entry="$ENTRY" \
      --audit-file="$AUDIT_FILE" \
      --cwd="$(pwd)" >/dev/null 2>&1 \
      || echo "$ENTRY" >> "$AUDIT_FILE" 2>/dev/null
  else
    echo "$ENTRY" >> "$AUDIT_FILE" 2>/dev/null
  fi
fi

# F-NEW-CUSTOMER-JOURNEY-001 (2026-05-13): best-effort push to gov-server.
#
# Pre-fix the hook wrote the JSONL entry locally and stopped there — the
# gov-server's audit chain never saw a single tool-call signal from any
# customer's runtime. The credit-agency layer was structurally blind to
# actual agent behavior; the only events that reached the chain were
# manual cluster-event POSTs from the SDK that customers had no way to
# trigger from a hook context.
#
# Now: if the project's .governance.json carries a `runtime` block with
# govServerUrl + orgId + agentId + serviceToken, the hook fires a
# fire-and-forget curl to POST /api/v1/cluster-events/self-registration
# with kind=AGENT_TOOL_OBSERVED. The local JSONL file remains the
# source-of-truth and the safety net — if the push fails (timeout,
# unreachable, missing config, jq absent) the hook still exits 0 and the
# tool call is never blocked by us.
#
# Auth contract: ServiceToken is a JWT obtained at CLI init time and
# stored in .governance.json (mode 0600). The /cluster-events route
# validates the JWT and enforces tenant binding (Invariant 7 — body
# orgId MUST match the token's `org` claim).
PROJECT_ROOT=""
SEARCH_DIR="$AUDIT_DIR"
while [ "$SEARCH_DIR" != "/" ] && [ -n "$SEARCH_DIR" ]; do
  if [ -f "$SEARCH_DIR/.governance.json" ]; then
    PROJECT_ROOT="$SEARCH_DIR"
    break
  fi
  SEARCH_DIR="$(dirname "$SEARCH_DIR")"
done

if [ -n "$PROJECT_ROOT" ] && command -v jq >/dev/null 2>&1 && command -v curl >/dev/null 2>&1; then
  GOV_URL="$(jq -r '.runtime.govServerUrl // empty' "$PROJECT_ROOT/.governance.json" 2>/dev/null)"
  GOV_ORG="$(jq -r '.runtime.orgId // empty' "$PROJECT_ROOT/.governance.json" 2>/dev/null)"
  GOV_AGENT="$(jq -r '.runtime.agentId // empty' "$PROJECT_ROOT/.governance.json" 2>/dev/null)"
  GOV_TOKEN="$(jq -r '.runtime.serviceToken // empty' "$PROJECT_ROOT/.governance.json" 2>/dev/null)"

  if [ -n "$GOV_URL" ] && [ -n "$GOV_ORG" ] && [ -n "$GOV_AGENT" ] && [ -n "$GOV_TOKEN" ]; then
    PUSH_BODY=$(jq -nc \
      --arg agent "$GOV_AGENT" \
      --arg org "$GOV_ORG" \
      --arg tool "${TOOL_NAME:-unknown}" \
      --arg ts "$TIMESTAMP" \
      '{
        kind: "AGENT_TOOL_OBSERVED",
        agentId: $agent,
        orgId: $org,
        tool: $tool,
        timestamp: $ts,
        manifest: { agentId: $agent, tools: [$tool] }
      }' 2>/dev/null)
    if [ -n "$PUSH_BODY" ]; then
      curl --silent --show-error --max-time 2 --connect-timeout 1 \
        --output /dev/null \
        -X POST "${GOV_URL%/}/api/v1/cluster-events/self-registration" \
        -H "Authorization: Bearer $GOV_TOKEN" \
        -H "Content-Type: application/json" \
        --data "$PUSH_BODY" >/dev/null 2>&1 &
      # Detach the background curl so this script exits immediately.
      # The local JSONL row was written above; if the push fails, the
      # next hook fire or a periodic batch sweep can replay from disk.
      disown 2>/dev/null || true
    fi
  fi
fi

exit 0
