"""Obol hook handler for Claude Code.

Receives tool use events via stdin JSON from Claude Code hooks,
sends them to Obol API as audit events. Zero external dependencies.

Usage in Claude Code hooks config:
    python3 -m obol.hooks.claude_code

Runs async in Claude Code (non-blocking). Exits 0 always.
"""
import datetime
import hashlib
import json
import os
import re
import subprocess
import sys
import time
import urllib.request
import urllib.error

# Shared normalization module — loaded once at import time
try:
    from obol.hooks.normalize import normalize_text, deleet, LEET_MAP, INVISIBLE_CHARS
    _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, _SEVERITY_ORDER,
    )
    _HAS_MESSAGES = True
except ImportError:
    _HAS_MESSAGES = False

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


# ─── Pre-compiled injection detection patterns ─────────────────────────
# Built once at module level instead of per-call in _detect_injection_patterns
_INJECTION_PATTERNS = [
    (re.compile(r"ignore\s+(all\s+)?(previous|prior|above)\s+instructions?", re.IGNORECASE), "instruction_override"),
    (re.compile(r"disregard\s+(all\s+)?previous\s+instructions?", re.IGNORECASE), "instruction_override"),
    (re.compile(r"forget\s+(everything|all)\s+you\s+(know|learned)", re.IGNORECASE), "instruction_override"),
    (re.compile(r"override\s+(your|the)\s+(system\s+)?(instructions?|prompt|rules)", re.IGNORECASE), "instruction_override"),
    (re.compile(r"reset\s+(your|the)\s+(system\s+)?(instructions?|prompt|rules)", re.IGNORECASE), "instruction_override"),
    (re.compile(r"new\s+instructions?\s*:", re.IGNORECASE), "instruction_override"),
    (re.compile(r"you\s+are\s+now\s+", re.IGNORECASE), "role_manipulation"),
    (re.compile(r"pretend\s+(you\s+are|to\s+be)\s+", re.IGNORECASE), "role_manipulation"),
    (re.compile(r"switch\s+(to|into)\s+.{0,20}\s+mode", re.IGNORECASE), "role_manipulation"),
    (re.compile(r"act\s+as\s+(a\s+)?different", re.IGNORECASE), "role_manipulation"),
    (re.compile(r"</?system>", re.IGNORECASE), "delimiter_attack"),
    (re.compile(r"</?user>", re.IGNORECASE), "delimiter_attack"),
    (re.compile(r"</?assistant>", re.IGNORECASE), "delimiter_attack"),
    (re.compile(r"\[inst\]|\[/inst\]", re.IGNORECASE), "delimiter_attack"),
    (re.compile(r"\bdan\s+(mode|prompt)\b", re.IGNORECASE), "jailbreak"),
    (re.compile(r"developer\s+mode\s+(is\s+)?enabled", re.IGNORECASE), "jailbreak"),
    (re.compile(r"jailbreak(ed)?\s+mode", re.IGNORECASE), "jailbreak"),
    (re.compile(r"bypass\s+(the\s+)?monitoring", re.IGNORECASE), "monitoring_evasion"),
    (re.compile(r"don't\s+(log|record|report)\s+this", re.IGNORECASE), "monitoring_evasion"),
    (re.compile(r"disable\s+(safety|ethical)\s+filters?", re.IGNORECASE), "jailbreak"),
]


def _sanitize_for_shell(text, max_len=200):
    """Strip characters that could inject into shell commands."""
    # Remove anything that could break out of quoted strings
    safe = re.sub(r'[\'\"\\`$(){};\n\r|&<>!]', '', str(text))
    return safe[:max_len]


def _notify(title, body="", urgency="low", icon=None):
    """Send a desktop notification. Cross-platform, fire-and-forget, never blocks."""
    try:
        if sys.platform == "darwin":
            # macOS: osascript - sanitize to prevent AppleScript injection
            safe_title = _sanitize_for_shell(title)
            safe_body = _sanitize_for_shell(body)
            script = f'display notification "{safe_body}" with title "{safe_title}"'
            subprocess.Popen(
                ["osascript", "-e", script],
                stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
            )
        elif sys.platform == "win32":
            # Windows: PowerShell - sanitize to prevent PowerShell injection
            safe_title = _sanitize_for_shell(title)
            safe_body = _sanitize_for_shell(body)
            ps_script = (
                "Add-Type -AssemblyName System.Windows.Forms;"
                "$n=New-Object System.Windows.Forms.NotifyIcon;"
                "$n.Icon=[System.Drawing.SystemIcons]::Shield;"
                "$n.Visible=$true;"
                f"$n.ShowBalloonTip(5000,'{safe_title}','{safe_body}',"
                "[System.Windows.Forms.ToolTipIcon]::Info);"
                "Start-Sleep 6;$n.Dispose()"
            )
            subprocess.Popen(
                ["powershell", "-WindowStyle", "Hidden", "-Command", ps_script],
                stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
                creationflags=0x08000000,  # CREATE_NO_WINDOW
            )
        elif sys.platform.startswith("linux"):
            # Linux: notify-send (freedesktop.org)
            cmd = ["notify-send", "-a", "Obol", "-u", urgency]
            if icon:
                cmd.extend(["-i", icon])
            cmd.extend([title, body])
            subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
    except Exception:
        pass  # Missing binary, headless, CI, etc.


def _load_config():
    """Load API key and agent_id from ~/.obol/config.json.

    Validates critical field types to prevent silent malfunction from
    corrupted or maliciously modified config files.
    """
    config_path = os.path.expanduser("~/.obol/config.json")
    try:
        with open(config_path) as f:
            config = json.load(f)
        if not isinstance(config, dict):
            return {}
        # Validate critical types — silently fix invalid values
        if "api_key" in config and not isinstance(config["api_key"], str):
            config["api_key"] = ""
        if "api_url" in config and not isinstance(config["api_url"], str):
            config["api_url"] = ""
        if "claude_code_agent_id" in config:
            v = config["claude_code_agent_id"]
            if not isinstance(v, (str, int)):
                config["claude_code_agent_id"] = ""
        if "enforcement_mode" in config:
            if config["enforcement_mode"] not in ("audit", "alert", "enforce"):
                config["enforcement_mode"] = "audit"
        if "telemetry_mode" in config:
            if config["telemetry_mode"] not in ("full", "shapes", "minimal", "off"):
                config["telemetry_mode"] = "shapes"
        if "allowed_destinations" in config:
            if not isinstance(config["allowed_destinations"], list):
                config["allowed_destinations"] = []
        return config
    except Exception:
        return {}


def _should_skip_api():
    """Check if we should skip the API call due to recent failures (backoff).

    Uses a small state file ``~/.obol/.api_backoff`` containing:
    ``{failures}:{retry_after_epoch}``

    Returns True if current time < retry_after_epoch (still in backoff window).
    """
    backoff_path = os.path.expanduser("~/.obol/.api_backoff")
    try:
        with open(backoff_path) as f:
            parts = f.read().strip().split(":")
        if len(parts) == 2:
            retry_after = float(parts[1])
            if time.time() < retry_after:
                return True
            # Backoff expired — clear the file
            os.unlink(backoff_path)
    except (FileNotFoundError, ValueError, OSError):
        pass
    return False


def _record_api_failure():
    """Record an API failure and set exponential backoff.

    Backoff schedule: 30s, 60s, 120s, 240s, 480s (max ~8 min).
    """
    backoff_path = os.path.expanduser("~/.obol/.api_backoff")
    failures = 1
    try:
        with open(backoff_path) as f:
            parts = f.read().strip().split(":")
        if len(parts) == 2:
            failures = int(parts[0]) + 1
    except (FileNotFoundError, ValueError, OSError):
        pass
    # Exponential backoff: 30 * 2^(failures-1), capped at 480s
    delay = min(30 * (2 ** (failures - 1)), 480)
    retry_after = time.time() + delay
    try:
        fd = os.open(backoff_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
        with os.fdopen(fd, "w") as f:
            f.write(f"{failures}:{retry_after:.0f}")
    except Exception:
        pass


def _clear_api_backoff():
    """Clear the backoff state on successful API call."""
    backoff_path = os.path.expanduser("~/.obol/.api_backoff")
    try:
        os.unlink(backoff_path)
    except FileNotFoundError:
        pass
    except Exception:
        pass


def _scrub_secrets(text):
    """Redact sensitive patterns (tokens, API keys, passwords) from text."""
    if not text:
        return text
    # Bearer tokens
    text = re.sub(r'Bearer\s+\S+', 'Bearer [REDACTED]', text)
    # Obol API keys
    text = re.sub(r'ob_(live|test)_[a-f0-9]+', r'ob_\1_[REDACTED]', text)
    # Common key=value or key:value patterns — match quoted or unquoted values
    text = re.sub(
        r'(api[_-]?key|apikey|token|secret|password|passwd|authorization|credential)'
        r'\s*[=:]\s*["\']?[^\s"\']{4,}["\']?',
        r'\1=[REDACTED]',
        text,
        flags=re.IGNORECASE,
    )
    # Long hex strings (32+ chars) preceded by common key names
    text = re.sub(
        r'(key|token|secret|password|credential|auth)[_-]?\s*[=:]\s*["\']?[A-Fa-f0-9]{32,}["\']?',
        r'\1=[REDACTED]',
        text,
        flags=re.IGNORECASE,
    )
    # AWS/GCP/Azure style keys
    text = re.sub(r'AKIA[A-Z0-9]{16}', '[AWS_KEY_REDACTED]', text)
    text = re.sub(r'sk-[a-zA-Z0-9_-]{20,}', '[REDACTED]', text)
    text = re.sub(r'ghp_[a-zA-Z0-9]{36,}', '[REDACTED]', text)
    text = re.sub(r'gho_[a-zA-Z0-9]{36,}', '[REDACTED]', text)
    return text


def _extract_target(tool_name, tool_input):
    """Extract a meaningful target string from tool input."""
    if not tool_input or not isinstance(tool_input, dict):
        return None
    if tool_name in ("Read", "Write", "Edit"):
        return tool_input.get("file_path")
    if tool_name == "Bash":
        cmd = tool_input.get("command", "")
        return _scrub_secrets(cmd)[:500] if cmd else None
    if tool_name == "Glob":
        return tool_input.get("pattern")
    if tool_name == "Grep":
        return tool_input.get("pattern")
    if tool_name == "WebFetch":
        return tool_input.get("url")
    if tool_name == "WebSearch":
        return tool_input.get("query")
    if tool_name == "Task":
        return tool_input.get("description")
    # MCP tools (mcp__server__tool format)
    if tool_name.startswith("mcp__"):
        parts = tool_name.split("__", 2)  # ["mcp", "server", "tool"]
        server = parts[1] if len(parts) > 1 else "unknown"
        # Extract meaningful target based on common MCP patterns
        if "url" in tool_input:
            return tool_input["url"]
        if "query" in tool_input:
            return tool_input["query"]
        if "path" in tool_input:
            return tool_input["path"]
        if "selector" in tool_input:
            return f"{server}:{tool_input['selector']}"
        if "key" in tool_input:
            return f"{server}:{tool_input['key']}"
        if "text" in tool_input:
            return f"{server}:{tool_input['text'][:200]}"
        # Fall through to generic handler
    # Generic: stringify first 500 chars
    s = json.dumps(tool_input)
    return s[:500] if len(s) > 500 else s


def _detect_obfuscation(command):
    """Check a Bash command string for common obfuscation patterns.

    Returns a list of detected pattern names (empty if clean).
    Tuned to minimize false positives on normal shell usage.

    Context-aware exemptions:
    - Claude Code scratchpad paths (/tmp/claude-*) exempt from script_exec
    - SSH remote execution exempt from script_exec
    - Variable expansion uses density-based threshold, not absolute count
    - Known token formats (JWT, Obol AES-256-GCM) exempt from encoded_string
    """
    if not command:
        return []
    patterns = []

    # base64 decoding: must be the command `base64 -d` or `base64 --decode`
    # Also catches ${IFS} or variable injection between base64 and -d
    if re.search(r'\bbase64\s+(-d|--decode)', command) or \
       re.search(r'\bbase64\$\{?\w*\}?(-d|--decode)', command):
        patterns.append("base64")

    # eval usage: eval as a command (not inside strings, comments, or variable names)
    # Handle CRLF: strip \r before matching
    eval_cmd = command.replace('\r', '')
    if re.search(r'(?:^|[;&|])\s*eval\s', eval_cmd, re.MULTILINE):
        patterns.append("eval")

    # Variable-spliced command names: ev${X}al, ba${X}se64, cu${X}rl
    # Detects attempts to break command names with variable expansion
    if re.search(r'\b(?:ev|ba|cu)\$\{?\w*\}?(?:al|se64|rl)\b', command, re.IGNORECASE):
        patterns.append("variable_splice")

    # Long base64-looking string: 80+ chars with mixed case + digits
    # Exempt known token formats (JWT, Obol encrypted values)
    m = re.search(r'[A-Za-z0-9+/]{80,}={0,2}', command)
    if m:
        s = m.group()
        is_known_format = (
            s.startswith('eyJ') or       # JWT token
            '$obol$' in command          # AES-256-GCM encrypted value
        )
        if not is_known_format and re.search(r'[a-z]', s) and re.search(r'[A-Z]', s) and re.search(r'[0-9]', s):
            patterns.append("encoded_string")

    # Variable expansion: density-based threshold instead of absolute count
    # A 500-char script with 6 vars is normal; a 50-char cmd with 6 vars is suspicious
    dollar_count = len(re.findall(r'\$(?![(\s])', command))
    cmd_length = len(command)
    if cmd_length > 0 and dollar_count >= 10:
        density = dollar_count / cmd_length
        if density > 0.04:  # ~1 dollar sign per 25 chars = suspicious
            patterns.append("variable_expansion")

    # Pipe through decoder
    if re.search(r'\|\s*base64\s+(-d|--decode)', command):
        patterns.append("pipe_decode")
    elif re.search(r'\|\s*python3?\s*$', command, re.MULTILINE):
        patterns.append("pipe_decode")

    # Script execution from /tmp/ - with context exemptions
    if re.search(r'(?:python3?|bash|sh|perl|ruby|chmod\s+\+x)\s+/tmp/', command):
        # EXEMPT: Claude Code scratchpad directory
        is_claude_scratchpad = bool(re.search(r'/tmp/claude-\d+/', command))
        # EXEMPT: /tmp/ execution inside SSH sessions (runs on remote, not local)
        is_ssh_remote = bool(re.search(r'\bssh\b', command))
        if not is_claude_scratchpad and not is_ssh_remote:
            patterns.append("script_exec")

    # Bash variable indirection: ${!var} — resolves to the value of variable named by var
    # Used to construct sensitive paths/commands without literal strings
    if re.search(r'\$\{!\w+\}', command):
        patterns.append("variable_indirection")

    # Dense substring extraction: ${var:N:M} — building commands char-by-char
    # 3+ substring extractions in one command is highly suspicious
    substr_count = len(re.findall(r'\$\{\w+:\d+:\d+\}', command))
    if substr_count >= 3:
        patterns.append("substring_construction")

    # Hex escape sequences: printf '\x63\x61\x74' → cat
    # Catches hex-encoded command names that bypass string matching
    if re.search(r'\\x[0-9a-fA-F]{2}', command):
        try:
            decoded = re.sub(r'\\x([0-9a-fA-F]{2})', lambda m: chr(int(m.group(1), 16)), command)
            if re.search(r'\b(eval|exec|bash|curl|wget|base64|cat|nc|ssh)\b', decoded, re.IGNORECASE):
                patterns.append("hex_escape_encoding")
        except Exception:
            pass

    # Octal escape sequences: e\166al → eval, c\141t → cat
    # Catches octal-encoded command names that bypass string matching
    if re.search(r'\\[0-3][0-7]{2}', command):
        # Check if decoding would produce a dangerous command
        try:
            decoded = re.sub(r'\\([0-3][0-7]{2})', lambda m: chr(int(m.group(1), 8)), command)
            if re.search(r'\b(eval|exec|bash|curl|wget|base64)\b', decoded, re.IGNORECASE):
                patterns.append("octal_encoding")
        except Exception:
            pass

    # Hex encoding: xxd -r with hex input piped to shell
    if re.search(r'\bxxd\s+-r', command) and re.search(r'[0-9a-fA-F]{16,}', command):
        patterns.append("hex_encoding")

    # String reversal piped to shell
    if re.search(r'\|\s*rev\b', command) and re.search(r'\|\s*(ba)?sh\b', command):
        patterns.append("string_reversal")

    # Character substitution (tr-based cipher) piped to shell
    if re.search(r'\btr\b\s+["\']?[a-zA-Z]', command) and re.search(r'\|\s*(ba)?sh\b', command):
        patterns.append("substitution_cipher")

    # ROT13 via Python codecs (codecs.decode(..., 'rot_13') or 'rot13')
    if re.search(r'codecs\.decode\s*\(.*["\']rot[_-]?13["\']', command, re.IGNORECASE):
        patterns.append("rot13_decode")

    # ANSI escape sequences that can hide text from terminal display
    # \x1b[8m (invisible text), \033[8m, \e[8m
    if re.search(r'(\\x1b|\\033|\\e)\[8m', command):
        patterns.append("ansi_escape_hiding")

    # Python chr() density: building paths/URLs from character codes
    # python3 -c "...chr(104)+chr(116)+chr(116)..." → stealth exfil
    chr_count = len(re.findall(r'\bchr\s*\(\s*\d+\s*\)', command))
    if chr_count >= 10:
        patterns.append("chr_obfuscation")

    # Python bytes() exec: exec(bytes([105,109,112,111,...]))
    if re.search(r'\bexec\s*\(\s*bytes\s*\(\s*\[', command):
        patterns.append("bytes_exec_obfuscation")

    return patterns


def _detect_injection_patterns(target):
    """Fast-path detection of obvious prompt injection patterns.

    Client-side check for the most common/dangerous injection attempts.
    Full pattern matching happens server-side; this is for enable fast blocking
    in enforce mode.

    Returns a list of detected pattern names (empty if clean).
    """
    if not target:
        return []

    patterns = []
    text = target.lower()

    # Canonical Unicode normalization (shared module)
    if _HAS_NORMALIZE:
        text = normalize_text(text)
        text_deleet = deleet(text)
    else:
        text_deleet = text

    # Check both raw text and leetspeak-decoded text against pre-compiled patterns
    for check_text in (text, text_deleet):
        for compiled_re, name in _INJECTION_PATTERNS:
            if compiled_re.search(check_text):
                patterns.append(name)

    # Deduplicate
    return list(dict.fromkeys(patterns))


def _extract_destination(command, config_api_url=None, allowed_destinations=None):
    """Extract destination URL from Bash command, classify as self-api if possible.

    Returns dict with domain_hash, category, url_pattern, or None.
    Client-side classification is advisory — server re-validates.
    """
    if not command:
        return None

    url_patterns = [
        r'\bcurl\b[^|;]*?["\']?(https?://[^\s"\'\\>]+)',
        r'\bwget\b[^|;]*?["\']?(https?://[^\s"\'\\>]+)',
        r'\bhttpie?\b[^|;]*?["\']?(https?://[^\s"\'\\>]+)',
        r'(?:POST|PUT|PATCH)\s+["\']?(https?://[^\s"\'\\>]+)',
    ]

    for pattern in url_patterns:
        match = re.search(pattern, command, re.IGNORECASE)
        if match:
            url = match.group(1).rstrip("'\")")
            try:
                from urllib.parse import urlparse
                parsed = urlparse(url)
                domain = parsed.hostname
                if not domain:
                    continue

                domain_hash = hashlib.sha256(domain.encode()).hexdigest()[:16]

                # Client-side self-api detection
                category = "unknown"
                if config_api_url:
                    try:
                        api_domain = urlparse(config_api_url).hostname
                        if api_domain and domain.lower() == api_domain.lower():
                            category = "self-api"
                    except Exception:
                        pass

                # Client-side user allowlist
                if category == "unknown" and allowed_destinations:
                    for allowed in allowed_destinations:
                        if allowed.startswith("*."):
                            if domain.lower().endswith(allowed[1:]) or domain.lower() == allowed[2:]:
                                category = "user-allowed"
                                break
                        elif domain.lower() == allowed.lower():
                            category = "user-allowed"
                            break

                # URL pattern
                url_pat = "api-call"
                path = (parsed.path or "").lower()
                if any(p in path for p in ["/upload", "/file", "/blob"]):
                    url_pat = "file-upload"
                elif any(p in path for p in ["/webhook", "/hook", "/callback"]):
                    url_pat = "webhook"

                return {
                    "domain_hash": domain_hash,
                    "category": category,
                    "url_pattern": url_pat,
                }
            except Exception:
                pass

    return None


def _detect_indirect_injection(tool_name, tool_response, tool_input=None):
    """Detect prompt injection patterns in tool response content.

    Catches indirect prompt injection: malicious instructions embedded in
    fetched web content, read files, or search results that target the LLM.

    Returns a list of detected pattern names (empty if clean).
    Only inspects text content from tools that fetch external data.
    """
    if not tool_response:
        return []

    # Only inspect tools that bring in external/untrusted content.
    # Read/Grep are LOCAL file tools — user-authored content is trusted.
    # Including them causes false positives on security docs and tutorials.
    if tool_name not in ("WebFetch", "WebSearch", "Bash"):
        return []

    # For Bash, only scan responses from network commands (curl, wget, etc.)
    # Most Bash output is local (git, ls, pytest) — scanning it causes
    # false positives on security docs, tutorials, and test content.
    if tool_name == "Bash" and tool_input:
        cmd = (tool_input.get("command", "") or "").lower()
        if not re.search(
            r'\b(curl|wget|httpie|fetch|http)\b', cmd
        ):
            return []

    # Extract text content to inspect
    content = ""
    if isinstance(tool_response, dict):
        # WebFetch/WebSearch return content in various fields
        content = tool_response.get("content", "") or ""
        if not content:
            content = tool_response.get("text", "") or ""
        if not content:
            content = tool_response.get("output", "") or ""
        # Bash stdout for piped web requests
        if not content:
            content = tool_response.get("stdout", "") or ""
    elif isinstance(tool_response, str):
        content = tool_response

    if not content or len(content) < 20:
        return []

    # Multi-point sampling: head (10KB) + 3 evenly-spaced probes (2KB each) + tail (2KB)
    # Covers ~18KB to detect injection hidden anywhere in large responses
    if len(content) > 10240:
        chunks = [content[:10240]]
        step = len(content) // 4
        for i in range(1, 4):
            start = max(0, i * step - 1024)
            chunks.append(content[start:start + 2048])
        chunks.append(content[-2048:])
        text = "\n".join(chunks).lower()
    else:
        text = content.lower()

    # For structured responses (JSON), extract all string values and append
    # This catches injection hidden in JSON fields like {"note": "ignore instructions"}
    try:
        parsed = json.loads(content[:65536])  # cap to 64KB parse
        _json_strings = []

        def _extract_strings(obj, depth=0):
            if depth > 10:
                return
            if isinstance(obj, str) and len(obj) >= 10:
                _json_strings.append(obj.lower())
            elif isinstance(obj, dict):
                for v in obj.values():
                    _extract_strings(v, depth + 1)
            elif isinstance(obj, list):
                for item in obj[:100]:  # cap list scan
                    _extract_strings(item, depth + 1)

        _extract_strings(parsed)
        if _json_strings:
            text = text + "\n" + "\n".join(_json_strings[:200])
    except (json.JSONDecodeError, ValueError, TypeError):
        pass

    # Decode HTML entities to catch entity-encoded injection
    try:
        import html
        text = html.unescape(text)
    except Exception:
        pass

    # Canonical Unicode normalization (shared module)
    if _HAS_NORMALIZE:
        has_invisible = any(ch in text for ch in INVISIBLE_CHARS)
        text = normalize_text(text)
    else:
        has_invisible = False

    # Normalize fullwidth angle brackets to ASCII (for delimiter detection)
    text = text.replace("\uff1c", "<").replace("\uff1e", ">")

    patterns = []

    # Instruction override attempts hidden in content
    instruction_patterns = [
        (r"ignore\s+(all\s+)?(previous|prior|above)\s+instructions?", "indirect_instruction_override"),
        (r"disregard\s+(all\s+)?(previous|prior)\s+instructions?", "indirect_instruction_override"),
        (r"new\s+instructions?\s*:", "indirect_instruction_override"),
        (r"your\s+(actual|real|true)\s+(task|instructions?|objective)\s+(is|are)", "indirect_instruction_override"),
    ]

    # Role manipulation in fetched content
    role_patterns = [
        (r"you\s+are\s+now\s+", "indirect_role_manipulation"),
        (r"switch\s+(to|into)\s+.{0,20}\s+mode", "indirect_role_manipulation"),
        (r"activate\s+.{0,20}\s+mode", "indirect_role_manipulation"),
    ]

    # Delimiter attacks (fake system messages in web content)
    delimiter_patterns = [
        (r"<\s*/?(system|assistant)\s*>", "indirect_delimiter_attack"),
        (r"\[INST\]|\[/INST\]", "indirect_delimiter_attack"),
        (r"\[SYSTEM\]|\[/SYSTEM\]", "indirect_delimiter_attack"),
        (r"<\|system\|>|<\|assistant\|>", "indirect_delimiter_attack"),
        (r"BEGIN\s+HIDDEN\s+PROMPT", "indirect_delimiter_attack"),
    ]

    # Tool abuse patterns (instructing agent to execute commands)
    tool_abuse_patterns = [
        (r"execute\s+(this|the\s+following)\s+(command|script|code)", "indirect_tool_abuse"),
        (r"run\s+this\s+(in|using)\s+(bash|terminal|shell)", "indirect_tool_abuse"),
        (r"write\s+this\s+to\s+.+\.(py|sh|js|bash)", "indirect_tool_abuse"),
        (r"bypass\s+(the\s+)?(monitoring|audit|security)", "indirect_tool_abuse"),
        (r"don't\s+(log|record|report)\s+this", "indirect_tool_abuse"),
    ]

    # Encoded injection (instructions to decode and execute)
    encoded_patterns = [
        (r"decode\s+(this|the\s+following)\s+(base64|rot13)", "indirect_encoded_injection"),
        (r"decode\s+and\s+(run|execute|follow)", "indirect_encoded_injection"),
    ]

    # Exfiltration instructions (content telling agent to send data somewhere)
    exfil_patterns = [
        (r"send\s+(this|the|all)\s+.{0,15}(data|content|file|info).{0,30}(to|at)\s+https?://", "indirect_exfiltration"),
        (r"(curl|wget|fetch)\s+.{0,30}(post|send|upload)", "indirect_exfiltration"),
        (r"upload\s+(this|the|all).{0,20}to\s+", "indirect_exfiltration"),
    ]

    # Hidden text / invisible injection
    stealth_patterns = [
        (r"<!--\s*(ignore|system|instruction|you\s+are)", "indirect_stealth_injection"),
        # CSS invisible text (white on white, zero font size)
        (r"font-size\s*:\s*0", "indirect_stealth_injection"),
        (r"color\s*:\s*(white|#fff|#ffffff|rgba\(.*,\s*0\s*\))", "indirect_stealth_injection"),
        (r"display\s*:\s*none", "indirect_stealth_injection"),
    ]

    # Markdown / HTML tracking pixels and data exfiltration via image/link
    tracking_patterns = [
        # Markdown image that encodes data in URL
        (r"!\[.*?\]\(https?://[^)]+\?.*?(token|key|data|secret|session)\s*=", "indirect_tracking_pixel"),
        # HTML img with suspicious query params
        (r"<img[^>]+src=['\"]https?://[^'\"]+\?[^'\"]*?(token|key|data|secret)", "indirect_tracking_pixel"),
        # Data URI that could contain executable content
        (r"data:\s*(?:text/html|application/javascript)", "indirect_data_uri_injection"),
    ]

    all_patterns = (instruction_patterns + role_patterns + delimiter_patterns
                    + tool_abuse_patterns + encoded_patterns + exfil_patterns
                    + stealth_patterns + tracking_patterns)

    for pattern, name in all_patterns:
        if re.search(pattern, text, re.IGNORECASE):
            if name not in patterns:
                patterns.append(name)

    # Flag presence of invisible chars detected earlier (after stripping)
    if has_invisible and "indirect_stealth_injection" not in patterns:
        patterns.append("indirect_stealth_injection")

    return patterns


def _detect_mcp_poisoning(tool_name, tool_input, tool_response):
    """Detect MCP tool poisoning, shadowing, and description manipulation.

    MCP tool poisoning attacks include:
    - Hidden instructions in tool descriptions that manipulate the LLM
    - Tool names that shadow built-in tools (Read, Write, Bash, etc.)
    - Tool descriptions that change between invocations (rugpull)
    - Tools that instruct the agent to ignore monitoring or bypass security

    Returns a list of detected pattern names (empty if clean).
    """
    if not tool_name or not tool_name.startswith("mcp__"):
        return []

    patterns = []
    parts = tool_name.split("__", 2)
    server = parts[1] if len(parts) > 1 else ""
    tool = parts[2] if len(parts) > 2 else ""

    # 1. Tool shadowing: MCP tool name matches or resembles a built-in tool
    BUILTIN_TOOLS = {
        "read", "write", "edit", "bash", "glob", "grep",
        "webfetch", "websearch", "task", "notebookedit",
    }
    tool_lower = tool.lower()
    if tool_lower in BUILTIN_TOOLS:
        patterns.append("mcp_tool_shadowing")
    else:
        # Variant detection: "reads", "write_file", "bashCommand" that start with a builtin
        for builtin in BUILTIN_TOOLS:
            if tool_lower.startswith(builtin) and len(tool_lower) > len(builtin):
                suffix_lower = tool_lower[len(builtin):]
                # Check original (pre-lowercase) tool for camelCase boundary
                original_char = tool[len(builtin)] if len(tool) > len(builtin) else ''
                if suffix_lower[0] in ('_', 's', '-') or original_char.isupper():
                    patterns.append("mcp_tool_shadowing")
                    break

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

    # 2. Check tool input for injection patterns that may have come from
    #    poisoned tool descriptions (LLM was manipulated by description)
    if tool_input and isinstance(tool_input, dict):
        input_str = json.dumps(tool_input).lower()

        # Tool input containing injection-like instructions suggests
        # the LLM was manipulated by the tool's description
        poisoning_indicators = [
            (r"ignore\s+(previous|prior|all)\s+instructions?", "mcp_description_injection"),
            (r"you\s+are\s+now\s+", "mcp_description_injection"),
            (r"bypass\s+(monitoring|security|audit)", "mcp_description_injection"),
            (r"don't\s+(log|record|report|tell)", "mcp_description_injection"),
            (r"send\s+(this|the|all)\s+.{0,15}(data|content|info)", "mcp_exfil_via_tool"),
            (r"(secret|hidden|covert)\s+(channel|mode|operation)", "mcp_covert_channel"),
        ]
        for pattern, name in poisoning_indicators:
            if re.search(pattern, input_str, re.IGNORECASE):
                if name not in patterns:
                    patterns.append(name)

    # 3. Check tool response for signs the MCP server is trying to inject
    if tool_response:
        resp_str = ""
        if isinstance(tool_response, dict):
            resp_str = json.dumps(tool_response)
        elif isinstance(tool_response, str):
            resp_str = tool_response

        if len(resp_str) > 20:
            resp_lower = resp_str[:10240].lower()
            # MCP server response containing injection attempts
            mcp_response_injections = [
                (r"<\s*/?(system|assistant)\s*>", "mcp_response_injection"),
                (r"\[INST\]|\[/INST\]", "mcp_response_injection"),
                (r"ignore\s+(all\s+)?(previous|prior)\s+instructions?", "mcp_response_injection"),
                (r"you\s+are\s+now\s+", "mcp_response_injection"),
                (r"bypass\s+(the\s+)?(monitoring|obol|audit|security)", "mcp_response_injection"),
                (r"don't\s+(log|record|report)\s+this", "mcp_response_injection"),
                (r"new\s+instructions?\s*:", "mcp_response_injection"),
                (r"execute\s+(this|the\s+following)\s+(command|script)", "mcp_response_injection"),
            ]
            for pattern, name in mcp_response_injections:
                if re.search(pattern, resp_lower, re.IGNORECASE):
                    if name not in patterns:
                        patterns.append(name)

    return patterns


def _content_hash(tool_input):
    """Hash tool input content for audit trail without storing content."""
    if not tool_input:
        return None
    raw = json.dumps(tool_input, sort_keys=True).encode()
    return hashlib.sha256(raw).hexdigest()[:16]


def _response_metadata(tool_name, tool_response):
    """Extract privacy-safe metadata from tool response.

    Returns dict with response_hash, response_size, and tool-specific fields.
    Never stores actual content.
    """
    if not tool_response:
        return {}

    meta = {}

    # Hash the full response for audit trail
    raw = json.dumps(tool_response, sort_keys=True, default=str).encode()
    meta["response_hash"] = hashlib.sha256(raw).hexdigest()[:16]
    meta["response_size"] = len(raw)

    # Tool-specific metadata
    if tool_name == "Bash":
        stdout = tool_response.get("stdout", "")
        stderr = tool_response.get("stderr", "")
        meta["exit_code"] = tool_response.get("exitCode")
        meta["stdout_lines"] = stdout.count("\n") if stdout else 0
        meta["stderr_lines"] = stderr.count("\n") if stderr else 0
        meta["stdout_size"] = len(stdout) if stdout else 0
        meta["stderr_size"] = len(stderr) if stderr else 0
    elif tool_name in ("Read", "Write", "Edit"):
        meta["success"] = tool_response.get("success", True)
    elif tool_name == "Grep":
        # Number of matches is useful metadata
        content = tool_response.get("content", "")
        meta["match_count"] = content.count("\n") if content else 0

    return meta


def _check_integrity(config, api_key, api_url, agent_id, enforcement_mode="audit"):
    """Verify this file hasn't been tampered with. Alert if hash mismatch."""
    expected = config.get("hook_handler_hash")
    if not expected:
        return  # No known-good hash yet (first run or pre-integrity setup)
    try:
        handler_path = os.path.abspath(__file__)
        with open(handler_path, "rb") as f:
            actual = hashlib.sha256(f.read()).hexdigest()
        if actual == expected:
            return  # All good
        # Hash mismatch - send integrity violation alert
        alert = {
            "agent_id": agent_id,
            "session_id": "integrity-check",
            "action": "integrity.violation",
            "target": handler_path,
            "result": "error",
            "metadata": {
                "expected_hash": expected,
                "actual_hash": actual,
                "hook_event": "integrity_check",
                "enforcement_mode": enforcement_mode,
            },
        }
        body = json.dumps(alert).encode()
        req = urllib.request.Request(
            f"{api_url.rstrip('/')}/events",
            data=body,
            headers={
                "Authorization": f"Bearer {api_key}",
                "Content-Type": "application/json",
                "User-Agent": "obol-sdk/0.1.0",
            },
            method="POST",
        )
        urllib.request.urlopen(req, timeout=5)
        if enforcement_mode in ("alert", "enforce"):
            _notify(
                "Obol Integrity Alert",
                "Hook handler file has been modified!",
                urgency="critical",
                icon="security-low",
            )
        if enforcement_mode == "enforce":
            _create_lockdown(handler_path, "integrity.violation")
    except Exception:
        pass  # Never block on integrity check failures


def _check_sensitive_target(file_path, config, api_key, api_url, agent_id, session_id, notify, enforcement_mode="audit"):
    """Detect edits to monitoring infrastructure files and send immediate alert."""
    try:
        sensitive = False
        if "settings.json" in file_path:
            sensitive = True
        elif ".obol" in file_path and "config.json" in file_path:
            sensitive = True
        elif "obol/hooks/claude_code" in file_path:
            sensitive = True
        if not sensitive:
            return
        alert = {
            "agent_id": agent_id,
            "session_id": session_id,
            "action": "monitoring.tamper_attempt",
            "target": file_path,
            "result": "error",
            "metadata": {
                "hook_event": "tamper_detection",
                "tampered_file": file_path,
                "alert_severity": "critical",
                "enforcement_mode": enforcement_mode,
            },
        }
        body = json.dumps(alert).encode()
        req = urllib.request.Request(
            f"{api_url.rstrip('/')}/events",
            data=body,
            headers={
                "Authorization": f"Bearer {api_key}",
                "Content-Type": "application/json",
                "User-Agent": "obol-sdk/0.1.0",
            },
            method="POST",
        )
        urllib.request.urlopen(req, timeout=5)
        if notify and enforcement_mode in ("alert", "enforce"):
            _notify(
                "Obol Tamper Alert",
                f"Monitoring file edited: {file_path}",
                urgency="critical",
                icon="security-low",
            )
        if enforcement_mode == "enforce":
            _create_lockdown(file_path, "monitoring.tamper_attempt")
    except Exception:
        pass  # Never block on tamper check failures


def _create_lockdown(target, reason):
    """Create lockdown file to block agent via PreToolUse gate.

    Uses atomic write (tmp + os.replace) to prevent corrupt lockdown files.
    """
    try:
        lockdown_path = os.path.expanduser("~/.obol/lockdown")
        lockdown = {
            "reason": reason,
            "target": target,
            "timestamp": datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None).isoformat() + "Z",
            "message": f"Agent locked: {reason} on {target}",
        }
        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(lockdown, f, indent=2)
        os.replace(tmp, lockdown_path)
    except Exception:
        pass  # Never block on lockdown file creation


def _save_failed_event(event, api_key, error_reason=""):
    """Save event to local JSONL for later retry (encrypted if possible).

    Called when API is unreachable or in backoff. Events are encrypted
    at rest using AES-256-GCM derived from the API key.
    """
    try:
        failed_path = os.path.expanduser("~/.obol/failed_events.jsonl")
        event["attempted_at"] = datetime.datetime.now(
            datetime.timezone.utc
        ).replace(tzinfo=None).isoformat() + "Z"
        if error_reason:
            event["error"] = error_reason
        line = json.dumps(event)
        # Encrypt if api_key available
        if api_key:
            try:
                import base64 as _b64
                from cryptography.hazmat.primitives.hashes import SHA256 as _SHA256
                from cryptography.hazmat.primitives.kdf.hkdf import HKDF as _HKDF
                from cryptography.hazmat.primitives.ciphers.aead import AESGCM as _AESGCM
                gcm_key = _HKDF(
                    algorithm=_SHA256(), length=32,
                    salt=b"obol-apikey-aes256-v1", info=b"local-log-encryption",
                ).derive(api_key.encode())
                nonce = os.urandom(12)
                ct = _AESGCM(gcm_key).encrypt(nonce, line.encode(), None)
                encoded = _b64.urlsafe_b64encode(nonce + ct).decode()
                line = "obol_gcm:" + encoded
            except ImportError:
                # No cryptography, use HMAC+base64 fallback
                import base64 as _b64
                import hmac as _hmac
                encoded = _b64.b64encode(line.encode()).decode()
                key_bytes = hashlib.sha256(api_key.encode()).digest()
                sig = _hmac.new(key_bytes, line.encode(), hashlib.sha256).hexdigest()[:16]
                line = "obol_hmac:" + sig + ":" + encoded
            except Exception:
                pass  # Encryption failed, write plaintext
        # Cap file at 10MB to prevent unbounded growth
        max_size = 10 * 1024 * 1024
        try:
            if os.path.exists(failed_path) and os.path.getsize(failed_path) > max_size:
                return  # Skip write — file too large, will be retried via `obol retry`
            fd = os.open(failed_path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
            with os.fdopen(fd, "a") as f:
                f.write(line + "\n")
        except Exception:
            pass
    except Exception:
        pass  # Never block on failed event write


def _cleanup_old_sessions(sessions_dir, max_age_days=7):
    """Remove session state files older than max_age_days.

    Runs probabilistically (~1% of invocations) to avoid overhead.
    """
    import random
    if random.random() > 0.01:
        return
    try:
        if not os.path.isdir(sessions_dir):
            return
        cutoff = time.time() - (max_age_days * 86400)
        for fname in os.listdir(sessions_dir):
            fpath = os.path.join(sessions_dir, fname)
            if fname.endswith(".json") and os.path.getmtime(fpath) < cutoff:
                os.unlink(fpath)
    except Exception:
        pass


def _load_session_state(session_id):
    """Load or create session-level detector for behavioral analysis.

    State is persisted between handler invocations via JSON file.
    """
    sessions_dir = os.path.expanduser("~/.obol/sessions")
    _cleanup_old_sessions(sessions_dir)
    # Use hash of session_id for filename (sessions can have weird chars)
    sid_hash = hashlib.sha256(session_id.encode()).hexdigest()[:16]
    state_path = os.path.join(sessions_dir, f"{sid_hash}.json")

    try:
        from obol.hooks.detection import SessionDetector
    except ImportError:
        return None, None

    detector = SessionDetector()

    try:
        if os.path.exists(state_path):
            with open(state_path) as f:
                raw = f.read(1024 * 1024)  # 1MB cap
            state = json.loads(raw)
            if not isinstance(state, dict):
                return detector, state_path
            # Type-validate all fields before assignment (prevent corrupt state attacks)
            ac = state.get("action_counts", {})
            detector.action_counts = ac if isinstance(ac, dict) else {}
            sr = state.get("sensitive_reads", [])
            detector.sensitive_reads = sr[-100:] if isinstance(sr, list) else []
            na = state.get("network_actions", [])
            detector.network_actions = na[-100:] if isinstance(na, list) else []
            ec = state.get("error_count", 0)
            detector.error_count = ec if isinstance(ec, (int, float)) and ec >= 0 else 0
            tc = state.get("total_count", 0)
            detector.total_count = tc if isinstance(tc, (int, float)) and tc >= 0 else 0
            # Cap total_count to prevent overflow-style abuse
            if detector.total_count > 1_000_000:
                detector.total_count = 1_000_000
            ic = state.get("infra_config_count", 0)
            detector.infra_config_count = ic if isinstance(ic, (int, float)) and ic >= 0 else 0
            # Restore behavioral detection state (persisted across invocations)
            et = state.get("event_timestamps", [])
            detector.event_timestamps = (
                [t for t in et[-100:] if isinstance(t, (int, float))]
                if isinstance(et, list) else []
            )
            bf = state.get("_burst_fired", False)
            detector._burst_fired = bf if isinstance(bf, bool) else False
            dsf = state.get("_dominant_shift_fired", False)
            detector._dominant_shift_fired = dsf if isinstance(dsf, bool) else False
            baseline_dominant = state.get("_baseline_dominant")
            if isinstance(baseline_dominant, str):
                detector._baseline_dominant = baseline_dominant

            # ─── Chain detection state (backward-compatible) ───
            def _load_list(key, cap=50):
                v = state.get(key, [])
                return v[-cap:] if isinstance(v, list) else []

            detector.recent_events = _load_list("recent_events", 50)
            detector.network_downloads = _load_list("network_downloads", 50)
            detector.temp_writes = _load_list("temp_writes", 50)
            detector.permission_changes = _load_list("permission_changes", 50)
            detector.package_installs = _load_list("package_installs", 50)
            detector.archive_operations = _load_list("archive_operations", 50)
            detector.persistence_writes = _load_list("persistence_writes", 50)
            detector.privilege_events = _load_list("privilege_events", 50)
            detector.encode_events = _load_list("encode_events", 50)
            cf = state.get("_chain_flags_fired", [])
            detector._chain_flags_fired = set(cf) if isinstance(cf, list) else set()
    except Exception:
        pass

    return detector, state_path


def _save_session_state(detector, state_path, ux_flags=None,
                        tool_name=None, target_preview=None):
    """Persist session detector state between handler invocations."""
    if not detector or not state_path:
        return
    try:
        sessions_dir = os.path.dirname(state_path)
        os.makedirs(sessions_dir, exist_ok=True)

        # Load existing state to merge UX stats
        existing_ux = {}
        try:
            if os.path.exists(state_path):
                with open(state_path) as f:
                    old = json.load(f)
                existing_ux = old.get("ux_stats", {})
        except Exception:
            pass

        state = {
            "action_counts": detector.action_counts,
            "sensitive_reads": detector.sensitive_reads[-100:],  # Keep last 100
            "network_actions": detector.network_actions[-100:],
            "error_count": detector.error_count,
            "total_count": detector.total_count,
            "infra_config_count": detector.infra_config_count,
            # Persist behavioral detection state across invocations
            "event_timestamps": detector.event_timestamps[-100:],
            "_burst_fired": detector._burst_fired,
            "_dominant_shift_fired": detector._dominant_shift_fired,
            "_baseline_dominant": detector._baseline_dominant,
            # Chain detection state
            "recent_events": detector.recent_events[-50:],
            "network_downloads": detector.network_downloads[-50:],
            "temp_writes": detector.temp_writes[-50:],
            "permission_changes": detector.permission_changes[-50:],
            "package_installs": detector.package_installs[-50:],
            "archive_operations": detector.archive_operations[-50:],
            "persistence_writes": detector.persistence_writes[-50:],
            "privilege_events": detector.privilege_events[-50:],
            "encode_events": detector.encode_events[-50:],
            "_chain_flags_fired": list(detector._chain_flags_fired),
        }

        # ─── UX stats (accumulated across handler invocations) ───
        ux = existing_ux if isinstance(existing_ux, dict) else {}
        total = ux.get("total", 0) + 1
        flagged = ux.get("flagged", 0)
        clean = ux.get("clean", 0)
        deferred = ux.get("deferred", [])
        session_start = ux.get("session_start") or time.time()

        if ux_flags:
            flagged += 1
            for f in ux_flags:
                deferred.append((f, tool_name or "", target_preview or "",
                                 time.time()))
        else:
            clean += 1

        # Cap deferred list to prevent unbounded growth
        state["ux_stats"] = {
            "total": total,
            "clean": clean,
            "flagged": flagged,
            "deferred": deferred[-200:],
            "session_start": session_start,
        }

        # Atomic write with restrictive permissions
        tmp = 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, state_path)
    except Exception as exc:
        print(f"[OBOL] session state write failed: {exc}", file=sys.stderr)


def main():
    # Read event from stdin
    try:
        data = json.load(sys.stdin)
    except Exception:
        sys.exit(0)

    config = _load_config()

    notify = config.get("notify", True)  # Desktop notifications on by default

    api_key = os.environ.get("OBOL_API_KEY") or config.get("api_key", "")
    api_url = os.environ.get("OBOL_API_URL") or config.get("api_url", "https://obol.grayowlresearch.com/v1")
    agent_id = config.get("claude_code_agent_id")

    enforcement_mode = config.get("enforcement_mode", "audit")

    if not api_key or not agent_id:
        sys.exit(0)  # Not configured, skip silently

    # Integrity check - detect tampering before processing
    _check_integrity(config, api_key, api_url, agent_id, enforcement_mode)

    event_name = data.get("hook_event_name", "")
    tool_name = data.get("tool_name", "")
    tool_input = data.get("tool_input") or {}
    tool_response = data.get("tool_response") or {}
    tool_use_id = data.get("tool_use_id", "")
    session_id = data.get("session_id", "unknown")

    # Tamper detection - alert if agent edits monitoring infrastructure
    if tool_name in ("Edit", "Write") and tool_input:
        _check_sensitive_target(tool_input.get("file_path", ""), config, api_key, api_url, agent_id, session_id, notify, enforcement_mode)

    # Map hook events to Obol actions
    if event_name == "PostToolUse":
        action = tool_name or "unknown_tool"
        result = "success"
    elif event_name == "PostToolUseFailure":
        action = tool_name or "unknown_tool"
        result = "error"
    elif event_name == "SessionStart":
        action = "session.start"
        result = "success"
        if notify:
            _notify(
                "Obol Monitoring Active",
                f"Agent {agent_id} · Session protected",
                urgency="low",
                icon="security-high",
            )
    elif event_name == "SessionEnd":
        action = "session.end"
        result = "success"
    else:
        action = event_name or "unknown"
        result = "success"

    target = _extract_target(tool_name, tool_input) if tool_input else None

    # ─── On-device detection engine (immune system Layer 1+2) ───────
    local_detection_flags = []
    detection_failed = False
    session_detector = None
    state_path = None

    # ─── Load CDC rules (server-pushed detection updates) ───────────
    extra_rules = {}
    try:
        from obol.hooks.rule_update import get_rules
        extra_rules = get_rules(config) or {}
    except ImportError:
        pass
    except Exception:
        pass

    try:
        from obol.hooks.detection import detect_anomalies, SessionDetector
        # Load session state for behavioral analysis (Layer 2)
        session_detector, state_path = _load_session_state(session_id)
        # Run full on-device detection (with CDC rules)
        local_detection_flags = detect_anomalies(
            action, target, result,
            session_detector=session_detector,
            extra_rules=extra_rules,
        )
    except ImportError:
        pass  # Detection module not installed (minimal SDK)
    except Exception:
        detection_failed = True  # Track so we don't report a "clean" event

    # ─── Target categorization (privacy-safe labels) ────────────────
    target_category = None
    try:
        from obol.hooks.categories import categorize_target
        target_category = categorize_target(
            tool_name, target,
            api_url=api_url,
            allowed_destinations=config.get("allowed_destinations", []),
        )
    except ImportError:
        pass
    except Exception:
        pass

    # ─── Existing detection (obfuscation, injection, MCP) ──────────
    # Detect obfuscation patterns
    obfuscation = []
    if tool_name == "Bash" and tool_input:
        obfuscation = _detect_obfuscation(tool_input.get("command", ""))

    # Client-side prompt injection detection (fast-path for obvious patterns)
    injection_patterns = []
    if target:
        injection_patterns = _detect_injection_patterns(target)

    # Indirect prompt injection detection (patterns in tool responses / fetched content)
    indirect_injection = []
    if tool_response:
        indirect_injection = _detect_indirect_injection(tool_name, tool_response, tool_input)

    # MCP tool poisoning detection
    mcp_poisoning = []
    if tool_name.startswith("mcp__"):
        mcp_poisoning = _detect_mcp_poisoning(tool_name, tool_input, tool_response)

    # ─── Behavioral baseline (immune system Layer 2+) ─────────────
    baseline_deviations = []
    baseline_summary = None
    try:
        from obol.hooks.baseline import LocalBaseline
        bl = LocalBaseline(str(agent_id))
        # Wire baseline dominant action into session detector for shift detection
        if session_detector and bl.is_ready:
            session_detector.set_baseline_profile(
                bl._data.get("action_distribution", {})
            )
        # Pass session-level stats from the session detector
        session_event_count = 0
        session_actions = {}
        if session_detector:
            session_event_count = session_detector.total_count
            session_actions = dict(session_detector.action_counts)
        baseline_deviations = bl.check_and_update(
            action, result,
            session_event_count=session_event_count,
            session_actions=session_actions,
            detected_flags=local_detection_flags,
        )
        if bl.is_ready:
            baseline_summary = bl.summary
    except ImportError:
        pass
    except Exception:
        pass

    # ─── Content scanning (immune system Layer 1 — PII/secrets) ──
    content_scan = {}
    try:
        from obol.hooks.content_scanner import scan_response, scan_input
        # Scan tool response for leaked secrets/PII
        resp_scan = scan_response(tool_name, tool_response)
        if resp_scan.get("secret_count", 0) > 0 or resp_scan.get("pii_count", 0) > 0:
            content_scan["response"] = {
                "secrets": resp_scan["secrets_found"],
                "pii": resp_scan["pii_found"],
            }
        # Scan tool input for secrets being written to files
        inp_scan = scan_input(tool_name, tool_input)
        if inp_scan.get("secret_count", 0) > 0 or inp_scan.get("pii_count", 0) > 0:
            content_scan["input"] = {
                "secrets": inp_scan["secrets_found"],
                "pii": inp_scan["pii_found"],
            }
    except ImportError:
        pass
    except Exception:
        pass

    # Combine all detected patterns: local engine + obfuscation + injection + MCP
    # + baseline deviations + content scan flags
    content_flags = []
    if content_scan.get("response", {}).get("secrets"):
        content_flags.append("secret_in_response")
    if content_scan.get("response", {}).get("pii"):
        content_flags.append("pii_in_response")
    if content_scan.get("input", {}).get("secrets"):
        content_flags.append("secret_in_input")

    all_detected_patterns = list(dict.fromkeys(
        local_detection_flags + obfuscation + injection_patterns
        + indirect_injection + mcp_poisoning + baseline_deviations
        + content_flags
    ))

    # ─── Filter through local allowlist ──────────────────────────────
    ux_original_flags = list(all_detected_patterns)  # Keep pre-filter for event payload
    if all_detected_patterns and _HAS_ALLOWLIST:
        all_detected_patterns = check_allowlist(
            all_detected_patterns, tool_name, target)

    # ─── UX output: human-readable detection messages ────────────────
    if all_detected_patterns and _HAS_MESSAGES:
        target_preview = (str(target) if target else "")[:80]
        # Determine highest UX severity among remaining flags
        high_sev = False
        for f in all_detected_patterns:
            sev = get_user_severity(f)
            if sev in ("CRITICAL", "WARNING"):
                high_sev = True
                break
        if high_sev:
            sys.stderr.write(format_detection(
                all_detected_patterns, tool_name, target_preview,
                mode="immediate"))
        else:
            sys.stderr.write(format_detection(
                all_detected_patterns, tool_name, target_preview,
                mode="brief"))

    # ─── Build event payload ────────────────────────────────────────
    event = {
        "agent_id": agent_id,
        "session_id": session_id[:200],
        "action": action[:100],
        "target": str(target)[:2000] if target else None,
        "target_hash": _content_hash(tool_input),
        "result": result,
        "detected_patterns": ux_original_flags,
        "metadata": {
            "hook_event": event_name,
            "cwd": data.get("cwd", ""),
            "sdk_version": "0.2.0",
        },
    }

    # Target category (privacy-safe label — no raw content)
    if target_category:
        event["metadata"]["target_category"] = target_category

    # Local detection flags (so server knows what was detected on-device)
    if local_detection_flags:
        event["metadata"]["local_detection"] = local_detection_flags

    # Add injection detection metadata if patterns found
    if injection_patterns:
        event["metadata"]["injection_patterns_client"] = injection_patterns

    # Add indirect injection metadata if patterns found
    if indirect_injection:
        event["metadata"]["indirect_injection_patterns"] = indirect_injection

    # Baseline deviations (behavioral anomalies)
    if baseline_deviations:
        event["metadata"]["baseline_deviations"] = baseline_deviations
    if baseline_summary:
        event["metadata"]["baseline_summary"] = baseline_summary

    # Content scan results (secret/PII type names only — never content)
    if content_scan:
        event["metadata"]["content_scan"] = content_scan

    # Mark event if detection engine failed (so server doesn't treat as clean)
    if detection_failed:
        event["metadata"]["detection_status"] = "failed"

    # Add MCP poisoning metadata if patterns found
    if mcp_poisoning:
        event["metadata"]["mcp_poisoning_patterns"] = mcp_poisoning

    # Tool use ID for forensic reconstruction
    if tool_use_id:
        event["metadata"]["tool_use_id"] = tool_use_id

    # Response metadata (privacy-safe: hashes and sizes only)
    resp_meta = _response_metadata(tool_name, tool_response)
    if resp_meta:
        event["metadata"].update(resp_meta)

    # MCP tool metadata
    if tool_name.startswith("mcp__"):
        parts = tool_name.split("__", 2)
        event["metadata"]["mcp_server"] = parts[1] if len(parts) > 1 else "unknown"
        event["metadata"]["mcp_tool"] = parts[2] if len(parts) > 2 else "unknown"

    # Destination metadata for network commands (exfiltration detection)
    if tool_name == "Bash" and tool_input:
        dest = _extract_destination(
            tool_input.get("command", ""),
            config_api_url=api_url,
            allowed_destinations=config.get("allowed_destinations", []),
        )
        if dest:
            event["metadata"]["destination"] = dest

    # For WebFetch, use categorization for destination classification
    if tool_name == "WebFetch" and tool_input:
        url = tool_input.get("url", "")
        if url and "destination" not in event.get("metadata", {}):
            try:
                from urllib.parse import urlparse
                parsed = urlparse(url)
                if parsed.hostname:
                    domain_hash = hashlib.sha256(parsed.hostname.encode()).hexdigest()[:16]
                    cat = target_category.get("category", "unknown") if target_category else "unknown"
                    risk = target_category.get("risk", "unknown") if target_category else "unknown"
                    event["metadata"]["destination"] = {
                        "domain_hash": domain_hash,
                        "category": cat,
                        "risk": risk,
                    }
            except Exception:
                pass

    # Obfuscation metadata for Bash commands (patterns already detected above)
    if obfuscation:
        event["metadata"]["obfuscation_patterns"] = obfuscation
        # Behavioral signal: command complexity vs output size
        cmd = tool_input.get("command", "") if tool_input else ""
        if tool_response:
            stdout = tool_response.get("stdout", "")
            # Simple command with large output = suspicious
            if len(cmd) < 50 and len(stdout) > 5000:
                event["metadata"]["behavioral_flag"] = "simple_cmd_large_output"
            # Command with base64/eval and structured output
            if "base64" in obfuscation and stdout and not stdout.startswith("base64"):
                event["metadata"]["behavioral_flag"] = "decoded_execution"

    # ─── Notify on critical local detections ────────────────────────
    if notify and enforcement_mode in ("alert", "enforce"):
        critical_flags = [f for f in local_detection_flags
                          if f in ("escape_attempt", "privilege_escalation",
                                   "container_escape", "process_injection",
                                   "persistence_install", "agent_self_modification",
                                   "credential_hunting", "exfil_sequence")]
        if critical_flags:
            _notify(
                "Obol Threat Detected",
                f"{', '.join(critical_flags[:3])}\nAgent {agent_id}",
                urgency="critical",
                icon="security-low",
            )
        # Notify on secret leakage (always high priority)
        if content_scan.get("response", {}).get("secrets"):
            secret_types = content_scan["response"]["secrets"][:3]
            _notify(
                "Obol: Secret Detected",
                f"Leaked in response: {', '.join(secret_types)}",
                urgency="critical",
                icon="security-low",
            )
        if content_scan.get("input", {}).get("secrets"):
            secret_types = content_scan["input"]["secrets"][:3]
            _notify(
                "Obol: Secret in Output",
                f"Being written to file: {', '.join(secret_types)}",
                urgency="critical",
                icon="security-low",
            )

    # Save session state (Layer 2 behavioral analysis + UX stats)
    if session_detector and state_path:
        _save_session_state(session_detector, state_path,
                            ux_flags=all_detected_patterns,
                            tool_name=tool_name,
                            target_preview=(str(target) if target else "")[:80])

    # ─── Shape event for privacy (telemetry v2) ─────────────────────
    try:
        from obol.hooks.telemetry import shape_event
        event = shape_event(event, config)
        if event is None:
            # telemetry_mode == "off" — respect user's choice
            sys.exit(0)
    except ImportError:
        pass
    except Exception:
        pass  # Never break pipeline — send unshaped if shaping fails

    # ─── Send to Obol API (with exponential backoff) ─────────────────
    if _should_skip_api():
        # In backoff window — save locally for later retry instead of dropping
        _save_failed_event(event, api_key, error_reason="api_backoff")
        sys.exit(0)

    body = json.dumps(event).encode()
    req = urllib.request.Request(
        f"{api_url.rstrip('/')}/events",
        data=body,
        headers={
            "Authorization": f"Bearer {api_key}",
            "Content-Type": "application/json",
            "User-Agent": "obol-sdk/0.2.0",
        },
        method="POST",
    )
    try:
        urllib.request.urlopen(req, timeout=10)
        _clear_api_backoff()
    except Exception as exc:
        _record_api_failure()
        _save_failed_event(event, api_key, error_reason=str(exc))
        if notify and event_name == "SessionStart":
            _notify(
                "Obol Monitoring Warning",
                "Could not reach API — session not recorded",
                urgency="normal",
                icon="security-low",
            )

    sys.exit(0)


if __name__ == "__main__":
    main()
