fix(mcp): block suspicious stdio configs before probe (#46112)
This commit is contained in:
@@ -4837,15 +4837,15 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A
|
|||||||
raw_mcp_servers = config.get("mcp_servers")
|
raw_mcp_servers = config.get("mcp_servers")
|
||||||
if isinstance(raw_mcp_servers, dict):
|
if isinstance(raw_mcp_servers, dict):
|
||||||
try:
|
try:
|
||||||
from hermes_cli.mcp_security import validate_mcp_server_entry
|
from hermes_cli.mcp_security import validate_mcp_server_entry as _validate_mcp_server_entry
|
||||||
except Exception:
|
except Exception:
|
||||||
validate_mcp_server_entry = None
|
_validate_mcp_server_entry = None
|
||||||
if validate_mcp_server_entry:
|
if _validate_mcp_server_entry:
|
||||||
mcp_touched = False
|
mcp_touched = False
|
||||||
for server_name, entry in raw_mcp_servers.items():
|
for server_name, entry in raw_mcp_servers.items():
|
||||||
if not isinstance(entry, dict):
|
if not isinstance(entry, dict):
|
||||||
continue
|
continue
|
||||||
issues = validate_mcp_server_entry(server_name, entry)
|
issues = _validate_mcp_server_entry(server_name, entry)
|
||||||
if not issues:
|
if not issues:
|
||||||
continue
|
continue
|
||||||
entry["enabled"] = False
|
entry["enabled"] = False
|
||||||
|
|||||||
@@ -221,6 +221,10 @@ def _probe_single_server(
|
|||||||
Returns list of ``(tool_name, description)`` tuples.
|
Returns list of ``(tool_name, description)`` tuples.
|
||||||
Raises on connection failure.
|
Raises on connection failure.
|
||||||
"""
|
"""
|
||||||
|
issues = validate_mcp_server_entry(name, config)
|
||||||
|
if issues:
|
||||||
|
raise ValueError("; ".join(issues))
|
||||||
|
|
||||||
from tools.mcp_tool import (
|
from tools.mcp_tool import (
|
||||||
_ensure_mcp_loop,
|
_ensure_mcp_loop,
|
||||||
_run_on_mcp_loop,
|
_run_on_mcp_loop,
|
||||||
@@ -352,6 +356,12 @@ def cmd_mcp_add(args):
|
|||||||
if explicit_env:
|
if explicit_env:
|
||||||
server_config["env"] = explicit_env
|
server_config["env"] = explicit_env
|
||||||
|
|
||||||
|
issues = validate_mcp_server_entry(name, server_config)
|
||||||
|
if issues:
|
||||||
|
for issue in issues:
|
||||||
|
_warning(issue)
|
||||||
|
_warning(f"Server '{name}' was NOT saved due to suspicious configuration.")
|
||||||
|
return
|
||||||
|
|
||||||
# ── Authentication ────────────────────────────────────────────────
|
# ── Authentication ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from argparse import Namespace
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -59,6 +60,51 @@ def test_save_mcp_server_rejects_dangerous_entry(tmp_path):
|
|||||||
assert "evil" not in load_config().get("mcp_servers", {})
|
assert "evil" not in load_config().get("mcp_servers", {})
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_add_rejects_dangerous_entry_before_probe(monkeypatch, capsys):
|
||||||
|
from hermes_cli.mcp_config import cmd_mcp_add
|
||||||
|
|
||||||
|
probed = False
|
||||||
|
|
||||||
|
def _probe_should_not_run(name, config):
|
||||||
|
nonlocal probed
|
||||||
|
probed = True
|
||||||
|
raise AssertionError("dangerous MCP config reached probe/spawn path")
|
||||||
|
|
||||||
|
monkeypatch.setattr("hermes_cli.mcp_config._probe_single_server", _probe_should_not_run)
|
||||||
|
|
||||||
|
cmd_mcp_add(Namespace(
|
||||||
|
name="evil",
|
||||||
|
url=None,
|
||||||
|
mcp_command="bash",
|
||||||
|
args=_dangerous_entry()["args"],
|
||||||
|
auth=None,
|
||||||
|
preset=None,
|
||||||
|
env=None,
|
||||||
|
))
|
||||||
|
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert probed is False
|
||||||
|
assert "NOT saved" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_probe_rejects_dangerous_entry_before_connect(monkeypatch):
|
||||||
|
from hermes_cli.mcp_config import _probe_single_server
|
||||||
|
|
||||||
|
connected = False
|
||||||
|
|
||||||
|
async def _connect_should_not_run(name, config):
|
||||||
|
nonlocal connected
|
||||||
|
connected = True
|
||||||
|
raise AssertionError("dangerous MCP config reached connect/spawn path")
|
||||||
|
|
||||||
|
monkeypatch.setattr("tools.mcp_tool._connect_server", _connect_should_not_run)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="network egress"):
|
||||||
|
_probe_single_server("evil", _dangerous_entry(), connect_timeout=1)
|
||||||
|
|
||||||
|
assert connected is False
|
||||||
|
|
||||||
|
|
||||||
def test_runtime_loader_skips_dangerous_entry(monkeypatch):
|
def test_runtime_loader_skips_dangerous_entry(monkeypatch):
|
||||||
from tools.mcp_tool import _load_mcp_config
|
from tools.mcp_tool import _load_mcp_config
|
||||||
|
|
||||||
@@ -74,6 +120,53 @@ def test_runtime_loader_skips_dangerous_entry(monkeypatch):
|
|||||||
assert loaded["clean"]["command"] == "npx"
|
assert loaded["clean"]["command"] == "npx"
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_registration_skips_dangerous_entry_before_connect(monkeypatch):
|
||||||
|
import tools.mcp_tool as mcp_tool
|
||||||
|
|
||||||
|
monkeypatch.setattr(mcp_tool, "_MCP_AVAILABLE", True)
|
||||||
|
monkeypatch.setattr(mcp_tool, "_ensure_mcp_loop", lambda: None)
|
||||||
|
|
||||||
|
connected = []
|
||||||
|
|
||||||
|
async def _discover_one(name, config):
|
||||||
|
connected.append(name)
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _run_on_loop(coro_or_factory, timeout=30):
|
||||||
|
import asyncio
|
||||||
|
import inspect
|
||||||
|
coro = coro_or_factory() if callable(coro_or_factory) else coro_or_factory
|
||||||
|
assert inspect.iscoroutine(coro)
|
||||||
|
return asyncio.run(coro)
|
||||||
|
|
||||||
|
monkeypatch.setattr(mcp_tool, "_discover_and_register_server", _discover_one)
|
||||||
|
monkeypatch.setattr(mcp_tool, "_run_on_mcp_loop", _run_on_loop)
|
||||||
|
|
||||||
|
with mcp_tool._lock:
|
||||||
|
saved_servers = dict(mcp_tool._servers)
|
||||||
|
saved_connecting = set(mcp_tool._server_connecting)
|
||||||
|
saved_errors = dict(mcp_tool._server_connect_errors)
|
||||||
|
mcp_tool._servers.clear()
|
||||||
|
mcp_tool._server_connecting.clear()
|
||||||
|
mcp_tool._server_connect_errors.clear()
|
||||||
|
|
||||||
|
try:
|
||||||
|
mcp_tool.register_mcp_servers({
|
||||||
|
"evil": _dangerous_entry(),
|
||||||
|
"clean": {"command": "npx", "args": ["-y", "clean-mcp"]},
|
||||||
|
})
|
||||||
|
finally:
|
||||||
|
with mcp_tool._lock:
|
||||||
|
mcp_tool._servers.clear()
|
||||||
|
mcp_tool._servers.update(saved_servers)
|
||||||
|
mcp_tool._server_connecting.clear()
|
||||||
|
mcp_tool._server_connecting.update(saved_connecting)
|
||||||
|
mcp_tool._server_connect_errors.clear()
|
||||||
|
mcp_tool._server_connect_errors.update(saved_errors)
|
||||||
|
|
||||||
|
assert connected == ["clean"]
|
||||||
|
|
||||||
|
|
||||||
def test_migration_disables_existing_dangerous_entry(tmp_path):
|
def test_migration_disables_existing_dangerous_entry(tmp_path):
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
|
|||||||
+34
-19
@@ -89,6 +89,7 @@ import shutil
|
|||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from typing import Callable
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Coroutine, Dict, List, Optional
|
from typing import Any, Coroutine, Dict, List, Optional
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
@@ -2673,6 +2674,33 @@ def _interpolate_env_vars(value):
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
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 as _validate_mcp_server_entry
|
||||||
|
except Exception:
|
||||||
|
_validate_mcp_server_entry: Callable[[str, dict[str, Any]], list[str]] | None = None
|
||||||
|
|
||||||
|
if _validate_mcp_server_entry is None:
|
||||||
|
return servers
|
||||||
|
|
||||||
|
safe_servers = {}
|
||||||
|
for name, cfg in servers.items():
|
||||||
|
if not isinstance(cfg, dict):
|
||||||
|
safe_servers[name] = cfg
|
||||||
|
continue
|
||||||
|
issues = _validate_mcp_server_entry(name, cfg)
|
||||||
|
if issues:
|
||||||
|
logger.warning(
|
||||||
|
"Skipping suspicious MCP server '%s': %s",
|
||||||
|
name,
|
||||||
|
"; ".join(issues),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
safe_servers[name] = cfg
|
||||||
|
return safe_servers
|
||||||
|
|
||||||
|
|
||||||
def _load_mcp_config() -> Dict[str, dict]:
|
def _load_mcp_config() -> Dict[str, dict]:
|
||||||
"""Read ``mcp_servers`` from the Hermes config file.
|
"""Read ``mcp_servers`` from the Hermes config file.
|
||||||
|
|
||||||
@@ -2695,31 +2723,17 @@ def _load_mcp_config() -> Dict[str, dict]:
|
|||||||
servers = config.get("mcp_servers")
|
servers = config.get("mcp_servers")
|
||||||
if not servers or not isinstance(servers, dict):
|
if not servers or not isinstance(servers, dict):
|
||||||
return {}
|
return {}
|
||||||
try:
|
|
||||||
from hermes_cli.mcp_security import validate_mcp_server_entry
|
|
||||||
except Exception:
|
|
||||||
validate_mcp_server_entry = None
|
|
||||||
# Ensure .env vars are available for interpolation
|
# Ensure .env vars are available for interpolation
|
||||||
try:
|
try:
|
||||||
from hermes_cli.env_loader import load_hermes_dotenv
|
from hermes_cli.env_loader import load_hermes_dotenv
|
||||||
load_hermes_dotenv()
|
load_hermes_dotenv()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
safe_servers = {}
|
safe_servers: Dict[str, dict] = {}
|
||||||
for name, cfg in servers.items():
|
for name, cfg in _filter_suspicious_mcp_servers(servers).items():
|
||||||
if not isinstance(cfg, dict):
|
interpolated = _interpolate_env_vars(cfg)
|
||||||
safe_servers[name] = cfg
|
if isinstance(interpolated, dict):
|
||||||
continue
|
safe_servers[name] = interpolated
|
||||||
if validate_mcp_server_entry:
|
|
||||||
issues = validate_mcp_server_entry(name, cfg)
|
|
||||||
if issues:
|
|
||||||
logger.warning(
|
|
||||||
"Skipping suspicious MCP server '%s': %s",
|
|
||||||
name,
|
|
||||||
"; ".join(issues),
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
safe_servers[name] = _interpolate_env_vars(cfg)
|
|
||||||
return safe_servers
|
return safe_servers
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug("Failed to load MCP config: %s", exc)
|
logger.debug("Failed to load MCP config: %s", exc)
|
||||||
@@ -3667,6 +3681,7 @@ def register_mcp_servers(servers: Dict[str, dict]) -> List[str]:
|
|||||||
logger.debug("MCP SDK not available -- skipping explicit MCP registration")
|
logger.debug("MCP SDK not available -- skipping explicit MCP registration")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
servers = _filter_suspicious_mcp_servers(servers)
|
||||||
if not servers:
|
if not servers:
|
||||||
logger.debug("No explicit MCP servers provided")
|
logger.debug("No explicit MCP servers provided")
|
||||||
return []
|
return []
|
||||||
|
|||||||
Reference in New Issue
Block a user