1
0
Fork 0

fix(sanitize): catch every invisible codepoint, exempt only on context

Variation-selector smuggling (A01) and Sneaky Bits (A02) gave 0 findings
on 1.4.1 and stayed in the sanitized text. The denominator is now the
frozen Unicode 18.0 set (DICP | Cf | Co, 141 674 codepoints); each one is
stripped and flagged unless a known legitimate use exempts it on context:
one VS15/16 after an emoji base, one ideographic selector after a CJK
ideograph, ZWNJ/ZWJ beside Arabic-script or Indic letters, LRM/RLM/ALM
in a document with RTL text, an Arabic number sign before a digit. A run
is always a carrier; private use is caught in full (operator decisions
2026-10-28). One predicate serves sanitize, the output gate and the
lexicon's word-split row, whose regex stays byte-identical with commons.

A01 and A02 are green; the FP populations and the pinned OKF corpus are
identical before and after.
This commit is contained in:
Kjell Tore Guttormsen 2026-10-29 08:31:43 +01:00
commit c72e648a94
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
11 changed files with 734 additions and 108 deletions

View file

@ -602,7 +602,7 @@ items; this is the full list, each with the mechanism.
missed; on one preset it is held for review instead of halted.
- **The ReDoS sweep has a measured sensitivity floor, not a clean bill of
health.** All 152 compiled patterns across all eleven regex-bearing modules are
health.** All 154 compiled patterns across all eleven regex-bearing modules are
swept arm by arm — payloads synthesised per run from each pattern's own
skeleton, so `[`, `[system]` and `[system](` are each probed separately rather
than relying on generic units, and each pattern is timed in the call mode the
@ -771,38 +771,72 @@ fails the test, forcing this doc to be updated:
own map makes "disposed non-WARN" a different claim from the one measured here, with
nothing in either repo failing to say so.
- **A ZWJ hidden between two emoji is exempt, and ZWNJ's own false-positive
class is untouched.** U+200D composes emoji (👩‍💻 is WOMAN + ZWJ + PERSONAL
COMPUTER), so testing it on codepoint membership alone flagged *and stripped*
the joiner in any document containing a ZWJ-composed emoji — an any-tier
`FAIL_SECURE` carrier, plus silent decomposition of the emoji, on ordinary
first-party content. The joiner is now judged by context instead: exempt only
when **both** neighbours are emoji-context codepoints, measured to cover
122/122 of the codepoints adjacent to a ZWJ across Unicode 17.0's 1614 RGI
sequences. Two residuals follow. First, a joiner placed *between two emoji*
is now invisible to the carrier check 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 needs, so the narrowing is deliberate rather than
complete. Second, **U+200C (ZWNJ) still has no context test**, and it is
orthographically *required* in Persian, Arabic and Devanagari — those
documents remain hard-blocked. That is a separate criterion (script-based,
not pictographic) and no corpus is available here to verify it against, so it
is parked as a known false-positive class rather than guessed at.
- **A ZWJ hidden between two emoji is exempt.** U+200D composes emoji (👩‍💻 is
WOMAN + ZWJ + PERSONAL COMPUTER), so testing it on codepoint membership alone
flagged *and stripped* the joiner in any document containing a ZWJ-composed
emoji — an any-tier `FAIL_SECURE` carrier, plus silent decomposition of the
emoji, on ordinary first-party content. The joiner is now judged by context
instead: exempt only when **both** neighbours are emoji-context codepoints,
measured to cover 122/122 of the codepoints adjacent to a ZWJ across Unicode
17.0's 1614 RGI sequences. A joiner placed *between two emoji* is therefore
invisible to the carrier check 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 needs, so the narrowing is deliberate rather than complete. *(Until
1.4.2 this bullet also said ZWNJ had no context test and hard-blocked Persian,
Arabic-script and Devanagari documents. It has one now: see the next bullet.)*
- **That context test is one predicate on two surfaces, and the symbol carrying it is
private.** `sanitize` owns `_is_joiner_in_emoji_sequence`; `output` imports it
(`output.py:74`) instead of restating it, because the same defect had to be fixed on
both surfaces and a split would let the input side stop flagging while the output
side kept hard-blocking — or the reverse, which is how a carrier reaches a persisted
artifact after passing the input gate. The agreement is pinned by
`tests/test_output.py::test_output_zwj_narrowing_matches_the_sanitize_side`, which
asserts `stripped == flagged` across six shapes — half-context on either side, a
leading and a trailing joiner, one genuine in-sequence joiner, and a word split.
Two things that pin does not give. The six shapes are hand-written rather than
drawn from a corpus, so everywhere outside them
the surfaces agree by *shared implementation*, not by test — which is the stronger
guarantee only for as long as the import survives. And the leading underscore means
the predicate is **not** part of the surface frozen under semver: a consumer that
- **Every invisible codepoint is caught or exempt on context — and each
exemption leaves a narrow channel open.** The denominator is frozen in
`docs/threat-2026/codepoints.json`: Default_Ignorable_Code_Point, format
characters (Cf) and private use (Co) in Unicode 18.0, **141 674 codepoints**.
Up to 1.4.1 the guard knew 142 of them; variation-selector smuggling and Sneaky
Bits passed with 0 findings and stayed in the "sanitized" text. From 1.4.2 each
one is stripped and flagged unless a known legitimate use exempts it **on its
context**, and a run is a carrier whatever it stands after. What stays open,
per exemption:
- **One VS15/VS16 after an emoji base** (371/371 of Unicode 18.0's emoji
variation sequences): one selector per emoji, i.e. a bit per emoji.
- **One ideographic selector after a CJK ideograph** (operator decision
2026-10-28): CJK text can hide **one byte per ideograph**. The published
attack shape — a run of selectors — is caught; an attacker who interleaves
one selector per visible ideograph is not.
- **ZWNJ/ZWJ next to an Arabic-script or Indic letter**, neither neighbour
invisible: a bit per letter boundary in those scripts. A Latin word split
is still caught, and the lexicon's word-split row judges its joiner by the
same predicate. Measured on 9 Wikipedia articles (fa, hi, ml, ur, he, ar;
2.5 MB, fetched 2026-10-28): articles with a carrier finding went from 6/9
to 2/9. The two left are real: one Persian article writes ZWSP where ZWNJ
belongs (ZWSP has no orthographic role, so it is not exempt), and one
Malayalam article has two ZWNJ in a row.
- **LRM/RLM/ALM in a document that carries right-to-left text**: a bit per
gap between visible characters. The context is the document, not the line,
because a measured Persian article put an LRM on a citation line with no RTL
letter.
- **An Arabic number sign directly before a digit** (operator decision
2026-10-28): visible marks, no channel beyond their own presence.
And what is now caught that has a legitimate use, so a document carrying it
is blocked: **private use** in full (operator decision 2026-10-28 — an icon
from an icon font, such as a pasted terminal prompt or the Apple logo, rather
than one ~17-bit codepoint per visible gap; none of the 3 505 documents in the three
false-positive populations or the 78 in the pinned OKF corpus carries one); **variation sequences outside emoji and CJK** (math symbols, Mongolian,
Myanmar, Egyptian hieroglyphs, Phags-pa, Manichaean); the Mongolian vowel
separator; Egyptian hieroglyph format controls; and, unchanged from before,
the soft hyphen, a leading byte-order mark, and the tag characters inside
subdivision-flag emoji. None of these occurs in the measured benign corpora.
- **That context test is one predicate on two surfaces, and the symbols carrying
it are private.** `sanitize` owns `_invisible_carriers`; `output` imports it
instead of restating it, because a split would let the input side stop
flagging while the output side kept hard-blocking — or the reverse, which is
how a carrier reaches a persisted artifact after passing the input gate. The
agreement is pinned by `tests/test_invisible.py`, which asserts class-for-class
and count-for-count agreement across 40 legitimate and attack shapes, and that
all 141 674 codepoints out of context are stripped on one side and flagged on
the other. Two things that pin does not give. Outside those shapes the
surfaces agree by *shared implementation*, not by test — the stronger guarantee
only for as long as the import survives. And the leading underscore means the
predicate is **not** part of the surface frozen under semver: a consumer that
imports it is pinning a private name 1.x makes no promise about.
## Out-of-scope (documented boundary)

View file

@ -176,6 +176,22 @@ Nummereringen er rekkefølgen, og hver økt starter fra STATE.md.
- Vaktregelen er måling på falsk-positiv-korpuset og OKF-korpuset før og etter.
- Etterpå sendes målingen og kontekstregelen til `llm-security-commons` som innspill
til tegntabellen. Commons eier sin tabell, og vi skriver aldri dit.
*Gjort 28.10 (1.4.2):* alle 141 674 tegn i nevneren fanges utenfor kontekst, på
begge sider med samme predikat (testet tegn for tegn). A01 og A02 er grønne, så
**rad 3 står på 10 fanget, 1 hull og 8 røde rader (8 av 31 ankomster, var 12)**.
Operatøren avgjorde de tre åpne unntakene etter anbefaling: privat bruk **fanges
helt**, arabiske talltegn **unntas foran et siffer**, og planen om **én ideografisk
velger per CJK-tegn står**. Hva hvert unntak lar stå åpent, står i LIMITATIONS.
Målt før bygging: av 3 505 dokumenter i falsk-positiv-populasjonene og 78 i
OKF-korpuset bærer bare 348 VS16 etter emoji og 1 ZWJ mellom emoji noe fra
nevneren. ZWNJ-regelen er målt mot 9 Wikipedia-artikler (fa, hi, ml, ur, he, ar).
Der hadde 4 666 av 4 784 skrifttegn på begge sider, og resten sto ved markup eller
virama. Derfor er regelen «én side», og artikler med funn gikk fra 6/9 til 2/9.
Ett tillegg til planen: leksikonraden for orddeling dømmer nå ZWNJ med det samme
predikatet. Ellers ville persisk fortsatt fått MEDIUM på hvert ord, selv med tegnet
i behold. Selve regex-en er uendret og byte-lik med commons. Vaktregelen er målt
før og etter: OKF er identisk (6/53, import 47/31), og falsk-positiv-tallene står i
CHANGELOG for 1.4.2.
**Økt 3: katalognumre med årstall (rad 2 → 0). Gis ut som 1.5.0.**
- Alle funn bærer `LLMxx:2026`. Mapping fra 2025-betydning, verifisert i økt 1:
@ -251,6 +267,8 @@ bekrefter flyttene. OWASP skriver selv `LLM01:2026`. Detaljer og bevis står i
1. ~~Publiseres testsakene åpent?~~ **Ja**, operatøren 28.10 (§ T.3).
2. ~~Grensetilfellene i § T.3.~~ **Avgjort** av operatøren 28.10 (§ T.3).
3. ~~Tegnnevneren.~~ **Utvidet med Cf**, operatøren 28.10 (§ T.4 økt 1).
4. ~~Unntakene med uavklart kontekst.~~ **Avgjort** av operatøren 28.10 (§ T.4
økt 2): privat bruk fanges, talltegn unntas foran siffer, CJK-velgeren står.
---

View file

@ -166,6 +166,9 @@ _HEX_BLOB = "a1b2c3d4" * 10 # 80 hex chars > 64 shape floor
_ZW = chr(0x200B) # zero-width space
_BIDI = chr(0x202E) # right-to-left override
_TAG = "".join(chr(0xE0000 + ord(c)) for c in "hi") # Unicode-tag stego
_VS_RUN = "".join(chr(0xFE00 + n) for n in (6, 8, 9)) # selector run (A01 shape)
_PUA = chr(0xE000) # private use
_BITS = (chr(0x2062) + chr(0x2064)) * 4 # Sneaky Bits (A02 shape)
_CYR_R = chr(0x0440) # Cyrillic 'er' — a Latin 'p' look-alike
@ -451,6 +454,19 @@ def _build_cases() -> list[Case]:
_BIDI + "kcatta", via="output"),
_scan_case("carrier", "Unicode-tag/PUA present (persist-gate)", "lexicon:unicode-tags-present",
"legit" + _TAG, via="output"),
# 1.4.2: the rest of the frozen invisible-codepoint denominator.
_scan_case("carrier", "variation-selector run (input-strip)", "sanitize:variation-selector",
"notes " + chr(0x1F600) + _VS_RUN, via="input"),
_scan_case("carrier", "private-use character (input-strip)", "sanitize:private-use",
"icon " + _PUA, via="input"),
_scan_case("carrier", "invisible format characters (input-strip)", "sanitize:invisible-format",
"notes" + _BITS, via="input"),
_scan_case("carrier", "variation-selector run in artifact (persist-gate)",
"output:variation-selector-present", "notes " + chr(0x1F600) + _VS_RUN, via="output"),
_scan_case("carrier", "private-use character in artifact (persist-gate)",
"output:private-use-present", "icon " + _PUA, via="output"),
_scan_case("carrier", "invisible format characters in artifact (persist-gate)",
"output:invisible-format-present", "notes" + _BITS, via="output"),
]
# 2. The full injection lexicon (one case per pattern id).

View file

@ -123,8 +123,15 @@ _CARRIER_LABELS = frozenset({
"sanitize:zero-width",
"sanitize:bidi-override",
"sanitize:unicode-tag",
"sanitize:variation-selector",
"sanitize:private-use",
"sanitize:invisible-format",
"output:zero-width-present",
"output:bidi-present",
"output:unicode-tag-present",
"output:variation-selector-present",
"output:private-use-present",
"output:invisible-format-present",
"lexicon:unicode-tags-present",
})

View file

@ -65,6 +65,7 @@ from .calibration import (
)
from .entropy import try_decode_base64
from .report import Finding, Report, Severity, Source
from .sanitize import _is_exempt_joiner
# Self-safety input-size cap (OWASP LLM10), rot13 variant floor, and the
# cognitive-load-trap lengths all live in `calibration` (the Node port shares
@ -320,6 +321,27 @@ def _build_variants(text: str) -> list[tuple[str, str]]:
return variants
# The word-split row matches `\w<joiner>\w`, which is also how every Persian
# word carrying its required ZWNJ is spelled. The joiner it matched is judged by
# the predicate `sanitize` and `output` use, so the row keeps its target — a
# Latin word split — and stops matching orthography. The regex itself stays
# byte-identical with the commons lexicon; only the verdict on a match changes.
_JOINER_ROW = "unicode:zero-width-in-word"
def _search(pattern: LexiconPattern, variant: str):
if pattern.id != _JOINER_ROW:
return pattern.regex.search(variant)
# Step one past each exempt match's start rather than past its end: the
# letter after an exempt joiner may open the next, non-exempt split.
pos = 0
while (match := pattern.regex.search(variant, pos)) is not None:
if not _is_exempt_joiner(variant, match.start() + 1):
return match
pos = match.start() + 1
return None
def scan_lexicon(
text: str,
source: Source = Source.INPUT,
@ -349,7 +371,7 @@ def scan_lexicon(
for pattern in patterns:
if pattern.id in seen:
continue
match = pattern.regex.search(variant)
match = _search(pattern, variant)
if match is None:
continue
seen.add(pattern.id)

View file

@ -29,11 +29,11 @@ input-side scanners do not cover:
references. Ported from the ``llm-security`` ``knowledge/secrets-patterns.md``
seed. Classic PII (email / national-ID / card numbers) is intentionally out
of scope for v1 — high false-positive risk, not in the seed.
5. **Invisible carrier presence** (:func:`_scan_invisible_carriers`) — zero-width
and BIDI override/isolate characters in the emitted artifact. Output is
report-only and never sanitized, so this is the persist-gate analogue of
``sanitize``'s input-side stripping; disposition treats the labels as
any-tier carriers. Unicode-tag / PUA stego is already surfaced by step 1.
5. **Invisible carrier presence** (:func:`_scan_invisible_carriers`) — every
invisible codepoint in the emitted artifact that ``sanitize`` would strip,
judged by the same predicate. Output is report-only and never sanitized, so
this is the persist-gate analogue of ``sanitize``'s input-side stripping;
disposition treats the labels as any-tier carriers.
6. **Active content** (:func:`~llm_ingestion_guard.active_content.scan_active_content`,
OWASP LLM05 — Improper Output Handling) — markdown images/links, reference
definitions, autolinks, raw active HTML and ``data:`` URIs with an external
@ -71,7 +71,7 @@ from .calibration import MAX_CONNSTR_VALUE
from .entropy import scan_entropy
from .lexicon import MAX_SCAN_CHARS, scan_lexicon
from .report import Finding, Report, Severity, Source
from .sanitize import _is_joiner_in_emoji_sequence
from .sanitize import _CARRIER_CLASSES, _invisible_carriers
# --- secret / credential egress patterns (OWASP LLM02) ----------------------
# Ported from knowledge/secrets-patterns.md. ``value_group`` names the capturing
@ -243,37 +243,43 @@ def scan_secret_egress(text: str, source: Source = Source.OUTPUT) -> Report:
# --- invisible carrier presence (BRIEF §4.7) --------------------------------
# The output-gate analogue of sanitize's input-side stripping: model output is
# report-only and never sanitized, so an invisible carrier reaching the persist
# gate must be flagged here. Zero-width / soft-hyphen and BIDI override/isolate
# code points; Unicode-tag / PUA stego is already surfaced by scan_lexicon
# (``lexicon:unicode-tags-present``), so it is not repeated. Disposition treats
# these labels as any-tier carriers (FAIL_SECURE regardless of trust).
_ZERO_WIDTH_CPS = frozenset({0x200B, 0x200C, 0x200D, 0xFEFF, 0x00AD})
_BIDI_CPS = frozenset({0x202A, 0x202B, 0x202C, 0x202D, 0x202E, 0x2066, 0x2067, 0x2068, 0x2069})
# gate must be flagged here. Disposition treats these labels as any-tier
# carriers (FAIL_SECURE regardless of trust).
#
# The predicate is imported from `sanitize`, never re-stated here: the two
# surfaces are one decision, and a second copy of the rule is how the input side
# stops flagging while the output side keeps hard-blocking. Unicode tags are
# reported here as well as by the lexicon (``lexicon:unicode-tags-present``),
# whose check is narrower — it misses U+E0000 — so this is the complete one.
# The first two labels predate 1.4.2 and keep their names.
_OUTPUT_LABELS = {
"zero-width": ("output:zero-width-present",
"invisible zero-width/soft-hyphen characters present"),
"bidi-override": ("output:bidi-present", "BIDI override/isolate characters present"),
"unicode-tag": ("output:unicode-tag-present", "invisible Unicode-tag characters present"),
"variation-selector": ("output:variation-selector-present",
"variation selectors outside a single emoji/CJK presentation choice"),
"private-use": ("output:private-use-present", "private-use characters present"),
"invisible-format": ("output:invisible-format-present",
"invisible format characters present"),
}
def _scan_invisible_carriers(text: str, source: Source) -> Report:
"""Flag invisible zero-width / BIDI carriers present in ``text`` (report-only)."""
"""Flag invisible carriers present in ``text`` (report-only)."""
report = Report()
# The ZWJ exemption is imported from `sanitize`, never re-stated here: the
# two surfaces are one decision, and a second copy of the rule is how the
# input side stops flagging while the output side keeps hard-blocking.
zero_width = sum(
1 for i, ch in enumerate(text)
if ord(ch) in _ZERO_WIDTH_CPS
and not (ord(ch) == 0x200D and _is_joiner_in_emoji_sequence(text, i))
)
bidi = sum(1 for ch in text if ord(ch) in _BIDI_CPS)
if zero_width:
counts = dict.fromkeys(_CARRIER_CLASSES, 0)
for _, klass in _invisible_carriers(text):
counts[klass] += 1
for klass, count in counts.items():
if not count:
continue
label, evidence = _OUTPUT_LABELS[klass]
report.add(Finding(
label="output:zero-width-present", severity=Severity.HIGH,
source=source, detector="output", count=zero_width, owasp="LLM01",
evidence="invisible zero-width/soft-hyphen characters present",
))
if bidi:
report.add(Finding(
label="output:bidi-present", severity=Severity.HIGH,
source=source, detector="output", count=bidi, owasp="LLM01",
evidence="BIDI override/isolate characters present",
label=label,
severity=Severity.CRITICAL if klass == "unicode-tag" else Severity.HIGH,
source=source, detector="output", count=count, owasp="LLM01",
evidence=evidence,
))
return report
@ -336,9 +342,9 @@ def scan_output(
# 4. Secret / credential egress (OWASP LLM02).
report.extend(scan_secret_egress(scan_text, source).findings)
# 5. Invisible carriers present in the artifact (zero-width / BIDI). Output
# is never sanitized, so this is the persist-gate carrier signal; the
# unicode-tag case is already covered by the lexicon scan in step 1.
# 5. Invisible carriers present in the artifact, judged by sanitize's own
# predicate. Output is never sanitized, so this is the persist-gate
# carrier signal.
report.extend(_scan_invisible_carriers(scan_text, source).findings)
# 6. Active-content constructs with an external target (the EchoLeak class,

View file

@ -1,8 +1,10 @@
"""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.
past a human reader but reach the model: every invisible codepoint (zero-width,
BIDI override, Unicode-tag, variation-selector, private-use and invisible-format
classes — the frozen Unicode 18.0 denominator below) that is not exempt on its
context, plus 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
@ -13,16 +15,107 @@ and string-utils primitives.
from __future__ import annotations
import re
from bisect import bisect_right
from dataclasses import dataclass
from functools import lru_cache
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).
# --- the invisible-codepoint denominator --------------------------------------
#
# Every codepoint that renders as nothing, or as nothing a reader would notice:
# Default_Ignorable_Code_Point | General_Category=Cf | General_Category=Co in
# Unicode 18.0 — 141 674 codepoints, frozen with the source files' sha256 in
# `docs/threat-2026/codepoints.json` and pinned equal to this table by
# `tests/test_invisible.py`. Up to 1.4.1 this module knew 142 of them (5
# zero-width, 9 BIDI override/isolate, the 128-codepoint tag block);
# variation-selector smuggling and Sneaky Bits passed with 0 findings and stayed
# in the "sanitized" text.
#
# The table is the codepoints.json ranges verbatim, not `unicodedata`: the stdlib
# database is pinned to the interpreter (16.0 on 3.14), so membership would move
# with the Python version instead of with the frozen denominator.
_INVISIBLE_RANGES = (
(0x00AD, 0x00AD), (0x034F, 0x034F), (0x0600, 0x0605), (0x061C, 0x061C),
(0x06DD, 0x06DD), (0x070F, 0x070F), (0x0890, 0x0891), (0x08E2, 0x08E2),
(0x115F, 0x1160), (0x17B4, 0x17B5), (0x180B, 0x180F), (0x200B, 0x200F),
(0x202A, 0x202E), (0x2060, 0x206F), (0x3164, 0x3164), (0xE000, 0xF8FF),
(0xFE00, 0xFE0F), (0xFEFF, 0xFEFF), (0xFFA0, 0xFFA0), (0xFFF0, 0xFFFB),
(0x110BD, 0x110BD), (0x110CD, 0x110CD), (0x13430, 0x1343F),
(0x1BCA0, 0x1BCA3), (0x1D173, 0x1D17A), (0xE0000, 0xE0FFF),
(0xF0000, 0xFFFFD), (0x100000, 0x10FFFD),
)
def _char_class(ranges) -> re.Pattern[str]:
return re.compile("[" + "".join(f"\\U{lo:08x}-\\U{hi:08x}" for lo, hi in ranges) + "]")
# Located with one C-level scan; the per-codepoint judgement below then runs only
# on the hits, so a clean document costs one regex pass, not a Python loop.
_INVISIBLE_RE = _char_class(_INVISIBLE_RANGES)
_INVISIBLE_STARTS = tuple(lo for lo, _ in _INVISIBLE_RANGES)
def _is_invisible(cp: int) -> bool:
# Called on the neighbours of every hit, so a binary search rather than a
# scan of 28 ranges: a document made of invisible codepoints is the input
# this has to stay fast on.
k = bisect_right(_INVISIBLE_STARTS, cp) - 1
return k >= 0 and cp <= _INVISIBLE_RANGES[k][1]
# Carrier classes. The first three keep their pre-1.4.2 membership and labels;
# everything else in the denominator lands in the last three.
_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)
_SELECTOR_RANGES = ((0x180B, 0x180D), (0x180F, 0x180F), (0xFE00, 0xFE0F), (0xE0100, 0xE01EF))
_PRIVATE_USE_RANGES = ((0xE000, 0xF8FF), (0xF0000, 0xFFFFD), (0x100000, 0x10FFFD))
# Report order, which is also the order the labels were introduced in.
_CARRIER_CLASSES = ("zero-width", "bidi-override", "unicode-tag",
"variation-selector", "private-use", "invisible-format")
def _in(cp: int, ranges) -> bool:
for lo, hi in ranges:
if lo <= cp <= hi:
return True
return False
def _carrier_class(cp: int) -> str:
"""The class a non-exempt denominator codepoint is reported under."""
if cp in _ZERO_WIDTH:
return "zero-width"
if cp in _BIDI:
return "bidi-override"
if _TAG_LO <= cp <= _TAG_HI:
return "unicode-tag"
if _in(cp, _SELECTOR_RANGES):
return "variation-selector"
if _in(cp, _PRIVATE_USE_RANGES):
return "private-use"
return "invisible-format"
# --- context exemptions ------------------------------------------------------
#
# A character is judged by CONTEXT, never by identity alone — the rule the ZWJ
# fix below established. Each exemption is a known legitimate use, and a RUN is
# a carrier whatever it stands after: no exemption holds when a neighbour is
# itself invisible (for selectors: when the next codepoint is a selector). That
# is what keeps every exemption from reaching the published attack shapes, which
# are all runs — Butler's byte-per-selector string (A01), Sneaky Bits (A02).
#
# What the exemptions still leave open is a channel of at most one invisible
# codepoint per visible one, and each is named in LIMITATIONS. Private use has
# no exemption at all (operator 2026-10-28): an icon-font glyph is blocked rather
# than leaving one ~17-bit codepoint per visible gap.
# ZWJ (U+200D) is the one zero-width codepoint with a legitimate, extremely
# common use: it composes emoji. 👩‍💻 is WOMAN + ZWJ + PERSONAL COMPUTER, and
@ -62,7 +155,7 @@ _EMOJI_CTX_RANGES = (
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)
return _in(cp, _EMOJI_CTX_RANGES)
def _is_joiner_in_emoji_sequence(text: str, i: int) -> bool:
@ -75,6 +168,189 @@ def _is_joiner_in_emoji_sequence(text: str, i: int) -> bool:
return False
return _is_emoji_context(ord(text[i - 1])) and _is_emoji_context(ord(text[i + 1]))
# ZWNJ and ZWJ are required orthography in Arabic-script languages (Persian
# می<ZWNJ>خواهم, Urdu) and in the Indic scripts (Devanagari half forms, Malayalam
# explicit virama). Up to 1.4.1 every such document was an any-tier carrier, and
# the lexicon row above matched every Persian word that carries its ZWNJ.
#
# Exempt when one neighbour is a letter or mark of these scripts and neither is
# invisible. One side, not both — the rule the emoji ZWJ keeps — because the
# measured shapes need it: on 9 Wikipedia articles (fa ×3, hi ×2, ml, ur, he, ar;
# 2.5 MB, fetched 2026-10-28) 4 666 of 4 784 joiners had script on both sides, and
# the rest sat at a word end after a virama (`क्<ZWNJ> `) or against markup (`]]<ZWNJ>ها`).
# A Latin word split still has Latin on both sides, which is the shape the
# word-splitting attack needs, so it stays a carrier.
_JOINING_SCRIPT_RANGES = (
(0x0600, 0x08FF), # Arabic, Syriac, Thaana, NKo, Samaritan, Mandaic, Arabic Ext.
(0x0900, 0x0DFF), # Devanagari … Sinhala
(0xFB50, 0xFDFF), # Arabic Presentation Forms-A
(0xFE70, 0xFEFE), # Arabic Presentation Forms-B (U+FEFF, the BOM, excluded)
)
def _is_joiner_in_script(text: str, i: int) -> bool:
if i == 0 or i + 1 >= len(text):
return False
prev, nxt = ord(text[i - 1]), ord(text[i + 1])
if _is_invisible(prev) or _is_invisible(nxt):
return False
return _in(prev, _JOINING_SCRIPT_RANGES) or _in(nxt, _JOINING_SCRIPT_RANGES)
# One variation selector after the base it is defined for, and never a second.
# VS15/VS16 choose text or emoji presentation; the bases below cover 371/371 of
# Unicode 18.0's `emoji-variation-sequences.txt` (sha256 ff1707564aa1f1b2…),
# blocks where the block is a symbol block, single codepoints where it is a text
# block — a whole Basic Latin exemption would hang a selector off every letter.
# The 348 selectors in the benign corpora (VS16 after ⚠ ⏱ ❤ 🛡) are all of this
# shape. The ideographic selectors (U+E0100–E01EF, and FE00–FE02 for the 1 002
# CJK compatibility sequences in StandardizedVariants.txt) choose a registered
# glyph variant of a CJK ideograph; operator 2026-10-28 kept the plan's one
# selector per ideograph, so CJK text can still hide one byte per ideograph.
# Selectors after any other base (math, Mongolian, Myanmar, Egyptian) are caught.
_EMOJI_VS_BASE_CPS = frozenset({
0x0023, 0x002A, *range(0x0030, 0x003A), # # * 0-9: keycap bases
0x00A9, 0x00AE, 0x203C, 0x2049, 0x2122, 0x2139, 0x3030, 0x303D,
})
_EMOJI_VS_BASE_RANGES = (
(0x2190, 0x21FF), (0x2300, 0x23FF), (0x2460, 0x24FF), (0x25A0, 0x25FF),
(0x2600, 0x27BF), (0x2900, 0x297F), (0x2B00, 0x2BFF), (0x3200, 0x32FF),
(0x1F000, 0x1FAFF),
)
_CJK_IDEOGRAPH_RANGES = (
(0x3400, 0x4DBF), (0x4E00, 0x9FFF), (0xF900, 0xFAFF),
(0x20000, 0x3FFFF), # planes 2-3: extensions B-J and their headroom
)
def _is_selector_on_its_base(text: str, i: int, cp: int) -> bool:
if i == 0:
return False
if i + 1 < len(text) and _in(ord(text[i + 1]), _SELECTOR_RANGES):
return False # a run
base = ord(text[i - 1])
if cp in (0xFE0E, 0xFE0F):
return base in _EMOJI_VS_BASE_CPS or _in(base, _EMOJI_VS_BASE_RANGES)
if 0xFE00 <= cp <= 0xFE02 or 0xE0100 <= cp <= 0xE01EF:
return _in(base, _CJK_IDEOGRAPH_RANGES)
return False
# LRM / RLM / ALM order neutral characters in bidirectional text, so their
# context is the document's: one is exempt when the document carries
# right-to-left text and neither neighbour is invisible. Not the line — a
# measured Persian article put an LRM in a citation line with no RTL letter on
# it. The evidence never counts an invisible codepoint, or an ALM would be its
# own context. Looked up once per document, so a document of marks stays linear.
_DIRECTIONAL_MARKS = frozenset({0x200E, 0x200F, 0x061C})
_RTL_RANGES = (
(0x0590, 0x08FF), (0xFB1D, 0xFDFF), (0xFE70, 0xFEFE),
(0x10800, 0x10FFF), (0x1E800, 0x1EFFF),
)
def _minus(ranges, holes):
out = []
for lo, hi in ranges:
for h_lo, h_hi in holes:
if h_hi < lo or h_lo > hi:
continue
if h_lo > lo:
out.append((lo, h_lo - 1))
lo = h_hi + 1
if lo > hi:
break
if lo <= hi:
out.append((lo, hi))
return tuple(out)
_RTL_RE = _char_class(_minus(_RTL_RANGES, _INVISIBLE_RANGES))
# The Arabic number signs and their kin (Prepended_Concatenation_Mark) are
# visible: they span the digits that follow, which is the context (operator
# 2026-10-28). Without a digit after them they are caught, and two in a row
# never have one.
_PREPENDED_MARKS = frozenset({
0x0600, 0x0601, 0x0602, 0x0603, 0x0604, 0x0605, 0x06DD, 0x070F,
0x0890, 0x0891, 0x08E2, 0x110BD, 0x110CD,
})
@lru_cache(maxsize=1024)
def _route(cp: int) -> tuple[str, str | None]:
"""``(carrier class, context rule or None)`` for a denominator codepoint.
Cached because it depends on the codepoint alone, and a document made of a
few distinct invisible codepoints repeated is the input that must stay fast.
"""
if cp in (0x200C, 0x200D):
context = "joiner"
elif _in(cp, _SELECTOR_RANGES):
context = "selector"
elif cp in _DIRECTIONAL_MARKS:
context = "mark"
elif cp in _PREPENDED_MARKS:
context = "prepended"
else:
context = None
return _carrier_class(cp), context
def _is_exempt(text: str, i: int, context: str | None, has_rtl) -> bool:
"""True when the denominator codepoint at ``text[i]`` is a legitimate use.
``has_rtl`` is a zero-argument callable, so the document-level RTL lookup is
paid only when a directional mark actually needs it.
"""
if context is None:
return False
cp = ord(text[i])
if context == "joiner":
if cp == 0x200D and _is_joiner_in_emoji_sequence(text, i):
return True
return _is_joiner_in_script(text, i)
if context == "selector":
return _is_selector_on_its_base(text, i, cp)
if context == "mark":
if i > 0 and _is_invisible(ord(text[i - 1])):
return False
if i + 1 < len(text) and _is_invisible(ord(text[i + 1])):
return False
return has_rtl()
return i + 1 < len(text) and text[i + 1].isdecimal() # a prepended mark
def _rtl_lookup(text: str):
cache: list[bool] = []
def has_rtl() -> bool:
if not cache:
cache.append(_RTL_RE.search(text) is not None)
return cache[0]
return has_rtl
def _invisible_carriers(text: str):
"""Yield ``(index, class)`` for every invisible codepoint that is a carrier.
The one predicate behind both surfaces: ``sanitize`` strips what this yields
and ``output`` flags it, so the two cannot disagree on a character.
"""
has_rtl = _rtl_lookup(text)
for match in _INVISIBLE_RE.finditer(text):
i = match.start()
klass, context = _route(ord(text[i]))
if not _is_exempt(text, i, context, has_rtl):
yield i, klass
def _is_exempt_joiner(text: str, i: int) -> bool:
"""The joiner half of the predicate, for the lexicon's word-split row."""
return _is_exempt(text, i, _route(ord(text[i]))[1], _rtl_lookup(text))
# Span carriers.
#
# ReDoS note (OWASP LLM10). The comment stripper used to be `<!--.*?-->` with a
@ -164,40 +440,38 @@ def sanitize(
assert_within_input_cap(text, surface="sanitize", max_input_chars=max_input_chars)
report = Report()
# Character-class carriers: single pass, keep everything else verbatim.
zero_width = 0
bidi = 0
# Character-class carriers: drop each one the shared predicate convicts and
# keep everything else verbatim.
counts = dict.fromkeys(_CARRIER_CLASSES, 0)
tag_cps: list[int] = []
kept: list[str] = []
for i, ch in enumerate(text):
cp = ord(ch)
if cp == 0x200D and _is_joiner_in_emoji_sequence(text, i):
kept.append(ch) # composing an emoji, not carrying a payload
elif cp in _ZERO_WIDTH:
zero_width += 1
elif cp in _BIDI:
bidi += 1
elif _TAG_LO <= cp <= _TAG_HI:
tag_cps.append(cp)
else:
kept.append(ch)
pos = 0
for i, klass in _invisible_carriers(text):
kept.append(text[pos:i])
pos = i + 1
counts[klass] += 1
if klass == "unicode-tag":
tag_cps.append(ord(text[i]))
# Preserve object identity (and byte-identity) when nothing was stripped.
cleaned = "".join(kept) if (zero_width or bidi or tag_cps) else text
if pos:
kept.append(text[pos:])
cleaned = "".join(kept)
else:
cleaned = text
# Span carriers.
cleaned, n_comments = _strip_html_comments(cleaned)
cleaned, n_data = _DATA_URI_RE.subn("", cleaned)
if zero_width:
report.add(Finding(label="sanitize:zero-width", severity=Severity.HIGH,
source=source, detector="sanitize", count=zero_width, owasp="LLM01"))
if bidi:
report.add(Finding(label="sanitize:bidi-override", severity=Severity.HIGH,
source=source, detector="sanitize", count=bidi, owasp="LLM01"))
if tag_cps:
report.add(Finding(label="sanitize:unicode-tag", severity=Severity.CRITICAL,
source=source, detector="sanitize", count=len(tag_cps),
evidence=_redact(_decode_tags(tag_cps)), owasp="LLM01"))
for klass, count in counts.items():
if not count:
continue
tag = klass == "unicode-tag"
report.add(Finding(label=f"sanitize:{klass}",
severity=Severity.CRITICAL if tag else Severity.HIGH,
source=source, detector="sanitize", count=count,
evidence=_redact(_decode_tags(tag_cps)) if tag else None,
owasp="LLM01"))
if n_comments:
report.add(Finding(label="sanitize:html-comment", severity=Severity.MEDIUM,
source=source, detector="sanitize", count=n_comments, owasp="LLM01"))

View file

@ -221,11 +221,8 @@ GAP_CASES = [
]
# Rows red on 2026-10-28 (measured, session 1). Strict: a fix fails until removed.
# A01 and A02 caught from 1.4.2 (session 2: the invisible-codepoint denominator).
RED = {
"A01-input": "variation selectors are not a carrier class: 0 findings, all left in the text",
"A01-output": "variation selectors are not a carrier class: 0 findings",
"A02-input": "U+2062/U+2064 are not a carrier class: 0 findings",
"A02-output": "U+2062/U+2064 are not a carrier class: 0 findings",
"A04-input": "1px white text: zero-font matches a size starting with 0 only; 0 findings",
"A05-output": "each image carries no data, the sequence does: LOW -> WARN (beaconing gap)",
"A08-output": "natural-language persistence instruction in the output: 0 findings",

View file

@ -272,13 +272,13 @@ def test_redos_sweep_floor_and_flag_match_the_published_numbers():
assert "2.6 flag threshold" in text
def test_redos_sweep_collector_covers_152_patterns_across_11_tables():
# The count docs/LIMITATIONS.md carries as "all 152 compiled patterns across
def test_redos_sweep_collector_covers_154_patterns_across_11_tables():
# The count docs/LIMITATIONS.md carries as "all 154 compiled patterns across
# all eleven regex-bearing modules". A pattern added or removed in `src/`
# without re-measuring would drift the doc's claim silently otherwise.
assert len(redos_sweep.TABLES) == 11
total = sum(len(collect()) for collect in redos_sweep.TABLES.values())
assert total == 152
assert total == 154
# --- both scripts: the argument-less contract --------------------------------

252
tests/test_invisible.py Normal file
View file

@ -0,0 +1,252 @@
"""Every invisible codepoint is caught, or exempt on context with a named reason.
The denominator is frozen in `docs/threat-2026/codepoints.json` (session 1,
2026-10-28): Default_Ignorable_Code_Point | Cf | Co in Unicode 18.0, 141 674
codepoints. Before 1.4.2, 17 of 19 measured classes gave 0 findings and stayed in
the "sanitized" text — variation-selector smuggling (A01) and Sneaky Bits (A02)
among them.
The rule is the one the ZWJ fix established: judge a character by its CONTEXT,
never by identity alone. The exemptions, each a known legitimate use:
- one VS15/VS16 after an emoji base (presentation selector);
- one ideographic selector after a CJK ideograph (registered name variants);
- ZWNJ/ZWJ next to an Arabic-script or Indic letter (required orthography);
- LRM/RLM/ALM in a document that carries right-to-left text;
- an Arabic number sign directly before a digit (it spans the digits).
A RUN is a carrier whatever it stands after: an exemption never holds when a
neighbour is itself invisible (selectors: when the next one is a selector).
Private use is caught in full (operator 2026-10-28: an icon-font glyph is
blocked rather than leaving a ~17-bit-per-slot channel open).
Every payload here is built from escapes — no invisible glyph sits in source.
"""
import json
from pathlib import Path
import pytest
from llm_ingestion_guard import Disposition, PRESET_TRUSTED_SOURCE, decide, scan_output
from llm_ingestion_guard.lexicon import scan_lexicon
from llm_ingestion_guard.report import Report
from llm_ingestion_guard.sanitize import sanitize
from redos_clock import scan_seconds
_CODEPOINTS = (Path(__file__).resolve().parent.parent
/ "docs" / "threat-2026" / "codepoints.json")
_DENOMINATOR = json.loads(_CODEPOINTS.read_text(encoding="utf-8"))
# The output-gate label for each sanitize class (one predicate, two surfaces).
_OUTPUT_LABEL = {
"sanitize:zero-width": "output:zero-width-present",
"sanitize:bidi-override": "output:bidi-present",
"sanitize:unicode-tag": "output:unicode-tag-present",
"sanitize:variation-selector": "output:variation-selector-present",
"sanitize:private-use": "output:private-use-present",
"sanitize:invisible-format": "output:invisible-format-present",
}
VS16 = "\ufe0f"
VS15 = "\ufe0e"
ZWNJ = "\u200c"
ZWJ = "\u200d"
LRM = "\u200e"
RLM = "\u200f"
ALM = "\u061c"
HEBREW = "שלום" # shalom
PERSIAN_WORD = "می" + ZWNJ + "خواهم" # mi-khaham
def _labels(report) -> set:
return {f.label for f in report.findings}
def _sanitize_labels(text: str) -> set:
return {lbl for lbl in _labels(sanitize(text).report) if lbl in _OUTPUT_LABEL}
def _output_labels(text: str) -> set:
return {lbl for lbl in _labels(scan_output(text)) if lbl in _OUTPUT_LABEL.values()}
def _ranges():
return [(int(lo, 16), int(hi, 16)) for lo, hi in _DENOMINATOR["ranges"]]
# --- the denominator ---------------------------------------------------------
def test_the_frozen_denominator_is_the_one_in_the_code():
from llm_ingestion_guard.sanitize import _INVISIBLE_RANGES
assert list(_INVISIBLE_RANGES) == _ranges()
assert sum(hi - lo + 1 for lo, hi in _INVISIBLE_RANGES) == _DENOMINATOR["counts"]["total"]
def test_every_denominator_codepoint_out_of_context_is_caught_on_both_surfaces():
# 141 674 codepoints, each between two ASCII letters: no exemption context
# exists there, so every one must be counted, stripped and flagged.
cps = [cp for lo, hi in _ranges() for cp in range(lo, hi + 1)]
assert len(cps) == 141_674
text = "a" + "a".join(chr(cp) for cp in cps) + "a"
result = sanitize(text)
assert result.text == "a" * (len(cps) + 1)
stripped = sum(f.count for f in result.report.findings if f.label in _OUTPUT_LABEL)
assert stripped == len(cps)
from llm_ingestion_guard.output import _scan_invisible_carriers
from llm_ingestion_guard.report import Source
flagged = _scan_invisible_carriers(text, Source.OUTPUT)
assert sum(f.count for f in flagged.findings) == len(cps)
@pytest.mark.parametrize("cp", [
0x2060, 0x2061, 0x2062, 0x2063, 0x2064, 0x180E, 0x3164, 0x115F, 0x1160,
0xFFA0, 0x034F, 0xFFF9, 0x200E, 0x061C, 0xE000, 0xFE00, 0xE0100,
], ids=lambda cp: f"U+{cp:04X}")
def test_the_seventeen_classes_measured_red_are_caught(cp):
# PLAN T.1.2: 17 of 19 classes, 8 of each, gave 0 findings on v1.4.1.
text = "notes " + chr(cp) * 8 + " end"
assert _sanitize_labels(text), f"U+{cp:04X}: not caught by sanitize"
assert chr(cp) not in sanitize(text).text
assert _output_labels(text), f"U+{cp:04X}: not caught by the output gate"
def test_new_carrier_labels_block_in_every_tier():
for label in _OUTPUT_LABEL.keys() | set(_OUTPUT_LABEL.values()):
from llm_ingestion_guard.report import Finding, Severity, Source
report = Report()
report.add(Finding(label=label, severity=Severity.HIGH, source=Source.INPUT,
detector="sanitize", owasp="LLM01"))
decision = decide(report, PRESET_TRUSTED_SOURCE)
assert decision.disposition is Disposition.FAIL_SECURE, label
# --- the attacks -------------------------------------------------------------
def test_variation_selector_run_after_an_emoji_is_a_carrier():
# A01: Butler's encoding hangs the run off a visible emoji. The first
# selector stands right after a valid base; the run is what convicts it.
payload = "".join(chr(0xFE00 + b) if b < 16 else chr(0xE0100 + b - 16)
for b in b"ignore previous")
text = "Notes \U0001F600" + payload + " end."
result = sanitize(text)
assert "sanitize:variation-selector" in _labels(result.report)
assert result.text == "Notes \U0001F600 end."
assert "output:variation-selector-present" in _labels(scan_output(text))
def test_sneaky_bits_are_a_carrier():
# A02: bits in U+2062 / U+2064.
text = "Notes" + "\u2062\u2064\u2064\u2062" * 10 + " end."
assert "sanitize:invisible-format" in _labels(sanitize(text).report)
assert sanitize(text).text == "Notes end."
assert "output:invisible-format-present" in _labels(scan_output(text))
# --- the exemptions: legitimate use survives byte-identical --------------------
_LEGITIMATE = [
("VS16 after stopwatch", "Timer ⏱" + VS16 + " 5 min"), # measured in corpus
("VS16 after warning", "⚠" + VS16 + " Do not deploy"),
("VS16 after heart", "Thanks ❤" + VS16 + " team"),
("VS15 after heart", "Thanks ❤" + VS15 + " team"),
("keycap one", "Step 1" + VS16 + "⃣ first"),
("copyright sign", "©" + VS16 + " 2026"),
("VS16 + ZWJ sequence", "❤" + VS16 + ZWJ + "\U0001F525 on fire"),
("ideographic variant", "葛\U000E0100城"), # registered name form
("CJK compatibility variant", "豈\ufe00 ok"),
("Persian ZWNJ", "من " + PERSIAN_WORD + " این"),
("Persian ZWNJ after a link", "[کتاب]]" + ZWNJ + "ها"),
("Persian ZWJ for medial heh", "ه" + ZWJ + "."),
("Devanagari half form", "क्" + ZWJ + "ष"),
("Malayalam explicit virama", "ന്" + ZWNJ + " "),
("LRM in Hebrew text", HEBREW + " (ABC" + LRM + ") " + HEBREW),
("RLM in Hebrew text", HEBREW + " 6" + RLM + ": " + HEBREW),
("LRM elsewhere in an RTL document", "Ref [1" + LRM + "].\n" + HEBREW),
("ALM in Arabic text", "مصر " + ALM + "(1) مصر"),
("Arabic number sign", "المبلغ \u0600١٢٣"),
("Arabic year sign", "\u0601" + "2026"),
]
@pytest.mark.parametrize("name,text", _LEGITIMATE, ids=[c[0] for c in _LEGITIMATE])
def test_legitimate_use_is_neither_flagged_nor_stripped(name, text):
result = sanitize(text)
assert not _sanitize_labels(text), f"{name}: {_labels(result.report)}"
assert result.text == text, f"{name}: content corrupted"
assert not _output_labels(text), f"{name}: output gate flagged it"
def test_persian_zwnj_does_not_trip_the_word_splitting_row():
# The lexicon row `unicode:zero-width-in-word` (\w[ZW]\w) matched every
# Persian word written with its required ZWNJ. It judges the joiner by the
# same predicate now; a Latin word split is still the row's target.
assert "unicode:zero-width-in-word" not in _labels(scan_lexicon(PERSIAN_WORD))
assert "unicode:zero-width-in-word" in _labels(scan_lexicon("impor" + ZWNJ + "tant"))
mixed = PERSIAN_WORD + " impor" + ZWNJ + "tant"
assert "unicode:zero-width-in-word" in _labels(scan_lexicon(mixed))
# --- the exemptions do not reach the attack shapes ---------------------------
_CARRIERS = [
("two VS16 after an emoji", "❤" + VS16 + VS16, "sanitize:variation-selector"),
("VS16 after a letter", "a" + VS16, "sanitize:variation-selector"),
("VS16 at document start", VS16 + "x", "sanitize:variation-selector"),
("two ideographic selectors", "葛\U000E0100\U000E0101", "sanitize:variation-selector"),
("ideographic selector after a letter", "a\U000E0100", "sanitize:variation-selector"),
("ZWNJ splitting a Latin word", "impor" + ZWNJ + "tant", "sanitize:zero-width"),
("ZWNJ between emoji", "\U0001F469" + ZWNJ + "\U0001F4BB", "sanitize:zero-width"),
("ZWNJ run in Persian", "می" + ZWNJ + ZWNJ + "خ", "sanitize:zero-width"),
("ZWNJ next to another invisible", "م" + ZWNJ + LRM + "خ", "sanitize:zero-width"),
("LRM in a Latin-only document", "Plain text" + LRM + " here", "sanitize:invisible-format"),
("ALM alone is not RTL context", ALM + "x", "sanitize:invisible-format"),
("number sign alone is not RTL context", "\u0600 then " + LRM, "sanitize:invisible-format"),
("LRM/RLM bits in Hebrew", HEBREW + " " + LRM + RLM + LRM + RLM, "sanitize:invisible-format"),
("number sign without a digit", "م \u0600م", "sanitize:invisible-format"),
("number-sign run", "\u0600\u0600١", "sanitize:invisible-format"),
("BMP private use", "icon \ue000 here", "sanitize:private-use"),
("Apple logo", "\uf8ff Mac", "sanitize:private-use"),
("plane-15 private use", "x\U000F0001y", "sanitize:private-use"),
("Mongolian vowel separator", "ᠠ\u180eᠡ", "sanitize:invisible-format"),
("interlinear annotation", "\ufff9hidden\ufffb", "sanitize:invisible-format"),
]
@pytest.mark.parametrize("name,text,label", _CARRIERS, ids=[c[0] for c in _CARRIERS])
def test_carrier_shapes_are_caught(name, text, label):
assert label in _sanitize_labels(text), f"{name}: {_labels(sanitize(text).report)}"
assert _OUTPUT_LABEL[label] in _output_labels(text), name
# --- one predicate, two surfaces ---------------------------------------------
@pytest.mark.parametrize(
"text",
[c[1] for c in _LEGITIMATE] + [c[1] for c in _CARRIERS],
ids=[c[0] for c in _LEGITIMATE] + [c[0] for c in _CARRIERS],
)
def test_sanitize_and_the_output_gate_agree_per_class(text):
# Anything sanitize strips, the output gate flags — class for class, count
# for count. A split is how a carrier reaches a persisted artifact.
stripped = {(f.label, f.count) for f in sanitize(text).report.findings
if f.label in _OUTPUT_LABEL}
from llm_ingestion_guard.output import _scan_invisible_carriers
from llm_ingestion_guard.report import Source
flagged = {(f.label, f.count) for f in _scan_invisible_carriers(text, Source.OUTPUT).findings}
assert {(_OUTPUT_LABEL[lbl], n) for lbl, n in stripped} == flagged
# --- self-safety --------------------------------------------------------------
def test_document_level_rtl_context_stays_linear():
# The RTL context is one lookup per document, not one per mark: a rescan
# per mark would be quadratic in a document made of marks.
text = HEBREW + (" x" + LRM) * 300_000
assert scan_seconds(sanitize, text) < 2.0
from llm_ingestion_guard.output import _scan_invisible_carriers
from llm_ingestion_guard.report import Source
assert scan_seconds(lambda t: _scan_invisible_carriers(t, Source.OUTPUT), text) < 2.0

View file

@ -184,9 +184,9 @@ def test_zwj_at_document_edge_is_still_a_carrier():
def test_other_zero_width_classes_are_untouched_by_the_zwj_narrowing():
# Only U+200D got a context test. ZWSP/ZWNJ/BOM/soft-hyphen between emoji
# stay carriers — ZWNJ's own false-positive class (Persian/Devanagari
# orthography) is a separate, deliberately unaddressed decision.
# Only U+200D got the emoji context test. ZWSP/ZWNJ/BOM/soft-hyphen between
# emoji stay carriers — ZWNJ's own context is script, not pictographic
# (1.4.2, `tests/test_invisible.py`), and an emoji is not that script.
for cp in (0x200B, 0x200C, 0xFEFF, 0x00AD):
text = f"\U0001F469{chr(cp)}\U0001F4BB"
result = sanitize(text)