refactor(gateway): remove the single-tenant data-plane paths (audit #400 finding 3)

All three backends (docker, firecracker, macos-container) now launch through
the consolidated orchestrator, and every production gateway sets
BOT_BOTTLE_ORCHESTRATOR_URL — so the legacy single-tenant (`resolver is None`)
branches in the shared gateway's data plane were unreachable dead code, a second
security-relevant path to keep correct in parallel with the live one. Make the
orchestrator resolver mandatory and delete the single-tenant paths from the
three data-plane modules.

egress_addon.py: drop the static routes file entirely — EGRESS_ROUTES, _reload,
the SIGHUP handler, self.config, and the SUPERVISE_BOTTLE_SLUG env slug. The
per-request /resolve is the only policy source; __init__ fail-closes if
BOT_BOTTLE_ORCHESTRATOR_URL is unset. Introspection (`_egress.local/allowlist`)
now reports the calling bottle's *resolved* routes. The block/redact log gates
and _req_ctx redaction now read the per-flow config/env from the request-time
stash, so they use each bottle's log level and token set (they silently used the
empty static config before). Nothing sends `docker kill --signal HUP` to the
gateway in the consolidated model (the egress applicators fail closed), so
removing the SIGHUP reload is safe.

git_http_backend.py: resolver mandatory; no flat-root fallback. main() refuses
to start without an orchestrator URL; a request whose source resolves to no
bottle 404s.

supervise_server.py: resolver mandatory; every proposal is attributed to the
source-IP-resolved bottle. Remove handle_list_egress_routes (the proxy-fetch
introspection that only worked when the proxy carried one bottle's identity) —
list-egress-routes is answered from the resolved policy. main() refuses to start
without an orchestrator URL.

Tests: a host-side fake resolver serves each test's Config through the real
parse path (a small YAML-subset emitter round-trips route_to_yaml_dict); the
response/websocket hooks stash it as request() would. Deletes the tests for the
removed static-config, SIGHUP-reload, and single-tenant-passthrough paths; adds
fail-closed-without-orchestrator coverage.

Follow-up: gateway_init still forwards SIGHUP to the egress child (now dormant —
no one sends it); the README still describes the docker backend's per-bottle
topology. Both are outside the data-plane teardown.

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-17 18:02:59 -04:00
parent b1850be5d1
commit 4e7df6206e
8 changed files with 361 additions and 418 deletions
+71 -91
View File
@@ -10,10 +10,8 @@ import base64
import binascii
import json
import os
import signal
import sys
import typing
from pathlib import Path
from mitmproxy import http # type: ignore[import-not-found] # pylint: disable=import-error
@@ -33,7 +31,6 @@ from egress_addon_core import ( # type: ignore[import-not-found] # pylint: dis
decide_git_fetch,
is_git_fetch_request,
is_git_push_request,
load_config,
match_route,
resolve_client_context,
outbound_scan_headers,
@@ -61,14 +58,12 @@ except ImportError: # pragma: no cover - host-side path
from bot_bottle.policy_resolver import PolicyResolver
DEFAULT_ROUTES_PATH = "/etc/egress/routes.yaml"
INTROSPECT_HOST = "_egress.local"
# Consolidated (multi-tenant) mode: when this points at the per-host
# orchestrator's control plane, the addon resolves each client's Config by
# source IP per request instead of using a single static routes file. Unset
# → legacy per-bottle single-tenant mode (unchanged).
# The per-host orchestrator control plane the addon resolves every request's
# Config against, by source IP (PRD 0070). Mandatory: the consolidated gateway
# is the only topology now — there is no static per-bottle routes file to fall
# back to — so an unset value is a fatal misconfiguration (see __init__).
ORCHESTRATOR_URL_ENV = "BOT_BOTTLE_ORCHESTRATOR_URL"
# App-layer identity token. Delivered as proxy credentials
@@ -80,12 +75,10 @@ IDENTITY_HEADER = "x-bot-bottle-identity"
# Per-flow key under which `request()` stashes the resolved (Config, supervise
# slug, env) so the later `response()` and `websocket_message()` hooks scan
# against the *calling bottle's* policy. In the consolidated (multi-tenant)
# gateway the static `self.config` is empty — every request's real policy comes
# from the per-request `/resolve` — so a hook that fell back to `self.config`
# would find no route and silently skip its DLP scan (fail-open). Resolving once
# at the request and reusing it also avoids a `/resolve` round-trip per response
# and per WebSocket frame.
# against the *calling bottle's* policy the same one the request was decided
# on — without a second `/resolve` per response or per WebSocket frame. A hook
# on a flow that never resolved (no stash) fails closed to deny-all, so it's a
# safe no-op rather than an unscanned pass.
_FLOW_CTX_KEY = "bot_bottle_egress_ctx"
@@ -119,21 +112,30 @@ _TOKEN_ALLOW_JUSTIFICATION = (
class EgressAddon:
# Class default so addons built via __new__ (e.g. in tests) default to
# single-tenant; __init__ sets the instance attribute for real runs.
_resolver: "PolicyResolver | None" = None
# Bare annotations (no class value): __init__ sets a live PolicyResolver for
# real runs, and every host-side test builds an addon via __new__ and sets a
# fake resolver. Egress is resolver-only now — the per-request policy always
# comes from the orchestrator's /resolve (PRD 0070); there is no static
# per-bottle routes file, SIGHUP reload, or single-tenant fallback.
_resolver: "PolicyResolver"
# Class default so __new__-built addons have it (real runs get a fresh
# per-instance dict in __init__; only http_connect mutates it, which the
# request-flow tests don't exercise).
_conn_tokens: "dict[str, str]" = {}
def __init__(self) -> None:
self.routes_path = os.environ.get("EGRESS_ROUTES", DEFAULT_ROUTES_PATH)
self.config: Config = Config(routes=())
# Consolidated mode: resolve per-client Config from the orchestrator.
# Absent → single-tenant (static routes file); behaviour unchanged.
# Resolver-only: the gateway is always multi-tenant, resolving each
# request's policy by source IP against the orchestrator control plane
# (PRD 0070). The URL is mandatory — without a policy source the gateway
# must not come up (fail-closed), rather than silently allowing nothing.
orch_url = os.environ.get(ORCHESTRATOR_URL_ENV, "").strip()
self._resolver = PolicyResolver(orch_url) if orch_url else None
if not orch_url:
raise RuntimeError(
f"{ORCHESTRATOR_URL_ENV} is required: the egress gateway "
"resolves every request's policy from the orchestrator and has "
"no static routes file to fall back to."
)
self._resolver = PolicyResolver(orch_url)
# Tokens the operator has approved this session (PRD 0062), keyed by
# bottle so the shared gateway keeps each bottle's safelist separate —
# a global set would let bottle A's approved secret pass bottle B's DLP
@@ -144,16 +146,13 @@ class EgressAddon:
# `Proxy-Authorization` (HTTPS tunnels don't repeat it on the bumped
# inner requests). Keyed by client_conn.id; cleared on disconnect.
self._conn_tokens: dict[str, str] = {}
self._supervise_slug = os.environ.get("SUPERVISE_BOTTLE_SLUG", "").strip()
self._token_allow_timeout = _token_allow_timeout_from_env(os.environ)
self._reload(initial=True)
self._install_sighup()
@staticmethod
def _supervise_available(slug: str) -> bool:
"""Supervise is reachable for this request iff we resolved a bottle to
attribute its proposals to (single-tenant env slug, or a source-IP
-attributed bottle id). Empty → fail closed (no queue to write to)."""
attribute its proposals to (the source-IP-attributed bottle id). Empty
→ fail closed (no queue to write to)."""
return bool(slug)
def _safe_tokens_for(self, slug: str) -> set[str]:
@@ -162,40 +161,15 @@ class EgressAddon:
bottle's approved token into another's scan."""
return self._safe_tokens.setdefault(slug, set())
def _reload(self, *, initial: bool = False) -> None:
try:
text = Path(self.routes_path).read_text(encoding="utf-8")
new_config = load_config(text)
except (OSError, ValueError) as e:
tag = "boot" if initial else "SIGHUP"
sys.stderr.write(
f"egress: {tag} load failed: {e}\n"
)
if initial:
self.config = Config(routes=())
return
self.config = new_config
log_label = ("off", "blocks", "full")[self.config.log]
sys.stderr.write(
f"egress: loaded {len(self.config.routes)} route(s): "
f"{', '.join(r.host for r in self.config.routes)}"
f" [log={log_label}]\n"
)
def _install_sighup(self) -> None:
if not hasattr(signal, "SIGHUP"):
return
def handler(signum: int, frame: object) -> None:
del signum, frame
self._reload()
signal.signal(signal.SIGHUP, handler)
def _serve_introspection(self, flow: http.HTTPFlow, path: str) -> None:
def _serve_introspection(
self, flow: http.HTTPFlow, path: str, config: Config,
) -> None:
"""Serve the calling bottle's own allowlist. `config` is this flow's
resolved policy (the same one every hook uses), so the agent sees the
routes that actually apply to it."""
if path == "/allowlist":
payload = json.dumps(
{"routes": [route_to_yaml_dict(r) for r in self.config.routes]},
{"routes": [route_to_yaml_dict(r) for r in config.routes]},
indent=2,
).encode("utf-8")
flow.response = http.Response.make(
@@ -209,11 +183,21 @@ class EgressAddon:
{"Content-Type": "text/plain; charset=utf-8"},
)
def _flow_log(self, flow: http.HTTPFlow) -> int:
"""This flow's log level, from the policy `request()` resolved and
stashed. The block/redact log gates were a single global in the static-
config world; they are per bottle now, so they read it from the flow."""
return self._flow_ctx(flow)[0].log
def _req_ctx(self, flow: http.HTTPFlow) -> dict[str, object]:
# Redact with this flow's resolved env overlay (process env + the
# bottle's /resolve tokens), so the ctx scrubs the calling bottle's
# provisioned secrets, not just os.environ's.
env = self._flow_ctx(flow)[2]
return {
"host": redact_tokens(flow.request.pretty_host, env=os.environ),
"host": redact_tokens(flow.request.pretty_host, env=env),
"method": flow.request.method,
"path": redact_tokens(flow.request.path, env=os.environ),
"path": redact_tokens(flow.request.path, env=env),
}
def _block(
@@ -222,7 +206,7 @@ class EgressAddon:
reason: str,
ctx: dict[str, object] | None = None,
) -> None:
if self.config.log >= LOG_BLOCKS:
if self._flow_log(flow) >= LOG_BLOCKS:
entry: dict[str, object] = {"event": "egress_block", "reason": reason}
if ctx:
entry.update(ctx)
@@ -280,17 +264,12 @@ class EgressAddon:
def _resolve_flow(
self, flow: http.HTTPFlow,
) -> "tuple[Config, str, typing.Mapping[str, str]]":
"""The `(Config, supervise slug, env)` to apply to this request.
Single-tenant → the static `self.config`, the env slug, and the process
env. Consolidated → the calling bottle's Config + bottle id + auth
tokens, resolved by source IP in one round-trip (fail-closed to deny-all
+ empty slug if unattributed); `env` is the process env overlaid with
the bottle's tokens, so upstream-auth injection (and DLP) use *this*
bottle's credentials — exactly what the per-bottle gateway daemon's env did.
The identity token, if the agent injected one, is read then stripped so
it never leaks upstream."""
if self._resolver is None:
return self.config, self._supervise_slug, os.environ
"""The calling bottle's `(Config, supervise slug, env)`, resolved by
source IP in one round-trip against the orchestrator — fail-closed to
deny-all + empty slug if unattributed. `env` is the process env overlaid
with the bottle's `/resolve` tokens, so upstream-auth injection (and DLP)
use *this* bottle's credentials. The identity token, if the agent
injected one, is read then stripped so it never leaks upstream."""
conn = flow.client_conn
client_ip = conn.peername[0] if conn and conn.peername else ""
token = self._request_token(flow)
@@ -317,16 +296,16 @@ class EgressAddon:
self, flow: http.HTTPFlow,
) -> "tuple[Config, str, typing.Mapping[str, str]]":
"""The `(Config, supervise slug, env)` `request()` resolved for this
flow, so a later hook scans against the calling bottle's policy — not the
empty static config the consolidated gateway carries. Falls back to the
single-tenant static values for a flow that never passed through
`request()` (or a flow object without metadata)."""
flow, so a later hook scans against the calling bottle's policy. Falls
back to deny-all (empty routes, empty slug) for a flow that never passed
through `request()` (or a flow object without metadata) — fail-closed, so
a DLP hook on such a flow is a safe no-op rather than an unscanned pass."""
meta = getattr(flow, "metadata", None)
if isinstance(meta, dict):
ctx = meta.get(_FLOW_CTX_KEY)
if ctx is not None:
return ctx
return self.config, self._supervise_slug, os.environ
return Config(routes=()), "", os.environ
def _request_token(self, flow: http.HTTPFlow) -> str:
"""The per-bottle identity token for this request, from the proxy
@@ -362,15 +341,18 @@ class EgressAddon:
async def request(self, flow: http.HTTPFlow) -> None:
request_path, _, query = flow.request.path.partition("?")
if flow.request.pretty_host == INTROSPECT_HOST:
self._serve_introspection(flow, request_path)
return
config, slug, env = self._resolve_flow(flow)
# Stash for the response / websocket hooks so their DLP scans use this
# bottle's resolved policy, not the empty static config (see _flow_ctx).
# Stash for the response / websocket hooks so their DLP scans reuse this
# bottle's resolved policy (one /resolve per flow — see _flow_ctx).
self._stash_flow_ctx(flow, config, slug, env)
# Introspection ("_egress.local/allowlist") reports the calling bottle's
# own resolved routes — served after resolution so it reflects this
# bottle's policy, not a stale global.
if flow.request.pretty_host == INTROSPECT_HOST:
self._serve_introspection(flow, request_path, config)
return
# DLP outbound scan BEFORE stripping auth — catches tokens the
# agent tried to smuggle in any header, path, query param, or body.
# Hostname is included to catch DNS-tunnelling exfiltration attempts.
@@ -475,7 +457,7 @@ class EgressAddon:
# forwards; it fails closed only if a match survives the scrub.
if policy == ON_MATCH_REDACT:
if self._redact_outbound(flow, route, env):
if self.config.log >= LOG_BLOCKS:
if self._flow_log(flow) >= LOG_BLOCKS:
sys.stderr.write(json.dumps({
"event": "egress_redacted",
"reason": f"egress DLP: {result.reason}",
@@ -597,7 +579,7 @@ class EgressAddon:
_sv.STATUS_APPROVED, _sv.STATUS_MODIFIED,
):
self._safe_tokens_for(slug).add(result.matched)
if self.config.log >= LOG_BLOCKS:
if self._flow_log(flow) >= LOG_BLOCKS:
sys.stderr.write(json.dumps({
"event": "egress_token_allowed",
"reason": f"egress DLP: {result.reason}",
@@ -638,8 +620,7 @@ class EgressAddon:
def response(self, flow: http.HTTPFlow) -> None:
"""DLP inbound scan on response headers and body, against the calling
bottle's resolved config (multi-tenant) or the static config
(single-tenant) — see `_flow_ctx`."""
bottle's resolved config (`request()` stashed it — see `_flow_ctx`)."""
config, _slug, env = self._flow_ctx(flow)
route = match_route(config.routes, flow.request.pretty_host)
if route is None:
@@ -677,8 +658,7 @@ class EgressAddon:
def websocket_message(self, flow: http.HTTPFlow) -> None:
"""DLP scan on WebSocket frames, against the calling bottle's resolved
config (see `_flow_ctx`). `request()` resolves and stashes the per-flow
(config, slug, env) at the upgrade, so both the multi-tenant and
single-tenant gateways scan here.
(config, slug, env) at the upgrade, and every frame reuses it.
Outbound frames (from_client) are scanned for credential leakage;
inbound frames are scanned for prompt injection. On a block the
+40 -49
View File
@@ -7,14 +7,13 @@ wrapper serves the same `/git/*.git` bare repos through
`git http-backend`, so pre-receive and upstream forwarding remain the
git-gate enforcement point.
Consolidated (PRD 0070): when `BOT_BOTTLE_ORCHESTRATOR_URL` is set, one
shared gateway serves every bottle, and each request is served from the
calling bottle's repo namespace (`<root>/<bottle_id>`), attributed from
the unspoofable source IP via the orchestrator. Per-repo credentials +
One shared gateway serves every bottle (PRD 0070): each request is served
from the calling bottle's repo namespace (`<root>/<bottle_id>`), attributed
from the unspoofable source IP via the orchestrator. Per-repo credentials +
hooks scope by repo directory, so isolating the *root* per bottle isolates
its creds too. Unattributed clients fail closed (404). Unset → the legacy
per-bottle single-tenant flat root, unchanged — a transitional path that
gets stripped out once every backend runs the consolidated gateway.
its creds too. Unattributed clients — and a missing/unreachable orchestrator
— fail closed (404). `BOT_BOTTLE_ORCHESTRATOR_URL` is mandatory: there is no
single-tenant flat-root fallback.
"""
from __future__ import annotations
@@ -41,12 +40,10 @@ except ImportError: # pragma: no cover - host-side path
DEFAULT_PORT = 9420
# Consolidated (multi-tenant) mode: when this points at the per-host
# orchestrator's control plane, the backend serves each request from the
# *calling* bottle's repo namespace, selected by source IP, instead of a
# single flat repo root. Unset → legacy per-bottle single-tenant mode
# (unchanged). Same env the egress addon reads, so one orchestrator setting
# flips the whole shared gateway multi-tenant.
# The per-host orchestrator control plane the backend attributes each request
# to, serving from the *calling* bottle's repo namespace selected by source IP.
# Mandatory — the same env the egress addon requires; there is no single flat
# repo-root fallback.
ORCHESTRATOR_URL_ENV = "BOT_BOTTLE_ORCHESTRATOR_URL"
# App-layer identity token (defense-in-depth over the source-IP invariant);
@@ -55,8 +52,7 @@ ORCHESTRATOR_URL_ENV = "BOT_BOTTLE_ORCHESTRATOR_URL"
# (duplicated, not imported: egress_addon pulls in mitmproxy).
IDENTITY_HEADER = "x-bot-bottle-identity"
# Default flat repo root (single-tenant, and the base under which
# consolidated mode nests each sandbox's namespace).
# The base under which each bottle's `<bottle_id>` repo namespace is nested.
DEFAULT_REPO_ROOT = "/git"
@@ -71,25 +67,16 @@ class ResolverLike(typing.Protocol):
def resolve_sandbox_root(
resolver: "ResolverLike | None",
resolver: "ResolverLike",
base_root: Path,
source_ip: str,
identity_token: str = "",
) -> Path | None:
"""The per-sandbox repo root to serve this request from, or None to
deny (404).
Single-tenant (`resolver is None`): the flat `base_root`, unchanged.
NOTE: this legacy per-bottle single-tenant path is transitional — it
will be stripped out once every backend runs the consolidated gateway
(PRD 0070), leaving only the source-IP-attributed path below.
Consolidated: `base_root/<bottle_id>`, where the sandbox is attributed
from the source IP via the orchestrator. Fail-closed — an unattributed
client, a resolver error, or a namespace that would escape `base_root`
all deny, so one sandbox can never reach another's repos."""
if resolver is None:
return base_root
"""The per-sandbox repo root to serve this request from — `base_root/
<bottle_id>`, where the sandbox is attributed from the source IP via the
orchestrator — or None to deny (404). Fail-closed: an unattributed client, a
resolver error, or a namespace that would escape `base_root` all deny, so one
sandbox can never reach another's repos."""
try:
bottle_id = resolver.resolve_bottle_id(source_ip, identity_token)
except PolicyResolveError:
@@ -123,12 +110,13 @@ class GitHttpHandler(BaseHTTPRequestHandler):
self._run_backend()
def _sandbox_root(self) -> Path | None:
"""This request's per-sandbox repo root, or None to deny. Single-tenant
unless the server was started with a resolver (consolidated mode), in
which case the root is the calling sandbox's source-IP-selected
namespace. `GIT_PROJECT_ROOT` keeps git's own env-var name."""
"""This request's per-sandbox repo root (the calling bottle's source-IP-
selected `<base>/<bottle_id>` namespace), or None to deny. `GIT_PROJECT_
ROOT` keeps git's own env-var name."""
base = Path(os.environ.get("GIT_PROJECT_ROOT", DEFAULT_REPO_ROOT))
resolver = getattr(self.server, "policy_resolver", None)
if resolver is None:
return None # server started without a resolver (misconfig) → deny
token = self.headers.get(IDENTITY_HEADER, "")
return resolve_sandbox_root(resolver, base, self.client_address[0], token)
@@ -200,14 +188,11 @@ class GitHttpHandler(BaseHTTPRequestHandler):
"SERVER_PORT": str(self.server.server_port), # type: ignore
"SERVER_PROTOCOL": self.request_version,
})
# Consolidated mode: attribute the gitleaks-allow supervise proposal
# (written by receive-pack's pre-receive hook, a child of the CGI we
# spawn below) to the calling bottle. The namespaced root is
# `<base>/<bottle_id>`, so its final component is the bottle id — the
# same per-bottle key egress uses. Single-tenant leaves the hook's
# container-stamped SUPERVISE_BOTTLE_SLUG untouched.
if getattr(self.server, "policy_resolver", None) is not None:
env["SUPERVISE_BOTTLE_SLUG"] = sandbox_root.name
# Attribute the gitleaks-allow supervise proposal (written by
# receive-pack's pre-receive hook, a child of the CGI we spawn below) to
# the calling bottle. The namespaced root is `<base>/<bottle_id>`, so its
# final component is the bottle id — the same per-bottle key egress uses.
env["SUPERVISE_BOTTLE_SLUG"] = sandbox_root.name
for header, variable in (
("accept", "HTTP_ACCEPT"),
("content-encoding", "HTTP_CONTENT_ENCODING"),
@@ -297,14 +282,20 @@ class GitHttpHandler(BaseHTTPRequestHandler):
def main() -> int:
port = int(os.environ.get("GIT_HTTP_PORT", str(DEFAULT_PORT)))
server = ThreadingHTTPServer(("0.0.0.0", port), GitHttpHandler)
orch_url = os.environ.get(ORCHESTRATOR_URL_ENV, "").strip()
# Consolidated mode: resolve each request's sandbox namespace by source
# IP. Absent → single-tenant (flat repo root); behaviour unchanged.
resolver = PolicyResolver(orch_url) if orch_url else None
server.policy_resolver = resolver # type: ignore[attr-defined]
mode = "multi-tenant" if orch_url else "single-tenant"
sys.stdout.write(f"git-http listening on 0.0.0.0:{port} ({mode})\n")
if not orch_url:
# Resolver-only: without an orchestrator the backend can't attribute a
# request to a bottle namespace, so it must not serve (fail-closed).
sys.stderr.write(
f"git-http: {ORCHESTRATOR_URL_ENV} is required "
"(no single-tenant flat-root fallback)\n"
)
return 1
server = ThreadingHTTPServer(("0.0.0.0", port), GitHttpHandler)
# Resolve each request's sandbox namespace by source IP against the
# orchestrator control plane.
server.policy_resolver = PolicyResolver(orch_url) # type: ignore[attr-defined]
sys.stdout.write(f"git-http listening on 0.0.0.0:{port} (multi-tenant)\n")
sys.stdout.flush()
server.serve_forever()
return 0
+55 -103
View File
@@ -11,16 +11,12 @@ Each queued tool call:
3. Blocks polling for a matching Response row.
4. Returns the operator's `{status, notes}` to the agent.
The bottle slug arrives via SUPERVISE_BOTTLE_SLUG env (stamped at
container creation by the backend's start step). SUPERVISE_DB_PATH
One shared server fronts every bottle (PRD 0070) and attributes each
proposal to the calling bottle by source IP, resolved from the orchestrator
— an unattributed or unreachable source fails closed. BOT_BOTTLE_ORCHESTRATOR_URL
is mandatory: there is no fixed-slug single-tenant fallback. SUPERVISE_DB_PATH
points at the bind-mounted host database.
Consolidated (PRD 0070): when BOT_BOTTLE_ORCHESTRATOR_URL is set, one
shared server fronts every bottle and attributes each proposal to the
calling bottle by source IP (resolved from the orchestrator) instead of a
fixed slug — an unattributed source fails closed. Unset → the legacy
per-bottle single-tenant server, unchanged.
Speaks MCP over HTTP+JSON-RPC. Methods handled:
* `initialize` — handshake; returns server info + caps.
@@ -44,8 +40,6 @@ import socketserver
import sys
import time
import typing
import urllib.error
import urllib.request
from dataclasses import dataclass, replace
try:
@@ -85,12 +79,10 @@ ERR_INTERNAL = -32603
DEFAULT_RESPONSE_TIMEOUT_SECONDS = 30.0
MIN_RESPONSE_POLL_INTERVAL_SECONDS = 0.05
EGRESS_LIST_TIMEOUT_SECONDS = 5.0
# Consolidated (multi-tenant) mode: when set, one shared supervise server
# fronts every bottle and attributes each proposal to the calling bottle by
# source IP (resolved from the orchestrator), instead of a single
# SUPERVISE_BOTTLE_SLUG env. Unset → legacy per-bottle single-tenant.
# The per-host orchestrator control plane the shared supervise server attributes
# each proposal to, by source IP. Mandatory — there is no single-tenant
# SUPERVISE_BOTTLE_SLUG fallback.
ORCHESTRATOR_URL_ENV = "BOT_BOTTLE_ORCHESTRATOR_URL"
@@ -310,42 +302,6 @@ def handle_tools_list(_params: dict[str, object]) -> dict[str, object]:
return {"tools": TOOL_DEFINITIONS}
def handle_list_egress_routes(
_params: dict[str, object],
_config: ServerConfig,
) -> dict[str, object]:
"""Fetch the live egress route table via its
`_egress.local/allowlist` introspection endpoint. The
request goes through egress as a forward proxy; the
addon recognises the magic host and synthesizes a response —
no real upstream connection, no allowlist enforcement
against the magic host. Returns the JSON payload as the
tool's text content."""
proxy_handler = urllib.request.ProxyHandler({
"http": _sv.EGRESS_FORWARD_PROXY,
})
opener = urllib.request.build_opener(proxy_handler)
try:
with opener.open(_sv.EGRESS_INTROSPECT_URL, timeout=EGRESS_LIST_TIMEOUT_SECONDS) as resp:
body = resp.read().decode("utf-8")
except (urllib.error.URLError, OSError) as e:
return {
"content": [{
"type": "text",
"text": (
f"list-egress-routes: could not reach "
f"{_sv.EGRESS_INTROSPECT_URL!r} via "
f"{_sv.EGRESS_FORWARD_PROXY!r}: {e}"
),
}],
"isError": True,
}
return {
"content": [{"type": "text", "text": body}],
"isError": False,
}
def handle_tools_call(
params: dict[str, object],
config: ServerConfig,
@@ -353,14 +309,13 @@ def handle_tools_call(
"""Validates the proposal, writes it to the queue, blocks waiting
for a Response, returns the result wrapped in MCP `content`.
Side-effect-free `list-*` tools short-circuit before the queue/
blocking machinery — they're read-only introspection that
doesn't need operator approval."""
`list-egress-routes` never reaches here the handler answers it from
the calling bottle's resolved policy before dispatching (see
`MCPHandler._dispatch`); this path is the queued, operator-approved
`egress-allow` / `egress-block` tools."""
name = params.get("name")
if not isinstance(name, str):
raise _RpcClientError(ERR_INVALID_PARAMS, "tools/call missing 'name'")
if name == _sv.TOOL_LIST_EGRESS_ROUTES:
return handle_list_egress_routes(typing.cast(dict[str, object], params.get("arguments", {})), config)
args_raw = params.get("arguments", {})
if not isinstance(args_raw, dict):
@@ -531,36 +486,35 @@ 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.
# `list-egress-routes` is read-only introspection. The shared gateway
# has no static route table (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 on approval.
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
# under its own slug.
return self._resolved_routes_payload()
# Attribute the proposal to the source-IP-resolved bottle, so the one
# shared server queues each bottle's proposal under its own slug.
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`."""
def _resolver_or_fail(self) -> "PolicyResolver":
"""This server's policy resolver. A server started without one is a
misconfiguration, not a tenancy mode — fail closed rather than
attribute (or list) anything."""
resolver = getattr(self.server, "policy_resolver", None)
if resolver is None:
return None
raise _RpcInternalError("supervise server has no policy resolver")
return resolver
def _resolved_routes_payload(self) -> dict[str, object]:
"""The calling bottle's live egress routes as the `list-egress-routes`
JSON payload, resolved by (source_ip, identity token). 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 = self._resolver_or_fail()
headers = getattr(self, "headers", None)
token = headers.get(IDENTITY_HEADER, "") if headers is not None else ""
conf, _slug, _tokens = resolve_client_context(
@@ -572,14 +526,11 @@ class MCPHandler(http.server.BaseHTTPRequestHandler):
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
attributed from the source IP — **fail-closed**, an unattributed or
unreachable source raises so no proposal is queued under the wrong (or
empty) slug."""
resolver = getattr(self.server, "policy_resolver", None)
if resolver is None:
return config
"""The ServerConfig with `bottle_slug` bound to *this request's* bottle:
the bottle id attributed from the source IP — **fail-closed**, an
unattributed or unreachable source raises so no proposal is queued under
the wrong (or empty) slug."""
resolver = self._resolver_or_fail()
# The agent's MCP client sends the identity token as a request header
# (provisioned via `mcp add --header`); the orchestrator requires the
# (source_ip, token) pair, so a missing/wrong token fail-closes below.
@@ -616,8 +567,9 @@ class MCPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
allow_reuse_address = True
daemon_threads = True
config: ServerConfig = ServerConfig(bottle_slug="")
# None → single-tenant (proposals use config.bottle_slug); set → consolidated
# (each proposal attributed to the source-IP-resolved bottle).
# Set by `serve`; every proposal is attributed to the source-IP-resolved
# bottle. The class default is a placeholder — a server without a resolver
# fails closed per request (see `_resolver_or_fail`).
policy_resolver: "PolicyResolver | None" = None
@@ -626,21 +578,21 @@ class MCPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
def serve(
*,
bottle_slug: str,
resolver: "PolicyResolver",
port: int = _sv.SUPERVISE_PORT,
bind: str = "0.0.0.0",
response_timeout_seconds: float = DEFAULT_RESPONSE_TIMEOUT_SECONDS,
resolver: "PolicyResolver | None" = None,
) -> typing.NoReturn:
server = MCPServer((bind, port), MCPHandler)
# bottle_slug is a placeholder: every request's proposal is attributed to
# the source-IP-resolved bottle (see MCPHandler._attributed_config).
server.config = ServerConfig(
bottle_slug=bottle_slug,
bottle_slug="",
response_timeout_seconds=response_timeout_seconds,
)
server.policy_resolver = resolver
mode = "multi-tenant" if resolver else f"slug={bottle_slug!r}"
sys.stderr.write(
f"supervise listening on {bind}:{port}; {mode}; "
f"supervise listening on {bind}:{port}; multi-tenant; "
f"tools: {', '.join(t['name'] for t in TOOL_DEFINITIONS)}\n" # type: ignore[arg-type]
)
sys.stderr.flush()
@@ -656,12 +608,13 @@ def serve(
def main(argv: list[str]) -> int:
del argv # config is env-only, no CLI flags
orch_url = os.environ.get(ORCHESTRATOR_URL_ENV, "").strip()
resolver = PolicyResolver(orch_url) if orch_url else None
bottle_slug = os.environ.get("SUPERVISE_BOTTLE_SLUG", "")
# Consolidated mode resolves the slug per request, so the env slug is
# optional there; single-tenant still requires it.
if not bottle_slug and resolver is None:
sys.stderr.write("supervise: SUPERVISE_BOTTLE_SLUG env is unset\n")
if not orch_url:
# Resolver-only: without an orchestrator the server can't attribute a
# proposal to a bottle, so it must not serve (fail-closed).
sys.stderr.write(
f"supervise: {ORCHESTRATOR_URL_ENV} is required "
"(no single-tenant SUPERVISE_BOTTLE_SLUG fallback)\n"
)
return 2
port = int(os.environ.get("SUPERVISE_PORT", str(_sv.SUPERVISE_PORT)))
bind = os.environ.get("SUPERVISE_BIND", "0.0.0.0")
@@ -671,11 +624,10 @@ def main(argv: list[str]) -> int:
sys.stderr.write(f"supervise: {e}\n")
return 2
serve(
bottle_slug=bottle_slug,
resolver=PolicyResolver(orch_url),
port=port,
bind=bind,
response_timeout_seconds=response_timeout_seconds,
resolver=resolver,
)
return 0 # serve() does not return