"""sanitize — carrier stripping for untrusted content. Removes the invisible/steganographic carrier classes that smuggle instructions past a human reader but reach the model: zero-width characters, BIDI overrides, Unicode-tag steganography, HTML comments, and ``data:`` URIs. Contract (BRIEF §5, §9): this function only ever *removes* — never rewrites. Clean input returns byte-identical with an empty report, and the output is always a subsequence of the input. Per-class counts are reported so the caller can gate (WARN / block) on them. Ported from the ``llm-security`` unicode-scanner and string-utils primitives. """ from __future__ import annotations import re from dataclasses import dataclass from .calibration import MAX_INPUT_CHARS from .contract import assert_within_input_cap from .report import Finding, Report, Severity, Source # Invisible / steganographic character classes (codepoints). _ZERO_WIDTH = frozenset({0x200B, 0x200C, 0x200D, 0xFEFF, 0x00AD}) _BIDI = frozenset({0x202A, 0x202B, 0x202C, 0x202D, 0x202E, 0x2066, 0x2067, 0x2068, 0x2069}) _TAG_LO, _TAG_HI = 0xE0000, 0xE007F # Unicode Tags block (U+E0000–U+E007F) # ZWJ (U+200D) is the one zero-width codepoint with a legitimate, extremely # common use: it composes emoji. 👩‍💻 is WOMAN + ZWJ + PERSONAL COMPUTER, and # families, skin-tone professions and flag variants are all built the same way. # Testing membership alone therefore flagged — and *stripped* — the joiner in any # first-party document containing such an emoji, which `disposition` grades as an # any-tier carrier (FAIL_SECURE, no appeal). The strip was the worse half: it # silently decomposed 👩‍💻 into two unrelated emoji, so `sanitize` corrupted # content it was only ever supposed to remove carriers from. # # The fix is the one our own lexicon row `unicode:zero-width-in-word` # (`\w[ZW]\w`) already used: judge the joiner by CONTEXT, not identity. A ZWJ is # exempt only when BOTH neighbours are emoji-context codepoints — half-context is # not context, so `a👩` stays a carrier and an attacker cannot buy exemption # with a single emoji. # # The ranges are blocks, not an emoji table. Measured against Unicode 17.0's # `emoji-zwj-sequences.txt`: 1614 RGI sequences use 122 distinct codepoints # adjacent to a ZWJ, and these five ranges cover 122/122. Blocks are the point — # shipping the RGI sequence list itself would be precise the day it landed and # stale at the next Unicode release, reintroducing this exact false positive for # every new emoji until someone bumped the file. Whole blocks include the # unassigned headroom (458 Cn codepoints here) that future emoji are allocated # into, so the table does not age. # # Residual, documented in LIMITATIONS: a ZWJ hidden *between two emoji* is # exempt and could carry a covert channel. It costs an emoji per bit and cannot # split a word, which is the shape the word-splitting attack actually needs. _EMOJI_CTX_RANGES = ( (0x2190, 0x21FF), # Arrows — ↔ ↕ (U+2194/2195) (0x2600, 0x27BF), # Misc Symbols + Dingbats — ❤ ☠ ⚕ ⚧ ✈ ❄ ♀ ♂ ➡ (0x2B00, 0x2BFF), # Misc Symbols & Arrows — ⬛ (U+2B1B) (0xFE0F, 0xFE0F), # VARIATION SELECTOR-16, the emoji presentation selector (0x1F000, 0x1FAFF), # Emoji planes, incl. skin-tone modifiers U+1F3FB–FF ) def _is_emoji_context(cp: int) -> bool: """True when ``cp`` may legitimately sit adjacent to an emoji-composing ZWJ.""" return any(lo <= cp <= hi for lo, hi in _EMOJI_CTX_RANGES) def _is_joiner_in_emoji_sequence(text: str, i: int) -> bool: """True when ``text[i]`` is a ZWJ composing an emoji rather than a carrier. Requires an emoji-context codepoint on BOTH sides. A joiner at either edge of the document has no neighbour and so is never exempt. """ if i == 0 or i + 1 >= len(text): return False return _is_emoji_context(ord(text[i - 1])) and _is_emoji_context(ord(text[i + 1])) # Span carriers. # # ReDoS note (OWASP LLM10). The comment stripper used to be `` with a # comment that absence of nesting meant no catastrophic backtracking. That claim # was wrong in the same way `output`'s was: a lazy run in front of a REQUIRED # literal costs a full tail rescan at *every* start position when the literal # never arrives, so `` # is the ordinary case, not an edge one. Bounding the run would be a one-line # carrier bypass: a comment padded past the bound is exactly what this stripper # exists to remove. So the scan is done with `str.find`, which is linear and # semantically identical to the lazy regex — leftmost opener, nearest following # terminator, unterminated trailer left in place. _COMMENT_OPEN, _COMMENT_CLOSE = "" # `data:` not preceded by a letter (so "metadata:" / "userdata:" do not match), # consuming up to the next whitespace / quote / angle bracket / closing paren. _DATA_URI_RE = re.compile(r"(?)]+", re.IGNORECASE) @dataclass(frozen=True) class SanitizeResult: """The cleaned text plus a report of what was stripped.""" text: str report: Report def _redact(s: str, show_start: int = 12, show_end: int = 4) -> str: if len(s) <= show_start + show_end + 3: return s return f"{s[:show_start]}...{s[-show_end:]}" def _strip_html_comments(text: str) -> tuple[str, int]: """Remove ```` spans; return the cleaned text and the count. Linear replacement for the quantifier form (see the ReDoS note above). An unterminated ``