From 580322ef1e15d176aa3ce65eac55fb1fa2c6408a Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:00:30 -0700 Subject: [PATCH] fix(mcp): stdio MCP children get the routed profile's vault secrets, not the default's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under a multiplexed gateway, `_build_safe_env` forwarded `os.environ[name]` for every name tagged in the process-global `_SECRET_SOURCES` map. That map is filled by EVERY served profile's secret-source hydration, while `os.environ` only ever holds the LAUNCH (default) profile's values — so once any profile's 1Password/Bitwarden source supplied e.g. GITHUB_TOKEN, every profile's stdio MCP server was started with the default profile's token. Resolve those names through the active profile's secret scope (`get_secret`) instead: the routed profile's value, or omitted when that profile has none. Under multiplex `get_secret` never falls through to environ; single-profile runs keep the .env overlay + environ behaviour, so the existing "vault vars reach MCP subprocesses" contract still holds there. `secret_source_names()` exposes the tagged NAMES only — values are never read from the shared map. Docs: the multi-profile guide's "MCP subprocesses only see their own profile's secrets" claim is now true for source-injected names too; say so explicitly. --- hermes_cli/env_loader.py | 6 +++++ tests/tools/test_mcp_tool.py | 23 +++++++++++++++++++ tools/mcp_tool_config.py | 16 ++++++++----- .../docs/user-guide/multi-profile-gateways.md | 7 ++++-- 4 files changed, 44 insertions(+), 8 deletions(-) diff --git a/hermes_cli/env_loader.py b/hermes_cli/env_loader.py index 7508f53c25..ba99297d5d 100644 --- a/hermes_cli/env_loader.py +++ b/hermes_cli/env_loader.py @@ -88,6 +88,12 @@ def get_secret_source(env_var: str) -> str | None: return _SECRET_SOURCES.get(env_var) +def secret_source_names() -> tuple[str, ...]: + """Every env-var name some profile's external secret source supplied (names only — the map is + process-wide, so a value must be resolved through the active profile's secret scope).""" + return tuple(_SECRET_SOURCES) + + def get_secret_source_values(hermes_home: str | os.PathLike) -> dict[str, str]: """Return the external-secret value snapshot for ``hermes_home``.""" return dict(_SECRET_SOURCE_VALUES_BY_HOME.get(str(Path(hermes_home).resolve()), {})) diff --git a/tests/tools/test_mcp_tool.py b/tests/tools/test_mcp_tool.py index 6df52dc48a..7027d9e8ff 100644 --- a/tests/tools/test_mcp_tool.py +++ b/tests/tools/test_mcp_tool.py @@ -1392,6 +1392,29 @@ class TestBuildSafeEnv: assert result["NOTION_TOKEN"] == "from-op" assert "UNTRACKED_SECRET_KEY" not in result + def test_secret_source_vars_resolve_through_active_profile_scope(self, monkeypatch): + """Under multiplex the stdio child gets the ROUTED profile's value for a source-tagged name, + never the launch profile's os.environ copy; a name the profile lacks is omitted.""" + from agent.secret_scope import set_multiplex_active, set_secret_scope, reset_secret_scope + from hermes_cli import env_loader + from tools.mcp_tool_config import _build_safe_env + + monkeypatch.setitem(env_loader._SECRET_SOURCES, "GITHUB_TOKEN", "bitwarden") + monkeypatch.setitem(env_loader._SECRET_SOURCES, "NOTION_TOKEN", "onepassword") + fake_env = {"PATH": "/usr/bin", "GITHUB_TOKEN": "default-profile", "NOTION_TOKEN": "default-notion"} + set_multiplex_active(True) + token = set_secret_scope({"GITHUB_TOKEN": "profile-b"}) + try: + with patch.dict("os.environ", fake_env, clear=True): + result = _build_safe_env(None) + finally: + reset_secret_scope(token) + set_multiplex_active(False) + + assert result["PATH"] == "/usr/bin" + assert result["GITHUB_TOKEN"] == "profile-b" + assert "NOTION_TOKEN" not in result + def test_windows_location_vars_passed_without_secrets(self): """Windows launcher tools need location vars, but secrets stay filtered.""" from tools.mcp_tool_config import _build_safe_env diff --git a/tools/mcp_tool_config.py b/tools/mcp_tool_config.py index 419385b727..c981144ff0 100644 --- a/tools/mcp_tool_config.py +++ b/tools/mcp_tool_config.py @@ -95,14 +95,18 @@ def _build_safe_env(user_env: Optional[dict]) -> dict: """Filtered env for stdio subprocesses so API keys/tokens don't leak: the safe baseline keys, ``XDG_*``, vars injected by an external secret source (users configured that backend precisely so subprocesses can consume them), plus the server config's own ``env``.""" - try: - from hermes_cli.env_loader import get_secret_source - except Exception: # pragma: no cover — early bootstrap/import fallback - get_secret_source = None + from agent.secret_scope import get_secret + from hermes_cli.env_loader import secret_source_names env = { key: value for key, value in os.environ.items() - if key in _SAFE_ENV_KEYS or key.upper() in _SAFE_ENV_KEYS_CASE_INSENSITIVE - or key.startswith("XDG_") or (get_secret_source is not None and get_secret_source(key))} + if key in _SAFE_ENV_KEYS or key.upper() in _SAFE_ENV_KEYS_CASE_INSENSITIVE or key.startswith("XDG_")} + # Source-tagged names are process-wide (any profile's hydration tags them) while os.environ + # holds only the LAUNCH profile's values, so the value must come from the active profile's + # secret scope; a profile that lacks the name gets nothing, never another profile's token. + for key in secret_source_names(): + value = get_secret(key) + if value is not None: + env[key] = value for key in ("HERMES_KANBAN_DB", "HERMES_KANBAN_BOARD"): if key in os.environ: env[key] = os.environ[key] diff --git a/website/docs/user-guide/multi-profile-gateways.md b/website/docs/user-guide/multi-profile-gateways.md index cad5cccdcd..7cc7b976d8 100644 --- a/website/docs/user-guide/multi-profile-gateways.md +++ b/website/docs/user-guide/multi-profile-gateways.md @@ -212,8 +212,11 @@ keep working. Per-profile `.env` credential isolation is preserved and, if anything, stricter: a profile's keys are resolved from its own scope and are never unioned -into a shared environment (this also means subprocesses like MCP servers and -Kanban workers only ever see their own profile's secrets). Terminal settings +into a shared environment. Subprocesses like MCP servers and Kanban workers only +ever see their own profile's secrets — including credentials injected by an +external secret source (1Password, Bitwarden, …): a stdio MCP server started for +profile B receives B's value for such a name, or nothing if B has none, never the +default profile's. Terminal settings (`terminal.backend`, `terminal.cwd`, `terminal.docker_volumes`, `terminal.docker_shared_container_key`, SSH targets, …) are likewise resolved per profile on every routed turn: a profile that omits a terminal key gets the