From 335d42b99ceaf02adee4a24dfc6439c0131fa527 Mon Sep 17 00:00:00 2001 From: VoidChecksum Date: Sat, 30 May 2026 13:43:45 +0200 Subject: [PATCH] fix(llm): redact credentials from re-raised provider error messages The actionable-error translation in `decepticon/llm/factory.py` (`_reraise_with_actionable_message` and `_reraise_if_connection_error`) interpolated the raw upstream exception text (`str(exc)`) into the user-facing `RuntimeError` (e.g. `... Underlying: {msg}`). For LiteLLM/openai errors `str(exc)` is constructed from the provider's HTTP response body, which can echo back the `Authorization` / `x-api-key` / `Bearer` value that was sent on the request. Those RuntimeErrors propagate to the CLI and into logs, so a transient upstream failure could disclose a live API key to anyone reading the output. Add `_redact_secrets(text)`, a small regex-based scrubber applied to every place these reraise paths interpolate the exception text. It scrubs: - `Bearer ` -> `Bearer [REDACTED]` - `sk-ant-*` / `sk-*` provider key prefixes -> `[REDACTED]` - `authorization` / `api_key` / `x-api-key` header or kwarg values (JSON `"authorization": "..."` and `api_key=...` forms) -> value replaced with `[REDACTED]`, key name kept for readability - generic long opaque tokens (>=24 chars) -> `[REDACTED]` Branch matching still runs against the raw message (HTTP status codes and provider keywords are not secret-shaped), so the actionable guidance (status code, offending model id, remediation hint) is preserved verbatim; only secret material is removed. Non-secret text such as `model_group=anthropic/claude-opus-4-7` is left intact. Update `TestActionableErrorTranslation` to add coverage proving an exception carrying `Authorization: Bearer sk-ant-...` (and one with `api_key=sk-...`) is re-raised with the secret redacted while the status-code/guidance remains, plus a non-secret passthrough assertion. Tests use obviously-fake placeholder tokens only. --- packages/decepticon/decepticon/llm/factory.py | 58 +++++++++++++++++-- .../decepticon/tests/unit/llm/test_factory.py | 32 ++++++++++ 2 files changed, 84 insertions(+), 6 deletions(-) diff --git a/packages/decepticon/decepticon/llm/factory.py b/packages/decepticon/decepticon/llm/factory.py index 86928fcd..b85599df 100644 --- a/packages/decepticon/decepticon/llm/factory.py +++ b/packages/decepticon/decepticon/llm/factory.py @@ -22,6 +22,7 @@ import asyncio import json import os +import re from collections.abc import Awaitable from dataclasses import dataclass from enum import Enum @@ -1000,6 +1001,47 @@ def _get_request_payload( return payload +# Patterns matching secret-shaped substrings that providers may echo back +# inside HTTP error bodies. ``str(exc)`` for LiteLLM/openai errors is built +# from that body, so it can reflect the Authorization/x-api-key/Bearer +# credential we sent. Each pattern below scrubs one shape before the text is +# interpolated into a user-facing RuntimeError that lands in CLI output/logs. +_SECRET_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = ( + # "Bearer " auth scheme (RFC 6750), case-insensitive. + (re.compile(r"(?i)\bBearer\s+[\w.\-+/=]+"), "Bearer [REDACTED]"), + # Provider key prefixes: Anthropic sk-ant-* and OpenAI-style sk-* (any + # remaining long token after the prefix). + (re.compile(r"\bsk-(?:ant-)?[\w.\-]{8,}"), "[REDACTED]"), + # Header/field values for authorization / api_key / x-api-key, whether + # JSON ("authorization": "...") or kwarg (api_key=...). Keeps the key + # name so the guidance stays readable; only the value is scrubbed. + ( + re.compile( + r"(?i)(['\"]?(?:authorization|x-api-key|api[_-]?key)['\"]?\s*[:=]\s*)" + r"(['\"]?)[^'\"\s,}]+\2" + ), + r"\1\2[REDACTED]\2", + ), + # Generic long opaque tokens (>=24 chars of key-ish alphabet) that survive + # the targeted passes above — e.g. provider keys without an sk- prefix. + (re.compile(r"\b[A-Za-z0-9_\-]{24,}\b"), "[REDACTED]"), +) + + +def _redact_secrets(text: str) -> str: + """Scrub credential-shaped substrings from upstream error text. + + LiteLLM/openai surface the provider's raw HTTP response body via + ``str(exc)``; that body can echo the ``Authorization``/``x-api-key``/ + ``Bearer`` value we sent. Redacting here keeps the actionable guidance + (status code, model id, human hint) intact while preventing the secret + from leaking into CLI output and logs. + """ + for pattern, replacement in _SECRET_PATTERNS: + text = pattern.sub(replacement, text) + return text + + def _reraise_if_connection_error(exc: Exception) -> None: err_type = type(exc).__name__ if any( @@ -1009,7 +1051,7 @@ def _reraise_if_connection_error(exc: Exception) -> None: for kw in ("connection refused", "connect error", "proxy", "unreachable") ): raise RuntimeError( - f"LLM proxy unreachable ({err_type}): {exc}. " + f"LLM proxy unreachable ({err_type}): {_redact_secrets(str(exc))}. " f"Check 'decepticon logs litellm' for details." ) from exc @@ -1036,6 +1078,10 @@ def _reraise_with_actionable_message(exc: Exception, model_name: str) -> None: err_type = type(exc).__name__ msg = str(exc) msg_lower = msg.lower() + # Match on the raw text (status codes / keywords are not secret-shaped), + # but interpolate the scrubbed copy so an echoed credential never reaches + # the user-facing message — see _redact_secrets. + safe_msg = _redact_secrets(msg) # LiteLLM puts a recognizable prefix in the inner message when the # proxy ran out of fallback options for a model_group — issue #107. @@ -1046,14 +1092,14 @@ def _reraise_with_actionable_message(exc: Exception, model_name: str) -> None: f"Model '{model_name}' failed and no provider fallback was " f"available for it. Either configure another auth method in " f"DECEPTICON_AUTH_PRIORITY or fix the upstream error.\n" - f"Underlying: {msg}" + f"Underlying: {safe_msg}" ) from exc if "badrequest" in err_type.lower() or "code: 400" in msg_lower: raise RuntimeError( f"Model '{model_name}' rejected the request (400). " f"This usually means a parameter the model no longer supports " - f"(e.g. temperature on Claude Opus 4.7). Underlying: {msg}" + f"(e.g. temperature on Claude Opus 4.7). Underlying: {safe_msg}" ) from exc if ( @@ -1064,14 +1110,14 @@ def _reraise_with_actionable_message(exc: Exception, model_name: str) -> None: raise RuntimeError( f"Model '{model_name}' rejected your credentials (401). " f"Check the API key for that provider in ~/.decepticon/.env, " - f"or run 'decepticon onboard --reset'.\nUnderlying: {msg}" + f"or run 'decepticon onboard --reset'.\nUnderlying: {safe_msg}" ) from exc if "ratelimit" in err_type.lower() or "code: 429" in msg_lower: raise RuntimeError( f"Model '{model_name}' hit the provider's rate limit (429). " f"Add another method to DECEPTICON_AUTH_PRIORITY so the agent " - f"can fall back when this happens.\nUnderlying: {msg}" + f"can fall back when this happens.\nUnderlying: {safe_msg}" ) from exc if "notfound" in err_type.lower() or "code: 404" in msg_lower: @@ -1080,7 +1126,7 @@ def _reraise_with_actionable_message(exc: Exception, model_name: str) -> None: f"(404). For local Ollama, set OLLAMA_MODEL to something you " f"actually pulled ('ollama list'). For cloud providers, check " f"that the model id matches config/litellm.yaml.\n" - f"Underlying: {msg}" + f"Underlying: {safe_msg}" ) from exc diff --git a/packages/decepticon/tests/unit/llm/test_factory.py b/packages/decepticon/tests/unit/llm/test_factory.py index 117b148e..36f618fe 100644 --- a/packages/decepticon/tests/unit/llm/test_factory.py +++ b/packages/decepticon/tests/unit/llm/test_factory.py @@ -633,6 +633,38 @@ def test_unmatched_error_passes_through(self): # Should not raise from the helper. self._translate(exc, "anthropic/claude-opus-4-7") + def test_401_redacts_bearer_token_keeps_guidance(self): + exc = type("AuthenticationError", (Exception,), {})( + "Error code: 401 - {'error': 'invalid_api_key'} " + "Authorization: Bearer sk-ant-SECRET123FAKEPLACEHOLDERTOKEN" + ) + with pytest.raises(RuntimeError) as info: + self._translate(exc, "anthropic/claude-opus-4-7") + msg = str(info.value) + assert "sk-ant-SECRET123FAKEPLACEHOLDERTOKEN" not in msg + assert "SECRET123FAKEPLACEHOLDERTOKEN" not in msg + assert "[REDACTED]" in msg + assert "credentials (401)" in msg + assert "anthropic/claude-opus-4-7" in msg + + def test_400_redacts_api_key_kwarg_keeps_guidance(self): + exc = Exception( + "Error code: 400 - bad request from provider with api_key=sk-SECRETfakeplaceholdervalue99" + ) + with pytest.raises(RuntimeError) as info: + self._translate(exc, "openai/gpt-5.5") + msg = str(info.value) + assert "sk-SECRETfakeplaceholdervalue99" not in msg + assert "SECRETfakeplaceholdervalue99" not in msg + assert "[REDACTED]" in msg + assert "rejected the request (400)" in msg + + def test_redact_secrets_preserves_nonsecret_text(self): + from decepticon.llm.factory import _redact_secrets + + text = "No fallback model group found for model_group=anthropic/claude-opus-4-7." + assert _redact_secrets(text) == text + # ── DeepSeek V4 Pro reasoning_content passthrough ────────────────────────