fix(mcp): stdio MCP children get the routed profile's vault secrets, not the default's

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.
This commit is contained in:
Teknium
2026-09-10 12:00:30 -07:00
parent 89fb1d028e
commit 580322ef1e
4 changed files with 44 additions and 8 deletions

View File

@@ -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()), {}))

View File

@@ -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

View File

@@ -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]

View File

@@ -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