diff --git a/src/lh_harness/adapters/claude_code.py b/src/lh_harness/adapters/claude_code.py index 8586b09..c6410e3 100644 --- a/src/lh_harness/adapters/claude_code.py +++ b/src/lh_harness/adapters/claude_code.py @@ -62,8 +62,9 @@ def __init__( ] ) - # MCP support remains opt-in. --strict-mcp-config keeps unrelated - # user/project MCP servers out of every role. + # MCP support remains opt-in. The --strict-mcp-config flag appended to + # command_parts below keeps unrelated user/project MCP servers out of + # every role. mcp_config = mcp_config or os.getenv("LH_HARNESS_CLAUDECODE_MCP_CONFIG") if mcp_config: candidate = Path(mcp_config).expanduser() @@ -98,6 +99,10 @@ def __init__( "stream-json", "--verbose", "--dangerously-skip-permissions", + # Role isolation: without this the CLI also loads every MCP server + # the operator has configured at user or project scope. With it, + # a role sees only the servers in an explicit --mcp-config. + "--strict-mcp-config", ] deny_tools = [*policy.disallowed_tools, *path_deny_rules(hidden_paths)] if deny_tools: diff --git a/tests/test_claude_code_adapter.py b/tests/test_claude_code_adapter.py new file mode 100644 index 0000000..4573feb --- /dev/null +++ b/tests/test_claude_code_adapter.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import json +import shlex +from pathlib import Path + +import pytest + +from lh_harness.adapters.claude_code import ClaudeCodeAdapter +from lh_harness.adapters.claude_permissions import ClaudeRole + + +ROLES: tuple[ClaudeRole, ...] = ( + "manager", + "cli_executor", + "gui_executor", + "cli_auditor", + "gui_auditor", + "auditor_format_repair", + "final_response", +) + + +def _tokens(adapter: ClaudeCodeAdapter) -> list[str]: + return shlex.split(adapter.command_template.replace("< {prompt_path}", "")) + + +def _adapter(role: ClaudeRole, **overrides: object) -> ClaudeCodeAdapter: + kwargs: dict[str, object] = { + "model": "claude-sonnet-5", + "role": role, + "workspace_path": "/tmp/ws", + "prompt_dir": "/tmp/prompts", + } + kwargs.update(overrides) + return ClaudeCodeAdapter(**kwargs) # type: ignore[arg-type] + + +@pytest.mark.parametrize("role", ROLES) +def test_every_role_ignores_the_operator_mcp_registries(role: ClaudeRole) -> None: + """No role may inherit user- or project-scope MCP servers. + + The CLI loads `~/.claude.json` and a workspace `.mcp.json` unless it is told + not to, so an operator's unrelated servers otherwise reach every episode. + """ + assert "--strict-mcp-config" in _tokens(_adapter(role)) + + +def test_a_configured_mcp_config_is_still_the_only_source(tmp_path: Path) -> None: + """An explicit plugin config is loaded, and remains the sole source.""" + config = tmp_path / "computer-use.mcp.json" + config.write_text(json.dumps({"mcpServers": {}}), encoding="utf-8") + + tokens = _tokens(_adapter("cli_executor", mcp_config=str(config))) + + assert tokens[tokens.index("--mcp-config") + 1] == str(config.resolve()) + assert "--strict-mcp-config" in tokens + + +def test_the_environment_mcp_config_is_scoped_the_same_way( + monkeypatch, tmp_path: Path +) -> None: + """`LH_HARNESS_CLAUDECODE_MCP_CONFIG` widens the allow-list, not the scope.""" + config = tmp_path / "env.mcp.json" + config.write_text(json.dumps({"mcpServers": {}}), encoding="utf-8") + monkeypatch.setenv("LH_HARNESS_CLAUDECODE_MCP_CONFIG", str(config)) + + tokens = _tokens(_adapter("cli_auditor")) + + assert tokens[tokens.index("--mcp-config") + 1] == str(config.resolve()) + assert "--strict-mcp-config" in tokens