diff --git a/gateway/run.py b/gateway/run.py index 1164717391bbf..c0f308ce82b27 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -69,6 +69,30 @@ _ADAPTER_DISCONNECT_TIMEOUT_SECS_DEFAULT = 5.0 _TELEGRAM_COMMAND_MENTION_RE = re.compile(r"(? chat. -_GATEWAY_RAW_TEXT_PLATFORMS = frozenset( - {"local", "api_server", "webhook", "msgraph_webhook"} -) - - -def _gateway_surface_passes_raw_text(platform: Any) -> bool: - """True only for programmatic/local surfaces that must keep raw text.""" - return _gateway_platform_value(platform) in _GATEWAY_RAW_TEXT_PLATFORMS - - -_GATEWAY_PROVIDER_ERROR_RE = re.compile( - r"(" # infrastructure/provider error preambles, not ordinary assistant prose - r"api\s+(?:call\s+)?failed" - r"|provider\s+authentication\s+failed" - r"|non-retryable\s+error" - r"|rate\s+limited\s+after\s+\d+\s+retries" - r"|error\s+code\s*:" - r"|\bhttp\s*\d{3}\b" - r"|incorrect\s+api\s+key" - r"|invalid\s+api\s+key" - r")", - re.IGNORECASE, -) - -_GATEWAY_PROVIDER_POLICY_RE = re.compile( - r"(" # raw provider policy/safety bodies are noisy and may be sensitive - r"cybersecurity\s+risk" - r"|security\s+policy" - r"|safety\s+policy" - r"|policy\s+violation" - r"|violat(?:e|es|ed|ion)" - r"|blocked\s+(?:because|by|under)" - r"|request\s+(?:was\s+)?(?:blocked|rejected)" - r"|disallowed" - r"|moderation" - r")", - re.IGNORECASE, -) - -_GATEWAY_AUTH_ERROR_RE = re.compile( - r"(provider\s+authentication\s+failed|incorrect\s+api\s+key|invalid\s+api\s+key|\b401\b)", - re.IGNORECASE, -) - -_GATEWAY_RATE_LIMIT_RE = re.compile( - r"(rate\s+limit|rate-limited|\b429\b|quota|usage\s+limit)", - re.IGNORECASE, -) - -_GATEWAY_SECRET_PATTERNS = ( - re.compile(r"\bsk-[A-Za-z0-9][A-Za-z0-9_\-]{12,}\b"), - re.compile(r"\bgh[pousr]_[A-Za-z0-9_]{20,}\b"), - re.compile(r"\bxox[baprs]-[A-Za-z0-9\-]{20,}\b"), - re.compile(r"\bhf_[A-Za-z0-9]{20,}\b"), - re.compile(r"\bglpat-[A-Za-z0-9_\-]{20,}\b"), - re.compile(r"(?i)\b(Bearer\s+)[A-Za-z0-9._\-]{20,}\b"), -) - def _ensure_windows_gateway_venv_imports() -> None: """Make detached Windows gateway runs see the Hermes venv packages. @@ -208,68 +168,6 @@ def _ensure_windows_gateway_venv_imports() -> None: return -def _gateway_platform_value(platform: Any) -> str: - """Return a normalized gateway platform value for enums or raw strings.""" - return str(getattr(platform, "value", platform) or "").strip().lower() - - -def _non_conversational_metadata( - metadata: Optional[Dict[str, Any]] = None, - *, - platform: Any = None, -) -> Optional[Dict[str, Any]]: - """Mark Discord lifecycle/status sends without changing other platforms.""" - if _gateway_platform_value(platform) != "discord": - return metadata - merged = dict(metadata or {}) - merged["non_conversational"] = True - return merged - - -def _is_transient_network_error(exc: BaseException) -> bool: - """Return True for transient network errors safe to log + swallow. - - The crash class targeted by #31066 / #31110: an unhandled Telegram - ``TimedOut`` (or peer ``NetworkError`` / ``httpx`` connection error) - propagating to the event loop and killing the entire gateway - process. These are by definition transient — the next poll cycle or - user action recovers — so they must never crash the process. - - Walk the exception cause chain so wrapped errors (e.g. PTB's - ``NetworkError`` wrapping ``httpx.ConnectError``) are still - classified. The chain is bounded to avoid pathological cycles. - """ - seen: set[int] = set() - cur: Optional[BaseException] = exc - depth = 0 - transient_class_names = { - "TimedOut", - "NetworkError", - "ReadError", - "WriteError", - "ConnectError", - "ConnectTimeout", - "ReadTimeout", - "WriteTimeout", - "PoolTimeout", - "RemoteProtocolError", - "ServerDisconnectedError", - "ClientConnectorError", - "ClientOSError", - } - while cur is not None and depth < 12: - ident = id(cur) - if ident in seen: - break - seen.add(ident) - depth += 1 - name = type(cur).__name__ - if name in transient_class_names: - return True - cur = cur.__cause__ or cur.__context__ - return False - - def _gateway_loop_exception_handler( loop: "asyncio.AbstractEventLoop", context: Dict[str, Any] ) -> None: @@ -305,165 +203,16 @@ def _gateway_loop_exception_handler( loop.default_exception_handler(context) -def _redact_gateway_user_facing_secrets(text: str) -> str: - """Secret redaction before text can leave the gateway. - - Delegates to the authoritative ``agent.redact.redact_sensitive_text`` — the - same Tirith-grade redactor already applied to logs, tool output, and - approval-command prompts — so the outbound chat path masks the full - credential set the startup banner promises ("chat responses are scrubbed - before delivery"), not a divergent subset. ``force=True`` honors redaction - even when ``security.redact_secrets`` is off, matching the - ``_redact_approval_command`` reasoning (#23810). - - The narrow ``_GATEWAY_SECRET_PATTERNS`` set runs as a belt-and-suspenders - second pass so nothing the gateway historically caught can regress, and so - redaction still degrades gracefully if the import ever fails. - """ - redacted = str(text or "") - try: - from agent.redact import redact_sensitive_text - - redacted = redact_sensitive_text(redacted, force=True) - except Exception: - # Fail-soft: fall back to the local pattern pass below rather than - # letting a redactor import/error leak the raw text to chat. - pass - for pattern in _GATEWAY_SECRET_PATTERNS: - redacted = pattern.sub(lambda m: (m.group(1) if m.lastindex else "") + "[REDACTED]", redacted) - return redacted - - -def _redact_approval_command(cmd: "str | None") -> str: - """Redact credentials from a command before it goes into an approval prompt. - - Tirith's *findings* are already redacted, but the gateway approval prompt - is built from the raw command string, so a credential-shaped value Tirith - flagged would otherwise be echoed verbatim to the chat platform (#48456). - Uses ``redact_sensitive_text(force=True)`` — the same Tirith-grade redactor - — so the prompt honors redaction even when ``security.redact_secrets`` is - off. Module-level so the wiring is unit-testable (the call site is a deeply - nested gateway closure that cannot be driven directly). - """ - from agent.redact import redact_sensitive_text - - return redact_sensitive_text(str(cmd or ""), force=True) - - -def _gateway_provider_error_reply(text: str) -> str: - """Map raw provider/API errors to a short user-safe Telegram reply.""" - if _GATEWAY_AUTH_ERROR_RE.search(text): - return ( - "⚠️ Provider authentication failed. Check the configured credentials; " - "raw provider details are in the gateway logs." - ) - if _GATEWAY_PROVIDER_POLICY_RE.search(text): - return ( - "⚠️ The model provider rejected the request. I kept the raw provider " - "error out of chat; check gateway logs for details or try rephrasing." - ) - if _GATEWAY_RATE_LIMIT_RE.search(text): - return "⏱️ The model provider is rate-limiting requests. Please wait a moment and try again." - return ( - "⚠️ The model provider failed after retries. I kept raw provider details " - "out of chat; check gateway logs for diagnostics." +def _prepare_gateway_status_message( + platform: Any, event_type: str, message: str +) -> Optional[str]: + """Thin wrapper that forwards to :func:`gateway.text_sanitizer` with + the gateway-runner's Telegram-specific noisy-status regex.""" + return _prepare_gateway_status_message_raw( + platform, event_type, message, noisy_pattern=_TELEGRAM_NOISY_STATUS_RE, ) -_GATEWAY_PROVIDER_ERROR_SHAPE_RE = re.compile( - r"^\s*(\W*\s*)?(" - r"api\s+(?:call\s+)?failed" - r"|provider\s+authentication\s+failed" - r"|non-retryable\s+error" - r"|rate\s+limited\s+after\s+\d+\s+retries" - r"|error\s+code\s*:" - r"|http\s*\d{3}\b" - r"|incorrect\s+api\s+key" - r"|invalid\s+api\s+key" - r")", - re.IGNORECASE, -) - - -def _looks_like_gateway_provider_error(text: str) -> bool: - """True when text is infrastructure/provider failure, not normal content. - - Two heuristics combined so the rewrite only fires on actual provider - error envelopes, not on assistant prose that happens to mention an - HTTP status code: - - 1. The text is short — real provider errors are 1–3 lines of envelope - text; assistant answers are usually longer. - 2. AND the error marker appears at the start of the message (optionally - behind a punctuation/symbol prefix), not buried mid-paragraph in an - explanation like "HTTP 404 means 'not found' — ...". - """ - if not text: - return False - body = str(text).strip() - # Provider failure envelopes are short. Assistant answers that happen - # to mention HTTP status codes ("HTTP 404 means...") tend to be longer. - if len(body) > 400 or body.count("\n") > 4: - return False - return bool(_GATEWAY_PROVIDER_ERROR_SHAPE_RE.search(body)) - - -def _sanitize_gateway_final_response(platform: Any, text: str) -> str: - """Sanitize final gateway replies before sending them to chat surfaces. - - Every human-facing chat surface (Telegram, WhatsApp, Discord, Slack, - Signal, Matrix, plugin platforms, etc.) should receive concise, safe - provider failure categories with secrets redacted instead of raw HTTP - bodies, request IDs, leaked credentials, or policy text. Only programmatic - surfaces in ``_GATEWAY_RAW_TEXT_PLATFORMS`` (CLI/TUI ``local`` diagnostics, - API JSON, webhook payloads) keep the raw text unchanged. - """ - if not text: - return text - if _gateway_surface_passes_raw_text(platform): - return text - - redacted = _redact_gateway_user_facing_secrets(str(text)) - if _looks_like_gateway_provider_error(redacted): - return _gateway_provider_error_reply(redacted) - return redacted - - -def _prepare_gateway_status_message(platform: Any, event_type: str, message: str) -> Optional[str]: - """Filter/sanitize agent status callbacks before platform delivery. - - Local/CLI sessions keep the raw diagnostic stream. Messaging gateway - surfaces should not receive transient auxiliary/compression chatter. - """ - text = str(message or "").strip() - if not text: - return None - if _gateway_surface_passes_raw_text(platform): - return text - - text = _redact_gateway_user_facing_secrets(text) - if _TELEGRAM_NOISY_STATUS_RE.search(text): - return None - if _looks_like_gateway_provider_error(text): - return _gateway_provider_error_reply(text) - return text - - -def render_notice_line(notice) -> str: - """Render an AgentNotice to a single plaintext line for messaging platforms. - - Messaging has no persistent status bar (unlike the TUI), so a notice is a - one-shot standalone push. The notice policy already bakes the level glyph - (⚠ / • / ✕ / ✓) into the text, and the TUI + CLI REPL render that text - verbatim — so we emit it as-is here too. Prepending a per-level glyph would - DOUBLE it ("⚠ ⚠ Credits 90% used", "⛔ ✕ Credit access paused"). Plaintext - only — no markdown — so it renders uniformly across Telegram/Discord/Slack/ - SMS without per-platform escaping. Fail-soft: a malformed/empty notice - degrades to "" rather than raising on the agent's callback path. - """ - return str(getattr(notice, "text", "") or "").strip() - - async def _send_or_update_status_coro(adapter, chat_id, status_key, content, metadata): """Route a status message through adapter.send_or_update_status when supported. diff --git a/gateway/text_sanitizer.py b/gateway/text_sanitizer.py new file mode 100644 index 0000000000000..c9a49045e5151 --- /dev/null +++ b/gateway/text_sanitizer.py @@ -0,0 +1,350 @@ +"""Gateway text sanitization utilities. + +Extracted from ``gateway/run.py`` to keep the gateway runner focused on +lifecycle management while this module owns all text filtering, redaction, +and provider-error classification that protects chat surfaces from raw +infrastructure noise and leaked secrets. +""" + +from __future__ import annotations + +import re +from typing import Any, Dict, Optional + + +# --------------------------------------------------------------------------- +# Platform classification +# --------------------------------------------------------------------------- + +# Surfaces that consume gateway text programmatically (CLI/TUI "local" +# diagnostics, API JSON, webhook payloads) and therefore must keep RAW +# status/error text. EVERY other platform is a human-facing chat surface +# where operational lifecycle/provider-error noise (and any secrets in it) +# must be suppressed or sanitized. Widens #28533's Telegram-only filter to +# all chat gateways (#39293). Fail-closed: unknown/empty platform -> chat. +_GATEWAY_RAW_TEXT_PLATFORMS = frozenset( + {"local", "api_server", "webhook", "msgraph_webhook"} +) + + +def _gateway_platform_value(platform: Any) -> str: + """Return a normalized gateway platform value for enums or raw strings.""" + return str(getattr(platform, "value", platform) or "").strip().lower() + + +def _gateway_surface_passes_raw_text(platform: Any) -> bool: + """True only for programmatic/local surfaces that must keep raw text.""" + return _gateway_platform_value(platform) in _GATEWAY_RAW_TEXT_PLATFORMS + + +# --------------------------------------------------------------------------- +# Provider-error / policy regexes +# --------------------------------------------------------------------------- + +_GATEWAY_PROVIDER_ERROR_RE = re.compile( + r"(" # infrastructure/provider error preambles, not ordinary assistant prose + r"api\s+(?:call\s+)?failed" + r"|provider\s+authentication\s+failed" + r"|non-retryable\s+error" + r"|rate\s+limited\s+after\s+\d+\s+retries" + r"|error\s+code\s*:" + r"|\bhttp\s*\d{3}\b" + r"|incorrect\s+api\s+key" + r"|invalid\s+api\s+key" + r")", + re.IGNORECASE, +) + +_GATEWAY_PROVIDER_POLICY_RE = re.compile( + r"(" # raw provider policy/safety bodies are noisy and may be sensitive + r"cybersecurity\s+risk" + r"|security\s+policy" + r"|safety\s+policy" + r"|policy\s+violation" + r"|violat(?:e|es|ed|ion)" + r"|blocked\s+(?:because|by|under)" + r"|request\s+(?:was\s+)?(?:blocked|rejected)" + r"|disallowed" + r"|moderation" + r")", + re.IGNORECASE, +) + +_GATEWAY_AUTH_ERROR_RE = re.compile( + r"(provider\s+authentication\s+failed|incorrect\s+api\s+key|invalid\s+api\s+key|\b401\b)", + re.IGNORECASE, +) + +_GATEWAY_RATE_LIMIT_RE = re.compile( + r"(rate\s+limit|rate-limited|\b429\b|quota|usage\s+limit)", + re.IGNORECASE, +) + +_GATEWAY_PROVIDER_ERROR_SHAPE_RE = re.compile( + r"^\s*(\W*\s*)?(" + r"api\s+(?:call\s+)?failed" + r"|provider\s+authentication\s+failed" + r"|non-retryable\s+error" + r"|rate\s+limited\s+after\s+\d+\s+retries" + r"|error\s+code\s*:" + r"|http\s*\d{3}\b" + r"|incorrect\s+api\s+key" + r"|invalid\s+api\s+key" + r")", + re.IGNORECASE, +) + + +# --------------------------------------------------------------------------- +# Secret patterns (belt-and-suspenders with agent.redact) +# --------------------------------------------------------------------------- + +_GATEWAY_SECRET_PATTERNS = ( + re.compile(r"\bsk-[A-Za-z0-9][A-Za-z0-9_\-]{12,}\b"), + re.compile(r"\bgh[pousr]_[A-Za-z0-9_]{20,}\b"), + re.compile(r"\bxox[baprs]-[A-Za-z0-9\-]{20,}\b"), + re.compile(r"\bhf_[A-Za-z0-9]{20,}\b"), + re.compile(r"\bglpat-[A-Za-z0-9_\-]{20,}\b"), + re.compile(r"(?i)\b(Bearer\s+)[A-Za-z0-9._\-]{20,}\b"), +) + + +# --------------------------------------------------------------------------- +# Transient network error classification +# --------------------------------------------------------------------------- + +_TRANSIENT_NETWORK_ERROR_NAMES = frozenset({ + "TimedOut", + "NetworkError", + "ReadError", + "WriteError", + "ConnectError", + "ConnectTimeout", + "ReadTimeout", + "WriteTimeout", + "PoolTimeout", + "RemoteProtocolError", + "ServerDisconnectedError", + "ClientConnectorError", + "ClientOSError", +}) + + +def _non_conversational_metadata( + metadata: Optional[Dict[str, Any]] = None, + *, + platform: Any = None, +) -> Optional[Dict[str, Any]]: + """Mark Discord lifecycle/status sends without changing other platforms.""" + if _gateway_platform_value(platform) != "discord": + return metadata + merged = dict(metadata or {}) + merged["non_conversational"] = True + return merged + + +def _is_transient_network_error(exc: BaseException) -> bool: + """Return True for transient network errors safe to log + swallow. + + The crash class targeted by #31066 / #31110: an unhandled Telegram + ``TimedOut`` (or peer ``NetworkError`` / ``httpx`` connection error) + propagating to the event loop and killing the entire gateway + process. These are by definition transient — the next poll cycle or + user action recovers — so they must never crash the process. + + Walk the exception cause chain so wrapped errors (e.g. PTB's + ``NetworkError`` wrapping ``httpx.ConnectError``) are still + classified. The chain is bounded to avoid pathological cycles. + """ + seen: set[int] = set() + cur: Optional[BaseException] = exc + depth = 0 + while cur is not None and depth < 12: + ident = id(cur) + if ident in seen: + break + seen.add(ident) + depth += 1 + name = type(cur).__name__ + if name in _TRANSIENT_NETWORK_ERROR_NAMES: + return True + cur = cur.__cause__ or cur.__context__ + return False + + +# --------------------------------------------------------------------------- +# Redaction helpers +# --------------------------------------------------------------------------- + + +def _redact_gateway_user_facing_secrets(text: str) -> str: + """Secret redaction before text can leave the gateway. + + Delegates to the authoritative ``agent.redact.redact_sensitive_text`` — the + same Tirith-grade redactor already applied to logs, tool output, and + approval-command prompts — so the outbound chat path masks the full + credential set the startup banner promises ("chat responses are scrubbed + before delivery"), not a divergent subset. ``force=True`` honors redaction + even when ``security.redact_secrets`` is off, matching the + ``_redact_approval_command`` reasoning (#23810). + + The narrow ``_GATEWAY_SECRET_PATTERNS`` set runs as a belt-and-suspenders + second pass so nothing the gateway historically caught can regress, and so + redaction still degrades gracefully if the import ever fails. + """ + redacted = str(text or "") + try: + from agent.redact import redact_sensitive_text + + redacted = redact_sensitive_text(redacted, force=True) + except Exception: + # Fail-soft: fall back to the local pattern pass below rather than + # letting a redactor import/error leak the raw text to chat. + pass + for pattern in _GATEWAY_SECRET_PATTERNS: + redacted = pattern.sub(lambda m: (m.group(1) if m.lastindex else "") + "[REDACTED]", redacted) + return redacted + + +def _redact_approval_command(cmd: "str | None") -> str: + """Redact credentials from a command before it goes into an approval prompt. + + Tirith's *findings* are already redacted, but the gateway approval prompt + is built from the raw command string, so a credential-shaped value Tirith + flagged would otherwise be echoed verbatim to the chat platform (#48456). + Uses ``redact_sensitive_text(force=True)`` — the same Tirith-grade redactor + — so the prompt honors redaction even when ``security.redact_secrets`` is + off. Module-level so the wiring is unit-testable (the call site is a deeply + nested gateway closure that cannot be driven directly). + """ + from agent.redact import redact_sensitive_text + + return redact_sensitive_text(str(cmd or ""), force=True) + + +# --------------------------------------------------------------------------- +# Provider-error classification and reply mapping +# --------------------------------------------------------------------------- + + +def _gateway_provider_error_reply(text: str) -> str: + """Map raw provider/API errors to a short user-safe Telegram reply.""" + if _GATEWAY_AUTH_ERROR_RE.search(text): + return ( + "⚠️ Provider authentication failed. Check the configured credentials; " + "raw provider details are in the gateway logs." + ) + if _GATEWAY_PROVIDER_POLICY_RE.search(text): + return ( + "⚠️ The model provider rejected the request. I kept the raw provider " + "error out of chat; check gateway logs for details or try rephrasing." + ) + if _GATEWAY_RATE_LIMIT_RE.search(text): + return "⏱️ The model provider is rate-limiting requests. Please wait a moment and try again." + return ( + "⚠️ The model provider failed after retries. I kept raw provider details " + "out of chat; check gateway logs for diagnostics." + ) + + +def _looks_like_gateway_provider_error(text: str) -> bool: + """True when text is infrastructure/provider failure, not normal content. + + Two heuristics combined so the rewrite only fires on actual provider + error envelopes, not on assistant prose that happens to mention an + HTTP status code: + + 1. The text is short — real provider errors are 1–3 lines of envelope + text; assistant answers are usually longer. + 2. AND the error marker appears at the start of the message (optionally + behind a punctuation/symbol prefix), not buried mid-paragraph in an + explanation like "HTTP 404 means 'not found' — ...". + """ + if not text: + return False + body = str(text).strip() + # Provider failure envelopes are short. Assistant answers that happen + # to mention HTTP status codes ("HTTP 404 means...") tend to be longer. + if len(body) > 400 or body.count("\n") > 4: + return False + return bool(_GATEWAY_PROVIDER_ERROR_SHAPE_RE.search(body)) + + +# --------------------------------------------------------------------------- +# Final response / status sanitization +# --------------------------------------------------------------------------- + + +def _sanitize_gateway_final_response(platform: Any, text: str) -> str: + """Sanitize final gateway replies before sending them to chat surfaces. + + Every human-facing chat surface (Telegram, WhatsApp, Discord, Slack, + Signal, Matrix, plugin platforms, etc.) should receive concise, safe + provider failure categories with secrets redacted instead of raw HTTP + bodies, request IDs, leaked credentials, or policy text. Only programmatic + surfaces in ``_GATEWAY_RAW_TEXT_PLATFORMS`` (CLI/TUI ``local`` diagnostics, + API JSON, webhook payloads) keep the raw text unchanged. + """ + if not text: + return text + if _gateway_surface_passes_raw_text(platform): + return text + + redacted = _redact_gateway_user_facing_secrets(str(text)) + if _looks_like_gateway_provider_error(redacted): + return _gateway_provider_error_reply(redacted) + return redacted + + +def _prepare_gateway_status_message( + platform: Any, + event_type: str, + message: str, + *, + noisy_pattern: Any = None, +) -> Optional[str]: + """Filter/sanitize agent status callbacks before platform delivery. + + Local/CLI sessions keep the raw diagnostic stream. Messaging gateway + surfaces should not receive transient auxiliary/compression chatter. + + Parameters + ---------- + noisy_pattern: + Optional compiled regex whose matches suppress the message on + non-raw-text surfaces. The gateway runner passes its own + ``_TELEGRAM_NOISY_STATUS_RE`` here so this module stays free of + Telegram-specific constants. + """ + text = str(message or "").strip() + if not text: + return None + if _gateway_surface_passes_raw_text(platform): + return text + + text = _redact_gateway_user_facing_secrets(text) + if noisy_pattern is not None and noisy_pattern.search(text): + return None + if _looks_like_gateway_provider_error(text): + return _gateway_provider_error_reply(text) + return text + + +# --------------------------------------------------------------------------- +# Notice rendering +# --------------------------------------------------------------------------- + + +def render_notice_line(notice) -> str: + """Render an AgentNotice to a single plaintext line for messaging platforms. + + Messaging has no persistent status bar (unlike the TUI), so a notice is a + one-shot standalone push. The notice policy already bakes the level glyph + (⚠ / • / ✕ / ✓) into the text, and the TUI + CLI REPL render that text + verbatim — so we emit it as-is here too. Prepending a per-level glyph would + DOUBLE it ("⚠ ⚠ Credits 90% used", "⛔ ✕ Credit access paused"). Plaintext + only — no markdown — so it renders uniformly across Telegram/Discord/Slack/ + SMS without per-platform escaping. Fail-soft: a malformed/empty notice + degrades to "" rather than raising on the agent's callback path. + """ + return str(getattr(notice, "text", "") or "").strip() diff --git a/tests/gateway/test_text_sanitizer.py b/tests/gateway/test_text_sanitizer.py new file mode 100644 index 0000000000000..de3b641634fc1 --- /dev/null +++ b/tests/gateway/test_text_sanitizer.py @@ -0,0 +1,162 @@ +"""Tests for gateway.text_sanitizer — text formatting and redaction.""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path + +import pytest + + +def _load_module(): + spec = importlib.util.spec_from_file_location( + "_gw_text_sanitizer", + Path(__file__).resolve().parents[2] / "gateway" / "text_sanitizer.py", + ) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class TestGatewayPlatformValue: + def test_string(self): + m = _load_module() + assert m._gateway_platform_value("Telegram") == "telegram" + + def test_enum_value(self): + m = _load_module() + + class Fake: + value = "Discord" + + assert m._gateway_platform_value(Fake()) == "discord" + + def test_none(self): + m = _load_module() + assert m._gateway_platform_value(None) == "" + + +class TestGatewaySurfacePassesRawText: + def test_local_passes(self): + m = _load_module() + assert m._gateway_surface_passes_raw_text("local") is True + + def test_api_server_passes(self): + m = _load_module() + assert m._gateway_surface_passes_raw_text("api_server") is True + + def test_telegram_blocked(self): + m = _load_module() + assert m._gateway_surface_passes_raw_text("telegram") is False + + def test_none_blocked(self): + m = _load_module() + assert m._gateway_surface_passes_raw_text(None) is False + + +class TestLooksLikeGatewayProviderError: + def test_short_error_detected(self): + m = _load_module() + assert m._looks_like_gateway_provider_error("API call failed: 500") is True + + def test_auth_error_detected(self): + m = _load_module() + assert m._looks_like_gateway_provider_error("Incorrect API key provided") is True + + def test_long_text_rejected(self): + m = _load_module() + assert m._looks_like_gateway_provider_error("A" * 500) is False + + def test_empty_rejected(self): + m = _load_module() + assert m._looks_like_gateway_provider_error("") is False + + +class TestGatewayProviderErrorReply: + def test_auth_error(self): + m = _load_module() + reply = m._gateway_provider_error_reply("Provider authentication failed") + assert "credentials" in reply.lower() or "check" in reply.lower() + + def test_rate_limit(self): + m = _load_module() + reply = m._gateway_provider_error_reply("Rate limited after 3 retries") + assert "rate" in reply.lower() or "wait" in reply.lower() + + def test_generic_fallback(self): + m = _load_module() + reply = m._gateway_provider_error_reply("Something else went wrong") + assert reply # should return something non-empty + + +class TestRenderNoticeLine: + def test_normal_notice(self): + m = _load_module() + + class Notice: + text = "Credits 90% used" + + assert m.render_notice_line(Notice()) == "Credits 90% used" + + def test_empty_notice(self): + m = _load_module() + + class Notice: + text = "" + + assert m.render_notice_line(Notice()) == "" + + def test_none_text(self): + m = _load_module() + + class Notice: + text = None + + assert m.render_notice_line(Notice()) == "" + + +class TestIsTransientNetworkError: + def test_timed_out(self): + m = _load_module() + + class TimedOut(Exception): + pass + + assert m._is_transient_network_error(TimedOut("timed out")) is True + + def test_regular_error(self): + m = _load_module() + assert m._is_transient_network_error(ValueError("bad value")) is False + + def test_chained_transient(self): + m = _load_module() + + class ConnectError(Exception): + pass + + class NetworkError(Exception): + pass + + outer = NetworkError("network down") + inner = ConnectError("refused") + outer.__cause__ = inner + assert m._is_transient_network_error(outer) is True + + +class TestNonConversationalMetadata: + def test_discord_gets_flag(self): + m = _load_module() + result = m._non_conversational_metadata({"key": "val"}, platform="discord") + assert result["non_conversational"] is True + assert result["key"] == "val" + + def test_other_platform_unchanged(self): + m = _load_module() + result = m._non_conversational_metadata({"key": "val"}, platform="telegram") + assert result == {"key": "val"} + + def test_none_metadata(self): + m = _load_module() + result = m._non_conversational_metadata(None, platform="discord") + assert result["non_conversational"] is True