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:
|
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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user