diff --git a/bot_bottle/supervise_server.py b/bot_bottle/supervise_server.py index c9dff9c..6bb5734 100644 --- a/bot_bottle/supervise_server.py +++ b/bot_bottle/supervise_server.py @@ -51,12 +51,16 @@ from dataclasses import dataclass, replace try: # Same-directory imports inside the bundle container; these files are # COPYed flat under /app by Dockerfile.gateway. - from egress_addon_core import LOG_OFF, load_config + from egress_addon_core import ( + LOG_OFF, load_config, resolve_client_context, route_to_yaml_dict, + ) from policy_resolver import PolicyResolveError, PolicyResolver import supervise as _sv except ModuleNotFoundError: # Package imports for host-side tests and tooling. - from .egress_addon_core import LOG_OFF, load_config + from .egress_addon_core import ( + LOG_OFF, load_config, resolve_client_context, route_to_yaml_dict, + ) from .policy_resolver import PolicyResolveError, PolicyResolver from . import supervise as _sv @@ -527,6 +531,17 @@ class MCPHandler(http.server.BaseHTTPRequestHandler): if method == "tools/list": return handle_tools_list(req.params) if method == "tools/call": + # `list-egress-routes` is read-only introspection. In consolidated + # mode the gateway's *static* route table is empty (routes are + # resolved per request by source IP), so answer it from the calling + # bottle's resolved policy. Otherwise the agent sees an empty + # allowlist and composes an egress proposal that *replaces* the live + # routes instead of extending them — silently dropping base routes + # like api.anthropic.com when the operator approves it. + if req.params.get("name") == _sv.TOOL_LIST_EGRESS_ROUTES: + resolved = self._resolved_routes_payload() + if resolved is not None: + return resolved # Attribute the proposal to the calling bottle. Single-tenant → the # env slug on `config`; consolidated → the source-IP-resolved # bottle id, so one shared server queues each bottle's proposal @@ -534,6 +549,28 @@ class MCPHandler(http.server.BaseHTTPRequestHandler): return handle_tools_call(req.params, self._attributed_config(config)) raise _RpcClientError(ERR_METHOD_NOT_FOUND, f"method not found: {method}") + def _resolved_routes_payload(self) -> dict[str, object] | None: + """The calling bottle's live egress routes as the `list-egress-routes` + JSON payload, resolved by (source_ip, identity token) — the same shape + the single-tenant introspection endpoint returns. None when there is no + resolver (single-tenant), so the caller falls back to that endpoint. + + Fail-closed like `_attributed_config`: an unattributed source or an + unreachable orchestrator yields an empty route list (never another + bottle's), courtesy of `resolve_client_context`.""" + resolver = getattr(self.server, "policy_resolver", None) + if resolver is None: + return None + headers = getattr(self, "headers", None) + token = headers.get(IDENTITY_HEADER, "") if headers is not None else "" + conf, _slug, _tokens = resolve_client_context( + resolver, self.client_address[0], token, + ) + body = json.dumps( + {"routes": [route_to_yaml_dict(r) for r in conf.routes]}, indent=2, + ) + return {"content": [{"type": "text", "text": body}], "isError": False} + def _attributed_config(self, config: ServerConfig) -> ServerConfig: """The ServerConfig with `bottle_slug` bound to *this request's* bottle. Single-tenant (no resolver): unchanged. Consolidated: the bottle id diff --git a/tests/unit/test_supervise_server.py b/tests/unit/test_supervise_server.py index d73ffac..54f03da 100644 --- a/tests/unit/test_supervise_server.py +++ b/tests/unit/test_supervise_server.py @@ -631,9 +631,15 @@ class TestHttpEndToEnd(unittest.TestCase): class _FakeResolver: - def __init__(self, bottle_id: str | None = None, raises: bool = False) -> None: + def __init__( + self, + bottle_id: str | None = None, + raises: bool = False, + policy: str = "", + ) -> None: self._bottle_id = bottle_id self._raises = raises + self._policy = policy self.calls: list[str] = [] def resolve_bottle_id(self, source_ip: str, identity_token: str = "") -> str | None: @@ -645,6 +651,15 @@ class _FakeResolver: raise supervise_server.PolicyResolveError("orchestrator down") return self._bottle_id + def resolve_policy_and_bottle_id( + self, source_ip: str, identity_token: str = "", + ) -> "tuple[str, str | None, dict[str, str]]": + del identity_token + self.calls.append(source_ip) + if self._raises: + raise supervise_server.PolicyResolveError("orchestrator down") + return self._policy, self._bottle_id, {} + def _handler(resolver: object) -> MCPHandler: """A bare MCPHandler wired with a server (carrying the resolver) and a @@ -682,5 +697,41 @@ class TestAttributedConfig(unittest.TestCase): ) +class TestResolvedRoutesPayload(unittest.TestCase): + """`list-egress-routes` answers from the calling bottle's resolved policy in + consolidated mode — not the gateway's empty static table. Regression: an + empty list led agents to propose replace-all route files that dropped base + hosts like api.anthropic.com on approval.""" + + def test_returns_resolved_bottle_routes(self) -> None: + policy = ( + "routes:\n" + " - host: api.anthropic.com\n" + " - host: www.google.com\n" + ) + payload = _handler( + _FakeResolver(bottle_id="b1", policy=policy) + )._resolved_routes_payload() + assert payload is not None + self.assertFalse(payload["isError"]) # type: ignore[index] + data = json.loads(payload["content"][0]["text"]) # type: ignore[index] + hosts = {r["host"] for r in data["routes"]} + self.assertEqual({"api.anthropic.com", "www.google.com"}, hosts) + + def test_orchestrator_error_fails_closed_to_empty(self) -> None: + # resolve_client_context swallows resolver errors → deny-all (empty), + # never another bottle's routes. + payload = _handler( + _FakeResolver(raises=True) + )._resolved_routes_payload() + assert payload is not None + data = json.loads(payload["content"][0]["text"]) # type: ignore[index] + self.assertEqual([], data["routes"]) + + def test_single_tenant_returns_none(self) -> None: + # No resolver → caller falls back to the static introspection endpoint. + self.assertIsNone(_handler(None)._resolved_routes_payload()) + + if __name__ == "__main__": unittest.main()