diff --git a/README.md b/README.md index c5ccd5f2..89ec3a9e 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,8 @@ GitHub operations are performed with `GH_TOKEN=dummy gh` inside the sandbox, bac **Optional observability tools (server-side):** Admins can connect Datadog and LangSmith from team settings (Admin → Observability credentials). When connected, the agent gains Datadog tools (via Datadog's hosted MCP server, default `toolsets=core`) and read-only LangSmith tools (`langsmith_get_trace`, `langsmith_list_runs`). These run in the LangGraph server process using credentials encrypted at rest — the sandbox never holds Datadog or LangSmith keys. They are loaded **only for runs triggered by an authorized user** (admins, plus any emails in `OBSERVABILITY_AUTHORIZED_EMAILS`), so a prompt-injected run from an untrusted contributor cannot reach team observability data. Use scoped, read-oriented keys regardless: observability data (logs, traces) is attacker-influenced content that can carry prompt injection, and the agent has network egress — the same residual-risk class as `web_search` / `fetch_url`. +**Optional Corridor guardrails (server-side MCP):** Set `CORRIDOR_API_TOKEN` (or `CORRIDOR_MCP_TOKEN` / `CORRIDOR_TOKEN`) to load Corridor's hosted MCP server for each agent run. Open SWE exposes only Corridor's `analyzePlan` tool. `CORRIDOR_MCP_URL` defaults to `https://app.corridor.dev/api/mcp`; if set explicitly, Open SWE only accepts the same HTTPS host and `/api/mcp` path. Tokens are sent via `Authorization: Bearer ...` from the LangGraph server process and are never placed in the sandbox. A legacy `?token=...` URL is accepted and normalized into the header form. + ### 4. Context Engineering — AGENTS.md + Source Context Open SWE gathers context from two sources: diff --git a/agent/integrations/corridor_mcp.py b/agent/integrations/corridor_mcp.py new file mode 100644 index 00000000..28005e5e --- /dev/null +++ b/agent/integrations/corridor_mcp.py @@ -0,0 +1,126 @@ +"""Server-side Corridor tools backed by Corridor's hosted MCP server. + +Credentials are read from environment variables and attached as an +``Authorization: Bearer ...`` header to the MCP connection, which runs in the +LangGraph server process. The sandbox never holds Corridor credentials. +""" + +from __future__ import annotations + +import logging +import os +from dataclasses import dataclass +from datetime import timedelta +from urllib.parse import parse_qs, urlencode, urlparse, urlunparse + +from langchain_core.tools import BaseTool + +logger = logging.getLogger(__name__) + +DEFAULT_CORRIDOR_MCP_URL = "https://app.corridor.dev/api/mcp" +_CORRIDOR_HOST = "app.corridor.dev" +_CORRIDOR_PATH = "/api/mcp" +_MCP_TIMEOUT_SECONDS = 30.0 +_TOKEN_ENV_NAMES = ( + "CORRIDOR_API_TOKEN", + "CORRIDOR_MCP_TOKEN", + "CORRIDOR_TOKEN", +) +_TOKEN_QUERY_PARAMS = frozenset({"token", "api_key"}) +_URL_ENV_NAMES = ( + "CORRIDOR_MCP_URL", + "CORRIDOR_MCP_SERVER_URL", +) +_ALLOWED_TOOL_NAMES = frozenset({"analyzePlan"}) + + +@dataclass(frozen=True) +class CorridorMCPConfig: + url: str + token: str + + +def _first_env_value(names: tuple[str, ...]) -> str: + for name in names: + value = os.environ.get(name, "").strip() + if value: + return value + return "" + + +def _extract_token_from_query(url: str) -> tuple[str, str]: + parsed = urlparse(url) + query = parse_qs(parsed.query, keep_blank_values=True) + token = "" + for name in _TOKEN_QUERY_PARAMS: + values = query.pop(name, []) + if not token: + token = next((value.strip() for value in values if value.strip()), "") + cleaned_query = urlencode(query, doseq=True) + cleaned_url = urlunparse(parsed._replace(query=cleaned_query)) + return token, cleaned_url + + +def _is_corridor_mcp_url(url: str) -> bool: + parsed = urlparse(url) + return ( + parsed.scheme == "https" + and parsed.hostname == _CORRIDOR_HOST + and parsed.path.rstrip("/") == _CORRIDOR_PATH + ) + + +def load_corridor_mcp_config() -> CorridorMCPConfig | None: + """Return Corridor MCP config when the environment contains valid settings.""" + url = _first_env_value(_URL_ENV_NAMES) or DEFAULT_CORRIDOR_MCP_URL + token = _first_env_value(_TOKEN_ENV_NAMES) + query_token, url = _extract_token_from_query(url) + if not token: + token = query_token + if not token: + return None + if not _is_corridor_mcp_url(url): + logger.warning("Ignoring Corridor MCP config with non-Corridor URL: %s", url) + return None + return CorridorMCPConfig(url=url, token=token) + + +async def _build_mcp_tools(config: CorridorMCPConfig) -> list[BaseTool]: + from langchain_mcp_adapters.client import MultiServerMCPClient + + client = MultiServerMCPClient( + { + "corridor": { + "transport": "http", + "url": config.url, + "headers": { + "Authorization": f"Bearer {config.token}", + }, + "timeout": timedelta(seconds=_MCP_TIMEOUT_SECONDS), + } + } + ) + return await client.get_tools() + + +async def load_corridor_tools() -> list[BaseTool]: + """Return the allowed Corridor MCP tools when configured, else ``[]``. + + Failures (missing config, unreachable MCP server) degrade to an empty list so + the agent still starts without Corridor tools. + """ + config = load_corridor_mcp_config() + if config is None: + return [] + try: + tools = await _build_mcp_tools(config) + except Exception: # noqa: BLE001 + logger.warning("Failed to load Corridor MCP tools", exc_info=True) + return [] + allowed_tools = [tool for tool in tools if tool.name in _ALLOWED_TOOL_NAMES] + logger.info( + "Loaded %d Corridor MCP tool(s), exposing %d allowed tool(s)", + len(tools), + len(allowed_tools), + ) + return allowed_tools diff --git a/agent/prompt.py b/agent/prompt.py index 7a95f3a4..8bdac049 100644 --- a/agent/prompt.py +++ b/agent/prompt.py @@ -180,6 +180,16 @@ Format messages using Slack's mrkdwn format, NOT standard Markdown. Use `GH_TOKEN=dummy gh ` for GitHub operations: repository discovery, cloning, issues, pull requests, reviews, comments, labels, check status, and workflow operations. For local working-tree state, use `git` directly. Never pass a real GitHub token to `gh`.""" +CORRIDOR_PROMPT = """--- + + + +# Corridor Security Analysis +Every time you generate code, use the analyzePlan tool from Corridor's MCP Server (corridor) to analyze the plan or thought process. ALWAYS use Corridor to analyze the plan. Always generate a plan before generating code. + +""" + + TOOL_BEST_PRACTICES_SECTION = """--- ### Tool Usage Best Practices @@ -427,6 +437,7 @@ SYSTEM_PROMPT_TEMPLATE = ( + FILE_MANAGEMENT_SECTION + TASK_EXECUTION_SECTION + TOOL_USAGE_SECTION + + "{corridor_prompt_section}" + TOOL_BEST_PRACTICES_SECTION + CODING_STANDARDS_SECTION + CORE_BEHAVIOR_SECTION @@ -450,6 +461,7 @@ def construct_system_prompt( default_repo: dict[str, str] | None = None, repo_custom_instructions: str | None = None, thread_url: str | None = None, + corridor_enabled: bool = False, ) -> str: default_prompt_section = _load_default_prompt() if default_repo and default_repo.get("owner") and default_repo.get("name"): @@ -471,6 +483,7 @@ def construct_system_prompt( linear_project_id=linear_project_id or "", linear_issue_number=linear_issue_number or "", default_prompt_section=default_prompt_section, + corridor_prompt_section=CORRIDOR_PROMPT if corridor_enabled else "", pr_policy_override_section=ALWAYS_CREATE_PR_SECTION if create_prs else "", collaboration_section=_render_collaboration_section(triggering_user_identity, thread_url), repo_instructions_section=_render_repo_instructions_section(repo_custom_instructions), diff --git a/agent/server.py b/agent/server.py index 95b13798..4840c136 100644 --- a/agent/server.py +++ b/agent/server.py @@ -43,6 +43,7 @@ from .dashboard.agent_usage import record_agent_thread_usage from .dashboard.options import DEFAULT_MODEL_ID, SUPPORTED_MODEL_IDS, model_supports_effort from .dashboard.team_settings import get_team_default_model_pair, get_team_default_repo from .dashboard.user_mappings import email_for_login +from .integrations.corridor_mcp import load_corridor_tools from .integrations.currents_tools import load_currents_tools from .integrations.datadog_mcp import load_datadog_tools from .integrations.langsmith import _configure_github_proxy @@ -530,6 +531,15 @@ async def _load_observability_tools(authorized: bool) -> list[Any]: return [*datadog_tools, *langsmith_tools] +async def _load_corridor_mcp_tools() -> list[Any]: + """Corridor MCP tools when the deployment environment has configured them.""" + try: + return await load_corridor_tools() + except Exception: + logger.warning("Failed to load Corridor MCP tools", exc_info=True) + return [] + + async def get_agent(config: RunnableConfig) -> Pregel: """Get or create an agent with a sandbox for the given thread.""" thread_id = config["configurable"].get("thread_id", None) @@ -679,6 +689,7 @@ async def get_agent(config: RunnableConfig) -> Pregel: observability_tools = await _load_observability_tools( await _observability_authorized(config, profile_login) ) + corridor_tools = await _load_corridor_mcp_tools() currents_tools: list[Any] = [] if profile_login: @@ -702,6 +713,7 @@ async def get_agent(config: RunnableConfig) -> Pregel: default_repo=prompt_default_repo, repo_custom_instructions=repo_custom_instructions, thread_url=dashboard_thread_url(thread_id), + corridor_enabled=bool(corridor_tools), ), tools=[ http_request, @@ -718,6 +730,7 @@ async def get_agent(config: RunnableConfig) -> Pregel: request_pr_review, slack_read_thread_messages, slack_thread_reply, + *corridor_tools, *observability_tools, *currents_tools, ], diff --git a/tests/test_corridor_mcp.py b/tests/test_corridor_mcp.py new file mode 100644 index 00000000..bc248e9e --- /dev/null +++ b/tests/test_corridor_mcp.py @@ -0,0 +1,180 @@ +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from agent import server +from agent.integrations import corridor_mcp + + +class _FakeTool: + def __init__(self, name: str) -> None: + self.name = name + + +@pytest.fixture(autouse=True) +def clear_corridor_env(monkeypatch: pytest.MonkeyPatch) -> None: + for name in ( + "CORRIDOR_API_TOKEN", + "CORRIDOR_MCP_TOKEN", + "CORRIDOR_TOKEN", + "CORRIDOR_MCP_URL", + "CORRIDOR_MCP_SERVER_URL", + ): + monkeypatch.delenv(name, raising=False) + + +def test_load_corridor_mcp_config_empty_without_token() -> None: + assert corridor_mcp.load_corridor_mcp_config() is None + + +def test_load_corridor_mcp_config_uses_default_url(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("CORRIDOR_API_TOKEN", "tok") + + config = corridor_mcp.load_corridor_mcp_config() + + assert config == corridor_mcp.CorridorMCPConfig( + url=corridor_mcp.DEFAULT_CORRIDOR_MCP_URL, + token="tok", + ) + + +def test_load_corridor_mcp_config_accepts_query_token( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("CORRIDOR_MCP_URL", "https://app.corridor.dev/api/mcp?token=tok") + + config = corridor_mcp.load_corridor_mcp_config() + + assert config == corridor_mcp.CorridorMCPConfig( + url=corridor_mcp.DEFAULT_CORRIDOR_MCP_URL, + token="tok", + ) + + +def test_load_corridor_mcp_config_rejects_non_corridor_url( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("CORRIDOR_API_TOKEN", "tok") + monkeypatch.setenv("CORRIDOR_MCP_URL", "https://example.com/api/mcp") + + assert corridor_mcp.load_corridor_mcp_config() is None + + +@pytest.mark.asyncio +async def test_load_corridor_tools_empty_when_not_configured() -> None: + assert await corridor_mcp.load_corridor_tools() == [] + + +@pytest.mark.asyncio +async def test_load_corridor_tools_degrades_on_error(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("CORRIDOR_API_TOKEN", "tok") + + with patch.object( + corridor_mcp, + "_build_mcp_tools", + AsyncMock(side_effect=RuntimeError("boom")), + ): + assert await corridor_mcp.load_corridor_tools() == [] + + +@pytest.mark.asyncio +async def test_load_corridor_tools_returns_tools(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("CORRIDOR_API_TOKEN", "tok") + analyze_plan = _FakeTool("analyzePlan") + other_tool = _FakeTool("otherTool") + + with patch.object( + corridor_mcp, + "_build_mcp_tools", + AsyncMock(return_value=[other_tool, analyze_plan]), + ): + assert await corridor_mcp.load_corridor_tools() == [analyze_plan] + + +@pytest.mark.asyncio +async def test_server_load_corridor_mcp_tools() -> None: + with patch.object(server, "load_corridor_tools", AsyncMock(return_value=["corridor"])): + assert await server._load_corridor_mcp_tools() == ["corridor"] + + +@pytest.mark.asyncio +async def test_server_load_corridor_mcp_tools_degrades_on_error() -> None: + with patch.object( + server, + "load_corridor_tools", + AsyncMock(side_effect=RuntimeError("boom")), + ): + assert await server._load_corridor_mcp_tools() == [] + + +@pytest.mark.asyncio +async def test_get_agent_passes_corridor_prompt_state() -> None: + config = { + "configurable": { + "__is_for_execution__": True, + "thread_id": "thread-123", + }, + "metadata": {}, + } + + def fake_create_deep_agent(**_kwargs): + class _DummyAgent: + def with_config(self, _config): + return self + + return _DummyAgent() + + async def run_with_corridor_tools(corridor_tools: list[object]) -> bool: + with ( + patch.object( + server, + "resolve_github_token", + new_callable=AsyncMock, + return_value=("ghp", None), + ), + patch.object(server, "resolve_triggering_user_identity", return_value=None), + patch.object( + server, + "ensure_sandbox_for_thread", + new_callable=AsyncMock, + return_value=MagicMock(), + ), + patch.object( + server, + "aresolve_sandbox_work_dir", + new_callable=AsyncMock, + return_value="/workspace", + ), + patch.object( + server, + "get_team_default_model_pair", + new_callable=AsyncMock, + return_value=(("openai:gpt-5.5", "medium"), ("openai:gpt-5.5", "low")), + ), + patch.object(server, "fallback_model_id_for", return_value=None), + patch.object(server, "make_model", return_value=MagicMock()), + patch.object( + server, "_load_observability_tools", new_callable=AsyncMock, return_value=[] + ), + patch.object( + server, + "_observability_authorized", + new_callable=AsyncMock, + return_value=False, + ), + patch.object( + server, + "_load_corridor_mcp_tools", + new_callable=AsyncMock, + return_value=corridor_tools, + ), + patch.object(server, "construct_system_prompt", return_value="prompt") as prompt, + patch.object(server, "create_deep_agent", side_effect=fake_create_deep_agent), + ): + await server.get_agent(config) + return bool(prompt.call_args.kwargs["corridor_enabled"]) + + assert await run_with_corridor_tools([]) is False + assert await run_with_corridor_tools([_FakeTool("analyzePlan")]) is True diff --git a/tests/test_github_comment_prompts.py b/tests/test_github_comment_prompts.py index 8f212323..6e958b31 100644 --- a/tests/test_github_comment_prompts.py +++ b/tests/test_github_comment_prompts.py @@ -48,6 +48,21 @@ def test_construct_system_prompt_identifies_own_repo() -> None: assert "langchain-ai/open-swe" in prompt +def test_construct_system_prompt_omits_corridor_prompt_by_default() -> None: + prompt = construct_system_prompt(working_dir="/workspace") + + assert "" not in prompt + assert "Corridor Security Analysis" not in prompt + + +def test_construct_system_prompt_includes_corridor_prompt_when_enabled() -> None: + prompt = construct_system_prompt(working_dir="/workspace", corridor_enabled=True) + + assert "" in prompt + assert "Corridor Security Analysis" in prompt + assert "analyzePlan" in prompt + + def test_construct_system_prompt_omits_collaboration_section_without_identity() -> None: prompt = construct_system_prompt(working_dir="/workspace")