fix(secrets): Slack-pattern scoped credential reads — ntfy, Home Assistant, SMS, DingTalk

NTFY_TOKEN, HASS_TOKEN, TWILIO_ACCOUNT_SID/TWILIO_AUTH_TOKEN and
DINGTALK_CLIENT_SECRET now read through _get_scoped_secret. Also replaces
the SMS adapter's bare os.environ["TWILIO_AUTH_TOKEN"]/["TWILIO_ACCOUNT_SID"]
__init__ reads (KeyError-prone) with helper reads defaulting to "".
This commit is contained in:
Teknium
2026-08-02 00:53:55 -07:00
parent d7404f197c
commit 6333180c9a
4 changed files with 110 additions and 14 deletions
+27 -3
View File
@@ -103,6 +103,30 @@ from gateway.platforms.base import (
SendResult,
)
from agent.secret_scope import UnscopedSecretError as _UnscopedSecretError
from agent.secret_scope import get_secret as _scoped_get_secret
def _get_scoped_secret(name, default=None):
"""Scope-aware credential read with the default-profile startup fallback.
Secondary profiles construct their adapters under a profile secret
scope -- the scope is authoritative and a scoped miss returns ``default``
(no cross-profile borrow from ``os.environ``, which may hold another
profile's value). The DEFAULT profile's adapter constructs and sends
*unscoped* under multiplexing, where a bare ``get_secret`` would raise
``UnscopedSecretError`` and crash this path; there ``os.environ`` is that
profile's own value, so fall back to it. Same pattern as the Slack
``SLACK_APP_TOKEN`` read (#59739) and
``gateway/platforms/whatsapp_common.py::_get_wsecret``.
"""
try:
val = _scoped_get_secret(name, default)
except _UnscopedSecretError:
val = os.getenv(name)
return val if val is not None else default
logger = logging.getLogger(__name__)
MAX_MESSAGE_LENGTH = 20000
@@ -166,7 +190,7 @@ def check_dingtalk_requirements() -> bool:
httpx = _httpx
DINGTALK_STREAM_AVAILABLE = True
HTTPX_AVAILABLE = True
if not os.getenv("DINGTALK_CLIENT_ID") or not os.getenv("DINGTALK_CLIENT_SECRET"):
if not os.getenv("DINGTALK_CLIENT_ID") or not _get_scoped_secret("DINGTALK_CLIENT_SECRET"):
return False
return True
@@ -213,7 +237,7 @@ class DingTalkAdapter(BasePlatformAdapter):
self._client_id: str = extra.get("client_id") or os.getenv(
"DINGTALK_CLIENT_ID", ""
)
self._client_secret: str = extra.get("client_secret") or os.getenv(
self._client_secret: str = extra.get("client_secret") or _get_scoped_secret(
"DINGTALK_CLIENT_SECRET", ""
)
@@ -1842,7 +1866,7 @@ def _is_connected(config) -> bool:
extra = getattr(config, "extra", {}) or {}
return bool(
(extra.get("client_id") or os.getenv("DINGTALK_CLIENT_ID"))
and (extra.get("client_secret") or os.getenv("DINGTALK_CLIENT_SECRET"))
and (extra.get("client_secret") or _get_scoped_secret("DINGTALK_CLIENT_SECRET"))
)
+27 -3
View File
@@ -36,6 +36,30 @@ from gateway.platforms.base import (
SendResult,
)
from agent.secret_scope import UnscopedSecretError as _UnscopedSecretError
from agent.secret_scope import get_secret as _scoped_get_secret
def _get_scoped_secret(name, default=None):
"""Scope-aware credential read with the default-profile startup fallback.
Secondary profiles construct their adapters under a profile secret
scope -- the scope is authoritative and a scoped miss returns ``default``
(no cross-profile borrow from ``os.environ``, which may hold another
profile's value). The DEFAULT profile's adapter constructs and sends
*unscoped* under multiplexing, where a bare ``get_secret`` would raise
``UnscopedSecretError`` and crash this path; there ``os.environ`` is that
profile's own value, so fall back to it. Same pattern as the Slack
``SLACK_APP_TOKEN`` read (#59739) and
``gateway/platforms/whatsapp_common.py::_get_wsecret``.
"""
try:
val = _scoped_get_secret(name, default)
except _UnscopedSecretError:
val = os.getenv(name)
return val if val is not None else default
logger = logging.getLogger(__name__)
@@ -46,7 +70,7 @@ def check_ha_requirements() -> bool:
def validate_ha_config(config: PlatformConfig) -> bool:
"""Return True when Home Assistant has enough credential config to connect."""
token = (getattr(config, "token", None) or os.getenv("HASS_TOKEN", "")).strip()
token = (getattr(config, "token", None) or _get_scoped_secret("HASS_TOKEN", "")).strip()
return bool(token)
@@ -76,7 +100,7 @@ class HomeAssistantAdapter(BasePlatformAdapter):
# Configuration from extra
extra = config.extra or {}
token = config.token or os.getenv("HASS_TOKEN", "")
token = config.token or _get_scoped_secret("HASS_TOKEN", "")
url = extra.get("url") or os.getenv("HASS_URL", "http://homeassistant.local:8123")
self._hass_url: str = url.rstrip("/")
self._hass_token: str = token
@@ -488,7 +512,7 @@ async def _standalone_send(
extra = getattr(pconfig, "extra", {}) or {}
hass_url = (extra.get("url") or os.getenv("HASS_URL", "")).rstrip("/")
token = (getattr(pconfig, "token", None) or os.getenv("HASS_TOKEN", "")).strip()
token = (getattr(pconfig, "token", None) or _get_scoped_secret("HASS_TOKEN", "")).strip()
if not hass_url or not token:
return {
"error": (
+27 -3
View File
@@ -68,6 +68,30 @@ from gateway.platforms.base import (
SendResult,
)
from agent.secret_scope import UnscopedSecretError as _UnscopedSecretError
from agent.secret_scope import get_secret as _scoped_get_secret
def _get_scoped_secret(name, default=None):
"""Scope-aware credential read with the default-profile startup fallback.
Secondary profiles construct their adapters under a profile secret
scope -- the scope is authoritative and a scoped miss returns ``default``
(no cross-profile borrow from ``os.environ``, which may hold another
profile's value). The DEFAULT profile's adapter constructs and sends
*unscoped* under multiplexing, where a bare ``get_secret`` would raise
``UnscopedSecretError`` and crash this path; there ``os.environ`` is that
profile's own value, so fall back to it. Same pattern as the Slack
``SLACK_APP_TOKEN`` read (#59739) and
``gateway/platforms/whatsapp_common.py::_get_wsecret``.
"""
try:
val = _scoped_get_secret(name, default)
except _UnscopedSecretError:
val = os.getenv(name)
return val if val is not None else default
logger = logging.getLogger(__name__)
@@ -173,7 +197,7 @@ class NtfyAdapter(BasePlatformAdapter):
or os.getenv("NTFY_PUBLISH_TOPIC", "")
or self._topic
)
self._token: str = extra.get("token") or os.getenv("NTFY_TOKEN", "")
self._token: str = extra.get("token") or _get_scoped_secret("NTFY_TOKEN", "")
self._stream_task: Optional[asyncio.Task] = None
self._http_client: Optional["httpx.AsyncClient"] = None
@@ -472,7 +496,7 @@ def _env_enablement() -> dict | None:
publish_topic = os.getenv("NTFY_PUBLISH_TOPIC", "").strip()
if publish_topic:
seed["publish_topic"] = publish_topic
token = os.getenv("NTFY_TOKEN", "").strip()
token = _get_scoped_secret("NTFY_TOKEN", "").strip()
if token:
seed["token"] = token
markdown = os.getenv("NTFY_MARKDOWN", "").strip().lower()
@@ -526,7 +550,7 @@ async def _standalone_send(
if not publish_topic:
return {"error": "ntfy standalone send: NTFY_TOPIC not configured"}
token = extra.get("token") or os.getenv("NTFY_TOKEN", "")
token = extra.get("token") or _get_scoped_secret("NTFY_TOKEN", "")
markdown_env = os.getenv("NTFY_MARKDOWN", "").strip().lower()
markdown_enabled = bool(extra.get("markdown")) or markdown_env in ("1", "true", "yes")
+29 -5
View File
@@ -36,6 +36,30 @@ from gateway.platforms.base import (
)
from gateway.platforms.helpers import redact_phone, strip_markdown
from agent.secret_scope import UnscopedSecretError as _UnscopedSecretError
from agent.secret_scope import get_secret as _scoped_get_secret
def _get_scoped_secret(name, default=None):
"""Scope-aware credential read with the default-profile startup fallback.
Secondary profiles construct their adapters under a profile secret
scope -- the scope is authoritative and a scoped miss returns ``default``
(no cross-profile borrow from ``os.environ``, which may hold another
profile's value). The DEFAULT profile's adapter constructs and sends
*unscoped* under multiplexing, where a bare ``get_secret`` would raise
``UnscopedSecretError`` and crash this path; there ``os.environ`` is that
profile's own value, so fall back to it. Same pattern as the Slack
``SLACK_APP_TOKEN`` read (#59739) and
``gateway/platforms/whatsapp_common.py::_get_wsecret``.
"""
try:
val = _scoped_get_secret(name, default)
except _UnscopedSecretError:
val = os.getenv(name)
return val if val is not None else default
logger = logging.getLogger(__name__)
TWILIO_API_BASE = "https://api.twilio.com/2010-04-01/Accounts"
@@ -51,7 +75,7 @@ def check_sms_requirements() -> bool:
import aiohttp # noqa: F401
except ImportError:
return False
return bool(os.getenv("TWILIO_ACCOUNT_SID") and os.getenv("TWILIO_AUTH_TOKEN"))
return bool(_get_scoped_secret("TWILIO_ACCOUNT_SID") and _get_scoped_secret("TWILIO_AUTH_TOKEN"))
class SmsAdapter(BasePlatformAdapter):
@@ -66,8 +90,8 @@ class SmsAdapter(BasePlatformAdapter):
def __init__(self, config: PlatformConfig):
super().__init__(config, Platform.SMS)
self._account_sid: str = os.environ["TWILIO_ACCOUNT_SID"]
self._auth_token: str = os.environ["TWILIO_AUTH_TOKEN"]
self._account_sid: str = _get_scoped_secret("TWILIO_ACCOUNT_SID", "")
self._auth_token: str = _get_scoped_secret("TWILIO_AUTH_TOKEN", "")
self._from_number: str = os.getenv("TWILIO_PHONE_NUMBER", "")
self._webhook_port: int = int(
os.getenv("SMS_WEBHOOK_PORT", str(DEFAULT_WEBHOOK_PORT))
@@ -435,14 +459,14 @@ async def _standalone_send(
):
"""Out-of-process SMS delivery via the Twilio REST API. Implements the
standalone_sender_fn contract; replaces the legacy _send_sms helper."""
auth_token = getattr(pconfig, "api_key", None) or os.getenv("TWILIO_AUTH_TOKEN", "")
auth_token = getattr(pconfig, "api_key", None) or _get_scoped_secret("TWILIO_AUTH_TOKEN", "")
try:
import aiohttp
except ImportError:
return {"error": "aiohttp not installed. Run: pip install aiohttp"}
import base64
account_sid = os.getenv("TWILIO_ACCOUNT_SID", "")
account_sid = _get_scoped_secret("TWILIO_ACCOUNT_SID", "")
from_number = os.getenv("TWILIO_PHONE_NUMBER", "")
if not account_sid or not auth_token or not from_number:
return {"error": "SMS not configured (TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_PHONE_NUMBER required)"}