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:
@@ -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()), {}))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user