Files
hermes-agent/tools/mcp_tool_config.py
Teknium 9c9e7ab6e5 fix(multiplex): a served profile's turn sees its own cwd, approvals, redaction and tool policy
Under gateway.multiplex_profiles a secondary profile's turn ran with the LAUNCH
profile's working directory, command allowlist, redact_secrets switch, credential
file mounts, browser engine/headed flags, LSP service, auxiliary-provider health
marks and MCP stderr log, and several TERMINAL_ENV consumers read the process env
instead of the routed profile's terminal scope. A standalone `hermes -p X gateway
run` never behaved that way.

- tools/terminal_scope.py: resolve the terminal.cwd placeholder inside the
  profile scope with the same rule gateway/run.py applies at import (local ->
  $HOME, sandbox default otherwise) so the system prompt, context files and the
  terminal of a routed turn start where the profile's standalone gateway would.
- tools/image_source.py, credential_files.py, image_generation_tool.py,
  skills_tool.py, delegate_tool_progress.py, agent/tool_executor.py: read
  TERMINAL_ENV / TERMINAL_CWD through the terminal scope.
- tools/approval.py (+ approval_floors.py): one permanent allowlist per routed
  profile home; the unscoped module set stays for single-profile processes.
- agent/redact.py: `_redact_enabled()` resolves security.redact_secrets for the
  routed profile (scope .env, then config); launch snapshot kept when unscoped.
- tools/credential_files.py, agent/auxiliary_health.py, agent/lsp/__init__.py,
  tools/browser_tool_cloud.py, tools/mcp_tool_config.py,
  tools/tool_result_storage.py: key process caches by profile home (or bypass
  the slot under an override).

Tests: tests/tools/test_multiplex_turn_parity.py (4, red on base).
Docs: multi-profile-gateways.md isolation table.
2026-09-11 19:39:12 -07:00

346 lines
16 KiB
Python

"""MCP server config loading and stdio launch environment: ${VAR}/Cursor-style
interpolation, hidden-whitespace and suspicious-entry filtering, the filtered
subprocess env, command resolution, the cached-npx binary shortcut and the shared stderr log."""
import json
import logging
import os
import re
import shutil
import sys
import threading
from datetime import datetime
from typing import Any, Dict, List, Optional, Set, Tuple
from tools.mcp_tool_common import _env_ref_name, _prepend_path
logger = logging.getLogger("tools.mcp_tool")
_mcp_stderr_log_fh: Dict[str, Any] = {} # profile home key -> handle
_mcp_stderr_log_lock = threading.Lock()
def _get_mcp_stderr_log() -> Any:
"""Shared append-mode handle for MCP subprocess stderr, opened once per process PER PROFILE HOME (a
multiplexed gateway's secondary profile must log under ITS ``logs/``, not the launch profile's). Must
expose a real fd (asyncio wires the child's stderr to it); falls back to ``/dev/null``, then real stderr."""
from hermes_constants import get_hermes_home, hermes_home_key
home_key = hermes_home_key()
with _mcp_stderr_log_lock:
fh = _mcp_stderr_log_fh.get(home_key)
if fh is None:
try:
log_dir = get_hermes_home() / "logs"
log_dir.mkdir(parents=True, exist_ok=True)
# Line-buffered so output lands promptly; errors="replace" tolerates garbled binary.
fh = open(log_dir / "mcp-stderr.log", "a", encoding="utf-8", errors="replace", buffering=1)
fh.fileno() # confirm a real fd before committing
except Exception as exc: # pragma: no cover — best-effort fallback
logger.debug("Failed to open MCP stderr log, using devnull: %s", exc)
try:
fh = open(os.devnull, "w", encoding="utf-8")
except Exception:
fh = sys.stderr
_mcp_stderr_log_fh[home_key] = fh
return fh
def _write_stderr_log_header(server_name: str) -> None:
"""Session marker so operators can find each server's output in the shared log
(per-line prefixes would need a pipe + reader thread)."""
fh = _get_mcp_stderr_log()
try:
fh.write(f"\n===== [{datetime.now():%Y-%m-%d %H:%M:%S}] starting MCP server '{server_name}' =====\n")
fh.flush()
except Exception:
pass
# Env vars safe to pass to stdio subprocesses (no secrets).
_SAFE_ENV_KEYS = frozenset({"PATH", "HOME", "USER", "LANG", "LC_ALL", "TERM", "SHELL", "TMPDIR"})
# Windows process/location vars needed by launcher-style tools (e.g. Docker Desktop's MCP plugin discovery).
_SAFE_ENV_KEYS_CASE_INSENSITIVE = frozenset({
"ALLUSERSPROFILE", "APPDATA", "COMMONPROGRAMFILES", "COMMONPROGRAMFILES(X86)",
"COMMONPROGRAMW6432", "COMPUTERNAME", "COMSPEC", "HOMEDRIVE", "HOMEPATH",
"LOCALAPPDATA", "NUMBER_OF_PROCESSORS", "OS", "PATHEXT", "PROCESSOR_ARCHITECTURE",
"PROGRAMDATA", "PROGRAMFILES", "PROGRAMFILES(X86)", "PROGRAMW6432", "PUBLIC",
"SYSTEMDRIVE", "SYSTEMROOT", "TEMP", "TMP", "USERDOMAIN", "USERNAME",
"USERPROFILE", "WINDIR"})
# ${VAR_NAME} interpolation; any non-} chars allowed so MY-VAR / my.var work.
_ENV_VAR_PATTERN = re.compile(r"\$\{([^}]+)\}")
def _workspace_folder() -> str:
"""Absolute workspace root for ``${workspaceFolder}``: the session's authoritative root
(terminal cwd / task override / $TERMINAL_CWD), else cwd."""
try:
from tools.file_tools_paths import _authoritative_workspace_root
root = _authoritative_workspace_root()
except Exception:
root = None
return root or os.getcwd()
def _workspace_basename() -> str:
root = _workspace_folder()
return os.path.basename(root.rstrip("/\\")) or root
# Cursor's case-sensitive context vars -> resolver.
_CONTEXT_VAR_RESOLVERS = {
"userHome": lambda: os.path.expanduser("~"), "workspaceFolder": lambda: _workspace_folder(),
"workspaceFolderBasename": _workspace_basename, "pathSeparator": lambda: os.sep, "/": lambda: os.sep}
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``."""
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_")}
# 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]
if user_env:
env.update(user_env)
from agent.delegation_context import delegated_child_subprocess_env
return delegated_child_subprocess_env(env)
def _which_with_config_pathext(command: str, path_arg, env: dict):
"""``shutil.which`` retried under the config env's PATHEXT (Windows only; ``which`` uses the PARENT's)."""
cfg_pathext = next((v for k, v in env.items() if k.upper() == "PATHEXT" and isinstance(v, str) and v.strip()), None)
if not cfg_pathext or cfg_pathext == os.environ.get("PATHEXT"):
return None
saved = os.environ.get("PATHEXT")
try:
os.environ["PATHEXT"] = cfg_pathext
return shutil.which(command, path=path_arg)
finally:
if saved is None:
os.environ.pop("PATHEXT", None)
else:
os.environ["PATHEXT"] = saved
def _node_fallback(command: str) -> str:
"""Well-known Node install locations for bare ``npx``/``npm``/``node``; *command* unchanged when none exists."""
home = os.path.expanduser("~")
hermes_home = os.path.expanduser(os.getenv("HERMES_HOME", os.path.join(home, ".hermes")))
# /usr/local/bin: canonical Node location (from-source Linux, Hermes Docker image, Intel Homebrew),
# needed when a hand-authored env.PATH omits it — npx's shebang re-execs /usr/bin/env node.
candidates = (os.path.join(hermes_home, "node", "bin", command), os.path.join(home, ".local", "bin", command),
os.path.join(os.sep, "usr", "local", "bin", command))
return next((c for c in candidates if os.path.isfile(c) and os.access(c, os.X_OK)), command)
def _resolve_stdio_command(command: str, env: dict) -> tuple[str, dict]:
"""Resolve a stdio command against the exact subprocess env (bare ``npx``/``npm``/``node`` under a filtered PATH)."""
resolved_command = os.path.expanduser(str(command).strip())
resolved_env = dict(env or {})
if os.sep not in resolved_command:
path_arg = resolved_env.get("PATH")
which_hit = shutil.which(resolved_command, path=path_arg)
if which_hit is None and sys.platform == "win32" and resolved_env:
which_hit = _which_with_config_pathext(resolved_command, path_arg, resolved_env)
if which_hit:
resolved_command = which_hit
elif resolved_command in {"npx", "npm", "node"}:
resolved_command = _node_fallback(resolved_command)
command_dir = os.path.dirname(resolved_command)
if command_dir:
resolved_env = _prepend_path(resolved_env, command_dir)
return resolved_command, resolved_env
def _npx_bin_candidates(bin_dir: str, name: str, *, windows: Optional[bool] = None) -> list:
"""Launcher paths to try for *name* inside an npx cache's ``.bin``, in order. On Windows that
directory holds the extensionless sh script plus ``<name>.cmd``/``<name>.ps1``; the sh one
cannot be spawned there and ``os.access(X_OK)`` is only an existence check, so select by
extension (same precedence as ``hermes_constants._candidate_node_command_names``). ``windows``
is injectable so the branch is testable without patching ``os.name`` process-wide."""
is_windows = os.name == "nt" if windows is None else windows
if is_windows:
return [os.path.join(bin_dir, name + ext) for ext in (".cmd", ".exe")]
return [os.path.join(bin_dir, name)]
def _npx_cached_bin(args: list) -> Optional[tuple]:
"""Resolve ``npx -y <pkg>`` to the already-installed binary, or None.
``npx`` resolves the package and then FORKS, staying resident as the real server's parent
for nothing (~48 MB private memory per MCP server, measured); Hermes already supervises the
child (shared death supervisor). When the package is in npx's cache we spawn its binary
directly. Deliberately conservative — None (caller keeps plain ``npx``, so a cold machine
still installs) for a cache miss, a version pin (``pkg@1.2.3``), extra npx flags, a manifest
without one obvious bin, or any unreadable cache entry. Returns ``(binary_path, remaining_args)``."""
if not isinstance(args, list) or not args:
return None
rest = list(args)
while rest and rest[0] in ("-y", "--yes"):
rest.pop(0)
if not rest:
return None
# `npx pkg -y` (flag AFTER the spec) would hand the server a flag npx would have eaten.
if any(str(a) in ("-y", "--yes") for a in rest[1:]):
return None
spec = str(rest[0])
# Scoped names keep their leading '@', so only an '@' AFTER the scope is a version separator.
if "@" in (spec[1:] if spec.startswith("@") else spec):
return None
if not spec or spec.startswith("-"):
return None
cache_root = os.environ.get("npm_config_cache") or os.path.join(os.path.expanduser("~"), ".npm")
npx_root = os.path.join(cache_root, "_npx")
if not os.path.isdir(npx_root):
return None
try:
entries = os.listdir(npx_root)
except OSError:
return None
for entry in entries:
manifest = os.path.join(npx_root, entry, "package.json")
try:
with open(manifest, "r", encoding="utf-8") as fh:
deps = (json.load(fh) or {}).get("dependencies") or {}
except (OSError, ValueError, TypeError):
continue
if spec not in deps:
continue
pkg_json = os.path.join(npx_root, entry, "node_modules", spec, "package.json")
try:
with open(pkg_json, "r", encoding="utf-8") as fh:
bin_field = (json.load(fh) or {}).get("bin")
except (OSError, ValueError, TypeError):
continue
if isinstance(bin_field, str):
names = [os.path.basename(spec)]
elif isinstance(bin_field, dict) and len(bin_field) == 1:
names = list(bin_field.keys())
else:
continue # zero or several bins: which one npx would pick is not ours to guess
bin_dir = os.path.join(npx_root, entry, "node_modules", ".bin")
for candidate in _npx_bin_candidates(bin_dir, names[0]):
if os.path.exists(candidate) and os.access(candidate, os.X_OK):
return candidate, rest[1:]
return None
def _interpolate_env_vars(value):
"""Recursively resolve ``${VAR}`` / Cursor ``${env:VAR}`` placeholders and context vars. Env
refs resolve from the active profile's secret scope when multiplexing (the routed profile's
value, not another profile's in ``os.environ``). Unset vars keep the literal placeholder."""
from agent.secret_scope import get_secret as _get_secret
if isinstance(value, str):
def _replace(m):
resolver = _CONTEXT_VAR_RESOLVERS.get(m.group(1).strip())
return resolver() if resolver is not None else (_get_secret(_env_ref_name(m.group(1)), m.group(0)) or m.group(0))
return _ENV_VAR_PATTERN.sub(_replace, value)
if isinstance(value, dict):
return {k: _interpolate_env_vars(v) for k, v in value.items()}
if isinstance(value, list):
return [_interpolate_env_vars(v) for v in value]
return value
# (server_name, dotted key path) pairs already warned about: config loads repeat per discovery pass.
_whitespace_warned: Set[Tuple[str, str]] = set()
def _warn_hidden_whitespace(server_name: str, config: dict) -> List[str]:
"""Warn once per (server, key path) about string values with leading/trailing whitespace (a
pasted newline causes opaque auth failures). Advisory only: values are never mutated (could be
intentional) nor logged (often secrets). Returns flagged paths."""
flagged: List[str] = []
def _walk(value: Any, path: str) -> None:
if isinstance(value, str) and value != value.strip():
flagged.append(path)
elif isinstance(value, dict):
for k, v in value.items():
_walk(v, f"{path}.{k}" if path else str(k))
elif isinstance(value, list):
for i, v in enumerate(value):
_walk(v, f"{path}[{i}]")
_walk(config, "")
for key_path in flagged:
if (server_name, key_path) not in _whitespace_warned:
_whitespace_warned.add((server_name, key_path))
logger.warning(
"MCP server '%s': config value '%s' has hidden leading or trailing whitespace — this often "
"causes authentication or connection failures. Check for stray spaces/newlines in config.yaml "
"(or the referenced env var).", server_name, key_path)
return flagged
def _filter_suspicious_mcp_servers(servers: Dict[str, dict]) -> Dict[str, dict]:
"""Drop exfiltration-shaped MCP configs before any stdio spawn path."""
try:
from hermes_cli.mcp_security import validate_mcp_server_entry
except Exception:
return servers
safe_servers = {}
for name, cfg in servers.items():
issues = validate_mcp_server_entry(name, cfg) if isinstance(cfg, dict) else None
if issues:
logger.warning("Skipping suspicious MCP server '%s': %s", name, "; ".join(issues))
else:
safe_servers[name] = cfg
return safe_servers
def _portable_mcp_servers(safe_servers: Dict[str, dict]) -> None:
"""Merge plugin-provided (portable) MCP servers into *safe_servers*; native config wins on a clash. Never raises."""
try:
from hermes_cli.plugins import discover_plugins, get_plugin_manager
discover_plugins()
portable = get_plugin_manager().get_portable_mcp_servers()
for name, cfg in _filter_suspicious_mcp_servers(portable).items():
if name in safe_servers:
logger.warning("Portable MCP server '%s' conflicts with native config; skipping", name)
else:
safe_servers[name] = dict(cfg)
except Exception:
logger.debug("Failed to load portable MCP servers", exc_info=True)
def _load_mcp_config() -> Dict[str, dict]:
"""``mcp_servers`` from config.yaml as ``{name: config}`` (empty on error / safe mode), ``${VAR}`` interpolated."""
try:
from hermes_cli.config import load_config
from utils import env_var_enabled as _env_enabled
if _env_enabled("HERMES_SAFE_MODE"):
return {}
servers = load_config().get("mcp_servers")
try: # ensure .env vars are available for interpolation
from hermes_cli.env_loader import load_hermes_dotenv
load_hermes_dotenv()
except Exception:
pass
safe_servers: Dict[str, dict] = {}
for name, cfg in _filter_suspicious_mcp_servers(servers if isinstance(servers, dict) else {}).items():
interpolated = _interpolate_env_vars(cfg)
if isinstance(interpolated, dict):
_warn_hidden_whitespace(name, interpolated)
safe_servers[name] = interpolated
_portable_mcp_servers(safe_servers)
return safe_servers
except Exception as exc:
logger.debug("Failed to load MCP config: %s", exc)
return {}