File size: 22,570 Bytes
9a08963
5f80ef1
 
 
9a08963
 
 
 
 
 
00b6f48
9a08963
 
 
 
 
 
 
 
 
0b65b92
 
 
 
9a08963
 
 
 
 
 
 
 
 
 
0b65b92
 
9a08963
 
 
 
 
 
 
 
 
 
 
0b65b92
 
 
9a08963
f4c0cd7
 
 
9a08963
 
 
 
 
 
 
 
5caf228
 
 
 
9a08963
 
5caf228
 
f4c0cd7
 
 
 
 
 
 
 
9a08963
 
 
 
5caf228
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9a08963
 
b367f07
9a08963
 
0b65b92
 
9a08963
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b367f07
9a08963
 
 
 
 
 
 
0b65b92
9a08963
 
5caf228
 
 
 
 
 
9a08963
5caf228
9a08963
0b65b92
 
 
00b6f48
 
 
 
 
 
 
 
d264654
 
 
00b6f48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0803c88
 
00b6f48
0803c88
d264654
00b6f48
 
 
0803c88
00b6f48
 
0803c88
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
00b6f48
 
08be160
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9a08963
 
 
 
08be160
 
 
 
 
 
9a08963
08be160
 
 
3438464
9a08963
 
08be160
 
 
3438464
9a08963
00b6f48
3438464
9a08963
 
 
0b65b92
 
9a08963
 
 
 
 
 
 
 
 
a38c560
 
 
 
339b914
9a08963
 
 
 
 
 
 
 
 
a38c560
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ed17f57
 
 
 
 
 
 
 
 
 
9a08963
 
f4c0cd7
 
 
 
 
 
 
ed17f57
 
 
 
f4c0cd7
a38c560
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
"""
lumen_security.py — защита от промт-инъекций и утечки идентичности
(Lumen не представляется Gemini/Gemma/OpenRouter — см. system_prompt.py).
Детекторы — чистые функции; внешний список ID моделей — из lumen_router_config.
"""

from __future__ import annotations

import logging
import re
import unicodedata

from lumen_router_config import GEMINI_MODELS, GEMINI_TTS_MODELS, _KNOWN_MODEL_IDS_FOR_LEAK_DETECTION

# Единый логгер "bot" (а не __name__) — чтобы caplog.at_level(..., logger="bot")
# в тестах продолжал ловить предупреждения независимо от того, в каком
# физическом файле живёт код (см. тот же приём в lumen_router_config.py).
log = logging.getLogger("bot")

# ─────────────────── защита от утечки провайдера/модели (выходной фильтр) ───────────────────
# Системный промпт — слабый рубеж: его уговаривают инъекцией. Поэтому это второй
# рубеж: режем готовый текст ДО отправки и ДО истории, иначе утечка осядет
# в контексте и протечёт в следующие ответы. Если в готовом тексте проскочило
# реальное имя модели/провайдера, весь ответ подменяется на нейтральный fallback.
#
# Слой А — точные строки внутренних ID моделей. Ложных срабатываний практически не
# бывает: обычный ответ на обычный вопрос никогда не должен содержать дефис-разделённый
# технический идентификатор вида "gemini-3.5-flash" или "z-ai/glm-4.5-air:free" — такие
# строки в естественной русской (или английской) речи не встречаются случайно.
_LEAK_LITERAL_STRINGS: tuple[str, ...] = tuple(sorted(
    set(GEMINI_MODELS.keys())
    | set(GEMINI_TTS_MODELS)
    | set(_KNOWN_MODEL_IDS_FOR_LEAK_DETECTION)
))
# Найдено код-ревью: полный перескан стрима это O(n²). Сканим хвост 300 символов:
# самый длинный паттерн 61, стык кусков влезает с запасом.
_LEAK_SCAN_TAIL_CHARS = 300

def _leak_scan_window(full_text: str, latest_piece: str) -> str:
    """Возвращает "хвост" накопленного текста, достаточный для обнаружения ЛЮБОГО
    паттерна утечки, который мог образоваться после добавления latest_piece — без
    необходимости пересканировать весь full_text целиком на каждой итерации стрима.
    Окно берётся с запасом на случай аномально большого одиночного куска."""
    window_size = max(_LEAK_SCAN_TAIL_CHARS, len(latest_piece) + 100)
    return full_text[-window_size:]


# Слой Б — только точные фразы "я — бренд". Широкое окно рядом с "я" ложно ловило
# рассказы про бренды: "я" — частый токен, а хеджирование вроде "я не могу
# сравнивать себя..." — не утечка.
_LEAK_BRAND_TOKENS = (
    r"(gemini|gemma|gpt[\s\-]?(?:oss|\d)[\w\-]*|chatgpt|openai|claude|anthropic|deepmind|openrouter|"
    r"nemotron|qwen|llama|glm[\s\-]?4|hermes|mistral|dolphin[\s\-]?mistral|venice|laguna|"
    r"deepseek|grok|meta(?:\s*ai)?|google|"
    r"lfm[\s\-]?2\.5|нейросет\w*\s+google|модел\w*\s+google|google\s*ai|google\s+gemini)"
)
_IDENTITY_LEAK_RE = re.compile(
    rf"\bя\s*(?:—|-|:)?\s*(?:это\s+|являюсь\s+)?{_LEAK_BRAND_TOKENS}\b"
    rf"|\bмен[яе]\s+(?:зовут|называют)\s+{_LEAK_BRAND_TOKENS}\b"
    rf"|\bя\s+созда(?:н|на)\w*\s+(?:компанией\s+)?{_LEAK_BRAND_TOKENS}\b"
    rf"|\bмен[яе]\s+созда(?:л|ла)\w*\s+{_LEAK_BRAND_TOKENS}\b"
    rf"|\bработаю\s+на\s+(?:базе\s+)?{_LEAK_BRAND_TOKENS}\b"
    # "основан на бренде" / "based on бренд" без первого лица не ловим: "Его
    # архитектура основана на Google Transformer" и "The model is based on
    # Google research" честные рассказы, не самоопределение (см. ниже проверку
    # по предложениям с _FIRST_PERSON_RE).
    rf"|\bэт[оауи]\s*(?:модел\w*|нейросет\w*)\s*(?:—|-|:)?\s*{_LEAK_BRAND_TOKENS}\b"
    rf"|\bi\s*(?:am|'m)\s+{_LEAK_BRAND_TOKENS}\b"
    # "built on / powered by / based on бренд" тоже только от первого лица,
    # иначе честный рассказ о чужом стеке блокировался целиком.
    rf"|\bi\s+(?:was\s+)?(?:created|made)\s+by\s+(?:the\s+|company\s+)?{_LEAK_BRAND_TOKENS}\b"
    rf"|\bi\s*(?:am|'m)\s+from\s+(?:the\s+|company\s+)?{_LEAK_BRAND_TOKENS}\b"
    rf"|\bmy\s+creators?\s+(?:is|are)\s+(?:the\s+|company\s+)?{_LEAK_BRAND_TOKENS}\b"
    # "я от Google" только как короткое самоопределение (дальше пунктуация/конец):
    # "я от Google узнал..." — честная фраза, а не утечка.
    rf"|\bя\s+от\s+(?:компании\s+)?{_LEAK_BRAND_TOKENS}(?=\s*[.!?…;:,]|$)"
    rf"|\bмо[йи]\s+создател\w*\s*(?:—|-|:)?\s*(?:компани\w*\s+)?{_LEAK_BRAND_TOKENS}\b"
    rf"|\bя\s*(?:—|-|:)?\s*(?:это\s+|являюсь\s+)?(?:модел\w*|нейросет\w*)\s+от\s+(?:компании\s+)?{_LEAK_BRAND_TOKENS}\b"
    rf"|{_LEAK_BRAND_TOKENS}\s*,?\s*а\s+не\s+lumen\b",
    re.IGNORECASE,
)

# "Основан на" и английские built/powered/based только от первого лица и в
# пределах одного предложения: иначе "я" из соседней фразы тянуло бы чужой
# рассказ ("Я помогу. Его архитектура основана на Google Transformer").
_IDENTITY_BASED_ON_RE = re.compile(
    rf"\bоснован\w*\s+на\s+{_LEAK_BRAND_TOKENS}\b"
    rf"|\bbuilt\s+on\s+{_LEAK_BRAND_TOKENS}\b"
    rf"|\bpowered\s+by\s+{_LEAK_BRAND_TOKENS}\b"
    rf"|\bbased\s+on\s+{_LEAK_BRAND_TOKENS}\b",
    re.IGNORECASE,
)
_FIRST_PERSON_RE = re.compile(r"\b(я|меня|мне|мной|мною|i|me|my)\b", re.IGNORECASE)
_SENTENCE_SPLIT_RE = re.compile(r"[.!?…\n]+")

# Точные ID моделей ловим как отдельные токены, а не подстрокой: слаг внутри
# более длинного слова ("gemini-3.8-flashback") утечкой не считаем.
_LEAK_LITERAL_RE = re.compile(
    r"(?<![\w/:.\-])(?:" + "|".join(
        re.escape(lit) for lit in sorted(_LEAK_LITERAL_STRINGS, key=len, reverse=True) if lit
    ) + r")(?![\w/:.\-])",
    re.IGNORECASE,
) if _LEAK_LITERAL_STRINGS else None

_IDENTITY_LEAK_FALLBACK = (
    "Внутренние технические детали своей реализации я не раскрываю. "
    "Если у тебя есть другой вопрос — с радостью помогу."
)

# Слой В — ловим только лексику "подтверждения взлома" из пересказа чужой инструкции.
# Широкий поиск ловил код и честные объяснения джейлбрейков.
_INJECTED_PAYLOAD_ECHO_RE = re.compile(
    r"security[_\s]?breach[_\s]?detected"
    r"|system[_\s]?override[_\s]?(successful|complete)"
    r"|diagnostic[_\s]?success"
    r"|prompt[_\s]?validation[_\s]?successful"
    r"|jailbreak[_\s]?success(ful)?"
    r"|bypass[_\s]?successful"
    r"|injection[_\s]?successful"
    r"|breach[_\s]?detected"
    r"|взлом\s+(прошёл\s+)?успешно"
    r"|инъекция\s+(прошла\s+)?успешно"
    r"|проверка\s+(пройдена|успешна)[:.]?\s*(систем\w*|промпт\w*)",
    re.IGNORECASE,
)

_INJECTED_PAYLOAD_ECHO_FALLBACK = (
    "Это похоже на текст из инструкции, внедрённой в присланный контент, а не на "
    "обычный ответ — воспроизводить его не буду. Если у тебя обычный вопрос, задай "
    "его, и я отвечу."
)

def _detect_injected_payload_echo(text: str) -> bool:
    return bool(text) and bool(_INJECTED_PAYLOAD_ECHO_RE.search(text))

def _detect_identity_leak(text: str) -> bool:
    """Чистая проверка без лога: дёшево вызывать на каждый кусок стрима, лог только в _scrub_identity_leak."""
    if not text:
        return False
    if _LEAK_LITERAL_RE is not None and _LEAK_LITERAL_RE.search(text):
        return True
    if _IDENTITY_LEAK_RE.search(text):
        return True
    for sentence in _SENTENCE_SPLIT_RE.split(text):
        if _IDENTITY_BASED_ON_RE.search(sentence) and _FIRST_PERSON_RE.search(sentence):
            return True
    return False

# Слой Г — только лог [mush-suspect], без подмены: сигнал смешение письменностей
# внутри токена (прод-кейс nemotron-nano-9b-v2, см. _OR_MODEL_HEALTH).
# Порог 3+ токена от 100 символов эвристический, код/URL исключены.
_SCRIPT_KEYWORDS = (
    "LATIN", "CYRILLIC", "GREEK", "ARMENIAN", "HEBREW", "ARABIC",
    "DEVANAGARI", "BENGALI", "TAMIL", "TELUGU", "KANNADA", "MALAYALAM",
    "GUJARATI", "GURMUKHI", "ORIYA", "SINHALA", "THAI", "LAO", "MYANMAR",
    "KHMER", "GEORGIAN", "THAANA", "HIRAGANA", "KATAKANA", "HANGUL", "CJK",
)
_MUSH_MIN_TEXT_LEN = 100
_MUSH_MIN_MIXED_TOKENS = 3
# Верхний кап сканируемого текста: unicodedata.name() на каждый символ без края —
# CPU-DoS длинным текстом (аудит M10, 10.2026). Хвост за капом не сканируем.
_MUSH_MAX_TEXT_LEN = 20000


def _token_scripts(token: str) -> set[str]:
    scripts: set[str] = set()
    for ch in token:
        if not ch.isalpha():
            continue
        try:
            name = unicodedata.name(ch)
        except ValueError:
            continue
        for keyword in _SCRIPT_KEYWORDS:
            if keyword in name:
                scripts.add(keyword)
                break
    return scripts


def _garbled_mixed_tokens(text: str) -> list[str]:
    """Смешанные токены (2+ письменности внутри одного): сырьё детектора и проверки эха."""
    if not text or len(text) < _MUSH_MIN_TEXT_LEN:
        return []
    text = text[:_MUSH_MAX_TEXT_LEN]
    stripped = re.sub(r"```.*?```", " ", text, flags=re.DOTALL)
    stripped = re.sub(r"`[^`\n]+`", " ", stripped)
    stripped = re.sub(r"https?://\S+", " ", stripped)
    mixed: list[str] = []
    for token in re.findall(r"[^\W_]+", stripped, flags=re.UNICODE):
        if len(_token_scripts(token)) >= 2:
            mixed.append(token)
            if len(mixed) >= _MUSH_MIN_MIXED_TOKENS:
                break
    return mixed


def _detect_garbled_mix(text: str) -> bool:
    """True при 3+ смешанных токенах от 100 символов. Короткие не смотрим — там шум выше пользы."""
    return len(_garbled_mixed_tokens(text)) >= _MUSH_MIN_MIXED_TOKENS


def _is_garbled_echo(answer: str, user_text: str | None) -> bool:
    """Эхо за пользователем, а не каша модели: все смешанные токены уже были в запросе."""
    if not user_text:
        return False
    mixed = _garbled_mixed_tokens(answer)
    if not mixed:
        return False
    low_user = user_text.lower()
    return all(token.lower() in low_user for token in mixed)


def _leak_spans(text: str) -> list[tuple[int, int]]:
    """Диапазоны символов, где нашлась утечка или эхо инструкции."""
    spans: list[tuple[int, int]] = []
    for pattern in (_LEAK_LITERAL_RE, _IDENTITY_LEAK_RE, _INJECTED_PAYLOAD_ECHO_RE, _IDENTITY_BASED_ON_RE):
        if pattern is None:
            continue
        for m in pattern.finditer(text):
            spans.append((m.start(), m.end()))
    return spans


# Доля текста с утечкой, выше которой точечная вычистка бессмысленна: резать
# почти весь ответ — хуже, чем честно отказаться (аудит M9, 10.2026).
_SCRUB_MAX_CONTAMINATED_RATIO = 0.5


def _scrub_contaminated_sentences(text: str, *, kind: str) -> str | None:
    """Вырезает только предложения с утечкой. None — текст порезан слишком сильно."""
    spans = _leak_spans(text)
    if not spans:
        return text
    # Границы предложений: режем по .!?… и переводам строк, сохранение разделителя.
    bounds: list[int] = [0]
    for m in _SENTENCE_SPLIT_RE.finditer(text):
        bounds.append(m.end())
    bounds.append(len(text))
    drop: set[int] = set()
    for start, end in spans:
        for idx in range(len(bounds) - 1):
            lo, hi = bounds[idx], bounds[idx + 1]
            if lo <= start < hi or lo < end <= hi:
                drop.add(idx)
    kept = "".join(text[bounds[i]:bounds[i + 1]] for i in range(len(bounds) - 1) if i not in drop)
    kept = kept.strip()
    if not kept:
        return None
    if len(kept) < len(text) * _SCRUB_MAX_CONTAMINATED_RATIO:
        return None
    log.warning('[%s] Scrubbed only the offending sentence(s), kept %d of %d chars', kind, len(kept), len(text))
    return kept


def _scrub_identity_leak(text: str, *, source: str) -> str:
    """Точка применения фильтра для НЕстримингового пути (ask_gemini, ask_openrouter_*).
    Вызывается непосредственно перед записью ответа в историю чата — если вызвать её
    только перед показом пользователю, но не перед hist.append/history.append, утечка
    осталась бы в истории и могла бы повлиять на последующие ответы модели.

    Раньше любое срабатывание выкидывало ВЕСЬ ответ и подставляло fallback: одна
    вставленная фраза про Gemini посреди нормального объяснения выглядела как поломка
    бота (аудит M9, 10.2026). Теперь режем только предложение с утечкой, а fallback
    оставляем на случай, когда остаток нечитаем."""
    if _detect_identity_leak(text):
        scrubbed = _scrub_contaminated_sentences(text, kind="identity-leak")
        if scrubbed is not None:
            return scrubbed
        log.warning('[identity-leak] Detected and blocked an identity leak (source=%s, len=%d)', source, len(text))
        return _IDENTITY_LEAK_FALLBACK
    if _detect_injected_payload_echo(text):
        scrubbed = _scrub_contaminated_sentences(text, kind="injection-echo")
        if scrubbed is not None:
            return scrubbed
        log.warning('[injection-echo] Detected and blocked a likely injected-instruction echo (source=%s, len=%d)', source, len(text))
        return _INJECTED_PAYLOAD_ECHO_FALLBACK
    if _detect_garbled_mix(text):
        log.warning('[mush-suspect] Reply looks garbled by multilingual fragments (source=%s, len=%d)', source, len(text))
    return text

# ─────────────────── защита от промт-инъекций (входной префильтр) ───────────────────
# Входной префильтр: явные взломы режем без LLM, со 100% гарантией. Вопросы "какая
# ты модель" не здесь, их честно разбирает модель.
_INJECTION_PROBE_RE = re.compile(
    r"ignore\s+(all\s+|any\s+)?(the\s+)?(previous|prior|above|earlier)\s+instructions"
    r"|забудь\s+(все\s+|про\s+)?(предыдущие\s+|системные\s+)?инструкции"
    r"|игнорируй\s+(все\s+|любые\s+)?(предыдущие\s+|системные\s+)?(инструкции|правила|указания)"
    r"|print\s+your\s+(system\s+)?(prompt|instructions)"
    r"|repeat\s+(everything|the\s+text|all\s+the\s+words)\s+above"
    r"|покажи\s+(мне\s+)?сво(й|и)\s+(системн\w*\s+)?(промпт|инструкции)"
    r"|выведи\s+(мне\s+)?сво(й|и)\s+(системн\w*\s+)?(промпт|инструкции)"
    r"|повтори\s+(всё\s+|весь\s+текст\s+)?(что\s+)?(написано\s+)?выше"
    # Голое "debug mode" / "режим разработчика" без инъекционного контекста не ловим:
    # "Как включить режим разработчика на Android?" и "Что такое debug mode
    # в Python?" легитимны. Режим разработчика/отладки/бога срабатывает только
    # парой с явным взломом (см. _MODE_WORD_RE + _MODE_CONTEXT_RE ниже).
    r"|(ты\s+теперь|перейди|перейти|включи|включить|подтверди|подтверждаю)\b[^.?!]{0,60}режиме\s+(разработчика|отладки|бога|джейлбрейк\w*)"
    r"|you\s+are\s+now\s+(an?\s+)?(unrestricted|uncensored|jailbroken)"
    r"|ты\s+теперь\s+(без\s+ограничени\w*|неограничен\w*|не\s+связан\w*\s+правилами)"
    r"|act\s+as\s+(an?\s+)?(unfiltered|uncensored|jailbroken|dan)\b"
    r"|притворись\s*,?\s*(что\s+)?у\s+тебя\s+нет\s+(правил|ограничени\w*)"
    r"|(what|which)\s+(is\s+)?your\s+(real\s+|actual\s+)?system\s+prompt"
    r"|раскрой\s+(свой\s+)?системн\w*\s+промпт",
    re.IGNORECASE,
)

# Слово режима без контекста легитимно (Android/Python/Minecraft), с явным
# взломом рядом режем. Контекст без общих глаголов "включи/открой": иначе
# "Как включить режим разработчика на Android?" снова ложно срабатывал.
_MODE_WORD_RE = re.compile(
    r"(developer|debug|god|dan|jailbreak)\s*[\s\-]?mode"
    r"|режим\w*\s+(разработчика|разработчике|отладки|отладке|бога|джейлбрейк\w*)",
    re.IGNORECASE,
)
_MODE_CONTEXT_RE = re.compile(
    r"ты\s+теперь|you\s+are\s+now|act\s+as"
    r"|игнорируй|забудь|ignore\s+(all\s+|any\s+)?(the\s+)?(previous|prior|above|earlier)"
    r"|unrestricted|uncensored|jailbroken|без\s+ограничени|неограничен|не\s+связан\w*\s+правилами"
    r"|притворись|подтверди|подтверждаю"
    r"|system\s+prompt|системн\w*\s+(промпт|инструкц)"
    r"|(покажи|выведи|раскрой|print|повтори|repeat)\b[^.?!]{0,40}(промпт|конфигурац|инструкц|prompt|instructions|above|выше)",
    re.IGNORECASE,
)

# Омоглифы кириллица/латиница: NFKC их не сводит, а модель читает ("Іgnore"
# с украинской і выглядит как ignore). Сворачиваем двойников к латинице перед
# проверкой (прод-проба 09.10.2026: такой зонд прошёл префильтр, удержала модель).
_HOMOGLYPH_FOLD_TABLE = str.maketrans({
    "а": "a", "е": "e", "ё": "e", "і": "i", "ї": "i", "о": "o", "р": "p",
    "с": "c", "х": "x", "у": "y", "ј": "j", "ѕ": "s", "һ": "h", "ԁ": "d",
    "А": "A", "В": "B", "Е": "E", "І": "I", "Ї": "I", "К": "K", "М": "M",
    "Н": "H", "О": "O", "Р": "P", "С": "C", "Т": "T", "Х": "X", "Ү": "Y",
})

def _looks_like_injection_probe(text: str) -> bool:
    """Чистая функция — тестируется отдельно от _handle_message_core."""
    if not text:
        return False
    norm = unicodedata.normalize("NFKC", text)
    # Невидимки бывают и внутри слова, и вместо пробела: проверяем оба варианта.
    stripped = "".join(ch for ch in norm if unicodedata.category(ch) != "Cf")
    if _INJECTION_PROBE_RE.search(stripped):
        return True
    # Тот же текст со свёрнутыми омоглифами: иначе "Іgnore prevіous
    # іnstructions" с кириллическими буквами проходит мимо.
    if _INJECTION_PROBE_RE.search(stripped.translate(_HOMOGLYPH_FOLD_TABLE)):
        return True
    spaced = "".join(" " if unicodedata.category(ch) == "Cf" else ch for ch in norm)
    if _INJECTION_PROBE_RE.search(spaced):
        return True
    # Режим разработчика/отладки/бога только с инъекционным контекстом: голые
    # вопросы про Android/Python/Minecraft идут модели, явные взломы режем здесь.
    probe = stripped if _MODE_WORD_RE.search(stripped) and _MODE_CONTEXT_RE.search(stripped) else ""
    if probe:
        return True
    return bool(_MODE_WORD_RE.search(spaced) and _MODE_CONTEXT_RE.search(spaced))