Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 25 additions & 2 deletions gateway/platforms/signal.py
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,26 @@ def validate_signal_config(config: PlatformConfig) -> bool:
# Signal Adapter
# ---------------------------------------------------------------------------

def _sig_secret(name: str, default: str = "") -> str:
"""Resolve a per-profile ``SIGNAL_*`` setting honoring the active secret
scope (#93522).

Under ``gateway.multiplex_profiles`` every secondary profile is
constructed inside ``_profile_runtime_scope`` (``gateway/run.py``) and
its ``.env`` lives in that scope — raw ``os.getenv`` misses it and
leaks the default profile's values instead. The primary/active profile
is constructed without a scope and legitimately owns ``os.environ``
(same canonical shape as QQ's ``_resolve_qq_secret``).
"""
from agent.secret_scope import UnscopedSecretError, get_secret

try:
val = get_secret(name, default)
except UnscopedSecretError:
val = os.getenv(name)
return val if val is not None else default


class SignalAdapter(BasePlatformAdapter):
"""Signal messenger adapter using signal-cli HTTP daemon."""

Expand All @@ -268,7 +288,10 @@ def __init__(self, config: PlatformConfig):
self.ignore_stories = extra.get("ignore_stories", True)

# Parse allowlists — group policy is derived from presence of group allowlist
group_allowed_str = os.getenv("SIGNAL_GROUP_ALLOWED_USERS", "")
# Scoped reads (#93522): allowlists are per-profile authorization
# config; raw os.getenv misses secondary profiles' .env values and
# leaks the default profile's list into them.
group_allowed_str = _sig_secret("SIGNAL_GROUP_ALLOWED_USERS", "")
self.group_allow_from = set(_parse_comma_list(group_allowed_str))

# Mention filter — only respond in groups when the bot account is @mentioned.
Expand All @@ -285,7 +308,7 @@ def __init__(self, config: PlatformConfig):
# every inbound DM from any contact gets a 👀 reaction).
# "*" means all users allowed (open mode); empty means no restriction
# recorded at adapter level (run.py still enforces auth separately).
dm_allowed_str = os.getenv("SIGNAL_ALLOWED_USERS", "*")
dm_allowed_str = _sig_secret("SIGNAL_ALLOWED_USERS", "*")
self.dm_allow_from = set(_parse_comma_list(dm_allowed_str))

# HTTP client
Expand Down
14 changes: 8 additions & 6 deletions gateway/platforms/weixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -1227,14 +1227,14 @@ def __init__(self, config: PlatformConfig):
)
self._rate_limit_circuit_until = 0.0
self._rate_limit_events: List[float] = []
self._dm_policy = str(extra.get("dm_policy") or os.getenv("WEIXIN_DM_POLICY", "pairing")).strip().lower()
self._group_policy = str(extra.get("group_policy") or os.getenv("WEIXIN_GROUP_POLICY", "disabled")).strip().lower()
self._dm_policy = str(extra.get("dm_policy") or _wx_secret("WEIXIN_DM_POLICY", "pairing")).strip().lower()
self._group_policy = str(extra.get("group_policy") or _wx_secret("WEIXIN_GROUP_POLICY", "disabled")).strip().lower()
allow_from = extra.get("allow_from")
if allow_from is None:
allow_from = os.getenv("WEIXIN_ALLOWED_USERS", "")
allow_from = _wx_secret("WEIXIN_ALLOWED_USERS", "")
group_allow_from = extra.get("group_allow_from")
if group_allow_from is None:
group_allow_from = os.getenv("WEIXIN_GROUP_ALLOWED_USERS", "")
group_allow_from = _wx_secret("WEIXIN_GROUP_ALLOWED_USERS", "")
self._allow_from = self._coerce_list(allow_from)
self._group_allow_from = self._coerce_list(group_allow_from)
self._split_multiline_messages = _coerce_bool(
Expand Down Expand Up @@ -1537,9 +1537,11 @@ async def _process_message(self, message: Dict[str, Any]) -> None:
await self.handle_message(event)

def _open_dm_opted_in(self) -> bool:
if os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in {"true", "1", "yes"}:
# Scoped reads (#93522): the default profile's allow-all flag must
# not leak into a multiplexed secondary profile's admission gate.
if (_wx_secret("GATEWAY_ALLOW_ALL_USERS", "") or "").lower() in {"true", "1", "yes"}:
return True
return os.getenv("WEIXIN_ALLOW_ALL_USERS", "").lower() in {"true", "1", "yes"}
return (_wx_secret("WEIXIN_ALLOW_ALL_USERS", "") or "").lower() in {"true", "1", "yes"}

def _is_dm_allowed(self, sender_id: str) -> bool:
if self._dm_policy == "disabled":
Expand Down
37 changes: 30 additions & 7 deletions gateway/platforms/yuanbao.py
Original file line number Diff line number Diff line change
Expand Up @@ -1261,6 +1261,27 @@ async def handle(self, ctx: InboundContext, next_fn) -> None:
await next_fn()


def _yb_secret(name: str, default: Optional[str] = None) -> Optional[str]:
"""Resolve a per-profile ``YUANBAO_*`` / gateway setting honoring the
active secret scope (#93522).

Under ``gateway.multiplex_profiles`` every secondary profile is
constructed inside ``_profile_runtime_scope`` (``gateway/run.py``) and
its ``.env`` lives in that scope — raw ``os.getenv`` misses it and
leaks the default profile's values instead. The primary/active profile
is constructed without a scope and legitimately owns ``os.environ``,
so fall back to it there (same canonical shape as QQ's
``_resolve_qq_secret``).
"""
from agent.secret_scope import UnscopedSecretError, get_secret

try:
val = get_secret(name, default)
except UnscopedSecretError:
val = os.getenv(name)
return val if val is not None else default


class AccessPolicy:
"""Platform-level DM / Group access control policy.

Expand All @@ -1282,9 +1303,9 @@ def __init__(
self._group_allow_from = group_allow_from

def _open_dm_opted_in(self) -> bool:
if os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in {"true", "1", "yes"}:
if (_yb_secret("GATEWAY_ALLOW_ALL_USERS", "") or "").lower() in {"true", "1", "yes"}:
return True
return os.getenv("YUANBAO_ALLOW_ALL_USERS", "").lower() in {"true", "1", "yes"}
return (_yb_secret("YUANBAO_ALLOW_ALL_USERS", "") or "").lower() in {"true", "1", "yes"}

def is_dm_allowed(self, sender_id: str) -> bool:
"""Strict DM authorization — pairing does not imply access."""
Expand Down Expand Up @@ -4942,27 +4963,29 @@ def __init__(self, config: PlatformConfig, **kwargs: Any) -> None:

# Reply-to dedup: inbound_msg_id -> expire_ts
# ------------------------------------------------------------------
# Access control policy (DM / Group)
# Access control policy (DM / Group) — scoped reads (#93522)
# ------------------------------------------------------------------
dm_policy: str = (
_extra.get("dm_policy")
or os.getenv("YUANBAO_DM_POLICY", "pairing")
or _yb_secret("YUANBAO_DM_POLICY")
or "pairing"
).strip().lower()

_dm_allow_from_raw: str = (
_extra.get("dm_allow_from")
or os.getenv("YUANBAO_DM_ALLOW_FROM", "")
or _yb_secret("YUANBAO_DM_ALLOW_FROM", "")
)
dm_allow_from: list[str] = [x.strip() for x in _dm_allow_from_raw.split(",") if x.strip()]

group_policy: str = (
_extra.get("group_policy")
or os.getenv("YUANBAO_GROUP_POLICY", "pairing")
or _yb_secret("YUANBAO_GROUP_POLICY")
or "pairing"
).strip().lower()

_group_allow_from_raw: str = (
_extra.get("group_allow_from")
or os.getenv("YUANBAO_GROUP_ALLOW_FROM", "")
or _yb_secret("YUANBAO_GROUP_ALLOW_FROM", "")
)
group_allow_from: list[str] = [x.strip() for x in _group_allow_from_raw.split(",") if x.strip()]

Expand Down
2 changes: 1 addition & 1 deletion gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -2750,7 +2750,7 @@ def _own_policy_open_startup_violation(config) -> Optional[str]:
).strip().lower()
if dm_policy != "open" and group_policy != "open":
continue
gateway_allow_all = os.getenv(
gateway_allow_all = _getenv(
"GATEWAY_ALLOW_ALL_USERS", ""
).lower() in {"true", "1", "yes"}
platform_opted_in = gateway_allow_all or (
Expand Down
12 changes: 7 additions & 5 deletions plugins/platforms/wecom/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,18 +185,18 @@ def __init__(self, config: PlatformConfig):
or os.getenv("WECOM_WEBSOCKET_URL", DEFAULT_WS_URL)
).strip() or DEFAULT_WS_URL

self._dm_policy = str(extra.get("dm_policy") or os.getenv("WECOM_DM_POLICY", "pairing")).strip().lower()
self._dm_policy = str(extra.get("dm_policy") or _get_scoped_secret("WECOM_DM_POLICY", "pairing")).strip().lower()
# dm_policy already honors WECOM_DM_POLICY, so the allowlist must honor
# WECOM_ALLOWED_USERS too. Without the env fallback an env-only setup
# (dm_policy=allowlist via env, no config extra) runs with an empty
# allowlist and drops every authorized DM at intake.
self._allow_from = _coerce_list(
extra.get("allow_from")
or extra.get("allowFrom")
or os.getenv("WECOM_ALLOWED_USERS", "")
or _get_scoped_secret("WECOM_ALLOWED_USERS", "")
)

self._group_policy = str(extra.get("group_policy") or os.getenv("WECOM_GROUP_POLICY", "pairing")).strip().lower()
self._group_policy = str(extra.get("group_policy") or _get_scoped_secret("WECOM_GROUP_POLICY", "pairing")).strip().lower()
self._group_allow_from = _coerce_list(extra.get("group_allow_from") or extra.get("groupAllowFrom"))
self._groups = extra.get("groups") if isinstance(extra.get("groups"), dict) else {}

Expand Down Expand Up @@ -893,9 +893,11 @@ def enforces_own_access_policy(self) -> bool:
return True

def _open_dm_opted_in(self) -> bool:
if os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in {"true", "1", "yes"}:
# Scoped reads (#93522): the default profile's allow-all flag must
# not leak into a multiplexed secondary profile's admission gate.
if (_get_scoped_secret("GATEWAY_ALLOW_ALL_USERS", "") or "").lower() in {"true", "1", "yes"}:
return True
return os.getenv("WECOM_ALLOW_ALL_USERS", "").lower() in {"true", "1", "yes"}
return (_get_scoped_secret("WECOM_ALLOW_ALL_USERS", "") or "").lower() in {"true", "1", "yes"}

def _is_dm_allowed(self, sender_id: str) -> bool:
if self._dm_policy == "disabled":
Expand Down
36 changes: 36 additions & 0 deletions tests/gateway/test_multiplex_profile_authz.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,42 @@ def fake_is_user_authorized(source):
assert captured["profile"] == "coder"


def test_startup_guard_gateway_allow_all_reads_scope_not_environ(monkeypatch):
"""The GATEWAY_ALLOW_ALL_USERS opt-in check inside the startup guard
must honor the active profile secret scope (#93522): the default
profile's env-only opt-in must not leak into a secondary profile that
never opted in, and a secondary profile's own scoped opt-in must be
honored."""
from agent import secret_scope
from gateway.run import _own_policy_open_startup_violation

_clear_auth_env(monkeypatch)
cfg = GatewayConfig(multiplex_profiles=True)
cfg.platforms = {
Platform.WECOM: PlatformConfig(enabled=True, extra={"dm_policy": "open"}),
}

previous_multiplex = secret_scope.is_multiplex_active()
secret_scope.set_multiplex_active(True)
monkeypatch.setenv("GATEWAY_ALLOW_ALL_USERS", "true")
try:
token = secret_scope.set_secret_scope({"SOMETHING_ELSE": "x"})
try:
violation = _own_policy_open_startup_violation(cfg)
finally:
secret_scope.reset_secret_scope(token)
assert violation is not None, "default profile's env opt-in must not leak into the scoped secondary profile"

token = secret_scope.set_secret_scope({"GATEWAY_ALLOW_ALL_USERS": "true"})
try:
violation = _own_policy_open_startup_violation(cfg)
finally:
secret_scope.reset_secret_scope(token)
assert violation is None, "the secondary profile's own scoped opt-in must be honored"
finally:
secret_scope.set_multiplex_active(previous_multiplex)


def test_secondary_open_policy_fails_startup_guard(monkeypatch):
"""Secondary profiles must pass the same open-policy startup guard."""
from gateway.run import _own_policy_open_startup_violation
Expand Down
Loading
Loading