Files
hermes-agent/tests/gateway/test_email_secret_scope.py
John Paul Soliva b5a300fe34 fix(email): pairing, decline and gateway grants reach the gateway instead of dying in the adapter pre-gate
EmailAdapter._sender_accepted runs before any MessageEvent exists and
read only EMAIL_ALLOWED_USERS. Unset, it dropped every sender unless
allow-all was on; set, it dropped everyone not listed. The gateway's
own handling therefore never ran for email:
platforms.email.unauthorized_dm_behavior "pair" (the setup wizard's
"Use DM pairing") and "decline" sent nothing, and a sender admitted by
GATEWAY_ALLOWED_USERS or an approved pairing was dropped. bb304b4914
turned the empty-allowlist branch into drop-all after #50568 had made
"pair" email's explicit opt-in.

The gate now keeps a sender listed by address in EMAIL_ALLOWED_USERS
or GATEWAY_ALLOWED_USERS, a sender the registered gateway
authorization check admits (that is the only reader of the pairing
store), and, under an explicit pair or decline, an unknown sender the
gateway will answer. The default "ignore" still drops unknown senders
before a MessageEvent exists, so the mail-loop guard from fd9c32c0f2
holds.

Three guards keep the wider gate from widening access, and close two
forged-From: paths main already had:
- A sender admitted only so the gateway can answer it (pair or
  decline) must authenticate its From:, open access or not: the
  pairing code or refusal is mailed back to that address. A granted
  sender still needs it short of open access, since a pairing grant
  keys on From: just as the allowlist does. Open access follows the
  gateway's own order: EMAIL_ALLOW_ALL_USERS wins over a list, while
  GATEWAY_ALLOW_ALL_USERS beside a list admits nobody extra, so it no
  longer exempts a listed address from From: authentication either
  (on main a forged From: of a listed address got through there).
- Open access comes from the gateway's own verdict when a check is
  registered. GATEWAY_ALLOW_ALL_USERS beside a GATEWAY_ALLOWED_USERS
  list grants a stranger nothing there, so the env flag alone no
  longer exempts one from From: authentication (that path mailed a
  pairing code to a forged From: on main too).
- A sender whose local part alone matches an allowlist entry is
  dropped. The gateway's check also matches an address by its bare
  local part (#119446), so without this, GATEWAY_ALLOWED_USERS=alice
  (a chat username) would admit or pair alice@<any domain>. The lists
  are parsed as the gateway parses them, JSON list literals included,
  or '["alice"]' would slip past this guard.

_allowlist_in_effect only served the old condition and is removed.
The scope tests now assert the same scoped reads through
_sender_accepted, with GATEWAY_ALLOWED_USERS covered as well.

Measured end to end with the real GatewayRunner callback wired
(adapter -> gateway ingress):
- pair, decline, GATEWAY_ALLOWED_USERS and an approved pairing each
  went from 0 events reaching the gateway to 1. pair mails a pairing
  code, decline mails one refusal.
- An unauthenticated From: in pair mode, for a paired address or for a
  GATEWAY_ALLOWED_USERS address still reaches nothing.
- A bare GATEWAY_ALLOWED_USERS=stranger entry lets nothing from
  stranger@<domain> through, under ignore or pair. Without the
  local-part guard that mail reached the gateway in both.
- The same holds for a JSON-literal list, and a pair-mode stranger
  with a forged From: under allow-all beside an EMAIL_ or
  GATEWAY_ALLOWED_USERS list reaches nothing.
- The default still drops.
2026-09-23 07:48:32 -07:00

252 lines
11 KiB
Python

"""Tests for email adapter credential isolation under multiplexing.
Verifies that the email adapter reads EMAIL_ADDRESS, EMAIL_PASSWORD,
EMAIL_IMAP_HOST, and EMAIL_SMTP_HOST from the profile-scoped secret
store (agent.secret_scope.get_secret) instead of os.getenv, so that
a secondary profile in a multiplexed gateway does not inherit the
default profile's email credentials via os.environ.
Related issues: #50051, #52307
Related PRs: #51374 (config.py api_server guard), #50094 (config.py scoped env reads)
"""
import os
import unittest
from unittest.mock import patch, MagicMock
from agent import secret_scope as ss
class TestEmailAdapterSecretScope(unittest.TestCase):
"""Verify the email adapter honors the profile secret scope over os.environ."""
def setUp(self):
ss.set_multiplex_active(False)
def tearDown(self):
ss.set_multiplex_active(False)
@patch.dict(os.environ, {
"EMAIL_ADDRESS": "alpha@test.invalid",
"EMAIL_PASSWORD": "default-pw",
"EMAIL_IMAP_HOST": "imap.default.com",
"EMAIL_SMTP_HOST": "smtp.default.com",
}, clear=False)
def test_adapter_uses_scoped_credentials_not_environ(self):
"""When a secret scope is installed, the adapter must read from it,
not from os.environ which may hold another profile's values."""
from gateway.config import PlatformConfig, Platform
from plugins.platforms.email.adapter import EmailAdapter
scoped = {
"EMAIL_ADDRESS": "beta@test.invalid",
"EMAIL_PASSWORD": "secondary-pw",
"EMAIL_IMAP_HOST": "imap.secondary.example",
"EMAIL_SMTP_HOST": "smtp.secondary.example",
}
ss.set_multiplex_active(True)
token = ss.set_secret_scope(scoped)
try:
cfg = PlatformConfig(enabled=True)
adapter = EmailAdapter(cfg)
self.assertEqual(adapter._address, "beta@test.invalid")
self.assertEqual(adapter._password, "secondary-pw")
self.assertEqual(adapter._imap_host, "imap.secondary.example")
self.assertEqual(adapter._smtp_host, "smtp.secondary.example")
finally:
ss.reset_secret_scope(token)
@patch.dict(os.environ, {
"EMAIL_ADDRESS": "alpha@test.invalid",
"EMAIL_PASSWORD": "default-pw",
"EMAIL_IMAP_HOST": "imap.default.com",
"EMAIL_SMTP_HOST": "smtp.default.com",
}, clear=False)
def test_adapter_falls_back_to_environ_without_scope(self):
"""Without a secret scope (single-profile mode), the adapter reads
from os.environ — backward-compatible with legacy behavior."""
from gateway.config import PlatformConfig
from plugins.platforms.email.adapter import EmailAdapter
cfg = PlatformConfig(enabled=True)
adapter = EmailAdapter(cfg)
self.assertEqual(adapter._address, "alpha@test.invalid")
self.assertEqual(adapter._password, "default-pw")
self.assertEqual(adapter._imap_host, "imap.default.com")
self.assertEqual(adapter._smtp_host, "smtp.default.com")
@patch.dict(os.environ, {
"EMAIL_ADDRESS": "alpha@test.invalid",
"EMAIL_PASSWORD": "default-pw",
"EMAIL_IMAP_HOST": "imap.default.com",
"EMAIL_SMTP_HOST": "smtp.default.com",
}, clear=False)
def test_check_email_requirements_uses_scope(self):
"""check_email_requirements must also honor the secret scope so that
a secondary profile with scoped email creds is detected as configured."""
scoped = {
"EMAIL_ADDRESS": "beta@test.invalid",
"EMAIL_PASSWORD": "secondary-pw",
"EMAIL_IMAP_HOST": "imap.secondary.example",
"EMAIL_SMTP_HOST": "smtp.secondary.example",
}
ss.set_multiplex_active(True)
token = ss.set_secret_scope(scoped)
try:
from plugins.platforms.email.adapter import check_email_requirements
self.assertTrue(check_email_requirements())
finally:
ss.reset_secret_scope(token)
@patch.dict(os.environ, {
"EMAIL_ADDRESS": "alpha@test.invalid",
"EMAIL_PASSWORD": "default-pw",
"EMAIL_IMAP_HOST": "imap.default.com",
"EMAIL_SMTP_HOST": "smtp.default.com",
}, clear=False)
def test_adapter_scoped_missing_key_does_not_leak_environ(self):
"""If a key is absent from the scope but present in os.environ,
the adapter must NOT fall through to os.environ (which would leak
the default profile's value)."""
from gateway.config import PlatformConfig
from plugins.platforms.email.adapter import EmailAdapter
scoped = {
"EMAIL_ADDRESS": "beta@test.invalid",
# EMAIL_PASSWORD intentionally missing from scope
"EMAIL_IMAP_HOST": "imap.secondary.example",
"EMAIL_SMTP_HOST": "smtp.secondary.example",
}
ss.set_multiplex_active(True)
token = ss.set_secret_scope(scoped)
try:
cfg = PlatformConfig(enabled=True)
adapter = EmailAdapter(cfg)
self.assertEqual(adapter._address, "beta@test.invalid")
# Password must NOT be the default profile's "default-pw"
self.assertNotEqual(adapter._password, "default-pw")
self.assertEqual(adapter._password, "")
finally:
ss.reset_secret_scope(token)
@patch.dict(os.environ, {
"EMAIL_ADDRESS": "alpha@test.invalid",
"EMAIL_PASSWORD": "default-pw",
"EMAIL_IMAP_HOST": "imap.default.com",
"EMAIL_SMTP_HOST": "smtp.default.com",
"EMAIL_ALLOWED_USERS": "gamma@test.invalid",
}, clear=False)
def test_allowed_users_uses_scope(self):
"""EMAIL_ALLOWED_USERS must also be read from the secret scope
so a secondary profile gets its own allowlist, not the default's."""
scoped = {
"EMAIL_ADDRESS": "beta@test.invalid",
"EMAIL_PASSWORD": "secondary-pw",
"EMAIL_IMAP_HOST": "imap.secondary.example",
"EMAIL_SMTP_HOST": "smtp.secondary.example",
"EMAIL_ALLOWED_USERS": "epsilon@test.invalid,delta@test.invalid",
}
ss.set_multiplex_active(True)
token = ss.set_secret_scope(scoped)
try:
# The dispatch gate must match the scoped list, not the environ one.
from gateway.config import PlatformConfig
from plugins.platforms.email.adapter import EmailAdapter
adapter = EmailAdapter(PlatformConfig(enabled=True))
authenticated = {"sender_authenticated": True}
self.assertTrue(adapter._sender_accepted("epsilon@test.invalid", authenticated))
self.assertFalse(adapter._sender_accepted("gamma@test.invalid", authenticated))
finally:
ss.reset_secret_scope(token)
class TestEmailAdapterUnscopedUnderMultiplex(unittest.TestCase):
"""The DEFAULT profile's adapter constructs UNSCOPED under multiplexing.
In a multiplexed gateway only secondary profiles run inside
``_profile_runtime_scope``; the default profile's adapter is constructed
with no scope installed while multiplexing is active. A bare
``get_secret`` raises ``UnscopedSecretError`` there and would crash the
email path on startup — the exact WhatsApp defect fixed in 5438e9c629.
The Slack-pattern helper (``_get_esecret``) must swallow that and read
the default profile's own ``os.environ`` values instead.
"""
def setUp(self):
ss.set_multiplex_active(False)
def tearDown(self):
ss.set_multiplex_active(False)
@patch.dict(os.environ, {
"EMAIL_ADDRESS": "alpha@test.invalid",
"EMAIL_PASSWORD": "default-pw",
"EMAIL_IMAP_HOST": "imap.default.com",
"EMAIL_SMTP_HOST": "smtp.default.com",
"EMAIL_IMAP_PORT": "1993",
"EMAIL_SMTP_PORT": "1587",
"EMAIL_POLL_INTERVAL": "30",
}, clear=False)
def test_default_profile_constructs_unscoped_under_multiplex(self):
"""Multiplex ON + no scope: construction must not raise and must
read the default profile's own environ values."""
from gateway.config import PlatformConfig
from plugins.platforms.email.adapter import EmailAdapter
ss.set_multiplex_active(True)
cfg = PlatformConfig(enabled=True)
adapter = EmailAdapter(cfg) # must NOT raise UnscopedSecretError
self.assertEqual(adapter._address, "alpha@test.invalid")
self.assertEqual(adapter._password, "default-pw")
self.assertEqual(adapter._imap_host, "imap.default.com")
self.assertEqual(adapter._smtp_host, "smtp.default.com")
self.assertEqual(adapter._imap_port, 1993)
self.assertEqual(adapter._smtp_port, 1587)
self.assertEqual(adapter._poll_interval, 30)
@patch.dict(os.environ, {
"EMAIL_ADDRESS": "alpha@test.invalid",
"EMAIL_PASSWORD": "default-pw",
"EMAIL_IMAP_HOST": "imap.default.com",
"EMAIL_SMTP_HOST": "smtp.default.com",
"EMAIL_IMAP_PORT": "1993",
"EMAIL_SMTP_PORT": "1587",
"EMAIL_POLL_INTERVAL": "30",
"EMAIL_TRUST_FROM_HEADER": "true",
}, clear=False)
def test_scoped_ports_and_trust_flag_do_not_inherit_environ(self):
"""The env_int variants (EMAIL_IMAP_PORT / EMAIL_SMTP_PORT /
EMAIL_POLL_INTERVAL) and EMAIL_TRUST_FROM_HEADER must resolve from
the installed scope, not from the primary profile's environ."""
from gateway.config import PlatformConfig
from plugins.platforms.email.adapter import EmailAdapter
scoped = {
"EMAIL_ADDRESS": "beta@test.invalid",
"EMAIL_PASSWORD": "secondary-pw",
"EMAIL_IMAP_HOST": "imap.secondary.example",
"EMAIL_SMTP_HOST": "smtp.secondary.example",
"EMAIL_IMAP_PORT": "2993",
"EMAIL_SMTP_PORT": "2587",
"EMAIL_POLL_INTERVAL": "60",
# scope does NOT opt into trusting From: — environ's "true"
# must not leak in.
}
ss.set_multiplex_active(True)
token = ss.set_secret_scope(scoped)
try:
cfg = PlatformConfig(enabled=True)
adapter = EmailAdapter(cfg)
self.assertEqual(adapter._imap_port, 2993)
self.assertEqual(adapter._smtp_port, 2587)
self.assertEqual(adapter._poll_interval, 60)
# Environ's EMAIL_TRUST_FROM_HEADER=true must not leak in: the
# scope did not opt out, so authentication stays required.
self.assertTrue(adapter._require_authenticated_sender)
finally:
ss.reset_secret_scope(token)
if __name__ == "__main__":
unittest.main()