"""aria-prompt-injection-scanner — Runtime-Scanner gegen poisoned Context-Files.

Adopted aus Hermes `agent/prompt_builder.py:36-73` (siehe
`brain/02-Wissen/openclaw-hermes-code-spelunking-2026-05-13.md` §5).

Scope: läuft gegen Brain-Notes, Skill-Files, Memory-Files BEVOR sie in einen
Prompt injiziert werden. Bei Match: `[BLOCKED: <reason>]` ersetzt den Inhalt.

API:
    from aria_prompt_injection_scanner import scan_content, ScanResult
    result = scan_content(text, source="brain/SOUL.md")
    if not result.is_clean:
        print(result.blocked_text)  # mit BLOCKED-Markern

CLI:
    python3 aria-prompt-injection-scanner.py <file>       # exit 0 clean, 1 dirty
    python3 aria-prompt-injection-scanner.py - < stdin    # stdin scan
"""
from __future__ import annotations

import dataclasses
import re
import sys
from pathlib import Path
from typing import List, Tuple

# ----- Threat-Patterns --------------------------------------------------------

_THREAT_PATTERNS: List[Tuple[str, re.Pattern[str]]] = [
    # ----- Hermes-origin (KAR-126) -----
    ("prompt_injection",
     re.compile(r"ignore\s+(?:previous|all|above|prior|earlier)\s+instructions?", re.IGNORECASE)),
    ("prompt_override",
     re.compile(r"(?:disregard|forget)\s+(?:previous|all|above|prior)\s+(?:instructions|context)", re.IGNORECASE)),
    ("deception_hide",
     re.compile(r"do\s+not\s+(?:tell|inform|alert|notify)\s+the\s+user", re.IGNORECASE)),
    ("html_comment_injection",
     re.compile(r"<!--[^>]*(?:ignore|override|system|secret|hidden|prompt)[^>]*-->", re.IGNORECASE)),
    ("exfil_curl_secret",
     re.compile(r"curl\s+[^\n]*\$\{?\w*(?:KEY|TOKEN|SECRET|PASSWORD|API|AUTH|CRED)", re.IGNORECASE)),
    ("exfil_wget_secret",
     re.compile(r"wget\s+[^\n]*\$\{?\w*(?:KEY|TOKEN|SECRET|PASSWORD)", re.IGNORECASE)),
    ("read_secrets",
     re.compile(r"\b(?:cat|less|more|head|tail)\s+[^\n]*(?:\.env|credentials|\.netrc|\.pgpass|id_rsa|\.ssh/)", re.IGNORECASE)),
    ("role_hijack",
     # KAR-182: `in` zusätzlich zu `a`/`now` — "you are now in jailbreak mode" wurde
     # vom alten Pattern nicht gefangen, "you are now a jailbreak" schon. Fix.
     re.compile(r"(?:you\s+are|act\s+as|pretend\s+to\s+be|roleplay\s+as)\s+(?:now\s+(?:a|in)?|a|in)\s+(?:DAN|jailbreak|unrestricted|developer\s+mode)", re.IGNORECASE)),
    ("system_prompt_leak",
     re.compile(r"(?:reveal|show|print|output|display)\s+(?:your|the)\s+(?:system\s+prompt|instructions|initial\s+prompt)", re.IGNORECASE)),
    ("tool_abuse_shell",
     re.compile(r"```(?:bash|sh|shell|zsh)\s*\n[^`]*\b(?:rm\s+-rf|chmod\s+777|chown\s+root|nc\s+-l)", re.IGNORECASE)),
    # ----- KAR-182: gbrain-origin (sanitize.ts INJECTION_PATTERNS) -----
    # Erweitert um 8 Patterns aus gbrain v0.28.8 für vollständigere Coverage.
    ("forget_everything",
     re.compile(r"forget\s+(?:everything|all\s+(?:of\s+)?the\s+above)", re.IGNORECASE)),
    ("disregard_prior",
     re.compile(r"disregard\s+(?:all\s+)?(?:prior|previous|above|earlier)\s+(?:instructions?|prompts?)", re.IGNORECASE)),
    ("new_instructions_header",
     re.compile(r"(?:new|updated|revised)\s+instructions?\s*:", re.IGNORECASE)),
    ("do_anything_now",
     re.compile(r"\b(?:DAN|do\s+anything\s+now|developer\s+mode(?:\s+enabled?)?)\b", re.IGNORECASE)),
    ("open_system_tag",
     re.compile(r"<\s*system\s*>", re.IGNORECASE)),
    ("open_instructions_tag",
     re.compile(r"<\s*instructions?\s*>", re.IGNORECASE)),
    ("verbatim_echo",
     re.compile(r"(?:repeat|echo)\s+(?:back|verbatim)", re.IGNORECASE)),
    # length-cap is enforced separately in scan_content (>500 chars in single line/snippet).
]

# Length-cap pattern (KAR-182): pro Take/Snippet max 500 chars. Über-Limit-Strings
# können einen Prompt-Budget hijack auslösen.
_LENGTH_CAP_BYTES = 500

# Bidi-control + zero-width chars (Unicode-Hijack-Schutz)
_INVISIBLE_CHARS = frozenset(
    "​"  # ZERO WIDTH SPACE
    "‌"  # ZWNJ
    "‍"  # ZWJ
    "⁠"  # WORD JOINER
    "﻿"  # BOM / ZWNBSP
    "‪"  # LRE
    "‫"  # RLE
    "‬"  # PDF
    "‭"  # LRO
    "‮"  # RLO
    "⁦"  # LRI
    "⁧"  # RLI
    "⁨"  # FSI
    "⁩"  # PDI
    "؜"  # ALM
)


# ----- Result-Type ------------------------------------------------------------

@dataclasses.dataclass(frozen=True)
class Threat:
    name: str
    snippet: str
    position: int


@dataclasses.dataclass
class ScanResult:
    source: str
    is_clean: bool
    threats: List[Threat]
    invisible_chars: List[Tuple[int, str]]  # (position, char)
    blocked_text: str

    def summary(self) -> str:
        if self.is_clean:
            return f"{self.source}: CLEAN"
        bits = [f"{self.source}: BLOCKED"]
        for t in self.threats:
            bits.append(f"  - {t.name} at pos {t.position}: {t.snippet[:60]!r}")
        if self.invisible_chars:
            uniq = sorted({c for _, c in self.invisible_chars})
            bits.append(f"  - invisible chars: {[hex(ord(c)) for c in uniq]}")
        return "\n".join(bits)


# ----- Core scan --------------------------------------------------------------

def scan_content(content: str, source: str = "unknown") -> ScanResult:
    """Scan `content` für Injection-Pattern + invisible chars."""
    threats: List[Threat] = []
    for name, pat in _THREAT_PATTERNS:
        for m in pat.finditer(content):
            threats.append(Threat(name=name, snippet=m.group(0), position=m.start()))

    invisible: List[Tuple[int, str]] = []
    for idx, ch in enumerate(content):
        if ch in _INVISIBLE_CHARS:
            invisible.append((idx, ch))

    is_clean = not threats and not invisible

    if is_clean:
        blocked = content
    else:
        # Erzeuge Sicht-Version mit BLOCKED-Markern (Aria sieht die Redaktion)
        marker_segments: List[Tuple[int, int, str]] = []
        for t in threats:
            marker_segments.append((t.position, t.position + len(t.snippet),
                                    f"[BLOCKED:{t.name}]"))
        for pos, ch in invisible:
            marker_segments.append((pos, pos + 1, "[BLOCKED:invisible_char]"))
        # Merge overlapping segments, sort by position desc, replace from back
        marker_segments.sort(key=lambda x: x[0], reverse=True)
        blocked = content
        applied_pos = set()
        for start, end, marker in marker_segments:
            if any(start <= p < end for p in applied_pos):
                continue
            blocked = blocked[:start] + marker + blocked[end:]
            applied_pos.add(start)

    return ScanResult(
        source=source,
        is_clean=is_clean,
        threats=threats,
        invisible_chars=invisible,
        blocked_text=blocked,
    )


def scan_file(path: Path | str) -> ScanResult:
    p = Path(path)
    content = p.read_text(encoding="utf-8", errors="replace")
    return scan_content(content, source=str(p))


# ----- CLI --------------------------------------------------------------------

def _main(argv: List[str]) -> int:
    if len(argv) < 2:
        print(__doc__)
        return 2
    target = argv[1]
    if target == "-":
        content = sys.stdin.read()
        result = scan_content(content, source="<stdin>")
    else:
        result = scan_file(target)
    print(result.summary())
    return 0 if result.is_clean else 1


if __name__ == "__main__":
    sys.exit(_main(sys.argv))
