fix(supervise): list-egress-routes returns the bottle's real routes
In consolidated mode the gateway's static route table is empty (routes are resolved per request by source IP), but `list-egress-routes` was reading that static table via the `_egress.local/allowlist` introspection endpoint — so it always returned an empty allowlist. Agents, told to call it before composing an egress proposal, then sent a routes.yaml with only the newly-needed host; approving it replaced the whole policy and silently dropped base routes like api.anthropic.com, breaking the bottle's egress. Answer `list-egress-routes` from the calling bottle's resolved policy (same (source_ip, identity-token) attribution the proposal path uses, same JSON shape the single-tenant introspection endpoint returns). Fail-closed to an empty list on an unreachable orchestrator; falls back to the introspection endpoint in single-tenant mode (no resolver). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UoEZHDjv84ChoZbozQERhJ
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user