From 57864d07edf5d029a6b1f1b3714bbeea89f0a6d8 Mon Sep 17 00:00:00 2001 From: briandevans <252620095+briandevans@users.noreply.github.com> Date: Thu, 4 Jun 2026 14:22:56 -0700 Subject: [PATCH] fix(gateway): suppress operational status/error noise on all chat gateways, not just Telegram (#39293) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Telegram noise/secret filter added in #28533 gated its work on `_gateway_platform_value(platform) != "telegram"`, so `_sanitize_gateway_final_response` and `_prepare_gateway_status_message` only ran for Telegram. Every other human-facing chat surface (WhatsApp, Discord, Slack, Signal, Matrix, plugin platforms, etc.) received raw provider-error bodies verbatim — including any leaked credentials the secret-redaction pass (`sk-…`, `Bearer …`, `gh[pousr]_…`, `xox[baprs]-…`, `hf_…`, `glpat-…`) was meant to strip. Invert the gate from a one-platform allowlist into a small programmatic-surface denylist: only `local`, `api_server`, `webhook`, and `msgraph_webhook` consume gateway text programmatically and keep raw status/error text. Every other (chat) surface — including unknown/empty platform values and on-demand plugin pseudo-members — fails closed to the redacted, noise-filtered, sanitized path. This widens the same root-cause fix to both call sites: status callbacks and final replies. --- gateway/run.py | 31 +++++-- tests/gateway/test_telegram_noise_filter.py | 90 +++++++++++++++++++-- 2 files changed, 110 insertions(+), 11 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 441f83752..ea0ac5c51 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -87,6 +87,22 @@ _TELEGRAM_NOISY_STATUS_RE = re.compile( re.IGNORECASE | re.DOTALL, ) +# 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_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" @@ -370,15 +386,18 @@ def _looks_like_gateway_provider_error(text: str) -> bool: def _sanitize_gateway_final_response(platform: Any, text: str) -> str: - """Sanitize final gateway replies before sending them to high-noise chats. + """Sanitize final gateway replies before sending them to chat surfaces. - Telegram is Bob's mobile inbox, so it should receive concise, safe provider - failure categories instead of raw HTTP bodies, request IDs, or policy text. - Other platforms keep the existing behaviour for now. + 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_platform_value(platform) != "telegram": + if _gateway_surface_passes_raw_text(platform): return text redacted = _redact_gateway_user_facing_secrets(str(text)) @@ -392,7 +411,7 @@ def _prepare_gateway_status_message(platform: Any, event_type: str, message: str text = str(message or "").strip() if not text: return None - if _gateway_platform_value(platform) != "telegram": + if _gateway_surface_passes_raw_text(platform): return text text = _redact_gateway_user_facing_secrets(text) diff --git a/tests/gateway/test_telegram_noise_filter.py b/tests/gateway/test_telegram_noise_filter.py index b5cbf820b..ba8affeff 100644 --- a/tests/gateway/test_telegram_noise_filter.py +++ b/tests/gateway/test_telegram_noise_filter.py @@ -1,4 +1,6 @@ -"""Telegram-specific gateway filtering for noisy status/error output.""" +"""Gateway noise/secret filtering across chat surfaces (Telegram + siblings).""" + +import pytest from gateway.config import Platform from gateway.run import ( @@ -6,6 +8,34 @@ from gateway.run import ( _sanitize_gateway_final_response, ) +# Every human-facing chat surface that must receive noise-filtered, +# secret-redacted, provider-error-sanitized output (not just Telegram). +CHAT_PLATFORMS = [ + "telegram", + "whatsapp", + "discord", + "slack", + "signal", + "matrix", + "mattermost", + "dingtalk", + "feishu", + "wecom", + "weixin", + "bluebubbles", + "qqbot", + "homeassistant", + "sms", +] + +NOISY_STATUS_MESSAGES = [ + "🗜️ Preflight compression check before sending...", + "🗜️ Compacting context — summarizing earlier conversation so I can continue...", + "⚠ Compression summary failed: upstream error. Inserted a fallback context marker.", + "⏱️ Rate limited. Waiting 30.0s (attempt 2/3)...", + "⏳ Retrying in 4.2s (attempt 1/3)...", +] + def test_telegram_status_suppresses_auxiliary_and_retry_noise(): """Auxiliary failures and retry backoff chatter should not hit Telegram.""" @@ -23,12 +53,62 @@ def test_telegram_status_suppresses_auxiliary_and_retry_noise(): assert _prepare_gateway_status_message(Platform.TELEGRAM, "warn", message) is None -def test_non_telegram_status_is_unchanged(): - """The Telegram quieting policy must not hide CLI/Discord diagnostics.""" +def test_programmatic_surfaces_keep_raw_status(): + """Programmatic surfaces (local/api/webhook) must keep raw diagnostics. + + Negative case for the invariant: the chat-noise filter must not touch + CLI/TUI diagnostics, API JSON, or webhook payloads. + """ message = "⏳ Retrying in 4.2s (attempt 1/3)..." - assert _prepare_gateway_status_message(Platform.DISCORD, "lifecycle", message) == message - assert _prepare_gateway_status_message("local", "lifecycle", message) == message + for platform in ("local", "api_server", "webhook", "msgraph_webhook"): + assert ( + _prepare_gateway_status_message(platform, "lifecycle", message) == message + ) + + +@pytest.mark.parametrize("platform", CHAT_PLATFORMS) +@pytest.mark.parametrize("message", NOISY_STATUS_MESSAGES) +def test_all_chat_gateways_suppress_noise(platform, message): + """Operational lifecycle/retry noise must be suppressed on every chat surface.""" + assert _prepare_gateway_status_message(platform, "warn", message) is None + + +@pytest.mark.parametrize("platform", ["whatsapp", "slack", "signal", "matrix"]) +def test_chat_gateways_redact_secret_in_provider_error(platform): + """Provider-error bodies carrying secrets must never reach chat users. + + THE security invariant being widened from Telegram (#28533) to all chat + surfaces (#39293): a leaked bearer token in a provider error body must be + redacted/replaced before delivery on any chat platform. + """ + raw = ( + "API call failed after 3 retries: HTTP 401 Unauthorized — " + "Authorization: Bearer sk-ABCDEF0123456789abcdef0123" + ) + + sanitized = _sanitize_gateway_final_response(platform, raw) + + assert "sk-ABCDEF0123456789abcdef0123" not in sanitized + assert "sk-ABCDEF" not in sanitized + assert "HTTP 401" not in sanitized + # The user gets the safe provider-error category instead of the raw body. + assert "provider" in sanitized.lower() + + +def test_plugin_platform_string_suppresses_noise(): + """Unknown/plugin chat platforms fail closed to the chat-filter path.""" + message = "⏳ Retrying in 4.2s (attempt 1/3)..." + + assert _prepare_gateway_status_message("irc", "warn", message) is None + + +@pytest.mark.parametrize("platform", CHAT_PLATFORMS) +def test_chat_gateways_keep_normal_answers(platform): + """Normal assistant content must pass through unchanged on chat surfaces.""" + answer = "Here is the clean summary you asked for." + + assert _sanitize_gateway_final_response(platform, answer) == answer def test_telegram_status_sanitizes_raw_provider_security_errors():