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:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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": (
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user