From 122ad719b512ac4d8e5ae2910fb43d7dc8f376f2 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 13 Sep 2026 13:46:54 -0700 Subject: [PATCH] fix(telegram): runner-side allowlist gate decodes JSON list strings too The adapter fix decoded `'["-100","-200"]'` before comma-splitting, but the runner's central gate in gateway/authz_mixin.py::_coerce_allow_set reads the same YAML-bridged env chain (TELEGRAM_GROUP_ALLOWED_CHATS, TELEGRAM_ALLOWED_USERS via _auth_env) and still produced {'["1"', '"2"]'}, so a group message admitted by the adapter could still be rejected upstream. Move the decoder to gateway/platforms/_shared.py, which both the adapter and authz_mixin already import from (no plugin -> gateway cycle), and route _coerce_allow_set through it. One invariant test on the runner side, red before this change. --- gateway/authz_mixin.py | 4 +++- gateway/platforms/_shared.py | 17 ++++++++++++++ plugins/platforms/telegram/adapter.py | 23 ++++--------------- .../telegram/test_allowlist_json_adapter.py | 13 +++++++++++ 4 files changed, 38 insertions(+), 19 deletions(-) diff --git a/gateway/authz_mixin.py b/gateway/authz_mixin.py index edb209d757..4a9a9d31b8 100644 --- a/gateway/authz_mixin.py +++ b/gateway/authz_mixin.py @@ -48,6 +48,7 @@ _ALLOW_BOTS_ENV = { # Gate reads use the shared per-profile isolated reader (allowlist leak under multiplex, #72348). +from gateway.platforms._shared import decode_json_list_literal as _decode_json_list_literal # noqa: E402 from gateway.platforms._shared import platform_gate_env as _auth_env # noqa: E402 @@ -67,9 +68,10 @@ def _registry_entry(platform): def _coerce_allow_set(raw) -> set[str]: - """Parse an allowlist (YAML list or comma-separated scalar) into a set of strings.""" + """Parse an allowlist (YAML list, JSON list literal string, or comma-separated scalar) into a set of strings.""" if raw is None: return set() + raw = _decode_json_list_literal(raw) if isinstance(raw, list): return {str(part).strip() for part in raw if str(part).strip()} return {part.strip() for part in str(raw).split(",") if part.strip()} diff --git a/gateway/platforms/_shared.py b/gateway/platforms/_shared.py index 3dea6065e0..b3ab6a65b1 100644 --- a/gateway/platforms/_shared.py +++ b/gateway/platforms/_shared.py @@ -86,6 +86,23 @@ def platform_gate_env(name: str, default: str = "") -> str: return (os.getenv(name) or default).strip() +def decode_json_list_literal(raw): + """Decode a JSON-encoded allowlist written by ``hermes config set``. + + String-typed defaults keep list literals verbatim on write (``allowed_chats`` is + declared as ``""``), so the config can hold ``'["-100","-200"]'`` as a string. + Malformed JSON passes through unchanged and keeps the legacy comma-split path. + """ + if isinstance(raw, str) and raw.lstrip()[:1] == "[": + try: + loaded = json.loads(raw) + except ValueError: + return raw + if isinstance(loaded, list): + return loaded + return raw + + def extra_or_secret(extra: Optional[dict], key: str, env: str, default: Any = "", *, blank_is_unset: bool = True) -> Any: """``config.extra[key]`` when set, else the scoped env var ``env`` (else ``default``). diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index 00dbba0b59..1258c83fd8 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -18,7 +18,11 @@ from hermes_cli import setup_platforms logger = logging.getLogger(__name__) from agent.deadline import run_bounded_async -from gateway.platforms._shared import get_scoped_secret as _get_scoped_secret, platform_gate_env as _scoped_gate_env +from gateway.platforms._shared import ( + decode_json_list_literal as _decode_json_list_literal, + get_scoped_secret as _get_scoped_secret, + platform_gate_env as _scoped_gate_env, +) def _redact_telegram_error_text(error: object) -> str: @@ -33,23 +37,6 @@ def _redact_telegram_error_text(error: object) -> str: return "" -def _decode_json_list_literal(raw): - """Decode a JSON-encoded allowlist written by ``hermes config set``. - - String-typed defaults keep list literals verbatim on write (``allowed_chats`` is - declared as ``""``), so the config can hold ``'["-100","-200"]'`` as a string. - Malformed JSON passes through unchanged and keeps the legacy comma-split path. - """ - if isinstance(raw, str) and raw.lstrip()[:1] == "[": - try: - loaded = json.loads(raw) - except ValueError: - return raw - if isinstance(loaded, list): - return loaded - return raw - - def _consume_abandoned_task(task: asyncio.Task) -> None: """Observe a detached task's terminal exception to avoid noisy loop logs.""" try: diff --git a/tests/plugins/platforms/telegram/test_allowlist_json_adapter.py b/tests/plugins/platforms/telegram/test_allowlist_json_adapter.py index 528336b413..0ee911f3b0 100644 --- a/tests/plugins/platforms/telegram/test_allowlist_json_adapter.py +++ b/tests/plugins/platforms/telegram/test_allowlist_json_adapter.py @@ -50,3 +50,16 @@ def test_comma_and_malformed_strings_keep_the_legacy_split(): assert _adapter({"allowed_chats": "-100, -200"})._telegram_allowed_chats() == {"-100", "-200"} assert _adapter({"allowed_chats": ["-100", "-200"]})._telegram_allowed_chats() == {"-100", "-200"} assert _adapter({"allowed_chats": '["-100", "-200'})._telegram_allowed_chats() == {'["-100"', '"-200'} + + +def test_runner_side_allow_set_decodes_json_string(monkeypatch): + """The runner's central gate reads the same env chain (``TELEGRAM_GROUP_ALLOWED_CHATS`` + via the YAML bridge) and must not comma-split the brackets onto the ids either.""" + from gateway.authz_mixin import _coerce_allow_set + + monkeypatch.setenv("TELEGRAM_GROUP_ALLOWED_CHATS", '["-100","-200"]') + from gateway.platforms._shared import platform_gate_env + + assert _coerce_allow_set(platform_gate_env("TELEGRAM_GROUP_ALLOWED_CHATS")) == {"-100", "-200"} + assert _coerce_allow_set("-100, -200") == {"-100", "-200"} + assert _coerce_allow_set(["-100"]) == {"-100"}