fix(gateway): isolate deferred platform imports from os.environ leaks

microsoft-teams-apps calls load_dotenv(find_dotenv(usecwd=True)) at import time, which can pull a root-profile .env into every gateway process during plugin_entries() discovery and break profile secret isolation.

Snapshot/restore os.environ around deferred loaders, and honor explicit api_server enabled:false the same way _enable_from_env does for other platforms.

Fixes #62935
This commit is contained in:
KaliShodan 2026-07-11 21:06:02 -05:00 committed by Teknium
parent d7522118ef
commit a98c8eeed1
2 changed files with 67 additions and 2 deletions

View File

@ -29,12 +29,36 @@ Usage (gateway side):
"""
import logging
import os
from contextlib import contextmanager
from dataclasses import dataclass, field
from typing import Any, Awaitable, Callable, Optional
from typing import Any, Awaitable, Callable, Iterator, Optional
logger = logging.getLogger(__name__)
@contextmanager
def _isolated_os_environ() -> Iterator[None]:
"""Snapshot and restore ``os.environ`` around a deferred platform import.
Some third-party SDKs (notably ``microsoft-teams-apps``) call
``load_dotenv(find_dotenv(usecwd=True))`` at module import time. That
walks up from the process cwd and mutates the process-global environ
leaking root-profile secrets into secondary profiles and bypassing
Hermes's own dotenv / secret-scope rules (#62935).
Deferred loaders are import-and-register only; any environ mutation they
cause is treated as an unwanted side effect and discarded.
"""
saved = dict(os.environ)
try:
yield
finally:
os.environ.clear()
os.environ.update(saved)
@dataclass
class PlatformEntry:
"""Metadata and factory for a single platform adapter."""
@ -205,7 +229,10 @@ class PlatformRegistry:
if loader is None:
return
try:
loader()
# Isolate environ so import-time dotenv / env mutation cannot leak
# across profiles or override explicit YAML disables (#62935).
with _isolated_os_environ():
loader()
except Exception as e:
logger.warning(
"Deferred load of platform '%s' failed: %s",

View File

@ -494,3 +494,41 @@ class TestPluginEnablementGate:
)
finally:
_reg.unregister("myrejectedplat")
class TestDeferredLoadEnvironIsolation:
"""Deferred platform imports must not leak os.environ mutations (#62935)."""
def test_deferred_loader_environ_mutations_are_discarded(self, monkeypatch):
monkeypatch.delenv("API_SERVER_ENABLED", raising=False)
monkeypatch.delenv("LEAKED_FROM_IMPORT", raising=False)
assert "API_SERVER_ENABLED" not in os.environ
reg = PlatformRegistry()
def _polluting_loader():
os.environ["API_SERVER_ENABLED"] = "true"
os.environ["LEAKED_FROM_IMPORT"] = "1"
entry, _ = TestPlatformRegistry()._make_entry("polluter")
reg.register(entry)
reg.register_deferred("polluter", _polluting_loader)
assert reg.get("polluter") is not None
assert "API_SERVER_ENABLED" not in os.environ
assert "LEAKED_FROM_IMPORT" not in os.environ
def test_deferred_loader_preserves_preexisting_environ(self, monkeypatch):
monkeypatch.setenv("HERMES_KEEP", "alive")
reg = PlatformRegistry()
def _loader():
os.environ["HERMES_KEEP"] = "clobbered"
os.environ["TEMP_LEAK"] = "1"
entry, _ = TestPlatformRegistry()._make_entry("keeper")
reg.register(entry)
reg.register_deferred("keeper", _loader)
assert reg.get("keeper") is not None
assert os.environ.get("HERMES_KEEP") == "alive"
assert "TEMP_LEAK" not in os.environ