From 249c69b9586567d54dc7bcdeadd221f49aa2d304 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Jul 2026 01:25:52 -0700 Subject: [PATCH] fix(gateway): per-profile pairing whitelist isolation in multiplex mode (#53045 salvage) (#59330) * fix(gateway): per-profile pairing whitelist isolation for multiplex gateways Pairing approvals are stored per profile (profiles//pairing/) and authz routes pairing checks through the serving profile's store, so one profile's approved users no longer authorize against every other profile's whitelist in multiplex mode. The global store remains for the hermes pairing CLI and single-profile gateways; unregistered/unstamped sources fall back to it, preserving existing behavior. Salvaged from PR #53045 (pairing half). The SOUL.md half was dropped: the agent turn already runs inside _profile_runtime_scope on main, so load_soul_md() resolves per-profile without changes. Original work by @soddy022. * ci: redispatch after arm64 docker dashboard-slot flake (unrelated to this PR) --------- Co-authored-by: soddy022 <290613374+soddy022@users.noreply.github.com> --- gateway/authz_mixin.py | 23 +++++++- gateway/pairing.py | 29 +++++++-- gateway/run.py | 15 ++++- tests/gateway/test_pairing.py | 107 ++++++++++++++++++++++++++++++++++ 4 files changed, 166 insertions(+), 8 deletions(-) diff --git a/gateway/authz_mixin.py b/gateway/authz_mixin.py index f84ca259ccb..166635df59a 100644 --- a/gateway/authz_mixin.py +++ b/gateway/authz_mixin.py @@ -246,6 +246,21 @@ class GatewayAuthorizationMixin: return any(str(item).strip() for item in sender_allow) return False + def _pairing_store_for(self, source: "SessionSource"): + """Pick the per-profile PairingStore for a source, falling back to global. + + In a multiplexing gateway, each profile owns its own pairing whitelist + so isolation is preserved. When the source has no profile (single- + profile gateway, or a path that hasn't stamped profile yet) or the + profile isn't registered, fall back to ``self.pairing_store`` (the + global default) so existing behavior is preserved. + """ + per_profile = getattr(self, "pairing_stores", None) or {} + profile = getattr(source, "profile", None) + if profile and profile in per_profile: + return per_profile[profile] + return getattr(self, "pairing_store", None) + def _is_user_authorized(self, source: SessionSource) -> bool: """ Check if a user is authorized to use the bot. @@ -430,10 +445,14 @@ class GatewayAuthorizationMixin: # allowlist IS configured, operator approval also writes the user into # that allowlist (see PairingStore._approve_user), keeping a single # operator-visible source of truth. (#23778: the original bypass was the - # inbound message/approval-button gate, not this grant; that gate is + # inbound message/approval-button gate, not this gate; that gate is # fixed separately.) + # In multiplex gateways, route to the per-profile PairingStore so each + # profile's whitelist is isolated; falls back to the global store when + # the source has no profile or the profile isn't registered. platform_name = source.platform.value if source.platform else "" - if self.pairing_store.is_approved(platform_name, user_id): + pairing_store = self._pairing_store_for(source) + if pairing_store is not None and pairing_store.is_approved(platform_name, user_id): return True # Check platform-specific and global allowlists diff --git a/gateway/pairing.py b/gateway/pairing.py index c7d3a8c7440..4e3712c117b 100644 --- a/gateway/pairing.py +++ b/gateway/pairing.py @@ -195,22 +195,41 @@ class PairingStore: - {platform}-pending.json : pending pairing requests - {platform}-approved.json : approved (paired) users - _rate_limits.json : rate limit tracking + + When constructed with ``profile=""``, storage lives under + ``/profiles//pairing/`` (per-profile, used by + multiplexing gateways so each profile has its own whitelist). + Without a profile, storage is the global ``/pairing/`` + directory (backward-compat for the ``hermes pairing`` CLI). """ - def __init__(self): - PAIRING_DIR.mkdir(parents=True, exist_ok=True) + def __init__(self, profile: Optional[str] = None): + # Resolve storage directory lazily — tests use a temp HERMES_HOME + # and PairingStore may be constructed before the env is set. + if profile: + from hermes_constants import get_hermes_home + self._dir = get_hermes_home() / "profiles" / profile / "pairing" + else: + self._dir = PAIRING_DIR + self._dir.mkdir(parents=True, exist_ok=True) # Protects all read-modify-write cycles. The gateway runs multiple # platform adapters concurrently in threads sharing one PairingStore. self._lock = threading.RLock() + self._profile = profile # for diagnostics / log lines + + @property + def profile(self) -> Optional[str]: + """Profile name this store is scoped to, or None for the global store.""" + return self._profile def _pending_path(self, platform: str) -> Path: - return PAIRING_DIR / f"{platform}-pending.json" + return self._dir / f"{platform}-pending.json" def _approved_path(self, platform: str) -> Path: - return PAIRING_DIR / f"{platform}-approved.json" + return self._dir / f"{platform}-approved.json" def _rate_limit_path(self) -> Path: - return PAIRING_DIR / "_rate_limits.json" + return self._dir / "_rate_limits.json" def _load_json(self, path: Path) -> dict: if path.exists(): diff --git a/gateway/run.py b/gateway/run.py index 6162f10b6fb..4541d646575 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -3047,9 +3047,15 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew except Exception as exc: logger.debug("checkpoint auto-maintenance skipped: %s", exc) - # DM pairing store for code-based user authorization + # DM pairing store for code-based user authorization. + # ``pairing_store`` stays as the global/default store for the + # ``hermes pairing`` CLI and any caller without a profile context. + # ``pairing_stores`` is the per-profile map used by + # ``authz_mixin._is_user_authorized`` to route checks to the right + # whitelist (one per profile in multiplex mode). from gateway.pairing import PairingStore self.pairing_store = PairingStore() + self.pairing_stores: Dict[str, "PairingStore"] = {} # Event hook system from gateway.hooks import HookRegistry @@ -8335,6 +8341,13 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew try: from gateway.status import write_runtime_status served = [active] + sorted(self._profile_adapters.keys()) + # Per-profile PairingStores so authz_mixin can route pairing + # checks to the right whitelist. The active profile gets a store + # at its HERMES_HOME; additional served profiles get one under + # profiles//pairing/. See gateway.pairing.PairingStore. + for name in served: + if name and name not in self.pairing_stores: + self.pairing_stores[name] = PairingStore(profile=name) write_runtime_status(served_profiles=served) except Exception: logger.debug("could not record served_profiles", exc_info=True) diff --git a/tests/gateway/test_pairing.py b/tests/gateway/test_pairing.py index c08ac205577..aa4a9b1e9f4 100644 --- a/tests/gateway/test_pairing.py +++ b/tests/gateway/test_pairing.py @@ -720,3 +720,110 @@ class TestUnreadablePairingFile: # The warning must fire — otherwise this is the silent-failure bug. assert any(rec.levelno == logging.WARNING for rec in caplog.records), \ "PermissionError on approved.json must produce a WARNING log line" +# Profile-scoped storage (multiplexing gateway isolation) +# --------------------------------------------------------------------------- + + +class TestProfileScopedStorage: + """PairingStore(profile="") should isolate per-profile whitelists + under /profiles//pairing/ so a multiplexing gateway + can keep each profile's allowlist separate. + """ + + def test_default_store_uses_global_dir(self, tmp_path, monkeypatch): + """PairingStore() (no profile) keeps the legacy global path so the + ``hermes pairing`` CLI continues to work without a profile context.""" + from hermes_constants import get_hermes_home + monkeypatch.setattr("hermes_constants.get_hermes_home", lambda: tmp_path) + # Re-import PAIRING_DIR (it's a module-level constant resolved at + # import time) so the test exercises the right path. We patch it + # rather than re-importing so the assertion is unambiguous. + with patch("gateway.pairing.PAIRING_DIR", tmp_path): + store = PairingStore() + assert store.profile is None + assert store._dir == tmp_path + assert store._approved_path("weixin") == tmp_path / "weixin-approved.json" + + def test_profile_store_uses_profiles_subdir(self, tmp_path, monkeypatch): + """PairingStore(profile="yangyang") puts files under + /profiles/yangyang/pairing/.""" + from hermes_constants import get_hermes_home + monkeypatch.setattr("hermes_constants.get_hermes_home", lambda: tmp_path) + store = PairingStore(profile="yangyang") + assert store.profile == "yangyang" + expected = tmp_path / "profiles" / "yangyang" / "pairing" + assert store._dir == expected + assert store._approved_path("weixin") == expected / "weixin-approved.json" + # Auto-creates the directory + assert expected.is_dir() + + def test_profile_approval_does_not_leak_to_global(self, tmp_path, monkeypatch): + """Approving in a profile-scoped store must not appear in the global + store — and vice versa. This is the whole point of the fix.""" + from hermes_constants import get_hermes_home + monkeypatch.setattr("hermes_constants.get_hermes_home", lambda: tmp_path) + with patch("gateway.pairing.PAIRING_DIR", tmp_path): + global_store = PairingStore() + profile_store = PairingStore(profile="yangyang") + + # Approve in the profile store + profile_store._approve_user("weixin", "yangyang_user", "杨洋") + # And in the global store, a different user + global_store._approve_user("weixin", "global_user", "Default") + + # Cross-isolation: each store only sees its own user + assert profile_store.is_approved("weixin", "yangyang_user") is True + assert profile_store.is_approved("weixin", "global_user") is False + assert global_store.is_approved("weixin", "global_user") is True + assert global_store.is_approved("weixin", "yangyang_user") is False + + def test_profile_uses_distinct_rate_limit_file(self, tmp_path, monkeypatch): + """Rate-limit state is per-profile, not shared globally — otherwise + one profile's flood would lock out the other profile's users.""" + from hermes_constants import get_hermes_home + monkeypatch.setattr("hermes_constants.get_hermes_home", lambda: tmp_path) + with patch("gateway.pairing.PAIRING_DIR", tmp_path): + global_store = PairingStore() + profile_store = PairingStore(profile="yangyang") + + assert global_store._rate_limit_path() == tmp_path / "_rate_limits.json" + assert profile_store._rate_limit_path() == ( + tmp_path / "profiles" / "yangyang" / "pairing" / "_rate_limits.json" + ) + + def test_pairing_store_for_helper_routes_by_profile(self, tmp_path, monkeypatch): + """_pairing_store_for(source) on a gateway-like object picks the + per-profile store when source.profile is set, and falls back to + the global store when it isn't (defensive — single-profile + gateways, or any code path that hasn't stamped source.profile).""" + from gateway.session import SessionSource + from gateway.config import Platform + + class FakeGateway: + def __init__(self): + self.pairing_store = object() # sentinel + self.pairing_stores = { + "default": "default-store", + "yangyang": "yangyang-store", + } + + # Method under test — copy of the real helper so this test + # is self-contained even if the real one moves. + def _pairing_store_for(self, source): + per_profile = getattr(self, "pairing_stores", None) or {} + profile = getattr(source, "profile", None) + if profile and profile in per_profile: + return per_profile[profile] + return getattr(self, "pairing_store", None) + + g = FakeGateway() + # source with profile="yangyang" → per-profile store + s_yy = SessionSource(platform=Platform.WEIXIN, chat_id="c", profile="yangyang") + assert g._pairing_store_for(s_yy) == "yangyang-store" + # source with no profile → fallback to global + s_none = SessionSource(platform=Platform.WEIXIN, chat_id="c") + assert g._pairing_store_for(s_none) is g.pairing_store + # source with an unknown profile → fallback (defensive) + s_unknown = SessionSource(platform=Platform.WEIXIN, chat_id="c", profile="ghost") + assert g._pairing_store_for(s_unknown) is g.pairing_store +