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:
2026-07-16 22:18:14 -04:00
parent b1df380ae1
commit c15eed4f2e
2 changed files with 91 additions and 3 deletions
+39 -2
View File
@@ -51,12 +51,16 @@ from dataclasses import dataclass, replace
try: try:
# Same-directory imports inside the bundle container; these files are # Same-directory imports inside the bundle container; these files are
# COPYed flat under /app by Dockerfile.gateway. # 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 from policy_resolver import PolicyResolveError, PolicyResolver
import supervise as _sv import supervise as _sv
except ModuleNotFoundError: except ModuleNotFoundError:
# Package imports for host-side tests and tooling. # 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 .policy_resolver import PolicyResolveError, PolicyResolver
from . import supervise as _sv from . import supervise as _sv
@@ -527,6 +531,17 @@ class MCPHandler(http.server.BaseHTTPRequestHandler):
if method == "tools/list": if method == "tools/list":
return handle_tools_list(req.params) return handle_tools_list(req.params)
if method == "tools/call": 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 # Attribute the proposal to the calling bottle. Single-tenant → the
# env slug on `config`; consolidated → the source-IP-resolved # env slug on `config`; consolidated → the source-IP-resolved
# bottle id, so one shared server queues each bottle's proposal # 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)) return handle_tools_call(req.params, self._attributed_config(config))
raise _RpcClientError(ERR_METHOD_NOT_FOUND, f"method not found: {method}") 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: def _attributed_config(self, config: ServerConfig) -> ServerConfig:
"""The ServerConfig with `bottle_slug` bound to *this request's* bottle. """The ServerConfig with `bottle_slug` bound to *this request's* bottle.
Single-tenant (no resolver): unchanged. Consolidated: the bottle id Single-tenant (no resolver): unchanged. Consolidated: the bottle id
+52 -1
View File
@@ -631,9 +631,15 @@ class TestHttpEndToEnd(unittest.TestCase):
class _FakeResolver: 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._bottle_id = bottle_id
self._raises = raises self._raises = raises
self._policy = policy
self.calls: list[str] = [] self.calls: list[str] = []
def resolve_bottle_id(self, source_ip: str, identity_token: str = "") -> str | None: 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") raise supervise_server.PolicyResolveError("orchestrator down")
return self._bottle_id 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: def _handler(resolver: object) -> MCPHandler:
"""A bare MCPHandler wired with a server (carrying the resolver) and a """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__": if __name__ == "__main__":
unittest.main() unittest.main()