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:
@@ -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