#!/usr/bin/env bash
# file-boundary-guard.sh -- PreToolUse hook
# Blocks file writes outside an agent's designated project directory.
# Prevents cross-project contamination and unauthorized file access.
# Exit code 2 = block. Exit code 0 = allow.
#
# Part of @connexum/ai-governance

set -euo pipefail

# AIAG-011: `realpath -m` is not universal. On macOS (default install)
# `realpath` is absent entirely. On Alpine and some BSDs, `-m` is not
# supported. Provide a portable resolver that never silently returns the
# unvalidated path (which would let `../` slip through boundary checks).
_resolve() {
  local p="$1"
  if command -v realpath >/dev/null 2>&1; then
    # Try -m (allow non-existing), fall back to plain realpath, fall back to pwd-based.
    realpath -m "$p" 2>/dev/null || realpath "$p" 2>/dev/null || _resolve_posix "$p"
  elif command -v python3 >/dev/null 2>&1; then
    python3 -c 'import os,sys; print(os.path.abspath(sys.argv[1]))' "$p"
  elif command -v readlink >/dev/null 2>&1 && readlink -f / >/dev/null 2>&1; then
    readlink -f "$p"
  else
    _resolve_posix "$p"
  fi
}

# POSIX-only fallback: if path is absolute, collapse `..` segments manually;
# otherwise prefix with cwd and collapse. Not as robust as realpath but at
# least it won't allow `../../../etc` to pose as `/project/file`.
_resolve_posix() {
  local p="$1"
  case "$p" in
    /*) ;;
    *) p="$PWD/$p" ;;
  esac
  # Collapse `.` and `..` segments.
  local IFS='/'
  # shellcheck disable=SC2206
  local parts=($p)
  local out=()
  for seg in "${parts[@]}"; do
    case "$seg" in
      ''|'.') ;;
      '..') [ ${#out[@]} -gt 0 ] && unset 'out[${#out[@]}-1]' ;;
      *) out+=("$seg") ;;
    esac
  done
  printf '/%s' "${out[@]}"
  printf '\n'
}

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)
TOOL_INPUT=$(echo "$INPUT" | jq -r '.tool_input // empty' 2>/dev/null)

# Only gate file-write tools
case "$TOOL_NAME" in
  Edit|Write|NotebookEdit) ;;
  *) exit 0 ;;
esac

# Extract file path from tool input
FILE_PATH=$(echo "$TOOL_INPUT" | jq -r '.file_path // .path // empty' 2>/dev/null)

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

# Resolve to absolute path
RESOLVED_PATH=$(_resolve "$FILE_PATH")

# F-NEW-MED-19 + CRIT-39g: hard-deny writes into the audit JSONL directory
# regardless of allow-list. Covers every path the audit-dir-picker
# (src/hooks/audit-dir-picker.sh) can choose:
#   .claude/governance/audit/   (Claude Code)
#   .cursor/governance/audit/   (Cursor)
#   .continue/governance/audit/ (Continue)
#   .cody/governance/audit/     (Sourcegraph Cody)
#   .aider/governance/audit/    (Aider)
#   .gemini/governance/audit/   (Gemini Code Assist)
#   .governance/audit/          (vendor-neutral fallback)
#   GOVERNANCE/audit/           (legacy back-compat)
#   .claude/audit/              (legacy v1 location)
# Plus any path the operator pinned via CXNI_AUDIT_DIR. Path-traversal
# is defeated by checking the resolved absolute path, not the raw input.
case "$RESOLVED_PATH" in
  */.claude/governance/audit/*|*/.cursor/governance/audit/*|*/.continue/governance/audit/*|*/.cody/governance/audit/*|*/.aider/governance/audit/*|*/.gemini/governance/audit/*|*/.governance/audit/*|*/.claude/audit/*|*/GOVERNANCE/audit/*)
    echo "BLOCKED: Write to audit directory '$FILE_PATH' is denied (F-NEW-MED-19 + CRIT-39g; tamper-evident chain protection)." >&2
    exit 2
    ;;
esac
if [ -n "${CXNI_AUDIT_DIR:-}" ]; then
  case "$RESOLVED_PATH" in
    *"$CXNI_AUDIT_DIR"/*)
      echo "BLOCKED: Write to operator-pinned audit directory ($CXNI_AUDIT_DIR) is denied." >&2
      exit 2
      ;;
  esac
fi

# Load boundary config
# Format: one directory per line in .claude/governance/project-boundaries.conf
BOUNDARY_FILE=""
for candidate in \
  ".claude/governance/project-boundaries.conf" \
  ".claude/rules/project-boundaries.conf" \
  "GOVERNANCE/project-boundaries.conf"; do
  if [ -f "$candidate" ]; then
    BOUNDARY_FILE="$candidate"
    break
  fi
done

# If no boundary file, allow (no restrictions configured).
#
# F-NEW-MED-17 (2026-05-02): emit a one-time runtime advisory so the
# customer SEES that the hook is dormant. Without this, the customer
# install ships file-boundary-guard.sh wired into Claude Code, the
# hook returns 0 silently on every PreToolUse, and the customer
# believes their files are protected when they are not. Gated by
# CXNI_BOUNDARY_NOOP_WARNED so the advisory only fires once per
# session-equivalent (the env var is intended to be set by the
# operator after acknowledging the gap). Backward compat preserved:
# hook still exits 0.
if [ -z "$BOUNDARY_FILE" ]; then
  if [ -z "${CXNI_BOUNDARY_NOOP_WARNED:-}" ]; then
    echo "[CXNI_BOUNDARY_NOOP] file-boundary-guard is installed but DORMANT: no project-boundaries.conf found. Create one at .claude/governance/project-boundaries.conf (one allowed directory per line). Set CXNI_BOUNDARY_NOOP_WARNED=true in your shell to silence this advisory after acknowledgement." >&2
  fi
  exit 0
fi

# Check if file path is within any allowed boundary
ALLOWED=false
while IFS= read -r boundary; do
  # Skip comments and empty lines
  [[ "$boundary" =~ ^#.*$ ]] && continue
  [[ -z "$boundary" ]] && continue

  RESOLVED_BOUNDARY=$(_resolve "$boundary")
  case "$RESOLVED_PATH" in
    "$RESOLVED_BOUNDARY"*) ALLOWED=true; break ;;
  esac
done < "$BOUNDARY_FILE"

if [ "$ALLOWED" = false ]; then
  echo "BLOCKED: File write to '$FILE_PATH' is outside allowed project boundaries." >&2
  exit 2
fi

exit 0
