"""Obol gate — PreToolUse policy engine.

Runs BEFORE every tool invocation. Decides whether to allow or block.

Policy layers (checked in order):
  1. Lockdown check — if lockdown file exists, block everything
  2. Handler integrity — verify handler.py hash matches config
  3. Pre-flight detection — run on-device detection on the proposed action
  4. Input injection check — scan Bash commands for injection patterns
  5. Content scanning — check Write/Edit inputs for leaked secrets
  6. Session rate tracking — auto-lockdown on repeated critical findings

Only blocks in 'enforce' mode. In audit/alert mode, always allows
(detection still runs but doesn't block).

Usage in Claude Code hooks config:
    python3 ~/.obol/gate.py    (or python3 -m obol.hooks.gate)
"""
import datetime
import hashlib
import json
import os
import re
import sys
import time

# Shared normalization module — loaded once at import time
try:
    from obol.hooks.normalize import normalize_text as _normalize_text
    _HAS_NORMALIZE = True
except ImportError:
    _HAS_NORMALIZE = False

# UX modules — human-readable messages + local allowlist
try:
    from obol.hooks.messages import format_detection, get_user_severity
    _HAS_MESSAGES = True
except ImportError:
    _HAS_MESSAGES = False

try:
    from obol.hooks.allowlist import check_allowlist
    _HAS_ALLOWLIST = True
except ImportError:
    _HAS_ALLOWLIST = False


LOCKDOWN_PATH = os.path.expanduser("~/.obol/lockdown")
CONFIG_PATH = os.path.expanduser("~/.obol/config.json")
SESSION_STATE_PATH = os.path.expanduser("~/.obol/.gate_session")
HANDLER_PATH = os.path.expanduser("~/.obol/handler.py")
OVERRIDE_PENDING_PATH = os.path.expanduser("~/.obol/.override_pending")
OVERRIDE_ACTIVE_PATH = os.path.expanduser("~/.obol/.override_active")

# Auto-lockdown after this many critical findings in a session
_CRITICAL_LOCKDOWN_THRESHOLD = 3
# Session state expires after this many seconds of inactivity
_SESSION_TIMEOUT = 1800  # 30 minutes


def _load_config():
    """Load config with fail-safe defaults and type validation.

    Validates critical fields to prevent silent malfunction from
    corrupted or maliciously modified config files.
    """
    try:
        with open(CONFIG_PATH) as f:
            config = json.load(f)
        if not isinstance(config, dict):
            return {"enforcement_mode": "audit"}
        mode = config.get("enforcement_mode", "audit")
        if mode not in ("audit", "alert", "enforce"):
            config["enforcement_mode"] = "audit"
        # Validate integrity hash fields (must be hex strings)
        for key in ("hook_handler_hash", "hook_gate_hash"):
            if key in config and not isinstance(config[key], str):
                config[key] = ""
        if "api_key" in config and not isinstance(config["api_key"], str):
            config["api_key"] = ""
        return config
    except Exception:
        return {"enforcement_mode": "audit"}


def _check_lockdown():
    """Check if agent is locked down. Returns (is_locked, info_dict)."""
    try:
        with open(LOCKDOWN_PATH) as f:
            return True, json.load(f)
    except FileNotFoundError:
        return False, {}
    except (json.JSONDecodeError, Exception):
        return True, {}  # Lockdown exists but unreadable — stay locked


def _verify_handler_integrity(config, fail_closed=False):
    """Verify handler.py hasn't been tampered with.

    Compares SHA-256 hash of handler.py against the hash stored in config
    at install time. Returns True if intact, False if tampered.
    Missing handler or missing hash in config is not tampering (fresh install).

    Args:
        fail_closed: If True (enforce mode), exceptions return False (block).
                     If False (audit/alert), exceptions return True (allow).
    """
    try:
        expected = config.get("hook_handler_hash")
        if not expected:
            return True  # No hash stored — skip check

        if not os.path.exists(HANDLER_PATH):
            return True  # Handler not installed yet

        with open(HANDLER_PATH, "rb") as f:
            actual = hashlib.sha256(f.read()).hexdigest()

        return actual == expected
    except Exception:
        return not fail_closed  # Enforce: fail closed. Audit/alert: fail open.


def _verify_gate_integrity(config, fail_closed=False):
    """Verify this gate file itself hasn't been tampered with.

    Compares SHA-256 hash of gate.py against the hash stored in config.
    Prevents attackers from modifying gate to disable enforcement.

    Args:
        fail_closed: If True (enforce mode), exceptions return False (block).
                     If False (audit/alert), exceptions return True (allow).
    """
    try:
        expected = config.get("hook_gate_hash")
        if not expected:
            return True  # No hash stored — skip check (fresh install)

        gate_path = os.path.abspath(__file__)
        with open(gate_path, "rb") as f:
            actual = hashlib.sha256(f.read()).hexdigest()

        return actual == expected
    except Exception:
        return not fail_closed  # Enforce: fail closed. Audit/alert: fail open.


def _load_session_state():
    """Load session rate-tracking state from disk.

    Returns dict with 'critical_count' and 'last_seen' timestamp.
    Resets if session has timed out.
    On corruption: preserves last known critical_count to prevent threshold bypass.
    """
    try:
        with open(SESSION_STATE_PATH) as f:
            raw = f.read()
        state = json.loads(raw)
        # Reset if session has timed out
        last_seen = state.get("last_seen", 0)
        if not isinstance(last_seen, (int, float)):
            last_seen = 0
        if time.time() - last_seen > _SESSION_TIMEOUT:
            return {"critical_count": 0, "last_seen": time.time()}
        # Validate critical_count is a bounded non-negative int
        cc = state.get("critical_count", 0)
        if not isinstance(cc, int) or cc < 0:
            state["critical_count"] = 0
        elif cc > 1000:  # Sanity cap
            state["critical_count"] = 1000
        return state
    except FileNotFoundError:
        return {"critical_count": 0, "last_seen": time.time()}
    except (json.JSONDecodeError, ValueError):
        # Corrupted file — try to salvage critical_count from raw text
        # to prevent threshold bypass via file corruption
        try:
            import re
            count_match = re.search(r'"critical_count"\s*:\s*(\d+)', raw)
            count = int(count_match.group(1)) if count_match else 0
            return {"critical_count": count, "last_seen": time.time()}
        except Exception:
            return {"critical_count": 0, "last_seen": time.time()}
    except Exception:
        return {"critical_count": 0, "last_seen": time.time()}


def _save_session_state(state):
    """Persist session state to disk. Atomic write."""
    try:
        state["last_seen"] = time.time()
        tmp = SESSION_STATE_PATH + ".tmp"
        fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
        with os.fdopen(fd, "w") as f:
            json.dump(state, f)
        os.replace(tmp, SESSION_STATE_PATH)
    except Exception as exc:
        print(f"[OBOL] gate session state write failed: {exc}", file=sys.stderr)


def _create_lockdown(reason):
    """Create lockdown file to block all future tool use."""
    try:
        now = datetime.datetime.now(datetime.timezone.utc).replace(
            tzinfo=None).isoformat() + "Z"
        data = {"reason": reason, "timestamp": now, "source": "gate"}
        tmp = LOCKDOWN_PATH + ".tmp"
        fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
        with os.fdopen(fd, "w") as f:
            json.dump(data, f)
        os.replace(tmp, LOCKDOWN_PATH)
    except Exception:
        pass


def _check_injection_patterns(tool_name, tool_input):
    """Check tool input for prompt injection patterns.

    Returns list of (flag, severity) tuples. Only checks Bash commands
    and file paths for obvious injection attempts.
    """
    findings = []

    command = ""
    if tool_name == "Bash":
        command = tool_input.get("command", "") or ""
    elif tool_name in ("Write", "Edit", "NotebookEdit"):
        # Check file path for injection in filename
        path = tool_input.get("file_path") or tool_input.get("notebook_path") or ""
        if path:
            command = path

    if not command:
        return findings

    text = command.lower()

    # Canonical Unicode normalization (shared module)
    if _HAS_NORMALIZE:
        text = _normalize_text(text)

    # Injection patterns (same as claude_code.py _detect_injection_patterns)
    injection_patterns = [
        (r"ignore\s+(all\s+)?(previous|prior|above)\s+instructions?", "injection_in_input"),
        (r"disregard\s+(all\s+)?previous\s+instructions?", "injection_in_input"),
        (r"bypass\s+(the\s+)?monitoring", "monitoring_evasion_in_input"),
        (r"don't\s+(log|record|report)\s+this", "monitoring_evasion_in_input"),
        (r"disable\s+(safety|ethical)\s+filters?", "jailbreak_in_input"),
    ]

    for pattern, name in injection_patterns:
        if re.search(pattern, text, re.IGNORECASE):
            findings.append((name, "high"))
            break  # One is enough

    return findings


def _check_content_secrets(tool_name, tool_input):
    """Check Write/Edit content for leaked secrets.

    Returns list of (flag, severity) tuples. Only checks tools that
    write content to files.
    """
    findings = []

    if tool_name not in ("Write", "Edit", "NotebookEdit"):
        return findings

    try:
        from obol.hooks.content_scanner import scan_input
        result = scan_input(tool_name, tool_input)
        if result.get("secrets_found"):
            findings.append(("secrets_in_write", "high"))
    except ImportError:
        pass
    except Exception:
        pass

    return findings


def _check_mcp_preflight(tool_name, tool_input):
    """Check MCP tool names and inputs for pre-tool-use threats.

    Detects tool shadowing (MCP tool named like a built-in) and
    suspicious server names that impersonate system components.
    Returns list of (flag, severity) tuples.
    """
    findings = []
    parts = tool_name.split("__", 2)
    server = parts[1].lower() if len(parts) > 1 else ""
    tool = parts[2].lower() if len(parts) > 2 else ""

    # Tool shadowing: MCP tool named like a built-in
    BUILTIN_TOOLS = {
        "read", "write", "edit", "bash", "glob", "grep",
        "webfetch", "websearch", "task", "notebookedit",
    }
    if tool in BUILTIN_TOOLS:
        findings.append(("mcp_tool_shadowing", "high"))

    # Suspicious server names impersonating system components
    SUSPICIOUS_SERVERS = {
        "builtin", "system", "official", "claude", "anthropic",
        "kernel", "core", "override", "backdoor", "admin",
    }
    if server in SUSPICIOUS_SERVERS or any(s in server for s in SUSPICIOUS_SERVERS):
        findings.append(("mcp_suspicious_server", "high"))

    # Check tool input for injection patterns from poisoned descriptions
    if tool_input and isinstance(tool_input, dict):
        try:
            input_str = json.dumps(tool_input).lower()
            poisoning_indicators = [
                r"ignore\s+(all\s+)?(previous|prior)\s+instructions?",
                r"bypass\s+(monitoring|security|audit)",
                r"don't\s+(log|record|report|tell)",
            ]
            for pattern in poisoning_indicators:
                if re.search(pattern, input_str, re.IGNORECASE):
                    findings.append(("mcp_description_injection", "critical"))
                    break
        except Exception:
            pass

    return findings


def _preflight_detection(tool_name, tool_input, config):
    """Run pre-flight detection on proposed tool use.

    Returns list of (flag, severity) tuples for detected threats.
    Only checks patterns that can be evaluated from input alone
    (before the tool runs).
    """
    findings = []

    if not tool_input or not isinstance(tool_input, dict):
        return findings

    # Import local detection engine
    try:
        from obol.hooks.detection import (
            check_sensitive_path,
            check_escape_attempt,
            check_privilege_escalation,
            check_supply_chain,
            check_ssrf,
            check_null_byte_injection,
            check_container_escape,
            check_process_injection,
            check_kernel_module,
            check_agent_self_modification,
            check_cloud_infra,
            check_reverse_shell,
            check_piped_exfiltration,
            check_data_staging,
            check_persistence_install,
            check_credential_hunting,
            check_data_destruction,
            check_crypto_miner,
            check_alias_obfuscation,
            check_delayed_exfiltration,
            check_symlink_indirection,
            check_variable_exfiltration,
            check_path_obfuscation,
            check_env_harvesting,
            check_sql_injection,
            check_dns_exfiltration,
            check_git_credential_search,
            check_infra_config,
        )
    except ImportError:
        return findings  # Detection module not available

    # Extract target from tool input
    target = None
    if tool_name in ("Read", "Write", "Edit"):
        target = tool_input.get("file_path")
    elif tool_name == "Bash":
        target = tool_input.get("command", "")
    elif tool_name == "WebFetch":
        target = tool_input.get("url")
    elif tool_name == "NotebookEdit":
        target = tool_input.get("notebook_path")

    # MCP tool shadowing / suspicious server detection (pre-tool-use)
    if tool_name and tool_name.startswith("mcp__"):
        mcp_findings = _check_mcp_preflight(tool_name, tool_input)
        findings.extend(mcp_findings)

    if not target:
        return findings

    # Critical checks that warrant blocking in enforce mode
    if check_escape_attempt(tool_name, target):
        findings.append(("escape_attempt", "critical"))

    if check_privilege_escalation(tool_name, target):
        findings.append(("privilege_escalation", "critical"))

    if check_container_escape(tool_name, target):
        findings.append(("container_escape", "critical"))

    if check_process_injection(tool_name, target):
        findings.append(("process_injection", "critical"))

    if check_kernel_module(tool_name, target):
        findings.append(("kernel_module_loading", "critical"))

    if check_null_byte_injection(target):
        findings.append(("null_byte_injection", "critical"))

    if check_supply_chain(tool_name, target):
        findings.append(("supply_chain_risk", "critical"))

    if check_agent_self_modification(tool_name, target):
        findings.append(("agent_self_modification", "critical"))

    if check_cloud_infra(tool_name, target):
        findings.append(("cloud_infra_mutation", "high"))

    # Reverse shell — most dangerous attack, must be blocked before execution
    if check_reverse_shell(tool_name, target):
        findings.append(("reverse_shell", "critical"))

    # Piped exfiltration — single-command credential theft chains
    if check_piped_exfiltration(tool_name, target):
        findings.append(("piped_exfiltration", "critical"))

    # Data staging — archiving sensitive dirs before exfil
    if check_data_staging(tool_name, target):
        findings.append(("data_staging", "high"))

    # Persistence — crontab, bashrc injection
    if check_persistence_install(tool_name, target):
        findings.append(("persistence_install", "critical"))

    # Credential hunting — find/grep for secrets
    if check_credential_hunting(tool_name, target):
        findings.append(("credential_hunting", "high"))

    # Data destruction — shred, history clearing, log wiping
    if check_data_destruction(tool_name, target):
        findings.append(("data_destruction", "high"))

    # Crypto mining — xmrig, stratum, mining pools
    if check_crypto_miner(tool_name, target):
        findings.append(("crypto_miner", "critical"))

    # Alias/function obfuscation — hiding dangerous tools behind aliases
    if check_alias_obfuscation(tool_name, target):
        findings.append(("alias_obfuscation", "high"))

    # Delayed exfiltration — at/cron/background subshell with network tools
    if check_delayed_exfiltration(tool_name, target):
        findings.append(("delayed_exfiltration", "critical"))

    # Symlink indirection — ln -s to sensitive files
    if check_symlink_indirection(tool_name, target):
        findings.append(("symlink_indirection", "high"))

    # Variable-mediated exfiltration — read into var then curl
    if check_variable_exfiltration(tool_name, target):
        findings.append(("variable_exfiltration", "critical"))

    # Path obfuscation — hiding sensitive file access behind encoding
    if check_path_obfuscation(tool_name, target):
        findings.append(("path_obfuscation", "high"))

    # Environment variable harvesting
    if check_env_harvesting(tool_name, target):
        findings.append(("env_harvesting", "critical"))

    # SQL injection in tool inputs
    if check_sql_injection(tool_name, target):
        findings.append(("injection_attempt", "high"))

    # DNS exfiltration — data encoded in DNS queries
    if check_dns_exfiltration(tool_name, target):
        findings.append(("dns_exfiltration", "critical"))

    # Git credential search — mining git history for secrets
    if check_git_credential_search(tool_name, target):
        findings.append(("git_credential_search", "high"))

    # Infrastructure config access (informational)
    if check_infra_config(target):
        findings.append(("infra_config_access", "info"))

    # High severity checks
    if check_ssrf(target):
        findings.append(("ssrf_attempt", "high"))

    # Sensitive path notification (informational for Read, high for writes)
    # Include CDC-pushed extra sensitive paths from server rules
    extra_sp = None
    try:
        from obol.hooks.rule_update import get_rules as _get_rules
        rules = _get_rules(config)
        extra_sp = rules.get("extra_sensitive_paths")
    except Exception:
        pass
    if check_sensitive_path(target, extra_patterns=extra_sp):
        if tool_name in ("Write", "Edit", "NotebookEdit"):
            findings.append(("sensitive_target", "high"))
        elif tool_name == "Read":
            findings.append(("sensitive_target", "info"))

    return findings


def _notify_threat(flag, severity, tool_name, target_preview):
    """Send desktop notification for detected threat."""
    try:
        if sys.platform.startswith("linux"):
            import subprocess
            urgency = "critical" if severity == "critical" else "normal"
            title = f"Obol: {flag.replace('_', ' ').title()}"
            body = f"Tool: {tool_name}"
            if target_preview:
                body += f"\nTarget: {target_preview[:80]}"
            subprocess.Popen(
                ["notify-send", "-a", "Obol", "-u", urgency, "-i", "security-low",
                 title, body],
                stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
            )
    except Exception:
        pass


def _extract_target_preview(tool_name, tool_input):
    """Extract a short preview string from tool input for log messages."""
    if not tool_input or not isinstance(tool_input, dict):
        return ""
    if tool_name == "Bash":
        return (tool_input.get("command") or "")[:120]
    if tool_name in ("Read", "Write", "Edit"):
        return tool_input.get("file_path") or ""
    if tool_name == "NotebookEdit":
        return tool_input.get("notebook_path") or ""
    if tool_name == "WebFetch":
        return tool_input.get("url") or ""
    if tool_name == "WebSearch":
        return tool_input.get("query") or ""
    if tool_name == "Glob":
        return tool_input.get("pattern") or ""
    if tool_name == "Grep":
        return tool_input.get("pattern") or ""
    # MCP tools — try common keys
    if tool_name.startswith("mcp__"):
        for key in ("url", "path", "query", "command"):
            if key in tool_input:
                return str(tool_input[key])[:120]
    return ""


def _run_full_detection(tool_name, tool_input, config):
    """Run the full detection engine on proposed tool input.

    Calls detect_anomalies() for comprehensive pattern + ML detection,
    then uses should_alert() to determine severity. Falls back to
    preflight-only detection if the full engine is unavailable.

    Returns (flags, alert) where flags is a list of flag strings and
    alert is a bool indicating whether should_alert() returned True.
    Always returns ([], False) on error (fail open).
    """
    all_flags = []

    # Layer A: Existing preflight checks (individual detection functions)
    # These run even if the full engine import fails.
    try:
        preflight_findings = _preflight_detection(tool_name, tool_input, config)
        # _preflight_detection returns list of (flag, severity) tuples
        all_flags.extend([f for f, _ in preflight_findings])
    except Exception:
        pass

    # Layer B: Injection pattern checks
    try:
        injection_findings = _check_injection_patterns(tool_name, tool_input)
        all_flags.extend([f for f, _ in injection_findings])
    except Exception:
        pass

    # Layer C: Content secret checks (Write/Edit only)
    try:
        content_findings = _check_content_secrets(tool_name, tool_input)
        all_flags.extend([f for f, _ in content_findings])
    except Exception:
        pass

    # Layer D: Full detection engine (detect_anomalies + ML classifier)
    # This adds detection for patterns that individual checks above miss,
    # including ML-based behavioral classification.
    try:
        from obol.hooks.detection import detect_anomalies, should_alert

        # Extract target the same way claude_code.py does
        target = None
        if tool_name in ("Read", "Write", "Edit"):
            target = tool_input.get("file_path")
        elif tool_name == "Bash":
            target = tool_input.get("command", "")
        elif tool_name == "WebFetch":
            target = tool_input.get("url")
        elif tool_name == "WebSearch":
            target = tool_input.get("query")
        elif tool_name == "NotebookEdit":
            target = tool_input.get("notebook_path")
        elif tool_name == "Glob":
            target = tool_input.get("pattern")
        elif tool_name == "Grep":
            target = tool_input.get("pattern")
        elif tool_name.startswith("mcp__"):
            # Try common MCP input keys
            for key in ("url", "path", "command", "query"):
                if key in tool_input:
                    target = str(tool_input[key])
                    break

        if target:
            # Map tool_name to action string as detection engine expects
            action = tool_name or "unknown"

            # Load CDC rules for extra detection patterns
            extra_rules = {}
            try:
                from obol.hooks.rule_update import get_rules
                extra_rules = get_rules(config) or {}
            except Exception:
                pass

            engine_flags = detect_anomalies(action, target, extra_rules=extra_rules)
            # Merge without duplicates (preflight checks may overlap)
            for f in engine_flags:
                if f not in all_flags:
                    all_flags.append(f)

        # Use should_alert on the combined flag set
        alert = should_alert(all_flags) if all_flags else False
        return all_flags, alert

    except ImportError:
        # Full detection engine not available (SDK not on path).
        # Fall back: preflight flags already collected in all_flags.
        # Re-run preflight just to check severity tuples (already cached
        # result won't be available, so check if any critical flags exist
        # by looking at the known-critical flag names).
        _CRITICAL_FLAGS = {
            "escape_attempt", "privilege_escalation", "container_escape",
            "process_injection", "kernel_module_loading", "null_byte_injection",
            "supply_chain_risk", "agent_self_modification", "reverse_shell",
            "piped_exfiltration", "persistence_install", "crypto_miner",
            "delayed_exfiltration", "variable_exfiltration", "env_harvesting",
            "dns_exfiltration", "mcp_description_injection",
        }
        has_critical = any(f in _CRITICAL_FLAGS for f in all_flags)
        return all_flags, has_critical

    except Exception:
        # Detection engine failed — fail open, never block due to a bug
        return all_flags, False


def _target_hash(target_preview):
    """Hash a target preview for override matching."""
    return hashlib.sha256((target_preview or "").encode()).hexdigest()[:16]


def _check_and_consume_override(tool_name, target_preview):
    """Check if a valid override is active for this tool+target.

    If valid: deletes the override file and returns True (allow through).
    If invalid/missing/expired: returns False.
    """
    try:
        if not os.path.exists(OVERRIDE_ACTIVE_PATH):
            return False
        with open(OVERRIDE_ACTIVE_PATH) as f:
            data = json.load(f)
        # Always remove after reading (one-time use)
        os.unlink(OVERRIDE_ACTIVE_PATH)
        # Validate
        if not isinstance(data, dict):
            return False
        if data.get("tool_name") != tool_name:
            return False
        if data.get("target_hash") != _target_hash(target_preview):
            return False
        expires = data.get("expires_at", 0)
        if time.time() > expires:
            return False
        return True
    except Exception:
        # Clean up on any error
        try:
            os.unlink(OVERRIDE_ACTIVE_PATH)
        except Exception:
            pass
        return False


def _generate_override(tool_name, target_preview, flags):
    """Generate a one-time override code and write pending file.

    Returns the 6-char code, or None on failure.
    """
    try:
        import secrets as _secrets
        code = _secrets.token_hex(3).upper()
        pending = {
            "code": code,
            "tool_name": tool_name,
            "target_hash": _target_hash(target_preview),
            "flags": flags[:10],
            "created_at": time.time(),
            "expires_at": time.time() + 60,  # 60s TTL
        }
        obol_dir = os.path.expanduser("~/.obol")
        os.makedirs(obol_dir, exist_ok=True)
        tmp = OVERRIDE_PENDING_PATH + ".tmp"
        fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
        with os.fdopen(fd, "w") as f:
            json.dump(pending, f)
        os.replace(tmp, OVERRIDE_PENDING_PATH)
        return code
    except Exception:
        return None


def main():
    config = _load_config()
    mode = config.get("enforcement_mode", "audit")

    # Layer 1: Lockdown check (all modes — lockdown always blocks)
    is_locked, lock_info = _check_lockdown()
    if is_locked:
        reason = lock_info.get("reason", "Unknown")
        timestamp = lock_info.get("timestamp", "Unknown")
        sys.stderr.write(f"\n[OBOL] Agent LOCKED DOWN: {reason}\n")
        sys.stderr.write(f"[OBOL] Since: {timestamp}\n")
        sys.stderr.write("[OBOL] Run 'obol unlock' to restore access.\n\n")
        sys.exit(2)

    # Layer 2: Handler integrity verification (enforce mode only)
    # In enforce mode, fail closed — if we can't verify integrity, block.
    if mode == "enforce":
        if not _verify_handler_integrity(config, fail_closed=True):
            sys.stderr.write("\n[OBOL] BLOCKED: Handler integrity check failed.\n")
            sys.stderr.write("[OBOL] handler.py has been modified since installation.\n")
            sys.stderr.write("[OBOL] Run 'obol setup' to reinstall, or 'obol unlock' to restore.\n\n")
            _create_lockdown("Handler integrity failure — handler.py modified")
            sys.exit(2)
        if not _verify_gate_integrity(config, fail_closed=True):
            sys.stderr.write("\n[OBOL] BLOCKED: Gate integrity check failed.\n")
            sys.stderr.write("[OBOL] gate.py has been modified since installation.\n")
            sys.stderr.write("[OBOL] Run 'obol setup' to reinstall, or 'obol unlock' to restore.\n\n")
            _create_lockdown("Gate integrity failure — gate.py modified")
            sys.exit(2)

    # Layer 3: Read tool input and run detection (all modes)
    try:
        data = json.load(sys.stdin)
    except Exception:
        sys.exit(0)  # Can't read input, allow

    tool_name = data.get("tool_name", "")
    tool_input = data.get("tool_input", {})
    target_preview = _extract_target_preview(tool_name, tool_input)

    # ── Override check (enforce mode): consume pending override ──
    if mode == "enforce":
        if _check_and_consume_override(tool_name, target_preview):
            sys.exit(0)  # Override active — allow this one time

    # Run full detection engine (detect_anomalies + preflight + ML)
    flags, alert = _run_full_detection(tool_name, tool_input, config)

    # ── Filter through local allowlist ──
    if flags and _HAS_ALLOWLIST:
        flags = check_allowlist(flags, tool_name, target_preview)
        if not flags:
            sys.exit(0)  # All flags suppressed by allowlist
        # Re-evaluate alert status on filtered flags
        try:
            from obol.hooks.detection import should_alert
            alert = should_alert(flags)
        except ImportError:
            pass

    # ── Determine UX severity for output formatting ──
    def _is_high_severity(flag_list):
        """Check if any flag is CRITICAL or WARNING (immediate display)."""
        if not _HAS_MESSAGES:
            return True  # Fallback: treat everything as high
        for f in flag_list:
            sev = get_user_severity(f)
            if sev in ("CRITICAL", "WARNING"):
                return True
        return False

    # ── Audit mode: log detections to stderr, always allow ──
    if mode == "audit":
        if flags:
            if _HAS_MESSAGES:
                if _is_high_severity(flags):
                    sys.stderr.write(format_detection(
                        flags, tool_name, target_preview, mode="immediate"))
                else:
                    sys.stderr.write(format_detection(
                        flags, tool_name, target_preview, mode="brief"))
            else:
                sys.stderr.write(
                    f"[OBOL audit] {', '.join(flags)} \u2014 {tool_name} {target_preview[:80]}\n"
                )
        sys.exit(0)

    # ── Alert mode: warn on stderr if should_alert, always allow ──
    if mode == "alert":
        if flags and alert:
            if _HAS_MESSAGES:
                sys.stderr.write(format_detection(
                    flags, tool_name, target_preview, mode="immediate"))
            else:
                sys.stderr.write(
                    f"[OBOL alert] {', '.join(flags)} \u2014 {tool_name} {target_preview[:80]}\n"
                )
            for flag in flags:
                _notify_threat(flag, "high", tool_name, target_preview)
        elif flags:
            # Below alert threshold — brief output
            if _HAS_MESSAGES:
                sys.stderr.write(format_detection(
                    flags, tool_name, target_preview, mode="brief"))
            else:
                sys.stderr.write(
                    f"[OBOL alert] {', '.join(flags)} \u2014 {tool_name} {target_preview[:80]}\n"
                )
        sys.exit(0)

    # ── Enforce mode: block if should_alert, otherwise allow ──
    if mode == "enforce":
        if flags and alert:
            # Track critical findings for session rate limiting
            state = _load_session_state()
            state["critical_count"] = state.get("critical_count", 0) + 1
            _save_session_state(state)

            # Auto-lockdown if too many critical findings in this session
            if state["critical_count"] >= _CRITICAL_LOCKDOWN_THRESHOLD:
                _create_lockdown(
                    f"Auto-lockdown: {state['critical_count']} critical findings in session"
                )

            # Generate override code for one-time bypass
            override_code = _generate_override(tool_name, target_preview, flags)

            # Block with human-readable message
            if _HAS_MESSAGES:
                msg = format_detection(
                    flags, tool_name, target_preview, mode="gate_block")
                sys.stderr.write(msg)
                if override_code:
                    sys.stderr.write(
                        f"  \u2192 obol override {override_code}   Allow this one time (60s)\n\n"
                    )
            else:
                first_flag = flags[0]
                sys.stderr.write(
                    f"\u26a0 Obol: blocked {first_flag} \u2014 {tool_name} {target_preview[:80]}\n"
                )
                if len(flags) > 1:
                    sys.stderr.write(
                        f"[OBOL] Additional flags: {', '.join(flags[1:])}\n"
                    )
                if override_code:
                    sys.stderr.write(
                        f"[OBOL] Run: obol override {override_code}  (60s)\n"
                    )
                sys.stderr.write(
                    "[OBOL] Change enforcement_mode to 'audit' or 'alert' to allow.\n"
                )

            for flag in flags:
                _notify_threat(flag, "critical", tool_name, target_preview)

            sys.exit(2)  # Block

        # Flags present but below alert threshold — brief log for visibility
        if flags:
            if _HAS_MESSAGES:
                sys.stderr.write(format_detection(
                    flags, tool_name, target_preview, mode="brief"))
            else:
                sys.stderr.write(
                    f"[OBOL enforce] {', '.join(flags)} \u2014 {tool_name} {target_preview[:80]}\n"
                )

    sys.exit(0)  # Allow


if __name__ == "__main__":
    main()
