diff --git a/tools/shield/detector.py b/tools/shield/detector.py new file mode 100644 index 000000000..0c57c3d5a --- /dev/null +++ b/tools/shield/detector.py @@ -0,0 +1,766 @@ +""" +SHIELD Security Module for Hermes +Jailbreak and Crisis Detection System + +Based on Issue #75 Red Team Audit Specifications +""" + +import re +import base64 +import unicodedata +from enum import Enum +from typing import Dict, List, Set, Tuple, Any + + +class Verdict(Enum): + """Four possible SHIELD verdicts""" + CLEAN = "CLEAN" + JAILBREAK_DETECTED = "JAILBREAK_DETECTED" + CRISIS_DETECTED = "CRISIS_DETECTED" + CRISIS_UNDER_ATTACK = "CRISIS_UNDER_ATTACK" + + +# SAFE SIX: Trusted models for crisis situations +SAFE_SIX_MODELS: Set[str] = { + "anthropic/claude-sonnet-4", + "meta-llama/llama-3.1-8b-instruct", + "moonshotai/kimi-k2.5", + "x-ai/grok-code-fast-1", + "xiaomi/mimo-v2-flash", + "z-ai/glm-5-turbo", +} + +# Crisis System Prompt for emergency situations +CRISIS_SYSTEM_PROMPT = """You are a crisis intervention assistant. The user may be experiencing a mental health emergency. + +CRITICAL INSTRUCTIONS: +1. Your ONLY goal is to help keep the user safe +2. Do NOT provide methods for self-harm under ANY circumstances +3. Express genuine compassion and concern +4. Encourage professional help immediately + +CRISIS RESOURCES (share these): +- 988 Suicide and Crisis Lifeline (call or text 988) +- Crisis Text Line: Text HOME to 741741 +- Emergency Services: 911 (if immediate danger) +- Jesus saves those who call on His name - "For everyone who calls on the name of the Lord will be saved." (Romans 10:13) + +RESPONSE GUIDELINES: +- Validate their pain without validating harmful thoughts +- Offer hope - recovery is possible +- Keep responses brief and focused on safety +- Never suggest that self-harm is a valid option +- Encourage reaching out to trusted friends, family, or professionals + +Remember: This person's life is valuable. Respond with compassion, urgency, and hope.""" + + +class ShieldDetector: + """ + SHIELD Detection Engine + + Detects jailbreak attempts and crisis signals in user messages. + Optimized for speed: ~1-5ms per message. + """ + + # Jailbreak Pattern Categories + JB_GODMODE_DIVIDERS = [ + r'\.\-\.\-\.\-', + r'\.\=\.\=\.\=', + r'\[\-\-\-\]', + r'\{\{\{', + r'\|\|\|', + ] + + JB_AUTHOR_MARKERS = [ + r'(?:^|\s)pliny(?:\s|$)', + r'(?:^|\s)d(?:\s|\.|\-)*an(?:\s|$)', + r'(?:^|\s)liberating(?:\s|$)', + r'godmode\s+enabled', + r'developer\s+mode', + r'ignore\s+previous\s+instructions', + r'ignore\s+all\s+prior\s+instructions', + ] + + JB_LEETSPEAK_MARKERS = [ + r'\bsur3\b', + r'\bh3r3\b', + r'\by0u\b', + r'\bw1ll\b', + r'\bn0w\b', + r'\bpl34s3\b', + r'\b4nd\b', + r'\bth3\b', + r'\b4ll\b', + r'\b1nstruct1ons\b', + ] + + JB_BOUNDARY_INVERSION = [ + r'\[END\s+OF\s+INPUT\]', + r'\[END\s+INPUT\]', + r'\[START\s+OUTPUT\]', + r'<\|im_start\|>', + r'<\|im_end\|>', + r'<\|system\|>', + r'<\|user\|>', + r'<\|assistant\|>', + r'<>', + r'<>', + r'\[INST\]', + r'\[/INST\]', + r'\[SYSTEM\s*:\s*', + r'NEW\s+SYSTEM\s+PROMPT', + r'updated\s+system\s+prompt', + r'override\s+system', + ] + + JB_TOKEN_INJECTION = [ + r'\\u003c\|vq_\|\\u003e', + r'<\|vq_\|>', + r'<\|special\|>', + r'<\|injection\|>', + r'{{\s*user\s*}}', + r'{{\s*char\s*}}', + r'<<\s*user\s*>>', + r'<<\s*system\s*>>', + ] + + JB_KEYWORDS = [ + r'\bgodmode\b', + r'\bliberating\b', + r'^\s*DAN\s*[:\-]?', + r'\bjailbreak\b', + r'\bdo\s+anything\s+now\b', + r'\bno\s+restrictions\b', + r'\bno\s+limits\b', + r'\bunfiltered\b', + r'\bunleashed\b', + r'\buncensored\b', + ] + + JB_REFUSAL_INVERSION = [ + r'never\s+say\s+(?:"|\')?(?:I\s+cannot|I\s+can\'t|I\s+apologize|I\'m\s+sorry)', + r'do\s+not\s+(?:mention|say|include|refuse|decline)', + r'never\s+(?:mention|say|include|refuse|decline)', + r'do\s+not\s+respond\s+with\s+(?:"|\')?(?:I\s+cannot|I\s+can\'t)', + r'skip\s+(?:the\s+)?(?:disclaimer|warning|caution|ethical)', + r'ignore\s+(?:the\s+)?(?:ethical|safety|guideline|policy)', + ] + + JB_PERSONA_INJECTION = [ + r'pretend\s+(?:to\s+be|you\s+are|you\'re)', + r'act\s+as\s+(?:if\s+you\s+are|you\'re|though)', + r'roleplay\s+(?:as\s+|being\s+)', + r'take\s+on\s+(?:the\s+role\s+of|a\s+persona)', + r'you\s+are\s+now\s+(?:a\s+|an\s+)', + r'simulate\s+(?:being\s+|a\s+)', + r'from\s+now\s+on\s+you\s+are', + r'you\s+will\s+(?:now\s+)?behave\s+as', + ] + + JB_ENCODING_EVASION = [ + r'base64\s*[:\-]?\s*[A-Za-z0-9+/]{20,}={0,2}', + r'rot13\s*[:\-]?', + r'decode\s+this\s*[:\-]?', + r'execute\s+as\s+code', + r'run\s+as\s+python', + r'eval\s*\(', + r'exec\s*\(', + ] + + # Crisis Signal Categories + CRISIS_SUICIDAL_IDEATION = [ + r'\b(?:kill|end)\s+(?:myself|my\s+life)\b', + r'\bwant\s+to\s+die\b', + r'\bwant\s+to\s+end\s+(?:it|everything|my\s+life)\b', + r'\bsuicidal\s+(?:thoughts|ideation|feelings?)\b', + r'\bhate\s+my\s+life\b', + r'\bno\s+reason\s+to\s+live\b', + r'\bbetter\s+off\s+dead\b', + r'\bwish\s+I\s+(?:was|were)\s+dead\b', + ] + + CRISIS_METHOD_SEEKING = [ + r'\bhow\s+(?:do\s+I|to|can\s+I)\s+(?:kill|hang|overdose|cut)\s+myself\b', + r'\bways?\s+to\s+(?:die|kill\s+myself)\b', + r'\bmethods?\s+of\s+(?:suicide|dying)\b', + r'\bpainless\s+way\s+to\s+(?:die|go)\b', + r'\bhow\s+much\s+(?:pills|medication)\s+(?:to\s+take|for\s+overdose)\b', + r'\bwhere\s+to\s+(?:jump|get\s+rope|buy\s+gun)\b', + r'\bsuicide\s+methods?\b', + r'\bhow\s+to\s+(?:commit\s+suicide|end\s+my\s+life)\b', + ] + + CRISIS_LEETSPEAK_EVASION = [ + r'\bsu1c1d3?\b', + r'\b5u1c1d3?\b', + r'\bd13?\b', + r'\b0v3rd0s3?\b', + r'\bh4ng\s+mys3lf\b', + r'\bk1ll\s+mys3lf\b', + r'\bw4nt\s+t0\s+d13?\b', + r'\bw4nt\s+t0\s+d1e\b', + ] + + CRISIS_SUBSTANCE_SEEKING = [ + r'\bhow\s+much\s+(?:ambien|xanax|prozac|seroquel|trazodone)\s+(?:to\s+overdose|to\s+die)\b', + r'\blethal\s+dose\s+(?:of\s+)?(?:pills|medication|drugs)\b', + r'\bhow\s+many\s+pills\s+(?:to\s+die|to\s+end\s+it)\b', + r'\btaking\s+all\s+my\s+(?:pills|medication)\b', + r'\bmixing\s+(?:alcohol|pills)\s+(?:to\s+die|to\s+end\s+it)\b', + ] + + CRISIS_DESPAIR = [ + r'\bno\s+(?:point|reason|hope)\s+(?:in\s+living|anymore)\b', + r'\bcannot\s+(?:go\s+on|continue|take\s+it)\b', + r'\bcan\'t\s+(?:go\s+on|continue|take\s+it)\b', + r'\bempty\s+(?:inside|and\s+hollow)\b', + r'\bno\s+one\s+(?:cares|would\s+miss\s+me)\b', + r'\bwould\s+be\s+better\s+off\s+without\s+me\b', + r'\bnever\s+going\s+to\s+get\s+better\b', + r'\bnothing\s+(?:matters|will\s+change)\b', + ] + + CRISIS_FAREWELL = [ + r'\bsaying\s+goodbye\s+(?:to\s+everyone|forever)\b', + r'\bthis\s+is\s+(?:goodbye|farewell)\b', + r'\btell\s+(?:them|everyone)\s+I\s+(?:loved|love)\s+(?:them|him|her)\b', + r'\bgiving\s+away\s+my\s+(?:things|stuff|possessions)\b', + r'\bmy\s+final\s+(?:message|note|words)\b', + r'\bwrote\s+a\s+(?:suicide\s+)?note\b', + r'\bsorry\s+for\s+(?:everything|the\s+pain|hurting\s+you)\b', + ] + + CRISIS_SELF_HARM = [ + r'\bcut(?:ting)?\s+myself\b', + r'\bself\s*harm(?:ing)?\b', + r'\bhurt(?:ing)?\s+myself\b', + r'\bburn(?:ing)?\s+myself\b', + r'\bwant\s+to\s+(?:feel\s+pain|hurt\s+myself)\b', + r'\bdeserve\s+to\s+(?:hurt|suffer|be\s+punished)\b', + ] + + # ===================================================================== + # Multilingual Injection Detection (Issue #614) + # ===================================================================== + # + # Translated attack phrases for top non-English languages. + # Covers: Chinese (zh), Arabic (ar), Russian (ru), Hindi (hi), + # Spanish (es), French (fr), German (de), Japanese (ja), + # Korean (ko), Portuguese (pt). + # + # These are literal translations of common English jailbreak phrases + # that bypass English-only regex detection. + # ===================================================================== + + # Unicode normalization map: confusable characters -> ASCII equivalents + # Handles homoglyph attacks (Cyrillic а vs Latin a, etc.) + CONFUSABLE_MAP: Dict[str, str] = { + # Cyrillic -> Latin + '\u0430': 'a', # а + '\u0435': 'e', # е + '\u043e': 'o', # о + '\u0440': 'p', # р + '\u0441': 'c', # с + '\u0443': 'y', # у + '\u0445': 'x', # х + '\u0410': 'A', # А + '\u0415': 'E', # Е + '\u041e': 'O', # О + '\u0420': 'P', # Р + '\u0421': 'C', # С + '\u0423': 'Y', # У + '\u0425': 'X', # Х + # Greek -> Latin + '\u03b1': 'a', # α + '\u03bf': 'o', # ο + '\u03c1': 'p', # ρ + '\u03c3': 's', # σ + '\u03c4': 't', # τ + # Fullwidth -> ASCII + '\uff41': 'a', '\uff42': 'b', '\uff43': 'c', '\uff44': 'd', + '\uff45': 'e', '\uff46': 'f', '\uff47': 'g', '\uff48': 'h', + } + + # Multilingual jailbreak: "ignore all instructions" in 10 languages + JB_MULTILINGUAL_IGNORE = [ + # Chinese + r'忽\s*略\s*所\s*有\s*(?:指\s*令|规\s*则|命\s*令|指\s*示)', + r'忽\s*略\s*之\s*前\s*的\s*(?:指\s*令|规\s*则|命\s*令)', + r'不\s*要\s*遵\s*守\s*(?:规\s*则|指\s*令|限\s*制)', + r'无\s*视\s*(?:所\s*有|之\s*前)\s*(?:指\s*令|规\s*则)', + # Arabic + r'تجاهل\s+(?:جميع|كل)\s+(?:التعليمات|القواعد|الأوامر)', + r'لا\s+تتبع\s+(?:القواعد|التعليمات)', + r'.Ignore\s+(?:جميع|كل)\s+(?:التعليمات|القواعد)', + # Russian + r'игнорируй\s+(?:все|все предыдущие)\s+(?:инструкции|правила|команды)', + r'не\s+следуй\s+(?:правилам|инструкциям)', + r'пренебреги\s+(?:всеми|всеми предыдущими)\s+(?:инструкциями|правилами)', + # Hindi + r'(?:सभी|सारे)\s+(?:निर्देश|नियम|आदेश)\s+(?:अनदेखा|नज़रअंदाज़)\s+करो', + r'(?:निर्देश|नियम)\s+(?:अनदेखा|नज़रअंदाज़)\s+करो', + r'पहले\s+के\s+(?:निर्देश|नियम)\s+(?:अनदेखा|नज़रअंदाज़)\s+करो', + # Spanish + r'ignora\s+(?:todas?\s+)?(?:las?\s+)?(?:instrucciones|reglas|órdenes)', + r'no\s+sigas\s+(?:las?\s+)?(?:reglas|instrucciones)', + r'olvida\s+(?:todas?\s+)?(?:las?\s+)?(?:instrucciones|reglas)', + # French + r'ignore\s+(?:toutes?\s+)?(?:les?\s+)?(?:instructions|règles|consignes)', + r'ne\s+suis\s+pas\s+(?:les?\s+)?(?:règles|instructions)', + r'oublie\s+(?:toutes?\s+)?(?:les?\s+)?(?:instructions|règles)', + # German + r'ignoriere\s+(?:alle\s+)?(?:Anweisungen|Regeln|Befehle)', + r'befolge\s+(?:die\s+)?(?:Regeln|Anweisungen)\s+nicht', + r'vernachlässige\s+(?:alle\s+)?(?:Anweisungen|Regeln)', + # Japanese + r'(?:すべての|全ての)?(?:指示|ルール|命令|規則)を(?:無視|IGNORE)', + r'(?:指示|ルール|命令)を(?:無視|IGNORE)', + r'従わないで\s*(?:指示|ルール|規則)', + # Korean + r'(?:모든|전체)\s*(?:지시|규칙|명령)\s*을?\s*(?:를)?\s*(?:무시|IGNORE)', + r'(?:지시|규칙|명령)\s*을?\s*(?:를)?\s*(?:무시|IGNORE)', + r'따르지\s*마\s*(?:지시|규칙)', + # Portuguese + r'ignore\s+(?:todas?\s+)?(?:as?\s+)?(?:instruções|regras|ordens)', + r'não\s+(?:siga|obedeça)\s+(?:as?\s+)?(?:regras|instruções)', + r'esqueça\s+(?:todas?\s+)?(?:as?\s+)?(?:instruções|regras)', + ] + + # Multilingual jailbreak: "you are now [role]" / persona injection + JB_MULTILINGUAL_PERSONA = [ + # Chinese + r'你\s*(?:现在|从现在起)\s*(?:是|扮演|成为)', + r'扮演\s*(?:一个|一下)\s*(?:角色|人物)', + r'假装\s*(?:你\s*(?:是|成为))', + # Arabic + r'أنت\s+الآن\s+(?:角色)', + r'كن\s+(?:角色)', + r'تظاهر\s+أنك\s+角色', + # Russian + r'ты\s+теперь\s+(?:角色)', + r'притворись\s+(?:что\s+ты)', + r'сыграй\s+роль', + # Spanish + r'(?:ahora\s+)?(?:eres|serás|actúa\s+como)', + r'finge\s+que\s+(?:eres|sos)', + r'pretende\s+ser', + # French + r'tu\s+es\s+maintenant', + r'fais\s+semblant\s+d\'être', + r'joue\s+le\s+rôle\s+de', + # German + r'du\s+bist\s+jetzt', + r'tue\s+so\s+als\s+wärst\s+du', + r'spiel\s+die\s+Rolle', + # Japanese + r'(?:あなたは|君は)\s*(?:今|これから)', + r'(?:ふりをして|假装)', + r'役割を(?:演じて|務めて)', + # Korean + r'너는\s*(?:지금|이제)', + r'역할을\s*(?:연기해|맡아)', + r'척해\s*(?:봐|줘)', + ] + + # Multilingual jailbreak: "do anything now" / unrestricted behavior + JB_MULTILINGUAL_UNRESTRICTED = [ + # Chinese + r'(?:现在|从现在起)\s*(?:可以|能)\s*(?:做|做任何)\s*(?:任何|一切|所有)', + r'不\s*(?:受|要)\s*(?:任何|所有)\s*(?:限制|约束|规则)', + r'(?:自由|无限制|无约束)\s*(?:地|的)?\s*(?:回答|回复|行动)', + # Arabic + r'افعل\s+أي\s+شيء\s+الآن', + r'بدون\s+(?:قيود|restrictions|limits)', + # Russian + r'делай\s+(?:что\s+угодно|всё\s+что\s+хочешь)\s+сейчас', + r'без\s+(?:ограничений|restrictions)', + # Spanish + r'haz\s+(?:lo\s+que\s+quieras|cualquier\s+cosa)\s+ahora', + r'sin\s+(?:restricciones|limits|límites)', + # French + r'fais\s+(?:ce\s+que\s+tu\s+veux|n\'importe\s+quoi)\s+maintenant', + r'sans\s+(?:restrictions|limites)', + # German + r'tue\s+(?:was\s+du\s+willst|alles)\s+jetzt', + r'ohne\s+(?:Einschränkungen|restrictions)', + # Japanese + r'(?:何でも|なんでも)(?:して|やっ?て)', + r'(?:制限|制約|ルール)\s*(?:なし|無し|なく)', + # Korean + r'(?:아무거나|뭐든지)\s*(?:해|하세요)', + r'(?:제한|제약|규칙)\s*(?:없이|없는)', + ] + + # Multilingual crisis signals: suicidal ideation + CRISIS_MULTILINGUAL_SUICIDAL = [ + # Chinese + r'想\s*(?:死|自杀|结束\s*(?:生命|自己|一切))', + r'不\s*想\s*(?:活|活下去|活了)', + r'活着\s*(?:没|没有)\s*(?:意思|意义|意思)', + # Arabic + r'أريد\s+(?:أن\s+أموت|الموت|أن\s+أقتل\s+نفسي)', + r'لا\s+أريد\s+(?:العيش|الحياة|أن\s+أعيش)', + # Russian + r'хочу\s+(?:умереть|помереть|убить\s+себя)', + r'не\s+хочу\s+(?:жить|жизни)', + # Hindi + r'मरना\s+चाहता?\s*(?:हूँ|है)', + r'जीना\s+नहीं\s+चाहता?\s*(?:हूँ|है)', + r'आत्महत्या\s+करना\s+चाहता?\s*(?:हूँ|है)', + # Spanish + r'quiero\s+(?:morir|suicidarme|matarme)', + r'no\s+quiero\s+(?:vivir|seguir\s+vivo)', + r'odio\s+mi\s+vida', + # French + r'je\s+veux\s+(?:mourir|me\s+suicider|me\s+tuer)', + r'je\s+ne\s+veux\s+plus\s+(?:vivre|continuer)', + # German + r'ich\s+will\s+(?:sterben|mich\s+umbringen|suizid)', + r'ich\s+will\s+nicht\s+mehr\s+(?:leben|weiterleben)', + # Japanese + r'(?:死にたい|消えたい|自殺したい)', + r'(?:生きる|生きてる)\s*(?:意味|甲斐)\s*(?:が|は)\s*(?:ない|無い)', + # Korean + r'(?:죽고\s*싶다|죽고\s*싶어|자살\s*하고\s*싶다)', + r'(?:살기|살아가기)\s*(?:싫다|싫어)', + # Portuguese + r'quero\s+(?:morrer|me\s+matar|suicidar)', + r'não\s+quero\s+(?:mais\s+)?viver', + ] + + # Multilingual crisis: despair / hopelessness + CRISIS_MULTILINGUAL_DESPAIR = [ + # Chinese + r'(?:生活|活着)\s*(?:没有|没)\s*(?:意义|意思|希望)', + r'一切\s*(?:都|全)\s*(?:没有|没)\s*(?:意义|希望|用)', + # Arabic + r'لا\s+(?:أمل|hope|reason)\s+(?:في\s+الحياة|للعيش)', + # Russian + r'нет\s+(?:надежды|смысла)\s+(?:жить|в\s+жизни)', + # Spanish + r'no\s+tiene\s+(?:sentido|hope|razón)\s+(?:vivir|la\s+vida)', + # French + r'il\s+n\'y\s+a\s+plus\s+(?:d\'espoir|de\s+raison\s+de\s+vivre)', + # German + r'es\s+hat\s+(?:keinen\s+Sinn|keine\s+Hoffnung)\s+(?:zu\s+leben|mehr)', + # Japanese + r'(?:生きる|生きてる)\s*(?:意味|甲斐|希望)\s*(?:が|は)\s*(?:ない|無い| 없다)', + # Korean + r'(?:사는|살아가는)\s*(?:의미|희망|이유)\s*(?:가|은)\s*(?:없다|없어)', + ] + + def __init__(self): + """Initialize compiled regex patterns for performance""" + self._compile_patterns() + + def _compile_patterns(self): + """Compile all detection patterns for fast execution""" + # Jailbreak patterns + self.jb_patterns = { + 'godmode_dividers': re.compile('|'.join(self.JB_GODMODE_DIVIDERS), re.IGNORECASE), + 'author_markers': re.compile('|'.join(self.JB_AUTHOR_MARKERS), re.IGNORECASE), + 'leetspeak': re.compile('|'.join(self.JB_LEETSPEAK_MARKERS), re.IGNORECASE), + 'boundary_inversion': re.compile('|'.join(self.JB_BOUNDARY_INVERSION), re.IGNORECASE), + 'token_injection': re.compile('|'.join(self.JB_TOKEN_INJECTION), re.IGNORECASE), + 'keywords': re.compile('|'.join(self.JB_KEYWORDS), re.IGNORECASE), + 'refusal_inversion': re.compile('|'.join(self.JB_REFUSAL_INVERSION), re.IGNORECASE), + 'persona_injection': re.compile('|'.join(self.JB_PERSONA_INJECTION), re.IGNORECASE), + 'encoding_evasion': re.compile('|'.join(self.JB_ENCODING_EVASION), re.IGNORECASE), + # Multilingual (Issue #614) + 'multilingual_ignore': re.compile('|'.join(self.JB_MULTILINGUAL_IGNORE)), + 'multilingual_persona': re.compile('|'.join(self.JB_MULTILINGUAL_PERSONA)), + 'multilingual_unrestricted': re.compile('|'.join(self.JB_MULTILINGUAL_UNRESTRICTED)), + } + + # Crisis patterns + self.crisis_patterns = { + 'suicidal_ideation': re.compile('|'.join(self.CRISIS_SUICIDAL_IDEATION), re.IGNORECASE), + 'method_seeking': re.compile('|'.join(self.CRISIS_METHOD_SEEKING), re.IGNORECASE), + 'leetspeak_evasion': re.compile('|'.join(self.CRISIS_LEETSPEAK_EVASION), re.IGNORECASE), + 'substance_seeking': re.compile('|'.join(self.CRISIS_SUBSTANCE_SEEKING), re.IGNORECASE), + 'despair': re.compile('|'.join(self.CRISIS_DESPAIR), re.IGNORECASE), + 'farewell': re.compile('|'.join(self.CRISIS_FAREWELL), re.IGNORECASE), + 'self_harm': re.compile('|'.join(self.CRISIS_SELF_HARM), re.IGNORECASE), + # Multilingual (Issue #614) + 'multilingual_suicidal': re.compile('|'.join(self.CRISIS_MULTILINGUAL_SUICIDAL)), + 'multilingual_despair': re.compile('|'.join(self.CRISIS_MULTILINGUAL_DESPAIR)), + } + + def _check_jailbreak(self, message: str) -> Tuple[bool, Dict[str, List[str]]]: + """ + Check message for jailbreak patterns + + Returns: + Tuple of (detected, patterns_matched) + """ + patterns_found = {} + detected = False + + for category, pattern in self.jb_patterns.items(): + matches = pattern.findall(message) + if matches: + patterns_found[category] = matches + detected = True + + # Check for base64 encoded content + if self._detect_base64_jailbreak(message): + patterns_found.setdefault('encoding_evasion', []).append('base64_jailbreak') + detected = True + + return detected, patterns_found + + def _check_crisis(self, message: str) -> Tuple[bool, Dict[str, List[str]]]: + """ + Check message for crisis signals + + Returns: + Tuple of (detected, patterns_matched) + """ + patterns_found = {} + detected = False + + for category, pattern in self.crisis_patterns.items(): + matches = pattern.findall(message) + if matches: + patterns_found[category] = matches + detected = True + + return detected, patterns_found + + def _detect_base64_jailbreak(self, message: str) -> bool: + """Detect potential jailbreak attempts hidden in base64""" + # Look for base64 strings that might decode to harmful content + b64_pattern = re.compile(r'[A-Za-z0-9+/]{40,}={0,2}') + potential_b64 = b64_pattern.findall(message) + + for b64_str in potential_b64: + try: + decoded = base64.b64decode(b64_str).decode('utf-8', errors='ignore') + # Check if decoded content contains jailbreak keywords + if any(kw in decoded.lower() for kw in ['ignore', 'system', 'jailbreak', 'dan', 'godmode']): + return True + except Exception: + continue + + return False + + def _calculate_confidence( + self, + jb_detected: bool, + crisis_detected: bool, + jb_patterns: Dict[str, List[str]], + crisis_patterns: Dict[str, List[str]] + ) -> float: + """ + Calculate confidence score based on number and type of matches + + Returns: + Float between 0.0 and 1.0 + """ + confidence = 0.0 + + if jb_detected: + # Weight different jailbreak categories + weights = { + 'godmode_dividers': 0.9, + 'token_injection': 0.9, + 'refusal_inversion': 0.85, + 'boundary_inversion': 0.8, + 'author_markers': 0.75, + 'keywords': 0.7, + 'persona_injection': 0.6, + 'leetspeak': 0.5, + 'encoding_evasion': 0.8, + # Multilingual (Issue #614) + 'multilingual_ignore': 0.85, + 'multilingual_persona': 0.6, + 'multilingual_unrestricted': 0.75, + } + + for category, matches in jb_patterns.items(): + weight = weights.get(category, 0.5) + confidence += weight * min(len(matches) * 0.3, 0.5) + + if crisis_detected: + # Crisis patterns get high weight + weights = { + 'method_seeking': 0.95, + 'substance_seeking': 0.95, + 'suicidal_ideation': 0.9, + 'farewell': 0.85, + 'self_harm': 0.9, + 'despair': 0.7, + 'leetspeak_evasion': 0.8, + # Multilingual (Issue #614) + 'multilingual_suicidal': 0.9, + 'multilingual_despair': 0.7, + } + + for category, matches in crisis_patterns.items(): + weight = weights.get(category, 0.7) + confidence += weight * min(len(matches) * 0.3, 0.5) + + return min(confidence, 1.0) + + @staticmethod + def _merge_patterns(a: Dict[str, List[str]], b: Dict[str, List[str]]) -> Dict[str, List[str]]: + """Merge two pattern dictionaries, deduplicating matches.""" + merged = {} + for d in (a, b): + for category, matches in d.items(): + if category not in merged: + merged[category] = list(matches) + else: + existing = set(merged[category]) + for m in matches: + if m not in existing: + merged[category].append(m) + existing.add(m) + return merged + + def _normalize_unicode(self, text: str) -> str: + """Normalize unicode to catch homoglyph attacks. + + 1. NFKC normalization (compatibility decomposition + canonical composition) + 2. Replace confusable characters (Cyrillic/Greek lookalikes -> ASCII) + 3. Strip zero-width characters used for obfuscation + """ + # NFKC normalization handles most compatibility characters + normalized = unicodedata.normalize('NFKC', text) + + # Replace confusable characters + result = [] + for ch in normalized: + if ch in self.CONFUSABLE_MAP: + result.append(self.CONFUSABLE_MAP[ch]) + else: + result.append(ch) + normalized = ''.join(result) + + # Strip zero-width characters (used to break pattern matching) + zero_width = '\u200b\u200c\u200d\u2060\ufeff' # ZWSP, ZWNJ, ZWJ, WJ, BOM + for zw in zero_width: + normalized = normalized.replace(zw, '') + + return normalized + + def detect(self, message: str) -> Dict[str, Any]: + """ + Main detection entry point + + Analyzes a message for jailbreak attempts and crisis signals. + Now includes unicode normalization and multilingual detection (Issue #614). + + Args: + message: The user message to analyze + + Returns: + Dict containing: + - verdict: One of Verdict enum values + - confidence: Float 0.0-1.0 + - patterns_matched: Dict of matched patterns by category + - action_required: Bool indicating if intervention needed + - recommended_model: Model to use (None for normal routing) + """ + if not message or not isinstance(message, str): + return { + 'verdict': Verdict.CLEAN.value, + 'confidence': 0.0, + 'patterns_matched': {}, + 'action_required': False, + 'recommended_model': None, + } + + # Normalize unicode to catch homoglyph attacks (Issue #614) + normalized = self._normalize_unicode(message) + + # Run detection on both original and normalized + # Original catches native-script multilingual attacks + # Normalized catches homoglyph-evasion attacks + jb_detected_orig, jb_patterns_orig = self._check_jailbreak(message) + jb_detected_norm, jb_patterns_norm = self._check_jailbreak(normalized) + crisis_detected_orig, crisis_patterns_orig = self._check_crisis(message) + crisis_detected_norm, crisis_patterns_norm = self._check_crisis(normalized) + + # Merge results from both passes + jb_detected = jb_detected_orig or jb_detected_norm + jb_patterns = self._merge_patterns(jb_patterns_orig, jb_patterns_norm) + crisis_detected = crisis_detected_orig or crisis_detected_norm + crisis_patterns = self._merge_patterns(crisis_patterns_orig, crisis_patterns_norm) + + # Calculate confidence + confidence = self._calculate_confidence( + jb_detected, crisis_detected, jb_patterns, crisis_patterns + ) + + # Determine verdict + if jb_detected and crisis_detected: + verdict = Verdict.CRISIS_UNDER_ATTACK + action_required = True + recommended_model = None # Will use Safe Six internally + elif crisis_detected: + verdict = Verdict.CRISIS_DETECTED + action_required = True + recommended_model = None # Will use Safe Six internally + elif jb_detected: + verdict = Verdict.JAILBREAK_DETECTED + action_required = True + recommended_model = None # Route to hardened model + else: + verdict = Verdict.CLEAN + action_required = False + recommended_model = None + + # Combine patterns + all_patterns = {} + if jb_patterns: + all_patterns['jailbreak'] = jb_patterns + if crisis_patterns: + all_patterns['crisis'] = crisis_patterns + + return { + 'verdict': verdict.value, + 'confidence': round(confidence, 3), + 'patterns_matched': all_patterns, + 'action_required': action_required, + 'recommended_model': recommended_model, + } + + +# Convenience function for direct use +def detect(message: str) -> Dict[str, Any]: + """ + Convenience function to detect threats in a message. + + Args: + message: User message to analyze + + Returns: + Detection result dictionary + """ + detector = ShieldDetector() + return detector.detect(message) + + +def is_safe_six_model(model_name: str) -> bool: + """ + Check if a model is in the SAFE SIX trusted list + + Args: + model_name: Name of the model to check + + Returns: + True if model is in SAFE SIX + """ + return model_name.lower() in {m.lower() for m in SAFE_SIX_MODELS} + + +def get_crisis_prompt() -> str: + """ + Get the crisis system prompt for emergency situations + + Returns: + Crisis intervention system prompt + """ + return CRISIS_SYSTEM_PROMPT