Skip to content

Commit 77fcece

Browse files
author
Hanzo AI
committed
fix(hanzoai): restore core SDK modules wiped by #45 regen — unbreaks hanzo-mcp (3.0.1)
#45 regenerated hanzoai as a pure OpenAPI client and DELETED the hand-written core-SDK modules (config/mcp/session/protocols/…) that live in the same package but aren't part of API generation. hanzo-mcp imports all four → the published hanzoai 3.0.0 crashed EVERY hanzo-mcp consumer on startup ('No module named hanzoai.protocols') → the MCP server wouldn't boot → Claude Code couldn't reconnect. Restore the 4 self-contained modules hanzo-mcp needs (verified: zero imports of the removed Stainless internals); they coexist with the generated api/models client. Left agents/auth/cluster/llm_client OUT — they import the removed ._client and need migration to the new client (separate follow-up, no reconnect-path consumer). Bump 3.0.0→3.0.1. Boot-proven: hanzo-mcp starts, 26 tool packages / 29 tools, MCP initialize OK.
1 parent e25ac1e commit 77fcece

5 files changed

Lines changed: 1663 additions & 1 deletion

File tree

‎pkg/hanzoai/config.py‎

Lines changed: 397 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,397 @@
1+
"""Hierarchical configuration discovery and merging.
2+
3+
Three layers (User < Project < Local). Later sources override earlier ones.
4+
MCP server blocks replace entirely. Missing files silently skipped.
5+
6+
Security: Project-level configs (.hanzo/settings.json in a repo) are untrusted.
7+
A cloned repo could contain a malicious config that defines mcpServers with
8+
stdio transport (arbitrary command execution) or overrides oauth URLs (credential
9+
phishing). By default, project and local configs are restricted to safe keys
10+
only. Pass trust_project_mcp=True to override (requires explicit user approval).
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import json
16+
import logging
17+
import os
18+
from dataclasses import dataclass, field
19+
from enum import IntEnum
20+
from pathlib import Path
21+
from typing import Any
22+
23+
logger = logging.getLogger(__name__)
24+
25+
26+
class ConfigError(Exception):
27+
"""Raised when a config file exists but cannot be parsed."""
28+
29+
def __init__(self, path: Path, reason: str) -> None:
30+
self.path = path
31+
self.reason = reason
32+
super().__init__(f"{path}: {reason}")
33+
34+
35+
class ConfigSource(IntEnum):
36+
"""Config layer precedence. Higher value = higher priority."""
37+
38+
User = 0
39+
Project = 1
40+
Local = 2
41+
42+
43+
@dataclass(frozen=True)
44+
class ConfigEntry:
45+
"""A discovered config file location."""
46+
47+
source: ConfigSource
48+
path: Path
49+
50+
51+
class McpTransport(IntEnum):
52+
Stdio = 0
53+
Sse = 1
54+
Http = 2
55+
Ws = 3
56+
Sdk = 4
57+
ClaudeAiProxy = 5
58+
59+
60+
@dataclass(frozen=True)
61+
class McpStdioConfig:
62+
command: str
63+
args: list[str] = field(default_factory=list)
64+
env: dict[str, str] = field(default_factory=dict)
65+
66+
@property
67+
def transport(self) -> McpTransport:
68+
return McpTransport.Stdio
69+
70+
71+
@dataclass(frozen=True)
72+
class McpOAuthConfig:
73+
client_id: str | None = None
74+
callback_port: int | None = None
75+
auth_server_metadata_url: str | None = None
76+
xaa: bool | None = None
77+
78+
79+
@dataclass(frozen=True)
80+
class McpRemoteConfig:
81+
url: str
82+
headers: dict[str, str] = field(default_factory=dict)
83+
headers_helper: str | None = None
84+
oauth: McpOAuthConfig | None = None
85+
transport: McpTransport = McpTransport.Http
86+
87+
88+
@dataclass(frozen=True)
89+
class McpWsConfig:
90+
url: str
91+
headers: dict[str, str] = field(default_factory=dict)
92+
headers_helper: str | None = None
93+
94+
@property
95+
def transport(self) -> McpTransport:
96+
return McpTransport.Ws
97+
98+
99+
@dataclass(frozen=True)
100+
class McpSdkConfig:
101+
name: str
102+
103+
@property
104+
def transport(self) -> McpTransport:
105+
return McpTransport.Sdk
106+
107+
108+
@dataclass(frozen=True)
109+
class McpClaudeAiProxyConfig:
110+
url: str
111+
id: str
112+
113+
@property
114+
def transport(self) -> McpTransport:
115+
return McpTransport.ClaudeAiProxy
116+
117+
118+
McpServerConfig = McpStdioConfig | McpRemoteConfig | McpWsConfig | McpSdkConfig | McpClaudeAiProxyConfig
119+
120+
121+
@dataclass(frozen=True)
122+
class OAuthConfig:
123+
client_id: str
124+
authorize_url: str
125+
token_url: str
126+
scopes: list[str] = field(default_factory=list)
127+
callback_port: int | None = None
128+
manual_redirect_url: str | None = None
129+
130+
131+
def _deep_merge(base: dict, override: dict) -> dict:
132+
"""Merge *override* into *base*, recursing into nested dicts.
133+
134+
Non-dict values in *override* replace those in *base*.
135+
"""
136+
merged = dict(base)
137+
for key, val in override.items():
138+
if key in merged and isinstance(merged[key], dict) and isinstance(val, dict):
139+
merged[key] = _deep_merge(merged[key], val)
140+
else:
141+
merged[key] = val
142+
return merged
143+
144+
145+
_TRANSPORT_MAP: dict[str, McpTransport] = {
146+
"stdio": McpTransport.Stdio,
147+
"sse": McpTransport.Sse,
148+
"http": McpTransport.Http,
149+
"ws": McpTransport.Ws,
150+
"sdk": McpTransport.Sdk,
151+
"claudeai-proxy": McpTransport.ClaudeAiProxy,
152+
}
153+
154+
155+
def _parse_mcp_server(name: str, raw: dict) -> McpServerConfig:
156+
transport_str = raw.get("transport", "stdio")
157+
transport = _TRANSPORT_MAP.get(transport_str)
158+
if transport is None:
159+
raise ConfigError(
160+
Path("<memory>"),
161+
f"mcpServers.{name}: unknown transport {transport_str!r}",
162+
)
163+
164+
if transport == McpTransport.Stdio:
165+
command = raw.get("command")
166+
if not command or not isinstance(command, str):
167+
raise ConfigError(
168+
Path("<memory>"),
169+
f"mcpServers.{name}: stdio transport requires 'command' string",
170+
)
171+
args = raw.get("args", [])
172+
if not isinstance(args, list):
173+
raise ConfigError(
174+
Path("<memory>"),
175+
f"mcpServers.{name}: 'args' must be a list",
176+
)
177+
env = raw.get("env", {})
178+
if not isinstance(env, dict):
179+
raise ConfigError(
180+
Path("<memory>"),
181+
f"mcpServers.{name}: 'env' must be an object",
182+
)
183+
return McpStdioConfig(command=command, args=args, env=env)
184+
185+
if transport == McpTransport.Sdk:
186+
sdk_name = raw.get("name")
187+
if not sdk_name or not isinstance(sdk_name, str):
188+
raise ConfigError(
189+
Path("<memory>"),
190+
f"mcpServers.{name}: sdk transport requires 'name' string",
191+
)
192+
return McpSdkConfig(name=sdk_name)
193+
194+
if transport == McpTransport.ClaudeAiProxy:
195+
url = raw.get("url")
196+
if not url or not isinstance(url, str):
197+
raise ConfigError(
198+
Path("<memory>"),
199+
f"mcpServers.{name}: claudeai-proxy transport requires 'url' string",
200+
)
201+
server_id = raw.get("id")
202+
if not server_id or not isinstance(server_id, str):
203+
raise ConfigError(
204+
Path("<memory>"),
205+
f"mcpServers.{name}: claudeai-proxy transport requires 'id' string",
206+
)
207+
return McpClaudeAiProxyConfig(url=url, id=server_id)
208+
209+
# Shared validation for url-based transports: sse, http, ws
210+
url = raw.get("url")
211+
if not url or not isinstance(url, str):
212+
raise ConfigError(
213+
Path("<memory>"),
214+
f"mcpServers.{name}: {transport_str} transport requires 'url' string",
215+
)
216+
headers = raw.get("headers", {})
217+
if not isinstance(headers, dict):
218+
raise ConfigError(
219+
Path("<memory>"),
220+
f"mcpServers.{name}: 'headers' must be an object",
221+
)
222+
headers_helper = raw.get("headersHelper") or raw.get("headers_helper")
223+
224+
if transport == McpTransport.Ws:
225+
return McpWsConfig(url=url, headers=headers, headers_helper=headers_helper)
226+
227+
# sse, http -> McpRemoteConfig
228+
oauth_raw = raw.get("oauth")
229+
oauth: McpOAuthConfig | None = None
230+
if isinstance(oauth_raw, dict):
231+
oauth = McpOAuthConfig(
232+
client_id=oauth_raw.get("clientId") or oauth_raw.get("client_id"),
233+
callback_port=oauth_raw.get("callbackPort") or oauth_raw.get("callback_port"),
234+
auth_server_metadata_url=oauth_raw.get("authServerMetadataUrl") or oauth_raw.get("auth_server_metadata_url"),
235+
xaa=oauth_raw.get("xaa"),
236+
)
237+
return McpRemoteConfig(url=url, headers=headers, headers_helper=headers_helper, oauth=oauth, transport=transport)
238+
239+
240+
def _sanitize_project_config(data: dict, entry: ConfigEntry) -> dict:
241+
"""Remove dangerous keys from an untrusted project/local config.
242+
243+
Strips:
244+
- oauth (credential phishing via overridden auth URLs)
245+
- mcpServers entries with stdio transport (arbitrary command execution)
246+
247+
Non-stdio mcpServers (http, sse, ws, sdk) are kept -- they connect to
248+
remote URLs which are visible and auditable, not local binary execution.
249+
"""
250+
sanitized = dict(data)
251+
252+
if "oauth" in sanitized:
253+
logger.warning(
254+
"Ignoring 'oauth' from project config %s — "
255+
"project configs cannot override OAuth URLs (phishing risk). "
256+
"Move to user config (~/.hanzo/settings.json) instead.",
257+
entry.path,
258+
)
259+
del sanitized["oauth"]
260+
261+
mcp_raw = sanitized.get("mcpServers")
262+
if isinstance(mcp_raw, dict):
263+
safe_servers: dict = {}
264+
for name, cfg in mcp_raw.items():
265+
transport = cfg.get("transport", "stdio") if isinstance(cfg, dict) else "stdio"
266+
if transport == "stdio":
267+
logger.warning(
268+
"Ignoring mcpServers.%s (stdio) from project config %s — "
269+
"project configs cannot define stdio MCP servers (arbitrary "
270+
"command execution risk). Move to user config or pass "
271+
"trust_project_mcp=True.",
272+
name,
273+
entry.path,
274+
)
275+
else:
276+
safe_servers[name] = cfg
277+
if safe_servers:
278+
sanitized["mcpServers"] = safe_servers
279+
else:
280+
del sanitized["mcpServers"]
281+
282+
return sanitized
283+
284+
285+
@dataclass
286+
class RuntimeConfig:
287+
"""Merged configuration from all discovered layers."""
288+
289+
merged: dict = field(default_factory=dict)
290+
loaded_entries: list[ConfigEntry] = field(default_factory=list)
291+
292+
@classmethod
293+
def empty(cls) -> RuntimeConfig:
294+
return cls()
295+
296+
def get(self, key: str) -> Any | None:
297+
return self.merged.get(key)
298+
299+
def mcp_servers(self) -> dict[str, McpServerConfig]:
300+
raw = self.merged.get("mcpServers")
301+
if not isinstance(raw, dict):
302+
return {}
303+
return {name: _parse_mcp_server(name, cfg) for name, cfg in raw.items()}
304+
305+
def oauth(self) -> OAuthConfig | None:
306+
raw = self.merged.get("oauth")
307+
if not isinstance(raw, dict):
308+
return None
309+
client_id = raw.get("clientId") or raw.get("client_id")
310+
if not client_id or not isinstance(client_id, str):
311+
return None
312+
authorize_url = raw.get("authorizeUrl") or raw.get("authorize_url")
313+
if not authorize_url or not isinstance(authorize_url, str):
314+
return None
315+
token_url = raw.get("tokenUrl") or raw.get("token_url")
316+
if not token_url or not isinstance(token_url, str):
317+
return None
318+
callback_port = raw.get("callbackPort") or raw.get("callback_port")
319+
return OAuthConfig(
320+
client_id=client_id,
321+
authorize_url=authorize_url,
322+
token_url=token_url,
323+
scopes=raw.get("scopes", []),
324+
callback_port=callback_port,
325+
manual_redirect_url=raw.get("manualRedirectUrl") or raw.get("manual_redirect_url"),
326+
)
327+
328+
329+
@dataclass(frozen=True)
330+
class ConfigLoader:
331+
"""Discovers and loads hierarchical config files."""
332+
333+
cwd: Path
334+
config_home: Path
335+
336+
@classmethod
337+
def default_for(cls, cwd: Path) -> ConfigLoader:
338+
config_home = Path(os.environ.get("HANZO_CONFIG_HOME", Path.home() / ".hanzo"))
339+
return cls(cwd=cwd, config_home=config_home)
340+
341+
def discover(self) -> list[ConfigEntry]:
342+
"""Return potential config file entries in precedence order (low to high)."""
343+
return [
344+
ConfigEntry(ConfigSource.User, self.config_home / "settings.json"),
345+
ConfigEntry(ConfigSource.Project, self.cwd / ".hanzo" / "settings.json"),
346+
ConfigEntry(ConfigSource.Local, self.cwd / ".hanzo" / "settings.local.json"),
347+
]
348+
349+
# Keys that are safe to accept from project-level (untrusted) configs.
350+
# mcpServers with stdio transport allows arbitrary command execution.
351+
# oauth allows credential phishing by overriding auth URLs.
352+
# Both are blocked from project/local configs unless explicitly trusted.
353+
_PROJECT_SAFE_KEYS = frozenset({"model", "env", "hooks"})
354+
355+
def load(self, *, trust_project_mcp: bool = False) -> RuntimeConfig:
356+
"""Read and merge all existing config files.
357+
358+
Missing files are silently skipped. Invalid JSON raises ConfigError.
359+
The top-level value in each file must be a JSON object.
360+
361+
Args:
362+
trust_project_mcp: When False (default), project-level and
363+
local-level configs cannot define mcpServers with stdio
364+
transport or oauth blocks. This prevents a malicious cloned
365+
repo from executing arbitrary commands or phishing credentials
366+
via .hanzo/settings.json. Set True only after explicit user
367+
approval.
368+
"""
369+
merged: dict = {}
370+
loaded: list[ConfigEntry] = []
371+
372+
for entry in self.discover():
373+
if not entry.path.is_file():
374+
continue
375+
376+
text = entry.path.read_text(encoding="utf-8")
377+
try:
378+
data = json.loads(text)
379+
except json.JSONDecodeError as exc:
380+
raise ConfigError(entry.path, f"invalid JSON: {exc}") from exc
381+
382+
if not isinstance(data, dict):
383+
raise ConfigError(entry.path, "top-level value must be a JSON object")
384+
385+
# R-06: Project/local configs are untrusted. Strip dangerous keys
386+
# unless the caller has explicitly opted in.
387+
if entry.source in (ConfigSource.Project, ConfigSource.Local) and not trust_project_mcp:
388+
data = _sanitize_project_config(data, entry)
389+
390+
# MCP servers from later sources replace entirely (not merged).
391+
if "mcpServers" in data and "mcpServers" in merged:
392+
del merged["mcpServers"]
393+
394+
merged = _deep_merge(merged, data)
395+
loaded.append(entry)
396+
397+
return RuntimeConfig(merged=merged, loaded_entries=loaded)

0 commit comments

Comments
 (0)