fix(secrets): Slack-pattern scoped credential reads — IRC, LINE, Teams, Mattermost

Route IRC_SERVER_PASSWORD/IRC_NICKSERV_PASSWORD, LINE_CHANNEL_ACCESS_TOKEN/
LINE_CHANNEL_SECRET, TEAMS_GRAPH_ACCESS_TOKEN/TEAMS_CLIENT_SECRET and
MATTERMOST_TOKEN reads at __init__/availability/standalone-send time through
a module-level _get_scoped_secret helper (get_secret, UnscopedSecretError ->
os.getenv fallback), mirroring whatsapp_common._get_wsecret / Slack #59739.
Scoped miss returns the default — no cross-profile environ borrow.
This commit is contained in:
Teknium
2026-08-02 00:53:55 -07:00
parent 062d44bba7
commit d7404f197c
4 changed files with 120 additions and 24 deletions
+32 -8
View File
@@ -35,6 +35,30 @@ import ssl
import time
from typing import Any, Dict, List, Optional
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__)
# ---------------------------------------------------------------------------
@@ -118,8 +142,8 @@ class IRCAdapter(BasePlatformAdapter):
if os.getenv("IRC_USE_TLS")
else extra.get("use_tls", True)
)
self.server_password = os.getenv("IRC_SERVER_PASSWORD") or extra.get("server_password", "")
self.nickserv_password = os.getenv("IRC_NICKSERV_PASSWORD") or extra.get("nickserv_password", "")
self.server_password = _get_scoped_secret("IRC_SERVER_PASSWORD") or extra.get("server_password", "")
self.nickserv_password = _get_scoped_secret("IRC_NICKSERV_PASSWORD") or extra.get("nickserv_password", "")
# Auth
self.allowed_users: list = extra.get("allowed_users", [])
@@ -685,10 +709,10 @@ def _env_enablement() -> dict | None:
seed["use_tls"] = use_tls in {"1", "true", "yes"}
# Passwords live in PlatformConfig.extra as well for back-compat with
# existing config.yaml users; env-reads at construct time still win.
if os.getenv("IRC_SERVER_PASSWORD"):
seed["server_password"] = os.getenv("IRC_SERVER_PASSWORD")
if os.getenv("IRC_NICKSERV_PASSWORD"):
seed["nickserv_password"] = os.getenv("IRC_NICKSERV_PASSWORD")
if _get_scoped_secret("IRC_SERVER_PASSWORD"):
seed["server_password"] = _get_scoped_secret("IRC_SERVER_PASSWORD")
if _get_scoped_secret("IRC_NICKSERV_PASSWORD"):
seed["nickserv_password"] = _get_scoped_secret("IRC_NICKSERV_PASSWORD")
# Optional home-channel (usually the same as IRC_CHANNEL, but can be a
# dedicated reports channel). Defaults to IRC_CHANNEL so cron jobs
# with ``deliver=irc`` have a sensible target without extra config.
@@ -762,8 +786,8 @@ async def _standalone_send(
else:
use_tls = bool(extra.get("use_tls", True))
server_password = os.getenv("IRC_SERVER_PASSWORD") or extra.get("server_password", "")
nickserv_password = os.getenv("IRC_NICKSERV_PASSWORD") or extra.get("nickserv_password", "")
server_password = _get_scoped_secret("IRC_SERVER_PASSWORD") or extra.get("server_password", "")
nickserv_password = _get_scoped_secret("IRC_NICKSERV_PASSWORD") or extra.get("nickserv_password", "")
# Reject control characters in chat_id to block IRC command injection.
raw_target = chat_id or channel
+32 -8
View File
@@ -80,6 +80,30 @@ from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple
from urllib.parse import quote as _urlquote
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__)
# ---------------------------------------------------------------------------
@@ -678,11 +702,11 @@ class LineAdapter(BasePlatformAdapter):
# Credentials
self.channel_access_token = (
os.getenv("LINE_CHANNEL_ACCESS_TOKEN")
_get_scoped_secret("LINE_CHANNEL_ACCESS_TOKEN")
or extra.get("channel_access_token", "")
)
self.channel_secret = (
os.getenv("LINE_CHANNEL_SECRET")
_get_scoped_secret("LINE_CHANNEL_SECRET")
or extra.get("channel_secret", "")
)
@@ -1561,9 +1585,9 @@ def _is_relative_to(child: Path, parent: Path) -> bool:
def check_requirements() -> bool:
"""Plugin gate: require credentials AND aiohttp at runtime."""
if not os.getenv("LINE_CHANNEL_ACCESS_TOKEN"):
if not _get_scoped_secret("LINE_CHANNEL_ACCESS_TOKEN"):
return False
if not os.getenv("LINE_CHANNEL_SECRET"):
if not _get_scoped_secret("LINE_CHANNEL_SECRET"):
return False
try:
import aiohttp # noqa: F401
@@ -1575,10 +1599,10 @@ def check_requirements() -> bool:
def validate_config(config) -> bool:
extra = getattr(config, "extra", {}) or {}
has_token = bool(
os.getenv("LINE_CHANNEL_ACCESS_TOKEN") or extra.get("channel_access_token")
_get_scoped_secret("LINE_CHANNEL_ACCESS_TOKEN") or extra.get("channel_access_token")
)
has_secret = bool(
os.getenv("LINE_CHANNEL_SECRET") or extra.get("channel_secret")
_get_scoped_secret("LINE_CHANNEL_SECRET") or extra.get("channel_secret")
)
return has_token and has_secret
@@ -1595,7 +1619,7 @@ def _env_enablement() -> Optional[Dict[str, Any]]:
in ``.env`` without a ``platforms.line`` block in ``config.yaml``.
Mirrors the IRC plugin's pattern.
"""
if not (os.getenv("LINE_CHANNEL_ACCESS_TOKEN") and os.getenv("LINE_CHANNEL_SECRET")):
if not (_get_scoped_secret("LINE_CHANNEL_ACCESS_TOKEN") and _get_scoped_secret("LINE_CHANNEL_SECRET")):
return None
seeded: Dict[str, Any] = {}
if os.getenv("LINE_PORT"):
@@ -1635,7 +1659,7 @@ async def _standalone_send(
"""
extra = getattr(pconfig, "extra", {}) or {}
token = (
os.getenv("LINE_CHANNEL_ACCESS_TOKEN")
_get_scoped_secret("LINE_CHANNEL_ACCESS_TOKEN")
or extra.get("channel_access_token", "")
)
if not token or not chat_id:
+27 -3
View File
@@ -30,6 +30,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__)
# Mattermost post size limit (server default is 16383, but 4000 is the
@@ -75,7 +99,7 @@ def check_mattermost_requirements() -> bool:
def validate_mattermost_config(config: PlatformConfig) -> bool:
"""Return True when Mattermost has enough config to connect."""
extra = getattr(config, "extra", {}) or {}
token = (getattr(config, "token", None) or os.getenv("MATTERMOST_TOKEN", "")).strip()
token = (getattr(config, "token", None) or _get_scoped_secret("MATTERMOST_TOKEN", "")).strip()
url = (extra.get("url", "") or os.getenv("MATTERMOST_URL", "")).strip()
if not token:
logger.debug("Mattermost: MATTERMOST_TOKEN not set")
@@ -98,7 +122,7 @@ class MattermostAdapter(BasePlatformAdapter):
config.extra.get("url", "")
or os.getenv("MATTERMOST_URL", "")
).rstrip("/")
self._token: str = config.token or os.getenv("MATTERMOST_TOKEN", "")
self._token: str = config.token or _get_scoped_secret("MATTERMOST_TOKEN", "")
self._bot_user_id: str = ""
self._bot_username: str = ""
@@ -1022,7 +1046,7 @@ async def _standalone_send(
(getattr(pconfig, "extra", {}) or {}).get("url")
or os.getenv("MATTERMOST_URL", "")
).rstrip("/")
token = (getattr(pconfig, "token", None) or os.getenv("MATTERMOST_TOKEN", "")).strip()
token = (getattr(pconfig, "token", None) or _get_scoped_secret("MATTERMOST_TOKEN", "")).strip()
if not base_url or not token:
return {
"error": (
+29 -5
View File
@@ -91,6 +91,30 @@ from gateway.platforms.base import (
cache_media_bytes,
)
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__)
_DEFAULT_PORT = 3978
@@ -200,7 +224,7 @@ class TeamsSummaryWriter:
env_defaults = {
"delivery_mode": os.getenv("TEAMS_DELIVERY_MODE", ""),
"incoming_webhook_url": os.getenv("TEAMS_INCOMING_WEBHOOK_URL", ""),
"access_token": os.getenv("TEAMS_GRAPH_ACCESS_TOKEN", ""),
"access_token": _get_scoped_secret("TEAMS_GRAPH_ACCESS_TOKEN", ""),
"team_id": os.getenv("TEAMS_TEAM_ID", ""),
"channel_id": os.getenv("TEAMS_CHANNEL_ID", ""),
"chat_id": os.getenv("TEAMS_CHAT_ID", ""),
@@ -401,7 +425,7 @@ def validate_config(config) -> bool:
"""Return True when the config has the minimum required credentials."""
extra = getattr(config, "extra", {}) or {}
client_id = os.getenv("TEAMS_CLIENT_ID") or extra.get("client_id", "")
client_secret = os.getenv("TEAMS_CLIENT_SECRET") or extra.get("client_secret", "")
client_secret = _get_scoped_secret("TEAMS_CLIENT_SECRET") or extra.get("client_secret", "")
tenant_id = os.getenv("TEAMS_TENANT_ID") or extra.get("tenant_id", "")
return bool(client_id and client_secret and tenant_id)
@@ -423,7 +447,7 @@ def _env_enablement() -> dict | None:
``HomeChannel`` dataclass on the ``PlatformConfig`` via the core hook.
"""
client_id = os.getenv("TEAMS_CLIENT_ID", "").strip()
client_secret = os.getenv("TEAMS_CLIENT_SECRET", "").strip()
client_secret = _get_scoped_secret("TEAMS_CLIENT_SECRET", "").strip()
tenant_id = os.getenv("TEAMS_TENANT_ID", "").strip()
if not (client_id and client_secret and tenant_id):
return None
@@ -528,7 +552,7 @@ async def _standalone_send(
"""
extra = getattr(pconfig, "extra", {}) or {}
client_id = os.getenv("TEAMS_CLIENT_ID") or extra.get("client_id", "")
client_secret = os.getenv("TEAMS_CLIENT_SECRET") or extra.get("client_secret", "")
client_secret = _get_scoped_secret("TEAMS_CLIENT_SECRET") or extra.get("client_secret", "")
tenant_id = os.getenv("TEAMS_TENANT_ID") or extra.get("tenant_id", "")
if not (client_id and client_secret and tenant_id):
return {"error": "Teams standalone send: TEAMS_CLIENT_ID, TEAMS_CLIENT_SECRET, and TEAMS_TENANT_ID are all required"}
@@ -728,7 +752,7 @@ class TeamsAdapter(BasePlatformAdapter):
super().__init__(config, Platform("teams"))
extra = config.extra or {}
self._client_id = extra.get("client_id") or os.getenv("TEAMS_CLIENT_ID", "")
self._client_secret = extra.get("client_secret") or os.getenv("TEAMS_CLIENT_SECRET", "")
self._client_secret = extra.get("client_secret") or _get_scoped_secret("TEAMS_CLIENT_SECRET", "")
self._tenant_id = extra.get("tenant_id") or os.getenv("TEAMS_TENANT_ID", "")
self._port = _coerce_port(
extra.get("port") or os.getenv("TEAMS_PORT", str(_DEFAULT_PORT))