hermes-agent/tests/gateway/test_pairing_allowlist_bypass.py
Teknium 1bfe08145c
fix(gateway): pairing is a grant that syncs to the allowlist (#23778) (#56381)
Consolidates the pairing/allowlist authorization model. Reverses the
read-side AND-ing from #56346 (which made a paired user require ALSO
being in the allowlist) and restores pairing as a first-class grant:

- authz_mixin: a pairing-store entry authorizes regardless of the
  allowlist (union). approve_code is reachable only by the trusted
  operator (CLI / authenticated dashboard), never by an inbound sender,
  so it is not an attacker-controlled path — the #23778 bypass was the
  inbound message/approval-button gate, fixed separately.
- pairing: when an allowlist IS already configured for the platform,
  operator approval also appends the user to that allowlist env var
  (option i) and revoke removes them, keeping a single operator-visible,
  editable source of truth instead of an opaque approved.json. On an
  open gateway (no allowlist) approval is a no-op on the env var so we
  never silently lock an open gateway; the pairing store remains the
  grant record, honored by the union.
- auto-resume authz (0de67ad60) now honors paired users automatically
  via the same union — a legitimately-paired session survives restart.

Replaces the now-incorrect AND-ing tests with union + mirror + revoke
coverage. E2E verified: locked-gateway approve/revoke round-trips
through the allowlist; open-gateway approval stays open.
2026-07-01 05:31:15 -07:00

205 lines
7.2 KiB
Python

"""Pairing store <-> allowlist consolidation (#23778).
Design (union + option-i mirror):
* A pairing-store entry is a first-class authorization grant. A paired user
is authorized regardless of any configured allowlist (union), because
``approve_code`` is reachable only by the trusted operator (CLI/dashboard),
never by an inbound sender.
* When an allowlist IS already configured for the platform, approving a
pairing code ALSO writes the user into that allowlist env var (and revoking
removes them), so the two stay a single operator-visible source of truth.
* On an open gateway (no allowlist configured) approval does NOT create an
allowlist — that would silently lock an open gateway. The pairing store
remains the grant record, honored by the authz union.
"""
import os
from types import SimpleNamespace
import pytest
from gateway.session import Platform, SessionSource
@pytest.fixture(autouse=True)
def _isolate_env(monkeypatch):
for var in (
"TELEGRAM_ALLOWED_USERS",
"TELEGRAM_ALLOW_ALL_USERS",
"TELEGRAM_GROUP_ALLOWED_USERS",
"TELEGRAM_GROUP_ALLOWED_CHATS",
"GATEWAY_ALLOW_ALL_USERS",
"GATEWAY_ALLOWED_USERS",
):
monkeypatch.delenv(var, raising=False)
# --------------------------------------------------------------------------
# authz union: a paired user is authorized regardless of the allowlist
# --------------------------------------------------------------------------
def _make_runner(*, paired: bool):
from gateway.run import GatewayRunner
runner = object.__new__(GatewayRunner)
runner.pairing_store = SimpleNamespace(is_approved=lambda *_a, **_kw: paired)
return runner
def _make_source(user_id: str = "pairme", chat_type: str = "dm"):
return SessionSource(
platform=Platform.TELEGRAM,
chat_id="123",
chat_type=chat_type,
user_id=user_id,
user_name="SomeHuman",
is_bot=False,
)
def test_paired_user_authorized_even_when_not_in_allowlist(monkeypatch):
"""Union semantics: pairing is a grant, honored alongside the allowlist."""
runner = _make_runner(paired=True)
monkeypatch.setenv("TELEGRAM_ALLOWED_USERS", "owner1,owner2")
assert runner._is_user_authorized(_make_source("pairme")) is True
def test_paired_user_authorized_with_no_allowlist(monkeypatch):
runner = _make_runner(paired=True)
assert runner._is_user_authorized(_make_source("pairme")) is True
def test_unpaired_user_in_allowlist_still_authorized(monkeypatch):
runner = _make_runner(paired=False)
monkeypatch.setenv("TELEGRAM_ALLOWED_USERS", "owner1")
assert runner._is_user_authorized(_make_source("owner1")) is True
def test_unpaired_user_not_in_allowlist_denied(monkeypatch):
runner = _make_runner(paired=False)
monkeypatch.setenv("TELEGRAM_ALLOWED_USERS", "owner1")
assert runner._is_user_authorized(_make_source("stranger")) is False
def test_unpaired_user_no_allowlist_denied_no_failopen(monkeypatch):
runner = _make_runner(paired=False)
assert runner._is_user_authorized(_make_source("stranger")) is False
# --------------------------------------------------------------------------
# B2 mirror: approval writes into the allowlist iff one is configured
# --------------------------------------------------------------------------
@pytest.fixture
def store(tmp_path, monkeypatch):
"""A real PairingStore backed by a temp pairing dir."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
(tmp_path / ".hermes").mkdir(parents=True, exist_ok=True)
import importlib
import gateway.pairing as pairing_mod
importlib.reload(pairing_mod)
return pairing_mod.PairingStore()
def _approve_new_user(store, platform, user_id, user_name=""):
code = store.generate_code(platform, user_id, user_name)
assert code is not None
return store.approve_code(platform, code)
def test_approval_adds_to_configured_allowlist(store, monkeypatch):
"""When an allowlist exists, approval appends the user to it (option i)."""
monkeypatch.setenv("TELEGRAM_ALLOWED_USERS", "owner1")
# save_env_value writes to .env under HERMES_HOME; patch it to capture.
captured = {}
import hermes_cli.config as cfg
monkeypatch.setattr(cfg, "save_env_value",
lambda k, v: (captured.__setitem__(k, v),
os.environ.__setitem__(k, v)))
_approve_new_user(store, "telegram", "newuser99")
assert captured.get("TELEGRAM_ALLOWED_USERS") == "owner1,newuser99"
def test_approval_no_allowlist_leaves_gateway_open(store, monkeypatch):
"""Open gateway: approval must NOT create an allowlist (option i)."""
called = {}
import hermes_cli.config as cfg
monkeypatch.setattr(cfg, "save_env_value",
lambda k, v: called.__setitem__(k, v))
_approve_new_user(store, "telegram", "newuser99")
assert "TELEGRAM_ALLOWED_USERS" not in called
assert os.getenv("TELEGRAM_ALLOWED_USERS", "") == ""
# The pairing store still records the grant (union honors it).
assert store.is_approved("telegram", "newuser99") is True
def test_approval_idempotent_when_already_in_allowlist(store, monkeypatch):
monkeypatch.setenv("TELEGRAM_ALLOWED_USERS", "owner1,newuser99")
called = {}
import hermes_cli.config as cfg
monkeypatch.setattr(cfg, "save_env_value",
lambda k, v: called.__setitem__(k, v))
_approve_new_user(store, "telegram", "newuser99")
# Already present — no rewrite.
assert "TELEGRAM_ALLOWED_USERS" not in called
def test_approval_skips_wildcard_allowlist(store, monkeypatch):
monkeypatch.setenv("TELEGRAM_ALLOWED_USERS", "*")
called = {}
import hermes_cli.config as cfg
monkeypatch.setattr(cfg, "save_env_value",
lambda k, v: called.__setitem__(k, v))
_approve_new_user(store, "telegram", "newuser99")
assert "TELEGRAM_ALLOWED_USERS" not in called
def test_revoke_removes_from_allowlist(store, monkeypatch):
monkeypatch.setenv("TELEGRAM_ALLOWED_USERS", "owner1,newuser99")
saved = {}
removed = []
import hermes_cli.config as cfg
monkeypatch.setattr(cfg, "save_env_value",
lambda k, v: (saved.__setitem__(k, v),
os.environ.__setitem__(k, v)))
monkeypatch.setattr(cfg, "remove_env_value", lambda k: removed.append(k))
# Seed the approved list directly so revoke has something to remove.
store._approve_user("telegram", "newuser99", "")
assert store.revoke("telegram", "newuser99") is True
assert saved.get("TELEGRAM_ALLOWED_USERS") == "owner1"
def test_revoke_removes_env_var_when_list_empties(store, monkeypatch):
monkeypatch.setenv("TELEGRAM_ALLOWED_USERS", "newuser99")
removed = []
import hermes_cli.config as cfg
monkeypatch.setattr(cfg, "save_env_value",
lambda k, v: os.environ.__setitem__(k, v))
monkeypatch.setattr(cfg, "remove_env_value", lambda k: removed.append(k))
store._approve_user("telegram", "newuser99", "")
# _approve_user's own add is a no-op (already present); reset for the revoke.
os.environ["TELEGRAM_ALLOWED_USERS"] = "newuser99"
assert store.revoke("telegram", "newuser99") is True
assert "TELEGRAM_ALLOWED_USERS" in removed