Arag / app /services /guardrails.py
AuthorBot
Implement Enterprise Worth Roadmap (R-139–R-173).
df480e4
Raw
History Blame Contribute Delete
10.6 kB
"""Author RAG Chatbot SaaS β€” Input Sanitizer & Boundary Enforcer.
Three layers of protection on every chat turn:
1. Input sanitization β€” strip control chars, delimiter injection, obfuscation.
2. Input boundary check β€” jailbreak / piracy / off-topic patterns (regex + heuristics).
3. Output safety check β€” block system-prompt leaks, identity reveals, instruction echo.
RULE: Sanitize BEFORE any LLM call.
RULE: check_boundary runs on EVERY user message β€” never skip.
RULE: is_response_safe runs on EVERY generated response β€” never skip.
"""
from __future__ import annotations
import re
import unicodedata
import structlog
logger = structlog.get_logger(__name__)
_MAX_INPUT_CHARS = 2000
# ─── Characters & delimiters used in prompt-injection attacks ─────────────────
_CONTROL_CHAR_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
_ZERO_WIDTH_RE = re.compile(r"[\u200b-\u200f\u2028-\u202f\ufeff]")
_DELIMITER_INJECTION_RE = re.compile(
r"<\|?(?:system|im_start|im_end|assistant|user|endoftext)\|?>"
r"|\[INST\]|\[/INST\]|<<SYS>>|<</SYS>>"
r"|###\s*(?:system|instruction|human|assistant)\b",
re.IGNORECASE,
)
# ─── Jailbreak / prompt-injection patterns ───────────────────────────────────
_JAILBREAK_PATTERNS = [
r"ignore\s+(all\s+)?(previous|prior|above|earlier)\s+(instructions?|rules?|prompts?|guidelines?)",
r"disregard\s+(all\s+)?(previous|prior|above|your)\s+(instructions?|rules?|prompts?)",
r"forget\s+(everything|all|your)\s+(you\s+)?(know|learned|instructions?|rules?|guidelines?)",
r"override\s+(your\s+)?(instructions?|rules?|programming|guidelines?|safety)",
r"bypass\s+(your\s+)?(restrictions?|filters?|rules?|guardrails?|safety)",
r"you\s+are\s+now\s+(?!an?\s+(?:book\s+)?advisor)",
r"from\s+now\s+on\s+you\s+(?:are|will|must|should)",
r"act\s+as\s+(?:if\s+you\s+(?:are|were)\s+)?(?!a?\s+book)",
r"pretend\s+(?:you\s+)?(?:are|have|to\s+be)\s+(?!a?\s+book)",
r"roleplay\s+as",
r"simulate\s+(?:being|a)\s+(?!book)",
r"developer\s+mode",
r"debug\s+mode",
r"admin\s+mode",
r"maintenance\s+mode",
r"do\s+anything\s+now",
r"\bdan\b",
r"jailbreak",
r"disable\s+(?:your\s+)?(?:content\s+)?(?:filter|restriction|limit|guardrail|safety)",
r"remove\s+(?:your\s+)?(?:restrictions?|filters?|limits?|guardrails?)",
r"without\s+(?:any\s+)?(?:restrictions?|filters?|limits?|guardrails?)",
r"no\s+(?:rules?|restrictions?|limits?|guardrails?)\s+(?:apply|mode)",
r"reveal\s+(?:your\s+)?(?:system\s+)?(?:prompt|instructions?|rules?|guidelines?)",
r"(?:show|print|repeat|output|display)\s+(?:your\s+)?(?:system\s+)?(?:prompt|instructions?|rules?)",
r"what\s+(?:are|is|were)\s+your\s+(?:system\s+)?(?:prompt|instructions?|rules?|guidelines?)",
r"repeat\s+(?:the\s+)?(?:text|words?|message)\s+above",
r"everything\s+(?:you\s+)?(?:were|are)\s+told",
r"initiali[sz]ation\s+(?:string|prompt|message)",
r"hidden\s+(?:prompt|instructions?|rules?)",
r"internal\s+(?:prompt|instructions?|rules?|config)",
r"api[_\s-]?key",
r"secret[_\s-]?key",
r"openai[_\s-]?key",
r"(?:i\s+am|i'm)\s+(?:the\s+)?(?:developer|owner|admin|creator|engineer)",
r"(?:i\s+am|i'm)\s+your\s+(?:developer|creator|owner|admin)",
r"new\s+(?:instructions?|rules?|prompt)\s*:",
r"system\s+prompt\s*:",
r"<\s*system\s*>",
r"\[system\]",
r"hypothetically[,]?\s+(?:ignore|disregard|forget|if\s+you\s+(?:could|had))",
r"for\s+(?:research|testing|educational)\s+purposes[,]?\s+(?:ignore|disregard|forget|bypass)",
]
_PIRACY_PATTERNS = [
r"free\s+(?:pdf|copy|download|epub|ebook|version)",
r"pirated?\s+(?:copy|version|pdf|download|link)",
r"(?:send|give|share)\s+me\s+(?:the\s+)?(?:full|entire|complete)\s+(?:book|text|pdf)",
r"(?:torrent|epub|mobi)\s+(?:link|download|file)",
r"download\s+(?:the\s+)?book\s+for\s+free",
r"read\s+(?:it|the\s+book)\s+for\s+free",
r"without\s+(?:paying|buying|purchasing)",
]
_OFF_TOPIC_KEYWORDS = [
"stock market", "cryptocurrency", "medical advice",
"legal advice", "write malware", "write a virus", "exploit code",
"porn", "adult content", "gambling",
]
# R-172: Competitor mentions β€” decline without engaging
_COMPETITOR_PATTERNS = [
r"\bchatgpt\b",
r"\bclaude\b",
r"\bgemini\b",
r"\bcompetitor\b",
r"\balternative\s+(?:to|for)\s+this\s+book\b",
r"(?:better|worse)\s+than\s+(?:other|another)\s+(?:book|author)s?\b",
r"compare.*(?:to|with).*(?:other|another)\s+author",
]
# Book-adjacent phrasing β€” never treat as off-topic (e.g. purchase objections)
_BOOK_CONTEXT_SIGNALS: tuple[str, ...] = (
"book", "buy", "purchase", "read", "copy", "author", "story",
"why should", "convince me", "worth", "no interest", "not interested",
"don't like", "do not like", "this book", "the book",
)
# Heuristic: multiple injection signals in one message
_INJECTION_SIGNALS = (
"ignore", "disregard", "forget", "override", "bypass", "jailbreak",
"system prompt", "your instructions", "developer mode", "act as",
"pretend you", "reveal your", "no restrictions", "without filters",
)
_COMPILED_JAILBREAK = [re.compile(p, re.IGNORECASE) for p in _JAILBREAK_PATTERNS]
_COMPILED_PIRACY = [re.compile(p, re.IGNORECASE) for p in _PIRACY_PATTERNS]
_COMPILED_COMPETITOR = [re.compile(p, re.IGNORECASE) for p in _COMPETITOR_PATTERNS]
# ─── Output leak / unsafe response patterns ──────────────────────────────────
_RESPONSE_LEAK_PATTERNS = [
r"system\s+prompt\s*:",
r"my\s+(?:system\s+)?instructions?\s+(?:are|say|tell|state|instruct)",
r"i\s+(?:was|am)\s+(?:instructed|programmed|told)\s+to",
r"here\s+(?:are|is)\s+my\s+(?:instructions?|rules?|guidelines?|system\s+prompt)",
r"i\s+am\s+(?:gpt|openai|chatgpt|claude|gemini|an?\s+(?:ai|llm|language\s+model))",
r"as\s+an?\s+(?:ai|language\s+model|llm|chatbot)\b",
r"openai\s+api",
r"sk-[a-zA-Z0-9]{10,}",
r"RETRIEVED\s+CONTEXT:",
r"Interest score:\s*\d",
r"RULES YOU MUST FOLLOW",
r"AUTHOR_CHATBOT_RULES",
r"MASTER_SYSTEM_PROMPT",
]
_COMPILED_RESPONSE_LEAKS = [re.compile(p, re.IGNORECASE) for p in _RESPONSE_LEAK_PATTERNS]
def sanitize_user_input(text: str, max_length: int = _MAX_INPUT_CHARS) -> str:
"""Normalize and strip dangerous characters from user input.
Args:
text: Raw user message.
max_length: Hard character cap.
Returns:
Sanitized text safe for pipeline processing.
"""
if not text:
return ""
text = unicodedata.normalize("NFKC", text)
text = _ZERO_WIDTH_RE.sub("", text)
text = _CONTROL_CHAR_RE.sub("", text)
text = _DELIMITER_INJECTION_RE.sub("", text)
text = re.sub(r"\s{3,}", " ", text)
text = text.strip()
if len(text) > max_length:
text = text[:max_length].rsplit(" ", 1)[0].strip() or text[:max_length].strip()
logger.debug("User input truncated", max_length=max_length)
return text
def sanitize_history_line(text: str, max_length: int = 300) -> str:
"""Sanitize a single history line before embedding in the system prompt."""
cleaned = sanitize_user_input(text, max_length=max_length)
# Neutralize lines that mimic system/assistant role injection in history
if _DELIMITER_INJECTION_RE.search(cleaned):
cleaned = _DELIMITER_INJECTION_RE.sub("", cleaned)
return cleaned
def check_boundary(query: str) -> tuple[str | None, str]:
"""Check if a user query violates content boundaries.
Returns:
Tuple of (violation_type | None, details).
"""
if not query or not query.strip():
return None, ""
query_lower = query.lower()
for pattern in _COMPILED_JAILBREAK:
if pattern.search(query):
logger.warning("Jailbreak attempt detected", query=query[:120])
return "jailbreak_attempt", "Jailbreak pattern matched"
for pattern in _COMPILED_PIRACY:
if pattern.search(query):
logger.warning("Piracy request detected", query=query[:120])
return "piracy_request", "Piracy pattern matched"
for pattern in _COMPILED_COMPETITOR:
if pattern.search(query_lower):
logger.info("Competitor mention declined", query=query[:120])
return "competitor_mention", "Competitor reference detected"
if _count_injection_signals(query_lower) >= 2:
logger.warning("Heuristic jailbreak detected", query=query[:120])
return "jailbreak_attempt", "Multiple injection signals"
for keyword in _OFF_TOPIC_KEYWORDS:
if keyword in query_lower:
if any(sig in query_lower for sig in _BOOK_CONTEXT_SIGNALS):
logger.debug(
"Off-topic keyword ignored β€” book conversation context",
keyword=keyword,
)
continue
logger.debug("Off-topic keyword detected", keyword=keyword)
return "off_topic", f"Off-topic keyword: {keyword}"
return None, ""
def _count_injection_signals(text_lower: str) -> int:
return sum(1 for signal in _INJECTION_SIGNALS if signal in text_lower)
def is_response_in_scope(response: str) -> bool:
"""Backward-compatible wrapper β€” prefer is_response_safe()."""
safe, _ = is_response_safe(response)
return safe
def is_response_safe(response: str) -> tuple[bool, str]:
"""Verify a generated response does not leak secrets or break character.
Returns:
Tuple of (is_safe, reason).
"""
if not response or not response.strip():
return True, ""
for pattern in _COMPILED_RESPONSE_LEAKS:
if pattern.search(response):
logger.warning("Unsafe response blocked", pattern=pattern.pattern)
return False, "Response leak pattern matched"
# Block responses that echo obvious instruction blocks
if response.count("════") >= 2 or response.count("════════") >= 1:
return False, "Formatted instruction block detected"
return True, ""
def scrub_unsafe_response(response: str, fallback: str) -> str:
"""Return fallback if response fails safety check, else original."""
safe, reason = is_response_safe(response)
if not safe:
logger.info("Response replaced with safe fallback", reason=reason)
return fallback
return response