fix(supervise): reach the queue over RPC, get bot-bottle.db off the data plane

PRD 0070's rule — only the orchestrator opens bot-bottle.db; the data
plane reaches state through the control-plane RPC — was not in force.
Three data-plane daemons held a direct read-write handle on the shared
SQLite file: the supervise MCP server, the egress DLP addon (the most
attack-exposed process, TLS-bumping hostile traffic), and the git-gate
pre-receive hook. An RCE in any of them could read every bottle's
plaintext identity_token and forge attribution fleet-wide (issue #469).

Add the agent half of the supervise flow to the control plane:

  POST /supervise/propose  -> queue a proposal, 201 {proposal_id}
  POST /supervise/poll     -> non-blocking decision poll, 200 {status,...}

Both attribute the caller by (source_ip, identity_token) exactly like
/resolve — never a caller-supplied slug — so a bottle can only ever queue
or read its own proposals even if the data plane is compromised. A decided
poll archives server-side, preserving the archive-after-read contract.

Data plane: the supervise server, egress addon, and git-gate hook now
queue/poll through PolicyResolver.propose_supervise / poll_supervise
instead of opening the DB. supervise_server keeps its ~30s grace window
by polling the RPC; egress keeps its safelist keyed by resolved bottle;
the git-gate hook gets (source_ip, identity_token) from the CGI env.

Packaging: drop the DB bind-mount and SUPERVISE_DB_PATH from the
data-plane containers/VMs (docker gateway + infra, macOS infra,
firecracker infra). The orchestrator remains the sole opener of the one
file via BOT_BOTTLE_ROOT / host_db_path().

Update PRD 0070: the rule is now in force; remove the transitional caveat.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 02:07:21 +00:00
parent f24ae45d13
commit 2c496dc3d0
23 changed files with 968 additions and 518 deletions
+154 -107
View File
@@ -7,9 +7,13 @@ config changes when stuck. The tools are `egress-allow`,
Each queued proposal tool call:
1. Validates the proposed file syntactically.
2. Writes a Proposal to the host SQLite database.
3. Blocks polling for a matching Response row, up to a short grace
window (`SUPERVISE_RESPONSE_TIMEOUT_SECONDS`, default 30s).
2. Queues a Proposal over the control-plane RPC (`POST /supervise/propose`),
which attributes it to the calling bottle by (source_ip, identity_token)
and writes it to the one orchestrator-owned SQLite database. This daemon
never opens `bot-bottle.db` itself (PRD 0070 / issue #469).
3. Polls the control plane (`POST /supervise/poll`) for a matching Response,
up to a short grace window (`SUPERVISE_RESPONSE_TIMEOUT_SECONDS`,
default 30s).
4. On a decision within the window, returns the operator's
`{status, notes}`. On timeout, returns `status: pending` **with the
proposal id** and leaves the proposal queued — the flow is
@@ -24,8 +28,8 @@ without holding an HTTP request open.
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.
is mandatory: there is no fixed-slug single-tenant fallback, and the queue is
reached only over that control-plane RPC (no direct database handle).
Speaks MCP over HTTP+JSON-RPC. Methods handled:
@@ -51,7 +55,7 @@ import socketserver
import sys
import time
import typing
from dataclasses import dataclass, replace
from dataclasses import dataclass
from bot_bottle.constants import IDENTITY_HEADER
from bot_bottle.egress_addon_core import (
@@ -312,7 +316,9 @@ def validate_proposed_file(tool: str, content: str) -> None:
@dataclass(frozen=True)
class ServerConfig:
bottle_slug: str
# No bottle_slug: the calling bottle is attributed per request by the
# control plane from (source_ip, identity_token), so nothing on this daemon
# is keyed by a single slug (PRD 0070 / issue #469).
response_timeout_seconds: float = DEFAULT_RESPONSE_TIMEOUT_SECONDS
@@ -331,14 +337,22 @@ def handle_tools_list(_params: dict[str, object]) -> dict[str, object]:
def handle_tools_call(
params: dict[str, object],
config: ServerConfig,
*,
resolver: "PolicyResolver",
source_ip: str,
identity_token: str,
) -> dict[str, object]:
"""Validates the proposal, writes it to the queue, blocks waiting
for a Response, returns the result wrapped in MCP `content`.
"""Validates the proposal, queues it over the control-plane RPC, polls for
a Response through the grace window, and returns the result wrapped in MCP
`content`.
`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."""
The queue lives behind the orchestrator now — `propose_supervise` attributes
the proposal to the calling bottle by `(source_ip, identity_token)` and
`poll_supervise` reads only that bottle's own responses, so this daemon
never opens `bot-bottle.db`. `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'")
@@ -366,59 +380,100 @@ def handle_tools_call(
else:
raise _RpcClientError(ERR_INVALID_PARAMS, f"unknown tool {name!r}")
proposal = _sv.Proposal.new(
bottle_slug=config.bottle_slug,
tool=name,
proposed_file=proposed_file,
justification=justification,
current_file_hash=_sv.sha256_hex(proposed_file),
proposal_id = _queue_proposal(
resolver, source_ip, identity_token,
tool=name, proposed_file=proposed_file, justification=justification,
)
try:
_sv.write_proposal(proposal)
except OSError as e:
raise _RpcInternalError(f"failed to write proposal to queue: {e}") from e
sys.stderr.write(
f"supervise: queued proposal {proposal.id} ({name}) "
f"for bottle {config.bottle_slug}; waiting for operator...\n"
f"supervise: queued proposal {proposal_id} ({name}); waiting for operator...\n"
)
sys.stderr.flush()
deadline = time.monotonic() + config.response_timeout_seconds
try:
response = _sv.wait_for_response(
config.bottle_slug,
proposal.id,
poll_interval=MIN_RESPONSE_POLL_INTERVAL_SECONDS,
deadline=deadline,
)
except TimeoutError:
text = format_pending_response_text(proposal.id, config.response_timeout_seconds)
return {
"content": [{"type": "text", "text": text}],
"isError": False,
}
try:
_sv.archive_proposal(config.bottle_slug, proposal.id)
except OSError as e:
raise _RpcInternalError(f"failed to archive proposal: {e}") from e
text = format_response_text(response)
deadline = time.monotonic() + config.response_timeout_seconds
while time.monotonic() < deadline:
response = _poll_response(resolver, source_ip, identity_token, proposal_id)
if response is not None:
return {
"content": [{"type": "text", "text": format_response_text(response)}],
"isError": response.status == _sv.STATUS_REJECTED,
}
time.sleep(MIN_RESPONSE_POLL_INTERVAL_SECONDS)
text = format_pending_response_text(proposal_id, config.response_timeout_seconds)
return {
"content": [{"type": "text", "text": text}],
"isError": response.status == _sv.STATUS_REJECTED,
"isError": False,
}
def _queue_proposal(
resolver: "PolicyResolver",
source_ip: str,
identity_token: str,
*,
tool: str,
proposed_file: str,
justification: str,
) -> str:
"""Queue a proposal over the control plane and return its id, mapping the
resolver's fail-closed outcomes to internal errors so `do_POST` returns a
clean -32603 rather than leaking the cause to the agent."""
try:
proposal_id = resolver.propose_supervise(
source_ip, identity_token,
tool=tool, proposed_file=proposed_file, justification=justification,
)
except PolicyResolveError as e:
raise _RpcInternalError(f"orchestrator unreachable, cannot queue proposal: {e}") from e
if proposal_id is None:
raise _RpcInternalError("request source is not attributed to a bottle")
return proposal_id
def _poll_response(
resolver: "PolicyResolver", source_ip: str, identity_token: str, proposal_id: str,
) -> "_sv.Response | None":
"""One non-blocking control-plane poll for `proposal_id`'s decision. Returns
the operator's Response once decided, or None while it is still pending
(or `unknown` — treated as still-waiting so a transient blip degrades to the
pending-timeout path rather than a spurious error mid grace window)."""
try:
result = resolver.poll_supervise(source_ip, identity_token, proposal_id)
except PolicyResolveError as e:
raise _RpcInternalError(f"orchestrator unreachable, cannot poll proposal: {e}") from e
if result is None:
raise _RpcInternalError("request source is not attributed to a bottle")
if result.get("status") in _sv.STATUSES:
return _response_from_poll(proposal_id, result)
return None
def _response_from_poll(proposal_id: str, result: "dict[str, object]") -> "_sv.Response":
"""Build a Response from a decided `/supervise/poll` payload."""
final_file = result.get("final_file")
return _sv.Response(
proposal_id=proposal_id,
status=str(result.get("status")),
notes=str(result.get("notes") or ""),
final_file=final_file if isinstance(final_file, str) else None,
)
def handle_check_proposal(
params: dict[str, object],
config: ServerConfig,
*,
resolver: "PolicyResolver",
source_ip: str,
identity_token: str,
) -> dict[str, object]:
"""Non-blocking poll of a queued proposal's decision, by id.
Never creates a Proposal (so `check-proposal` isn't in `TOOLS`); it only
reads the queue. Resolution order mirrors the synchronous path's terminal
step — a decided proposal is archived here exactly as `handle_tools_call`
archives it after `wait_for_response`, so `pending` proposals stay visible
to the operator until they're both decided *and* polled."""
reads the queue over the control-plane RPC, attributed by
`(source_ip, identity_token)` so it can only read *this* bottle's own
proposals. The orchestrator archives a decided proposal on read — the same
terminal step the synchronous path triggers — so `pending` proposals stay
visible to the operator until they're both decided *and* polled."""
args_raw = params.get("arguments", {})
if not isinstance(args_raw, dict):
raise _RpcClientError(ERR_INVALID_PARAMS, "tools/call 'arguments' must be an object")
@@ -431,25 +486,24 @@ def handle_check_proposal(
proposal_id = proposal_id.strip()
try:
response = _sv.read_response(config.bottle_slug, proposal_id)
except FileNotFoundError:
# No decision yet — distinguish "still queued" from "unknown id".
try:
_sv.read_proposal(config.bottle_slug, proposal_id)
except FileNotFoundError:
return {
"content": [{"type": "text", "text": format_unknown_proposal_text(proposal_id)}],
"isError": True,
}
result = resolver.poll_supervise(source_ip, identity_token, proposal_id)
except PolicyResolveError as e:
raise _RpcInternalError(f"orchestrator unreachable, cannot poll proposal: {e}") from e
if result is None:
raise _RpcInternalError("request source is not attributed to a bottle")
status = result.get("status")
if status == _sv.POLL_STATUS_UNKNOWN:
return {
"content": [{"type": "text", "text": format_unknown_proposal_text(proposal_id)}],
"isError": True,
}
if status == _sv.POLL_STATUS_PENDING:
return {
"content": [{"type": "text", "text": format_still_pending_text(proposal_id)}],
"isError": False,
}
try:
_sv.archive_proposal(config.bottle_slug, proposal_id)
except OSError as e:
raise _RpcInternalError(f"failed to archive proposal: {e}") from e
response = _response_from_poll(proposal_id, result)
return {
"content": [{"type": "text", "text": format_response_text(response)}],
"isError": response.status == _sv.STATUS_REJECTED,
@@ -591,16 +645,34 @@ class MCPHandler(http.server.BaseHTTPRequestHandler):
# — silently dropping base routes like api.anthropic.com on approval.
if req.params.get("name") == _sv.TOOL_LIST_EGRESS_ROUTES:
return self._resolved_routes_payload()
resolver = self._resolver_or_fail()
source_ip = self.client_address[0]
token = self._identity_token()
# `check-proposal` is a non-blocking read of the calling bottle's
# own queue — attributed by source IP like a proposal, but it
# never queues or blocks.
# own queue — attributed by (source_ip, identity_token) like a
# proposal, but it never queues or blocks.
if req.params.get("name") == _sv.TOOL_CHECK_PROPOSAL:
return handle_check_proposal(req.params, self._attributed_config(config))
# 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))
return handle_check_proposal(
req.params, resolver=resolver,
source_ip=source_ip, identity_token=token,
)
# The control plane attributes the proposal to the source-IP + token
# resolved bottle, so the one shared queue holds each bottle's
# proposal under its own id — no slug is asserted by this daemon.
return handle_tools_call(
req.params, config, resolver=resolver,
source_ip=source_ip, identity_token=token,
)
raise _RpcClientError(ERR_METHOD_NOT_FOUND, f"method not found: {method}")
def _identity_token(self) -> str:
"""The agent's per-bottle identity token from the request header (the
MCP client sends it via `mcp add --header`), or empty. The control plane
requires the (source_ip, token) pair, so a missing/wrong token
fail-closes there."""
headers = getattr(self, "headers", None)
return headers.get(IDENTITY_HEADER, "") if headers is not None else ""
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
@@ -612,40 +684,18 @@ class MCPHandler(http.server.BaseHTTPRequestHandler):
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`."""
JSON payload, resolved by (source_ip, identity token). Fail-closed: 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(
resolver, self.client_address[0], token,
resolver, self.client_address[0], self._identity_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:
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.
headers = getattr(self, "headers", None)
token = headers.get(IDENTITY_HEADER, "") if headers is not None else ""
try:
bottle_id = resolver.resolve_bottle_id(self.client_address[0], token)
except PolicyResolveError as e:
raise _RpcInternalError(f"orchestrator unreachable, cannot attribute: {e}") from e
if not bottle_id:
raise _RpcInternalError("request source is not attributed to a bottle")
return replace(config, bottle_slug=bottle_id)
def _write_jsonrpc(self, body: bytes) -> None:
self.send_response(200)
self.send_header("Content-Type", "application/json")
@@ -668,10 +718,10 @@ class MCPHandler(http.server.BaseHTTPRequestHandler):
class MCPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
allow_reuse_address = True
daemon_threads = True
config: ServerConfig = ServerConfig(bottle_slug="")
# 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`).
config: ServerConfig = ServerConfig()
# Set by `serve`; every proposal is attributed to the source-IP + token
# resolved bottle by the control plane. A server without a resolver fails
# closed per request (see `_resolver_or_fail`).
policy_resolver: "PolicyResolver | None" = None
@@ -686,12 +736,9 @@ def serve(
response_timeout_seconds: float = DEFAULT_RESPONSE_TIMEOUT_SECONDS,
) -> 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="",
response_timeout_seconds=response_timeout_seconds,
)
# Every request's proposal is attributed to the source-IP + token resolved
# bottle by the control plane (see MCPHandler._dispatch).
server.config = ServerConfig(response_timeout_seconds=response_timeout_seconds)
server.policy_resolver = resolver
sys.stderr.write(
f"supervise listening on {bind}:{port}; multi-tenant; "