diff --git a/gateway/run.py b/gateway/run.py index c12c9f6cd64..f7b751b0c98 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -305,8 +305,29 @@ def _gateway_loop_exception_handler( def _redact_gateway_user_facing_secrets(text: str) -> str: - """Best-effort secret redaction before text can leave the gateway.""" + """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 diff --git a/tests/gateway/test_telegram_noise_filter.py b/tests/gateway/test_telegram_noise_filter.py index c252f23f499..9ce1619580f 100644 --- a/tests/gateway/test_telegram_noise_filter.py +++ b/tests/gateway/test_telegram_noise_filter.py @@ -119,7 +119,10 @@ def test_chat_gateways_redact_secret_in_non_error_body(platform): assert "sk-ABCDEF0123456789abcdef0123" not in sanitized assert "sk-ABCDEF" not in sanitized - assert "[REDACTED]" in sanitized + # The secret body is gone — assert the invariant, not the specific mask + # marker. The outbound redactor delegates to redact_sensitive_text (#23810), + # which masks as `***`/partial; the local pattern fallback uses `[REDACTED]`. + assert "***" in sanitized or "[REDACTED]" in sanitized # Non-secret prose is preserved — redaction is surgical, not a wholesale # rewrite, on bodies that are not provider-error envelopes. assert "here is the example request you asked for" in sanitized @@ -190,3 +193,41 @@ def test_telegram_final_response_keeps_normal_answers(): answer = "Here is the clean summary you asked for." assert _sanitize_gateway_final_response(Platform.TELEGRAM, answer) == answer + + +# Synthetic credential shapes from #23810. Bodies are placeholder gibberish — +# never real tokens — but they match the canonical redaction patterns. The +# outbound gateway redactor previously used a narrow local pattern subset that +# leaked the GitHub fine-grained PAT and Telegram bot-token shapes; it now +# delegates to agent.redact.redact_sensitive_text, the authoritative redactor +# already used for logs/tool-output/approval prompts. +_ISSUE_23810_SECRET_SHAPES = { + "openai_sk": "sk-" + "a1b2c3d4e5f6a7b8c9d0", + "github_fine_grained_pat": "github_pat_" + "1A" * 41, + "github_classic_pat": "ghp_" + "Ab3Cd4Ef5Gh6Ij7Kl8Mn9Op0Qr1St2Uv3Wx", + "telegram_bot_token": "bot1234567890:" + "AAH" * 13 + "x", + "openrouter_v1": "sk-or-v1-" + "Z9" * 36 + "q", +} + + +@pytest.mark.parametrize("platform", CHAT_PLATFORMS) +@pytest.mark.parametrize("shape_name", sorted(_ISSUE_23810_SECRET_SHAPES)) +def test_chat_gateways_redact_all_issue_23810_credential_shapes(platform, shape_name): + """Outbound chat must mask every credential shape the banner promises. + + Regression guard for #23810: the gateway claimed "chat responses are + scrubbed before delivery", but the outbound redactor used a divergent + narrow pattern set that leaked the GitHub fine-grained PAT and Telegram + bot-token shapes verbatim. Feed each shape as ordinary assistant prose + (not a provider-error envelope, so no wholesale rewrite fires) and assert + the secret body never reaches the user while surrounding prose survives. + """ + secret = _ISSUE_23810_SECRET_SHAPES[shape_name] + raw = f"Sure, here is the token you asked me to echo: {secret} — done." + + sanitized = _sanitize_gateway_final_response(platform, raw) + + assert secret not in sanitized, f"{shape_name} leaked verbatim on {platform}" + # Prose around the secret is preserved — redaction is surgical. + assert "here is the token you asked me to echo" in sanitized + assert sanitized.endswith("done.")