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
+2 -1
View File
@@ -26,8 +26,9 @@
# /git-gate-entrypoint.sh docker-cp'd at start time
# /git-gate/creds/* docker-cp'd at start time
# /git/* bare repos, populated at runtime
# /run/supervise/bot-bottle.db bind-mounted at run time
# /home/mitmproxy/.mitmproxy/ mitmproxy CA dir
# (No bot-bottle.db mount: the data plane reaches the supervise queue over the
# control-plane RPC and never opens the DB — PRD 0070 / issue #469.)
#
# Exposed ports inside the container:
# 9099 egress (mitmproxy, agent-facing HTTPS proxy)
+3 -2
View File
@@ -493,10 +493,11 @@ BOT_BOTTLE_ROOT=/var/lib/bot-bottle python3 -m bot_bottle.orchestrator \\
# Gateway data plane, multi-tenant: each request resolves source-IP ->
# policy against the local control plane. The VM backend reaches git over
# git-http (9420), so the git:// daemon (git-gate, needs a per-bottle
# entrypoint the consolidated model doesn't use) is left out.
# entrypoint the consolidated model doesn't use) is left out. No
# SUPERVISE_DB_PATH: the data plane reaches the supervise queue over the
# control-plane RPC and never opens bot-bottle.db (PRD 0070 / #469).
BOT_BOTTLE_GATEWAY_DAEMONS=egress,git-http,supervise \\
BOT_BOTTLE_ORCHESTRATOR_URL=http://127.0.0.1:{CONTROL_PLANE_PORT} \\
SUPERVISE_DB_PATH=/var/lib/bot-bottle/db/bot-bottle.db \\
python3 -m bot_bottle.gateway_init &
# Reap as PID 1; children are backgrounded, so `wait` blocks.
+4 -2
View File
@@ -100,10 +100,12 @@ def _init_script(port: int) -> str:
f"( cd {_SRC_IN_CONTAINER} && BOT_BOTTLE_ROOT={_DB_ROOT_IN_CONTAINER} "
f"python3 -m bot_bottle.orchestrator --host 0.0.0.0 --port {port} "
"--broker stub ) &\n"
# Gateway data plane, multi-tenant against the local control plane.
# Gateway data plane, multi-tenant against the local control plane. No
# SUPERVISE_DB_PATH: the data plane reaches the supervise queue over the
# control-plane RPC and never opens bot-bottle.db (PRD 0070 / #469).
f"( cd /app && BOT_BOTTLE_GATEWAY_DAEMONS={_GATEWAY_DAEMONS} "
f"BOT_BOTTLE_ORCHESTRATOR_URL=http://127.0.0.1:{port} "
f"SUPERVISE_DB_PATH={_DB_PATH_IN_CONTAINER} python3 -m bot_bottle.gateway_init ) &\n"
f"python3 -m bot_bottle.gateway_init ) &\n"
"while : ; do wait ; done\n"
)
+50 -28
View File
@@ -40,8 +40,13 @@ from bot_bottle.egress_addon_core import (
scan_inbound,
scan_outbound,
)
from bot_bottle import supervise as _sv
from bot_bottle.policy_resolver import PolicyResolver
from bot_bottle.policy_resolver import PolicyResolveError, PolicyResolver
from bot_bottle.supervise_types import (
STATUS_APPROVED,
STATUS_MODIFIED,
STATUSES,
TOOL_EGRESS_TOKEN_ALLOW,
)
INTROSPECT_HOST = "_egress.local"
@@ -314,8 +319,15 @@ class EgressAddon:
flow.request.headers.pop("Proxy-Authorization", None)
flow.request.headers.pop(IDENTITY_HEADER, None)
conn = flow.client_conn
if not token and conn is not None:
token = self._conn_tokens.get(getattr(conn, "id", ""), "")
conn_id = getattr(conn, "id", "") if conn is not None else ""
if not token and conn_id:
token = self._conn_tokens.get(conn_id, "")
# Remember the token per connection so a later token-block on this flow
# can attribute its supervise proposal by (source_ip, identity_token)
# over RPC — plain-HTTP requests carry it here, HTTPS tunnels captured
# it at CONNECT (http_connect); both land in `_conn_tokens`.
if token and conn_id:
self._conn_tokens[conn_id] = token
return token
def http_connect(self, flow: http.HTTPFlow) -> None:
@@ -592,42 +604,48 @@ class EgressAddon:
redact_tokens(request_path, env=env),
result,
)
proposal = _sv.Proposal.new(
bottle_slug=slug,
tool=_sv.TOOL_EGRESS_TOKEN_ALLOW,
proposed_file=payload,
justification=_TOKEN_ALLOW_JUSTIFICATION,
current_file_hash=_sv.sha256_hex(payload),
)
# Attribute the proposal by (source_ip, identity_token) over the control
# plane — the data plane no longer opens bot-bottle.db (PRD 0070 / #469).
# source_ip + token come from this bottle's connection; `slug` is kept
# only for its own DLP safelist.
conn = flow.client_conn
source_ip = conn.peername[0] if conn is not None and conn.peername else ""
token = self._conn_tokens.get(getattr(conn, "id", ""), "") if conn is not None else ""
try:
_sv.write_proposal(proposal)
except OSError as e:
proposal_id = self._resolver.propose_supervise(
source_ip, token,
tool=TOOL_EGRESS_TOKEN_ALLOW,
proposed_file=payload,
justification=_TOKEN_ALLOW_JUSTIFICATION,
)
except PolicyResolveError as e:
sys.stderr.write(
f"egress: could not queue token-allow proposal: {e}; "
"blocking request\n"
)
proposal_id = None
if proposal_id is None:
self._block(flow, f"egress DLP: {result.reason}", ctx=self._req_ctx(flow))
return False
sys.stderr.write(json.dumps({
"event": "egress_token_supervise",
"reason": f"egress DLP: {result.reason}",
"proposal": proposal.id,
"proposal": proposal_id,
**self._req_ctx(flow),
}) + "\n")
response = await self._await_token_response(proposal.id, slug)
_sv.archive_proposal(slug, proposal.id)
response = await self._await_token_response(proposal_id, source_ip, token)
if response is not None and response.status in (
_sv.STATUS_APPROVED, _sv.STATUS_MODIFIED,
if response is not None and response.get("status") in (
STATUS_APPROVED, STATUS_MODIFIED,
):
self._safe_tokens_for(slug).add(result.matched)
if self._flow_log(flow) >= LOG_BLOCKS:
sys.stderr.write(json.dumps({
"event": "egress_token_allowed",
"reason": f"egress DLP: {result.reason}",
"proposal": proposal.id,
"proposal": proposal_id,
**self._req_ctx(flow),
}) + "\n")
return True
@@ -645,19 +663,23 @@ class EgressAddon:
async def _await_token_response(
self,
proposal_id: str,
slug: str,
) -> "_sv.Response | None":
"""Poll the DB for the operator's response without blocking the
proxy event loop. Returns the Response, or None on timeout."""
source_ip: str,
token: str,
) -> "dict[str, object] | None":
"""Poll the control plane for the operator's decision without blocking
the proxy event loop. Returns the terminal `{status, ...}` payload once
decided, or None on timeout. A transient orchestrator error (or a
`pending`/`unknown` status) is retried until the deadline, then fails
closed — the caller blocks the request."""
loop = asyncio.get_running_loop()
deadline = loop.time() + self._token_allow_timeout
while True:
try:
return _sv.read_response(slug, proposal_id)
except (OSError, ValueError, KeyError):
# Not written yet, or a partial/malformed write — retry until
# the deadline, then fail closed.
pass
result = self._resolver.poll_supervise(source_ip, token, proposal_id)
except PolicyResolveError:
result = None
if result is not None and result.get("status") in STATUSES:
return result
if loop.time() >= deadline:
return None
await asyncio.sleep(TOKEN_ALLOW_POLL_INTERVAL_SECONDS)
+44 -37
View File
@@ -272,21 +272,27 @@ supervise_gitleaks_allow() {
proposal_id=$(
PYTHONPATH="/app${PYTHONPATH:+:$PYTHONPATH}" GITLEAKS_ALLOW_REF="$ref" python3 - "$report_file" <<'PY'
import datetime
import hashlib
import json
import os
import sys
from pathlib import Path
# Queue over the control-plane RPC — the git-gate data plane no longer opens
# bot-bottle.db (PRD 0070 / issue #469). Attribution is by (source_ip,
# identity_token), resolved server-side, so the proposal lands under the
# calling bottle exactly as a direct write once did.
try:
import supervise as _sv
from bot_bottle.policy_resolver import PolicyResolver, PolicyResolveError
from bot_bottle.supervise_types import TOOL_GITLEAKS_ALLOW
except ImportError:
from bot_bottle import supervise as _sv
from policy_resolver import PolicyResolver, PolicyResolveError
from supervise_types import TOOL_GITLEAKS_ALLOW
report_path = Path(sys.argv[1])
slug = os.environ.get("SUPERVISE_BOTTLE_SLUG", "")
if not slug:
source_ip = os.environ.get("SUPERVISE_SOURCE_IP", "")
token = os.environ.get("SUPERVISE_IDENTITY_TOKEN", "")
orch_url = os.environ.get("BOT_BOTTLE_ORCHESTRATOR_URL", "")
if not source_ip or not orch_url:
sys.exit(2)
try:
@@ -323,19 +329,21 @@ for i, finding in enumerate(raw, 1):
])
payload = "\n".join(lines).rstrip() + "\n"
proposal = _sv.Proposal.new(
bottle_slug=slug,
tool=_sv.TOOL_GITLEAKS_ALLOW,
proposed_file=payload,
justification=(
"git-gate found gitleaks findings hidden by # gitleaks:allow; "
"approve only for dummy test fixtures or confirmed false positives"
),
current_file_hash=hashlib.sha256(payload.encode("utf-8")).hexdigest(),
now=datetime.datetime.now(datetime.timezone.utc),
)
_sv.write_proposal(proposal)
print(proposal.id)
try:
proposal_id = PolicyResolver(orch_url).propose_supervise(
source_ip, token,
tool=TOOL_GITLEAKS_ALLOW,
proposed_file=payload,
justification=(
"git-gate found gitleaks findings hidden by # gitleaks:allow; "
"approve only for dummy test fixtures or confirmed false positives"
),
)
except PolicyResolveError:
sys.exit(4)
if not proposal_id:
sys.exit(2)
print(proposal_id)
PY
)
rc=$?
@@ -348,7 +356,6 @@ PY
return 1
fi
slug=${SUPERVISE_BOTTLE_SLUG:-}
timeout=${SUPERVISE_GITLEAKS_ALLOW_TIMEOUT_SECONDS:-300}
case "$timeout" in
''|*[!0-9]*)
@@ -360,20 +367,30 @@ PY
echo "git-gate: approve with './cli.py supervise' to continue this push" >&2
waited=0
while [ "$waited" -lt "$timeout" ]; do
status=$(PYTHONPATH="/app${PYTHONPATH:+:$PYTHONPATH}" python3 - "$slug" "$proposal_id" <<'PY'
status=$(PYTHONPATH="/app${PYTHONPATH:+:$PYTHONPATH}" python3 - "$proposal_id" <<'PY'
import os
import sys
# Non-blocking poll over the control plane. A decided proposal is archived
# server-side on read, so no separate archive step is needed here.
try:
import supervise as _sv
from bot_bottle.policy_resolver import PolicyResolver, PolicyResolveError
except ImportError:
from bot_bottle import supervise as _sv
from policy_resolver import PolicyResolver, PolicyResolveError
slug = sys.argv[1]
source_ip = os.environ.get("SUPERVISE_SOURCE_IP", "")
token = os.environ.get("SUPERVISE_IDENTITY_TOKEN", "")
orch_url = os.environ.get("BOT_BOTTLE_ORCHESTRATOR_URL", "")
try:
response = _sv.read_response(slug, sys.argv[2])
except FileNotFoundError:
result = PolicyResolver(orch_url).poll_supervise(source_ip, token, sys.argv[1])
except PolicyResolveError:
sys.exit(2)
print(response.status)
if result is None:
sys.exit(2)
status = result.get("status", "")
if status in ("pending", "unknown"):
sys.exit(2)
print(status)
PY
)
rc=$?
@@ -385,16 +402,6 @@ PY
if [ -n "$status" ]; then
case "$status" in
approved|modified)
PYTHONPATH="/app${PYTHONPATH:+:$PYTHONPATH}" python3 - "$slug" "$proposal_id" <<'PY' || true
import sys
try:
import supervise as _sv
except ImportError:
from bot_bottle import supervise as _sv
_sv.archive_proposal(sys.argv[1], sys.argv[2])
PY
echo "git-gate: supervisor approved # gitleaks:allow for $ref" >&2
return 0
;;
+7 -4
View File
@@ -167,11 +167,14 @@ class GitHttpHandler(BaseHTTPRequestHandler):
"SERVER_PORT": str(self.server.server_port), # type: ignore
"SERVER_PROTOCOL": self.request_version,
})
# Attribute the gitleaks-allow supervise proposal (written by
# Attribute the gitleaks-allow supervise proposal (queued 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
# the calling bottle. The hook queues over the control-plane RPC (PRD
# 0070 / issue #469), which re-resolves the bottle from these — the same
# (source_ip, identity_token) pair that selected the namespaced root
# above — so it can only ever queue its own proposals.
env["SUPERVISE_SOURCE_IP"] = self.client_address[0]
env["SUPERVISE_IDENTITY_TOKEN"] = self.headers.get(IDENTITY_HEADER, "")
for header, variable in (
("accept", "HTTP_ACCEPT"),
("content-encoding", "HTTP_CONTENT_ENCODING"),
+63
View File
@@ -27,6 +27,18 @@ vsock / unix-socket portability caveats):
POST /supervise/respond -> 200 {"responded": true} | 409 (operator)
body: {"proposal_id","bottle_slug",
"decision", ["notes"],["final_file"]}
POST /supervise/propose -> 201 {"proposal_id"} | 403 (agent)
body: {"source_ip","identity_token",
"tool","proposed_file","justification"}
POST /supervise/poll -> 200 {"status", ["notes"],["final_file"]} | 403
body: {"source_ip","identity_token",
"proposal_id"}
The `/supervise/propose` + `/supervise/poll` pair is the **agent** half of the
supervise flow: the data plane (supervise / egress / git-gate) queues a
proposal and polls for its response over RPC instead of opening `bot-bottle.db`
directly. Both attribute the caller by `(source_ip, identity_token)` exactly
like `/resolve`, so a bottle can only ever queue or read its own proposals.
`POST /bottles` / `DELETE` drive the full launch lifecycle: they mint (or
tear down) the bottle in the registry AND broker the backend-native launch
@@ -51,6 +63,7 @@ import typing
from urllib.parse import urlsplit
from ..paths import CONTROL_PLANE_TOKEN_ENV
from ..supervise_types import TOOLS
from .service import Orchestrator
# JSON body payload type (parsed request / rendered response).
@@ -235,6 +248,56 @@ def dispatch( # pylint: disable=too-many-return-statements,too-many-branches
return 200, {"responded": True}
return 409, {"error": err}
if method == "POST" and route == "/supervise/propose":
# Agent half: queue a proposal, attributed to the caller resolved from
# (source_ip, identity_token) — never a caller-supplied slug — so the
# data plane can't forge attribution. Fail-closed 403 when unattributed.
try:
data = _parse_json_object(body)
except ValueError as e:
return 400, {"error": f"invalid JSON: {e}"}
source_ip = data.get("source_ip")
token = data.get("identity_token")
tool = data.get("tool")
proposed_file = data.get("proposed_file")
justification = data.get("justification")
if not isinstance(source_ip, str) or not source_ip:
return 400, {"error": "source_ip (string) is required"}
if not isinstance(tool, str) or tool not in TOOLS:
return 400, {"error": f"tool (string) must be one of {TOOLS}"}
if not isinstance(proposed_file, str) or not proposed_file:
return 400, {"error": "proposed_file (string) is required"}
if not isinstance(justification, str) or not justification:
return 400, {"error": "justification (string) is required"}
rec = orch.resolve(source_ip, token if isinstance(token, str) else "")
if rec is None:
return 403, {"error": "unattributed"}
proposal_id = orch.supervise_queue_proposal(
rec.bottle_id, tool=tool, proposed_file=proposed_file,
justification=justification,
)
return 201, {"proposal_id": proposal_id}
if method == "POST" and route == "/supervise/poll":
# Agent half: non-blocking read of the caller's own proposal decision.
# Attributed like /propose, and scoped to the resolved bottle id, so a
# guessed proposal_id can never read another bottle's response.
try:
data = _parse_json_object(body)
except ValueError as e:
return 400, {"error": f"invalid JSON: {e}"}
source_ip = data.get("source_ip")
token = data.get("identity_token")
proposal_id = data.get("proposal_id")
if not isinstance(source_ip, str) or not source_ip:
return 400, {"error": "source_ip (string) is required"}
if not isinstance(proposal_id, str) or not proposal_id:
return 400, {"error": "proposal_id (string) is required"}
rec = orch.resolve(source_ip, token if isinstance(token, str) else "")
if rec is None:
return 403, {"error": "unattributed"}
return 200, orch.supervise_poll_response(rec.bottle_id, proposal_id)
if method == "POST" and route == "/resolve":
# The per-request lookup the multi-tenant gateway makes: returns the
# bottle's policy. Requires a matching (source_ip, identity_token)
+4 -20
View File
@@ -26,15 +26,8 @@ from ..docker_cmd import run_docker
from ..paths import (
CONTROL_PLANE_TOKEN_ENV,
host_control_plane_token,
host_db_path,
host_gateway_ca_dir,
)
from ..supervise import DB_PATH_IN_CONTAINER
# The host DB dir is bind-mounted here so the gateway's supervise daemon
# writes its queued proposals into the ONE host DB (the same file the
# orchestrator container opens and the operator reaches over HTTP).
_SUPERVISE_DB_DIR_IN_CONTAINER = os.path.dirname(DB_PATH_IN_CONTAINER)
# The gateway's mitmproxy writes its CA a beat after the container starts, so
# reads poll for it rather than assuming it's there on a fresh launch.
@@ -75,14 +68,6 @@ GATEWAY_DOCKERFILE = "Dockerfile.gateway"
_REPO_ROOT = Path(__file__).resolve().parents[2]
def _host_db_dir() -> str:
"""The host DB directory (created if missing), for the gateway's
supervise-DB bind-mount."""
db_dir = host_db_path().parent
db_dir.mkdir(parents=True, exist_ok=True)
return str(db_dir)
def rotate_gateway_ca(ca_dir: Path | None = None) -> list[Path]:
"""Delete the persisted mitmproxy CA so the next gateway start mints a
fresh one — the explicit, deliberate CA-rollover path (issue #450).
@@ -255,11 +240,10 @@ class DockerGateway(Gateway):
# container recreation AND docker volume pruning (agents trust it)
# — see host_gateway_ca_dir / issue #450.
"--volume", f"{host_gateway_ca_dir()}:{MITMPROXY_HOME}",
# Share the one host DB: the supervise daemon queues proposals
# into the same file the orchestrator (and the operator, over
# HTTP) reads — no second, disconnected DB in the container.
"--volume", f"{_host_db_dir()}:{_SUPERVISE_DB_DIR_IN_CONTAINER}",
"--env", f"SUPERVISE_DB_PATH={DB_PATH_IN_CONTAINER}",
# No DB mount: the data plane (egress / supervise / git-gate) reaches
# the supervise queue over the control-plane RPC and never opens
# bot-bottle.db, so the gateway container gets no file handle on it
# (PRD 0070 / issue #469).
]
for port in self._host_port_bindings:
argv += ["--publish", f"0.0.0.0:{port}:{port}"]
+4 -9
View File
@@ -29,14 +29,12 @@ from ..paths import (
host_control_plane_token,
host_gateway_ca_dir,
)
from ..supervise import DB_PATH_IN_CONTAINER
from .gateway import (
GATEWAY_DOCKERFILE,
GATEWAY_IMAGE,
GATEWAY_NETWORK,
GatewayError,
MITMPROXY_HOME,
_host_db_dir,
)
DEFAULT_PORT = 8099
@@ -69,12 +67,12 @@ _INFRA_DAEMONS = "egress,git-http,supervise,orchestrator"
# container. Separate from /app so the gateway's baked scripts
# (egress_addon.py, egress-entrypoint.sh) are not overlaid.
_SRC_IN_CONTAINER = "/bot-bottle-src"
# Bot-bottle host-root bind-mount inside the container (DB + state).
# Bot-bottle host-root bind-mount inside the container (DB + state). The
# orchestrator control plane opens bot-bottle.db under here (via BOT_BOTTLE_ROOT
# -> host_db_path()); it is the ONLY process in the container with a file
# handle on it (PRD 0070 / issue #469).
_ROOT_IN_CONTAINER = "/bot-bottle-root"
# The supervise daemon writes proposals into the host DB directory.
_SUPERVISE_DB_DIR_IN_CONTAINER = os.path.dirname(DB_PATH_IN_CONTAINER)
_HEALTH_POLL_SECONDS = 0.25
_HEALTH_REQUEST_TIMEOUT_SECONDS = 1.0
@@ -202,9 +200,6 @@ class OrchestratorService:
# recreation AND docker volume pruning (issue #450): every agent
# trusts this one CA, so a fresh one would break all running bottles.
"--volume", f"{host_gateway_ca_dir()}:{MITMPROXY_HOME}",
# Shared supervise DB (same file the operator reads over HTTP).
"--volume", f"{_host_db_dir()}:{_SUPERVISE_DB_DIR_IN_CONTAINER}",
"--env", f"SUPERVISE_DB_PATH={DB_PATH_IN_CONTAINER}",
# Live control-plane source, mounted to a path that does not
# overlay the gateway's baked /app scripts.
"--volume", f"{self._repo_root}:{_SRC_IN_CONTAINER}:ro",
+61
View File
@@ -31,16 +31,23 @@ from .gateway import Gateway
from ..supervise import (
AuditEntry,
COMPONENT_FOR_TOOL,
POLL_STATUS_PENDING,
POLL_STATUS_UNKNOWN,
Proposal,
Response,
STATUS_APPROVED,
STATUS_MODIFIED,
STATUS_REJECTED,
TOOL_EGRESS_ALLOW,
TOOL_EGRESS_BLOCK,
archive_proposal,
list_all_pending_proposals,
read_proposal,
read_response,
render_diff,
sha256_hex,
write_audit_entry,
write_proposal,
write_response,
)
@@ -188,6 +195,60 @@ class Orchestrator:
# The orchestrator owns the single DB *and* the live policy, so operator
# decisions are applied here, server-side, and reached over HTTP by the
# host TUI (no direct-DB access, one path for every backend).
#
# The *agent* half of the queue (queue a proposal, poll for its response)
# is served here too (PRD 0070): the data plane no longer opens the DB, so
# supervise_server / egress / git-gate reach the queue over the control
# plane. Both are keyed on the caller's orchestrator-resolved bottle id —
# never a caller-supplied slug — so a bottle can only queue or read its own
# proposals even if the data plane is compromised.
def supervise_queue_proposal(
self, bottle_id: str, *, tool: str, proposed_file: str, justification: str,
) -> str:
"""Queue an agent-side proposal under `bottle_id` and return its id.
`bottle_id` is the caller resolved from `(source_ip, identity_token)` by
the control plane, so the proposal is attributed to the calling bottle,
not to anything the (possibly hostile) data plane asserts. The
proposed-file self-hash is computed here so the agent never supplies it."""
proposal = Proposal.new(
bottle_slug=bottle_id,
tool=tool,
proposed_file=proposed_file,
justification=justification,
current_file_hash=sha256_hex(proposed_file),
)
write_proposal(proposal)
return proposal.id
def supervise_poll_response(self, bottle_id: str, proposal_id: str) -> dict[str, object]:
"""Non-blocking poll of one of `bottle_id`'s queued proposals.
Returns `{"status": ...}`: a terminal decision (`approved`/`modified`/
`rejected`, with `notes` + `final_file`) once the operator has responded,
`pending` while it's still queued undecided, or `unknown` when no such
queued proposal exists for this bottle (wrong id, or already resolved and
read). A decided proposal is archived here the same terminal step the
data plane used to run after reading the response so `pending`
proposals stay visible to the operator until decided *and* polled.
Reads are scoped to `bottle_id` (the queue key), so a caller can never
read another bottle's proposal even with a guessed id."""
try:
response = read_response(bottle_id, proposal_id)
except FileNotFoundError:
try:
read_proposal(bottle_id, proposal_id)
except FileNotFoundError:
return {"status": POLL_STATUS_UNKNOWN}
return {"status": POLL_STATUS_PENDING}
archive_proposal(bottle_id, proposal_id)
return {
"status": response.status,
"notes": response.notes,
"final_file": response.final_file,
}
def supervise_pending(self) -> list[dict[str, object]]:
"""All pending proposals across bottles, FIFO, as JSON dicts
+68 -16
View File
@@ -64,28 +64,80 @@ class PolicyResolver:
self._base = base_url.rstrip("/")
self._timeout = timeout
def _post_json(self, path: str, payload: dict[str, object]) -> dict[str, object] | None:
"""POST `payload` to control-plane `path`, returning the JSON object body
or None when the orchestrator answers `403` (unattributed / fail
closed). Raises `PolicyResolveError` on an unreachable / unexpected-status
/ malformed response so every caller can fail closed. Shared by
`_post_resolve` and the supervise propose/poll RPCs."""
body = json.dumps(payload).encode()
req = urllib.request.Request(
f"{self._base}{path}", data=body, method="POST",
headers={"Content-Type": "application/json", **_control_auth_headers()},
)
try:
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
data = json.loads(resp.read())
except urllib.error.HTTPError as e:
if e.code == 403:
return None # unattributed → fail closed (caller denies)
raise PolicyResolveError(f"{path} returned HTTP {e.code}") from e
except (urllib.error.URLError, TimeoutError, OSError, ValueError) as e:
raise PolicyResolveError(f"{path} unreachable or malformed: {e}") from e
return data if isinstance(data, dict) else {}
def _post_resolve(self, source_ip: str, identity_token: str) -> dict[str, object] | None:
"""The orchestrator's `/resolve` payload for this client, or None if
unattributed (a clean `403`). Raises `PolicyResolveError` on an
unreachable / unexpected-status / malformed response so every caller
can fail closed. Shared by `resolve` and `resolve_bottle_id`."""
body = json.dumps(
{"source_ip": source_ip, "identity_token": identity_token}
).encode()
req = urllib.request.Request(
f"{self._base}/resolve", data=body, method="POST",
headers={"Content-Type": "application/json", **_control_auth_headers()},
return self._post_json(
"/resolve", {"source_ip": source_ip, "identity_token": identity_token},
)
try:
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
payload = json.loads(resp.read())
except urllib.error.HTTPError as e:
if e.code == 403:
return None # unattributed → fail closed (caller denies)
raise PolicyResolveError(f"/resolve returned HTTP {e.code}") from e
except (urllib.error.URLError, TimeoutError, OSError, ValueError) as e:
raise PolicyResolveError(f"/resolve unreachable or malformed: {e}") from e
return payload if isinstance(payload, dict) else {}
def propose_supervise(
self,
source_ip: str,
identity_token: str,
*,
tool: str,
proposed_file: str,
justification: str,
) -> str | None:
"""Queue a supervise proposal on the control plane and return its
`proposal_id`. The orchestrator attributes the proposal to the calling
bottle by `(source_ip, identity_token)` exactly like `/resolve` so a
bottle can only ever queue its *own* proposals. Returns None when the
pair is unattributed (a clean `403`); raises `PolicyResolveError` if the
orchestrator can't be reached, so the data-plane caller fails closed
(blocks / refuses) rather than silently dropping the proposal."""
payload = self._post_json("/supervise/propose", {
"source_ip": source_ip,
"identity_token": identity_token,
"tool": tool,
"proposed_file": proposed_file,
"justification": justification,
})
if payload is None:
return None
proposal_id = payload.get("proposal_id")
return proposal_id if isinstance(proposal_id, str) and proposal_id else None
def poll_supervise(
self, source_ip: str, identity_token: str, proposal_id: str,
) -> dict[str, object] | None:
"""Poll a queued proposal for the operator's decision, **non-blocking**.
Attributed by `(source_ip, identity_token)` so a bottle can only read its
*own* proposal's response. Returns `{"status": ...}` where status is one
of the terminal decisions (`approved`/`modified`/`rejected`, carrying
`notes` + `final_file`), `pending` (queued, no decision yet), or
`unknown` (no such queued proposal for this bottle). Returns None when
unattributed; raises `PolicyResolveError` if unreachable."""
return self._post_json("/supervise/poll", {
"source_ip": source_ip,
"identity_token": identity_token,
"proposal_id": proposal_id,
})
def resolve(self, source_ip: str, identity_token: str = "") -> str | None:
"""The calling bottle's policy blob, or None if unattributed. Always
+4
View File
@@ -40,6 +40,8 @@ from pathlib import Path
from .supervise_types import (
ACTION_OPERATOR_EDIT,
AuditEntry,
POLL_STATUS_PENDING,
POLL_STATUS_UNKNOWN,
Proposal,
Response,
STATUSES,
@@ -257,6 +259,8 @@ __all__ = [
"STATUS_APPROVED",
"STATUS_MODIFIED",
"STATUS_REJECTED",
"POLL_STATUS_PENDING",
"POLL_STATUS_UNKNOWN",
"SUPERVISE_HOSTNAME",
"SUPERVISE_PORT",
"Supervise",
+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; "
+11
View File
@@ -37,6 +37,15 @@ STATUS_MODIFIED = "modified"
STATUS_REJECTED = "rejected"
STATUSES: tuple[str, ...] = (STATUS_APPROVED, STATUS_MODIFIED, STATUS_REJECTED)
# Non-terminal markers the control-plane supervise-poll RPC returns when a
# proposal has no operator decision yet (`PENDING`) or no queued proposal with
# that id exists for the calling bottle (`UNKNOWN`). They are never a
# `Response.status` — only the poll wire contract (see
# `Orchestrator.supervise_poll_response`) — so they are deliberately outside
# `STATUSES`.
POLL_STATUS_PENDING = "pending"
POLL_STATUS_UNKNOWN = "unknown"
ACTION_OPERATOR_EDIT = "operator-edit"
@@ -157,6 +166,8 @@ __all__ = [
"STATUS_APPROVED",
"STATUS_MODIFIED",
"STATUS_REJECTED",
"POLL_STATUS_PENDING",
"POLL_STATUS_UNKNOWN",
"TOOLS",
"TOOL_EGRESS_ALLOW",
"TOOL_EGRESS_BLOCK",
+8 -5
View File
@@ -299,11 +299,14 @@ and integrate a console against.
the data plane and the console reach state through the control-plane RPC,
never a direct file handle.** No agent-facing component gets the file, so
none can forge attribution. (This supersedes the earlier `ro`-mount idea.)
- *Transitional caveat:* today the per-bottle **supervise gateway
rw-bind-mounts `bot-bottle.db`** to write proposals — exactly the
pattern the orchestrator removes (supervise consolidates into the
orchestrator; gateway writes become RPC calls). Until that lands, don't
put the attribution registry behind a data-plane-writable mount.
This rule is now **in force** across the data plane (issue #469): the
supervise daemon, the egress addon, and the git-gate pre-receive hook queue
proposals and poll for their responses over the agent-side supervise RPC
(`POST /supervise/propose` + `POST /supervise/poll`, attributed by
`(source_ip, identity_token)` exactly like `/resolve`), so a bottle can only
ever queue or read its own proposals. The gateway/data-plane containers no
longer bind-mount the DB directory or carry `SUPERVISE_DB_PATH`; the
orchestrator is the sole opener of the one file.
Implementation note for the VM slices: SQLite **WAL** over a guest share
(virtiofs/9p) is finicky (the `-shm`/`-wal` files need real mmap/locking),
+70 -73
View File
@@ -209,6 +209,7 @@ from bot_bottle.egress_addon_core import ( # noqa: E402
Route,
route_to_yaml_dict,
)
from bot_bottle.policy_resolver import PolicyResolveError # noqa: E402
# ---------------------------------------------------------------------------
@@ -266,7 +267,42 @@ def _config_to_policy(config: Config) -> str:
}) + "\n"
class _StaticResolver:
class _SuperviseRpcFake:
"""Mixin adding the control-plane supervise RPCs to a fake resolver, now
that the egress data plane queues/polls proposals over RPC instead of
opening the DB (issue #469). `supervise_status` is the operator's eventual
decision (None models a timeout poll stays `pending`); `propose_error`
models an unreachable orchestrator. `propose_calls` records what was queued
so a test can assert the proposal was attributed by the caller's source IP."""
supervise_status: "str | None" = None
propose_error: bool = False
@property
def propose_calls(self) -> list:
if not hasattr(self, "_propose_calls"):
self._propose_calls: list = []
return self._propose_calls
def propose_supervise(
self, source_ip, identity_token, *, tool, proposed_file, justification,
):
del proposed_file, justification
self.propose_calls.append(
{"source_ip": source_ip, "identity_token": identity_token, "tool": tool}
)
if self.propose_error:
raise PolicyResolveError("orchestrator down")
return "prop-1"
def poll_supervise(self, source_ip, identity_token, proposal_id):
del source_ip, identity_token, proposal_id
if self.supervise_status is None:
return {"status": "pending"}
return {"status": self.supervise_status, "notes": "", "final_file": None}
class _StaticResolver(_SuperviseRpcFake):
"""Fake orchestrator resolver that serves one Config (+ optional bottle id
and per-bottle tokens) for every client the host-test stand-in for a
bottle's policy now that egress is resolver-only."""
@@ -323,7 +359,7 @@ def _with_client_ip(flow: _Flow, ip: str) -> _Flow:
return flow
class _CtxResolver:
class _CtxResolver(_SuperviseRpcFake):
"""Fake orchestrator resolver: maps source IP -> bottle id, and grants the
same allow-list to any attributed bottle (unattributed -> deny)."""
@@ -516,66 +552,33 @@ class TestOutboundDlpPolicy(unittest.TestCase):
# ---------------------------------------------------------------------------
def _fake_sv(response_status: str | None) -> types.SimpleNamespace:
"""Stand-in for the `supervise` module the adapter queues proposals to.
`response_status` of None models a timeout (read_response never returns a
decision); a status string models the operator's eventual answer."""
def _new_proposal(**_kw: Any) -> Any:
return types.SimpleNamespace(id="prop-1")
def _sha256_hex(_payload: Any) -> str:
return "hash"
def _noop(*_args: Any) -> None:
return None
def _read_response(_slug: Any, _pid: Any) -> Any:
if response_status is None:
raise OSError("not written yet") # forces poll -> timeout
return types.SimpleNamespace(status=response_status)
ns = types.SimpleNamespace()
ns.STATUS_APPROVED = "approved"
ns.STATUS_MODIFIED = "modified"
ns.TOOL_EGRESS_TOKEN_ALLOW = "egress_token_allow"
ns.Proposal = types.SimpleNamespace(new=_new_proposal)
ns.sha256_hex = _sha256_hex
ns.write_proposal = _noop
ns.archive_proposal = _noop
ns.read_response = _read_response
return ns
class TestSuperviseBranch(unittest.TestCase):
def _supervised_addon(self) -> EgressAddon:
def _supervised_addon(self, status: str | None) -> EgressAddon:
addon = _addon(Config(routes=(Route(host="api.example.com"),)), slug="test-bottle")
addon._token_allow_timeout = 0.05
cast(Any, addon._resolver).supervise_status = status
return addon
def test_operator_approval_allows_token_and_forwards(self) -> None:
addon = self._supervised_addon()
addon = self._supervised_addon("approved")
flow = _Flow(_Request(host="api.example.com", method="POST", body=f"k={_OPENAI_KEY}"))
with patch.object(_ea_mod, "_sv", _fake_sv("approved")):
_run_request(addon, flow)
_run_request(addon, flow)
self.assertIsNone(flow.response) # forwarded after approval
# Approval lands in the calling bottle's safelist (keyed by slug).
self.assertIn(_OPENAI_KEY, addon._safe_tokens_for("test-bottle"))
def test_operator_rejection_blocks(self) -> None:
addon = self._supervised_addon()
addon = self._supervised_addon("rejected")
flow = _Flow(_Request(host="api.example.com", method="POST", body=f"k={_OPENAI_KEY}"))
with patch.object(_ea_mod, "_sv", _fake_sv("rejected")):
_run_request(addon, flow)
_run_request(addon, flow)
assert flow.response is not None
self.assertEqual(403, flow.response.status_code)
self.assertIn("rejected", flow.response.get_text())
def test_supervise_timeout_blocks(self) -> None:
addon = self._supervised_addon()
addon = self._supervised_addon(None) # poll stays pending -> timeout
flow = _Flow(_Request(host="api.example.com", method="POST", body=f"k={_OPENAI_KEY}"))
with patch.object(_ea_mod, "_sv", _fake_sv(None)):
_run_request(addon, flow)
_run_request(addon, flow)
assert flow.response is not None
self.assertEqual(403, flow.response.status_code)
self.assertIn("timed out", flow.response.get_text())
@@ -772,19 +775,14 @@ class TestRedactSurfaces(unittest.TestCase):
class TestSuperviseWriteFailure(unittest.TestCase):
def test_write_proposal_oserror_blocks(self) -> None:
def test_propose_rpc_error_blocks(self) -> None:
# An unreachable orchestrator (propose RPC raises) fails closed: the
# request is blocked rather than forwarded unsupervised.
addon = _addon(Config(routes=(Route(host="api.example.com"),)), slug="test-bottle")
addon._token_allow_timeout = 0.05
cast(Any, addon._resolver).propose_error = True
flow = _Flow(_Request(host="api.example.com", method="POST", body=f"k={_OPENAI_KEY}"))
fake = _fake_sv("approved")
def _raise(_p: Any) -> None:
raise OSError("disk full")
fake.write_proposal = _raise
with patch.object(_ea_mod, "_sv", fake):
_run_request(addon, flow)
_run_request(addon, flow)
assert flow.response is not None
self.assertEqual(403, flow.response.status_code)
@@ -844,22 +842,23 @@ class TestSuperviseMultiTenant(unittest.TestCase):
"""Consolidated gateway: supervise proposals + the DLP safelist are keyed
per bottle, resolved by source IP (PRD 0070)."""
def _consolidated_addon(self) -> EgressAddon:
def _consolidated_addon(self, status: str | None = "approved") -> EgressAddon:
# Static config is empty; the resolver supplies each bottle's config.
addon = _addon(Config(routes=()))
addon._resolver = cast(Any, _CtxResolver({"10.0.0.1": "bottle-a", "10.0.0.2": "bottle-b"}))
resolver = _CtxResolver({"10.0.0.1": "bottle-a", "10.0.0.2": "bottle-b"})
resolver.supervise_status = status
addon._resolver = cast(Any, resolver)
addon._token_allow_timeout = 0.05
return addon
def test_approval_is_scoped_to_the_calling_bottle(self) -> None:
addon = self._consolidated_addon()
addon = self._consolidated_addon("approved")
# bottle-a (10.0.0.1) sends the token; the operator approves.
flow = _with_client_ip(
_Flow(_Request(host="api.example.com", method="POST", body=f"k={_OPENAI_KEY}")),
"10.0.0.1",
)
with patch.object(_ea_mod, "_sv", _fake_sv("approved")):
_run_request(addon, flow)
_run_request(addon, flow)
self.assertIsNone(flow.response) # forwarded after approval
# The approval lands ONLY in bottle-a's safelist — never bottle-b's.
# A global set here would be the cross-tenant leak this slice closes.
@@ -867,22 +866,19 @@ class TestSuperviseMultiTenant(unittest.TestCase):
self.assertNotIn(_OPENAI_KEY, addon._safe_tokens_for("bottle-b"))
def test_proposal_is_attributed_to_the_source_ip_bottle(self) -> None:
addon = self._consolidated_addon()
seen: list[str] = []
fake = _fake_sv("approved")
def _capture(**kw: Any) -> Any:
seen.append(kw["bottle_slug"])
return types.SimpleNamespace(id="p")
fake.Proposal = types.SimpleNamespace(new=_capture)
addon = self._consolidated_addon("approved")
flow = _with_client_ip(
_Flow(_Request(host="api.example.com", method="POST", body=f"k={_OPENAI_KEY}")),
"10.0.0.2",
)
with patch.object(_ea_mod, "_sv", fake):
_run_request(addon, flow)
self.assertEqual(["bottle-b"], seen) # proposal keyed by the resolved bottle
_run_request(addon, flow)
# The addon forwards the caller's source IP to the control plane, which
# attributes the proposal server-side by (source_ip, identity_token) —
# the addon never asserts a slug. The resolved bottle keys the safelist.
calls = cast(Any, addon._resolver).propose_calls
self.assertEqual(["10.0.0.2"], [c["source_ip"] for c in calls])
self.assertIn(_OPENAI_KEY, addon._safe_tokens_for("bottle-b"))
self.assertNotIn(_OPENAI_KEY, addon._safe_tokens_for("bottle-a"))
def test_auth_token_injected_from_resolved_tokens(self) -> None:
# The bottle's upstream token comes from /resolve (in-memory on the
@@ -925,16 +921,17 @@ class TestSuperviseMultiTenant(unittest.TestCase):
self.assertIsNotNone(flow.response) # blocked — token unset
def test_unattributed_source_ip_cannot_supervise(self) -> None:
addon = self._consolidated_addon()
addon = self._consolidated_addon("approved")
# 10.9.9.9 is not in the resolver map -> deny-all config, empty slug.
flow = _with_client_ip(
_Flow(_Request(host="api.example.com", method="POST", body=f"k={_OPENAI_KEY}")),
"10.9.9.9",
)
with patch.object(_ea_mod, "_sv", _fake_sv("approved")):
_run_request(addon, flow)
_run_request(addon, flow)
self.assertIsNotNone(flow.response) # blocked (no route, no supervise)
self.assertNotIn(_OPENAI_KEY, addon._safe_tokens_for(""))
# Never even reached the queue — no route means no supervise proposal.
self.assertEqual([], cast(Any, addon._resolver).propose_calls)
class TestMultiTenantInboundDlp(unittest.TestCase):
+13 -10
View File
@@ -213,22 +213,25 @@ class TestHookRender(unittest.TestCase):
# the suppressed findings for human approval.
self.assertIn("--ignore-gitleaks-allow", hook)
self.assertIn("--report-format=json", hook)
self.assertIn("tool=_sv.TOOL_GITLEAKS_ALLOW", hook)
self.assertIn("_sv.write_proposal", hook)
self.assertIn("_sv.read_response", hook)
self.assertIn("SUPERVISE_BOTTLE_SLUG", hook)
# The hook queues + polls over the control-plane RPC — it no longer
# opens the DB directly (PRD 0070 / issue #469).
self.assertIn("tool=TOOL_GITLEAKS_ALLOW", hook)
self.assertIn("propose_supervise", hook)
self.assertIn("poll_supervise", hook)
self.assertIn("SUPERVISE_SOURCE_IP", hook)
self.assertIn("SUPERVISE_IDENTITY_TOKEN", hook)
self.assertIn("supervisor approved # gitleaks:allow", hook)
self.assertIn("supervisor rejected # gitleaks:allow", hook)
def test_inline_gitleaks_allow_python_imports_work_in_gateway_layout(self):
hook = git_gate_render_hook()
# The gateway image copies supervise.py flat under /app, while
# host-side tests import it through the bot_bottle package.
# Hooks execute from the bare repo directory, so the embedded
# Python must include /app and support both import layouts.
# The gateway image copies the package modules flat under /app, while
# host-side tests import them through the bot_bottle package. Hooks
# execute from the bare repo directory, so the embedded Python must
# include /app and support both import layouts.
self.assertIn('PYTHONPATH="/app${PYTHONPATH:+:$PYTHONPATH}"', hook)
self.assertIn("import supervise as _sv", hook)
self.assertIn("from bot_bottle import supervise as _sv", hook)
self.assertIn("from bot_bottle.policy_resolver import PolicyResolver", hook)
self.assertIn("from policy_resolver import PolicyResolver", hook)
def test_inline_gitleaks_allow_fails_closed_without_supervisor(self):
hook = git_gate_render_hook()
+10 -8
View File
@@ -112,11 +112,13 @@ class TestGitHttpBackend(unittest.TestCase):
).strip()
self.assertEqual(head, cloned)
def test_consolidated_push_stamps_bottle_slug_for_the_hook(self):
# In consolidated mode the backend attributes the push by source IP and
# stamps SUPERVISE_BOTTLE_SLUG=<bottle_id> into the CGI env, so the
# gitleaks-allow pre-receive hook queues its proposal under the right
# bottle. The hook here just records what it received.
def test_consolidated_push_stamps_supervise_attribution_for_the_hook(self):
# In consolidated mode the backend attributes the push by (source IP,
# identity token) and stamps SUPERVISE_SOURCE_IP + SUPERVISE_IDENTITY_TOKEN
# into the CGI env, so the gitleaks-allow pre-receive hook can queue its
# proposal over the control-plane RPC (which re-resolves the bottle from
# exactly that pair — PRD 0070 / issue #469). The hook here just records
# the source IP it received.
from http.server import ThreadingHTTPServer
bottle_id = "bottleab12"
@@ -130,10 +132,10 @@ class TestGitHttpBackend(unittest.TestCase):
["git", "-C", str(bare), "config", "http.receivepack", "true"],
check=True,
)
capture = root / "slug-capture"
capture = root / "source-ip-capture"
hook = bare / "hooks" / "pre-receive"
hook.write_text(
f"#!/bin/sh\nprintf '%s' \"${{SUPERVISE_BOTTLE_SLUG:-UNSET}}\" > "
f"#!/bin/sh\nprintf '%s' \"${{SUPERVISE_SOURCE_IP:-UNSET}}\" > "
f"{capture}\ncat >/dev/null\nexit 0\n"
)
hook.chmod(0o755)
@@ -166,7 +168,7 @@ class TestGitHttpBackend(unittest.TestCase):
["git", "push", url, "HEAD:refs/heads/main"],
cwd=work, check=True, capture_output=True, text=True, timeout=5,
)
self.assertEqual(bottle_id, capture.read_text())
self.assertEqual("127.0.0.1", capture.read_text())
def test_post_forwards_git_cgi_headers(self):
from http.server import ThreadingHTTPServer
@@ -426,6 +426,112 @@ class TestDispatchSupervise(unittest.TestCase):
self.assertIn("no such proposal", str(payload["error"]))
class TestDispatchSuperviseAgentRpc(unittest.TestCase):
"""The agent half — `/supervise/propose` + `/supervise/poll` — attributed by
(source_ip, identity_token) like /resolve, so a bottle can only ever queue
or read its own proposals (PRD 0070 / issue #469)."""
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
root = Path(self._tmp.name)
db = root / "db" / "bot-bottle.db"
db.parent.mkdir(parents=True)
self._env = patch.dict("os.environ", {
"BOT_BOTTLE_ROOT": str(root),
"SUPERVISE_DB_PATH": str(db),
})
self._env.start()
self.store = RegistryStore(db)
self.store.migrate()
StoreManager(db).migrate()
secret = secrets.token_bytes(16)
self.orch = Orchestrator(self.store, StubBroker(secret), secret)
def tearDown(self) -> None:
self._env.stop()
self._tmp.cleanup()
def _register(self, source_ip: str = "10.243.0.9", slug: str = "demo"):
return self.store.register(
source_ip, metadata=json.dumps({"slug": slug}), policy="routes: []\n")
def _propose(self, rec, proposed: str = "routes:\n - host: g.com\n"):
return dispatch(self.orch, "POST", "/supervise/propose", _body({
"source_ip": rec.source_ip, "identity_token": rec.identity_token,
"tool": TOOL_EGRESS_ALLOW, "proposed_file": proposed, "justification": "need it",
}))
def _poll(self, rec, proposal_id: str):
return dispatch(self.orch, "POST", "/supervise/poll", _body({
"source_ip": rec.source_ip, "identity_token": rec.identity_token,
"proposal_id": proposal_id,
}))
def test_propose_queues_under_the_resolved_bottle(self) -> None:
rec = self._register()
status, payload = self._propose(rec)
self.assertEqual(201, status)
pid = payload["proposal_id"]
assert isinstance(pid, str) and pid
_, listing = dispatch(self.orch, "GET", "/supervise/proposals", b"")
self.assertEqual(pid, listing["proposals"][0]["id"])
# Queued under the orchestrator-resolved bottle id, never a caller slug.
self.assertEqual(rec.bottle_id, listing["proposals"][0]["bottle_slug"])
def test_propose_unattributed_is_403(self) -> None:
status, payload = dispatch(self.orch, "POST", "/supervise/propose", _body({
"source_ip": "10.9.9.9", "identity_token": "wrong",
"tool": TOOL_EGRESS_ALLOW, "proposed_file": "x\n", "justification": "j",
}))
self.assertEqual(403, status)
self.assertIn("unattributed", str(payload["error"]))
def test_propose_rejects_unknown_tool(self) -> None:
rec = self._register()
status, _ = dispatch(self.orch, "POST", "/supervise/propose", _body({
"source_ip": rec.source_ip, "identity_token": rec.identity_token,
"tool": "not-a-tool", "proposed_file": "x\n", "justification": "j",
}))
self.assertEqual(400, status)
def test_poll_pending_then_decided_then_archived(self) -> None:
rec = self._register()
_, proposed = self._propose(rec)
pid = proposed["proposal_id"]
assert isinstance(pid, str)
status, poll = self._poll(rec, pid)
self.assertEqual(200, status)
self.assertEqual("pending", poll["status"])
# Operator decides server-side.
dispatch(self.orch, "POST", "/supervise/respond", _body({
"proposal_id": pid, "bottle_slug": rec.bottle_id,
"decision": "approve", "notes": "ok",
}))
_, decided = self._poll(rec, pid)
self.assertEqual("approved", decided["status"])
self.assertEqual("ok", decided["notes"])
# The decided poll archived it: gone from pending, and a re-poll is
# 'unknown' rather than replaying the decision forever.
_, listing = dispatch(self.orch, "GET", "/supervise/proposals", b"")
self.assertEqual([], listing["proposals"])
_, again = self._poll(rec, pid)
self.assertEqual("unknown", again["status"])
def test_poll_cannot_read_another_bottles_proposal(self) -> None:
rec_a = self._register("10.0.0.1", "a")
rec_b = self._register("10.0.0.2", "b")
_, proposed = self._propose(rec_a)
pid = proposed["proposal_id"]
assert isinstance(pid, str)
# b polls a's proposal id: scoped to b's own queue → never a's response.
status, poll = self._poll(rec_b, pid)
self.assertEqual(200, status)
self.assertEqual("unknown", poll["status"])
if __name__ == "__main__":
unittest.main()
+5 -7
View File
@@ -124,13 +124,11 @@ class TestDockerGateway(unittest.TestCase):
src = ca_mounts[0].rsplit(":", 1)[0]
self.assertTrue(src.endswith("/" + GATEWAY_CA_DIRNAME), src)
self.assertTrue(Path(src).is_absolute(), src)
# Shares the ONE host DB: the supervise daemon queues into the same
# file the orchestrator + operator (over HTTP) use.
self.assertTrue(any(
a.startswith("SUPERVISE_DB_PATH=") and a.endswith("/run/supervise/bot-bottle.db")
for a in runs[0]))
self.assertTrue(any(
a.endswith(":/run/supervise") for a in runs[0]))
# No DB handle on the data plane: the supervise queue is reached over
# the control-plane RPC, so the gateway container carries neither the
# DB bind-mount nor SUPERVISE_DB_PATH (PRD 0070 / issue #469).
self.assertFalse(any(a.startswith("SUPERVISE_DB_PATH=") for a in runs[0]))
self.assertFalse(any(a.endswith(":/run/supervise") for a in runs[0]))
# Data plane resolves policy against the orchestrator control plane.
self.assertIn(f"BOT_BOTTLE_ORCHESTRATOR_URL={_ORCH_URL}", runs[0])
+37
View File
@@ -323,6 +323,43 @@ class TestOrchestratorSupervise(unittest.TestCase):
self.assertFalse(ok)
self.assertIn("no longer registered", err)
# --- agent half: queue + poll (issue #469) -----------------------------
def test_queue_proposal_then_poll_pending(self) -> None:
bottle_id = self._register("demo", "routes: []\n")
pid = self.orch.supervise_queue_proposal(
bottle_id, tool=TOOL_EGRESS_ALLOW,
proposed_file="routes:\n - host: google.com\n", justification="need it")
# Visible to the operator, keyed by the bottle id.
pending = self.orch.supervise_pending()
self.assertEqual([pid], [p["id"] for p in pending])
self.assertEqual(
{"status": "pending"}, self.orch.supervise_poll_response(bottle_id, pid))
def test_poll_returns_decision_and_archives(self) -> None:
bottle_id = self._register("demo", "routes: []\n")
pid = self.orch.supervise_queue_proposal(
bottle_id, tool=TOOL_EGRESS_ALLOW,
proposed_file="routes:\n - host: google.com\n", justification="need it")
self.orch.supervise_respond(
pid, bottle_slug=bottle_id, decision="approve", notes="ok")
decided = self.orch.supervise_poll_response(bottle_id, pid)
self.assertEqual("approved", decided["status"])
self.assertEqual("ok", decided["notes"])
# Archived on read → gone from pending, and a re-poll is 'unknown'.
self.assertEqual([], self.orch.supervise_pending())
self.assertEqual(
{"status": "unknown"}, self.orch.supervise_poll_response(bottle_id, pid))
def test_poll_unknown_for_other_bottle(self) -> None:
bottle_id = self._register("demo", "routes: []\n")
pid = self.orch.supervise_queue_proposal(
bottle_id, tool=TOOL_EGRESS_ALLOW,
proposed_file="routes:\n - host: google.com\n", justification="j")
# A different bottle id can't read demo's proposal (scoped by queue key).
self.assertEqual(
{"status": "unknown"}, self.orch.supervise_poll_response("other-bottle", pid))
if __name__ == "__main__":
unittest.main()
+52
View File
@@ -106,6 +106,58 @@ class TestPolicyResolver(unittest.TestCase):
with self.assertRaises(PolicyResolveError):
self.r.resolve_policy_and_bottle_id("10.243.0.1")
# --- supervise agent RPCs (issue #469) ---------------------------------
def test_propose_supervise_returns_id_and_posts_payload(self) -> None:
with patch(_URLOPEN, return_value=_resp({"proposal_id": "p-7"})) as m:
pid = self.r.propose_supervise(
"10.243.0.7", "the-token",
tool="egress-allow", proposed_file="routes:\n", justification="j",
)
self.assertEqual("p-7", pid)
req = m.call_args.args[0]
self.assertTrue(req.full_url.endswith("/supervise/propose"))
sent = json.loads(req.data)
self.assertEqual("10.243.0.7", sent["source_ip"])
self.assertEqual("the-token", sent["identity_token"])
self.assertEqual("egress-allow", sent["tool"])
self.assertEqual("routes:\n", sent["proposed_file"])
def test_propose_supervise_unattributed_is_none(self) -> None:
with patch(_URLOPEN, side_effect=_http_error(403)):
self.assertIsNone(self.r.propose_supervise(
"10.9.9.9", "t", tool="egress-allow", proposed_file="x", justification="j"))
def test_propose_supervise_missing_id_is_none(self) -> None:
with patch(_URLOPEN, return_value=_resp({})):
self.assertIsNone(self.r.propose_supervise(
"10.243.0.1", "t", tool="egress-allow", proposed_file="x", justification="j"))
def test_propose_supervise_unreachable_raises(self) -> None:
with patch(_URLOPEN, side_effect=urllib.error.URLError("refused")):
with self.assertRaises(PolicyResolveError):
self.r.propose_supervise(
"10.243.0.1", "t", tool="egress-allow", proposed_file="x", justification="j")
def test_poll_supervise_returns_status(self) -> None:
with patch(_URLOPEN, return_value=_resp(
{"status": "approved", "notes": "ok", "final_file": None})
) as m:
result = self.r.poll_supervise("10.243.0.7", "tok", "p-7")
self.assertEqual("approved", result["status"])
req = m.call_args.args[0]
self.assertTrue(req.full_url.endswith("/supervise/poll"))
self.assertEqual("p-7", json.loads(req.data)["proposal_id"])
def test_poll_supervise_unattributed_is_none(self) -> None:
with patch(_URLOPEN, side_effect=_http_error(403)):
self.assertIsNone(self.r.poll_supervise("10.9.9.9", "t", "p-7"))
def test_poll_supervise_unreachable_raises(self) -> None:
with patch(_URLOPEN, side_effect=urllib.error.URLError("refused")):
with self.assertRaises(PolicyResolveError):
self.r.poll_supervise("10.243.0.1", "t", "p-7")
if __name__ == "__main__":
unittest.main()
+188 -189
View File
@@ -1,4 +1,11 @@
"""Unit: supervise daemon MCP server (PRD 0013)."""
"""Unit: supervise daemon MCP server (PRD 0013, PRD 0070).
The daemon no longer opens bot-bottle.db: it queues proposals and polls for
their responses over the control-plane RPC (issue #469). These tests drive the
handlers with `_FakeSuperviseResolver`, an in-process stand-in for
`PolicyResolver.propose_supervise` / `poll_supervise` backed by the real queue
store so the operator-response and archive contracts are still exercised
end-to-end, just through the RPC seam instead of a direct file handle."""
import http.client
import json
@@ -44,6 +51,81 @@ from bot_bottle.supervise_server import (
validate_proposed_file,
)
# Fixed caller identity for the handler tests. The control plane attributes by
# (source_ip, identity_token); the fake resolver ignores them and answers for a
# fixed bottle, since attribution itself is covered by the orchestrator tests.
_SRC = "10.0.0.7"
_TOK = "tok"
class _FakeSuperviseResolver:
"""Stand-in for `PolicyResolver`'s supervise RPCs, backed by the real queue
store (as the orchestrator is). `bottle_id=None` models an unattributed
caller (a clean 403 None); `raises=True` models an unreachable
orchestrator (`PolicyResolveError`)."""
def __init__(
self, bottle_id: str | None = "dev", raises: bool = False, policy: str = "",
) -> None:
self.bottle_id = bottle_id
self.raises = raises
self._policy = policy
def propose_supervise(
self, source_ip, identity_token, *, tool, proposed_file, justification,
):
del source_ip, identity_token
if self.raises:
raise supervise_server.PolicyResolveError("orchestrator down")
if self.bottle_id is None:
return None
proposal = _sv.Proposal.new(
bottle_slug=self.bottle_id, tool=tool, proposed_file=proposed_file,
justification=justification, current_file_hash=_sv.sha256_hex(proposed_file),
)
_sv.write_proposal(proposal)
return proposal.id
def poll_supervise(self, source_ip, identity_token, proposal_id):
del source_ip, identity_token
if self.raises:
raise supervise_server.PolicyResolveError("orchestrator down")
if self.bottle_id is None:
return None
try:
response = _sv.read_response(self.bottle_id, proposal_id)
except FileNotFoundError:
try:
_sv.read_proposal(self.bottle_id, proposal_id)
except FileNotFoundError:
return {"status": _sv.POLL_STATUS_UNKNOWN}
return {"status": _sv.POLL_STATUS_PENDING}
_sv.archive_proposal(self.bottle_id, proposal_id)
return {
"status": response.status, "notes": response.notes,
"final_file": response.final_file,
}
# Used by list-egress-routes (`_resolved_routes_payload`), unchanged path.
def resolve_policy_and_bottle_id(self, source_ip, identity_token=""):
del source_ip, identity_token
if self.raises:
raise supervise_server.PolicyResolveError("orchestrator down")
return self._policy, self.bottle_id, {}
def _tools_call(resolver, params, config=None):
return handle_tools_call(
params, config or ServerConfig(),
resolver=resolver, source_ip=_SRC, identity_token=_TOK,
)
def _check(resolver, params):
return handle_check_proposal(
params, resolver=resolver, source_ip=_SRC, identity_token=_TOK,
)
# --- Validation ------------------------------------------------------------
@@ -111,32 +193,35 @@ class TestRpcErrorTaxonomy(unittest.TestCase):
validate_proposed_file(_sv.TOOL_EGRESS_ALLOW, "routes: nope\n")
def test_unknown_tool_in_tools_call_is_client_error(self):
config = ServerConfig(bottle_slug="dev")
with self.assertRaises(_RpcClientError) as cm:
handle_tools_call({"name": "no-such-tool", "arguments": {}}, config)
_tools_call(_FakeSuperviseResolver(), {"name": "no-such-tool", "arguments": {}})
self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code)
class TestRpcInternalErrorOnIoFailure(unittest.TestCase):
def test_write_proposal_os_error_raises_internal(self):
config = ServerConfig(
bottle_slug="dev",
)
with patch.object(_sv, "write_proposal", side_effect=OSError("disk full")), \
self.assertRaises(_RpcInternalError) as cm:
handle_tools_call(
{
"name": _sv.TOOL_EGRESS_ALLOW,
"arguments": {
"routes_yaml": "routes:\n - host: example.com\n",
"justification": "x",
},
},
config,
)
class TestRpcInternalErrorOnRpcFailure(unittest.TestCase):
"""A queue RPC that can't reach the orchestrator (or returns unattributed)
surfaces as ERR_INTERNAL the daemon fails closed rather than leaking the
cause to the agent."""
_ARGS = {
"name": _sv.TOOL_EGRESS_ALLOW,
"arguments": {
"routes_yaml": "routes:\n - host: example.com\n",
"justification": "x",
},
}
def test_unreachable_orchestrator_raises_internal(self):
with self.assertRaises(_RpcInternalError) as cm:
_tools_call(_FakeSuperviseResolver(raises=True), self._ARGS)
self.assertEqual(ERR_INTERNAL, cm.exception.code)
self.assertIsNotNone(cm.exception.__cause__)
def test_unattributed_source_raises_internal(self):
with self.assertRaises(_RpcInternalError) as cm:
_tools_call(_FakeSuperviseResolver(bottle_id=None), self._ARGS)
self.assertEqual(ERR_INTERNAL, cm.exception.code)
# --- JSON-RPC parsing ------------------------------------------------------
@@ -265,8 +350,8 @@ class TestHandleToolsList(unittest.TestCase):
class TestHandleToolsCall(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory(prefix="supervise-server-test.")
self._home_patch = self._patch_home(Path(self._tmp.name))
self.config = ServerConfig(bottle_slug="dev")
self._home_patch = use_bottle_root(Path(self._tmp.name) / ".bot-bottle")
self.resolver = _FakeSuperviseResolver("dev")
_qs.QueueStore("dev").migrate()
_as.AuditStore().migrate()
@@ -274,12 +359,9 @@ class TestHandleToolsCall(unittest.TestCase):
self._home_patch()
self._tmp.cleanup()
def _patch_home(self, fake_home: Path):
return use_bottle_root(fake_home / ".bot-bottle")
def _respond_when_proposal_appears(self, status: str, notes: str = "") -> threading.Thread:
"""Background thread: poll the queue for a fresh proposal, write a
matching response. Returns the thread so the test can join it."""
matching response the operator half, out of band."""
def runner():
for _ in range(200):
pending = _sv.list_pending_proposals("dev")
@@ -298,16 +380,13 @@ class TestHandleToolsCall(unittest.TestCase):
def test_call_round_trips_through_queue(self):
responder = self._respond_when_proposal_appears(_sv.STATUS_APPROVED, notes="lgtm")
try:
result = handle_tools_call(
{
"name": _sv.TOOL_EGRESS_BLOCK,
"arguments": {
"routes_yaml": "routes:\n - host: example.com\n",
"justification": "need example.com",
},
result = _tools_call(self.resolver, {
"name": _sv.TOOL_EGRESS_BLOCK,
"arguments": {
"routes_yaml": "routes:\n - host: example.com\n",
"justification": "need example.com",
},
self.config,
)
})
finally:
responder.join()
self.assertFalse(result["isError"]) # type: ignore[index]
@@ -318,16 +397,13 @@ class TestHandleToolsCall(unittest.TestCase):
def test_allow_round_trips_through_queue(self):
responder = self._respond_when_proposal_appears(_sv.STATUS_APPROVED, notes="ok")
try:
result = handle_tools_call(
{
"name": _sv.TOOL_EGRESS_ALLOW,
"arguments": {
"routes_yaml": "routes:\n - host: example.com\n",
"justification": "need example.com",
},
result = _tools_call(self.resolver, {
"name": _sv.TOOL_EGRESS_ALLOW,
"arguments": {
"routes_yaml": "routes:\n - host: example.com\n",
"justification": "need example.com",
},
self.config,
)
})
finally:
responder.join()
self.assertFalse(result["isError"]) # type: ignore[index]
@@ -338,94 +414,70 @@ class TestHandleToolsCall(unittest.TestCase):
def test_rejected_response_sets_isError(self):
responder = self._respond_when_proposal_appears(_sv.STATUS_REJECTED, notes="nope")
try:
result = handle_tools_call(
{
"name": _sv.TOOL_EGRESS_ALLOW,
"arguments": {
"routes_yaml": "routes:\n - host: example.com\n",
"justification": "needed for tests",
},
result = _tools_call(self.resolver, {
"name": _sv.TOOL_EGRESS_ALLOW,
"arguments": {
"routes_yaml": "routes:\n - host: example.com\n",
"justification": "needed for tests",
},
self.config,
)
})
finally:
responder.join()
self.assertTrue(result["isError"]) # type: ignore[index]
def test_invalid_tool_name_raises(self):
with self.assertRaises(_RpcError) as cm:
handle_tools_call(
{"name": "not-a-tool", "arguments": {}},
self.config,
)
_tools_call(self.resolver, {"name": "not-a-tool", "arguments": {}})
self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code)
def test_missing_justification_raises(self):
with self.assertRaises(_RpcError):
handle_tools_call(
{
"name": _sv.TOOL_EGRESS_ALLOW,
"arguments": {"routes_yaml": "routes:\n - host: example.com\n"},
},
self.config,
)
_tools_call(self.resolver, {
"name": _sv.TOOL_EGRESS_ALLOW,
"arguments": {"routes_yaml": "routes:\n - host: example.com\n"},
})
def test_missing_name_raises(self):
with self.assertRaises(_RpcError) as cm:
handle_tools_call({"arguments": {}}, self.config)
_tools_call(self.resolver, {"arguments": {}})
self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code)
def test_arguments_must_be_object(self):
with self.assertRaises(_RpcError) as cm:
handle_tools_call(
{
"name": _sv.TOOL_EGRESS_ALLOW,
"arguments": [],
},
self.config,
)
_tools_call(self.resolver, {"name": _sv.TOOL_EGRESS_ALLOW, "arguments": []})
self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code)
self.assertIn("must be an object", cm.exception.message)
def test_capability_block_call_raises_unknown_tool(self):
with self.assertRaises(_RpcError) as cm:
handle_tools_call(
{
"name": "capability-block",
"arguments": {
"dockerfile": "FROM python:3.13\n",
"justification": "need git",
},
_tools_call(self.resolver, {
"name": "capability-block",
"arguments": {
"dockerfile": "FROM python:3.13\n",
"justification": "need git",
},
self.config,
)
})
self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code)
self.assertIn("unknown tool", cm.exception.message)
def test_archives_proposal_after_response(self):
responder = self._respond_when_proposal_appears(_sv.STATUS_APPROVED)
try:
handle_tools_call(
{
"name": _sv.TOOL_EGRESS_ALLOW,
"arguments": {
"routes_yaml": "routes:\n - host: example.com\n",
"justification": "x",
},
_tools_call(self.resolver, {
"name": _sv.TOOL_EGRESS_ALLOW,
"arguments": {
"routes_yaml": "routes:\n - host: example.com\n",
"justification": "x",
},
self.config,
)
})
finally:
responder.join()
# No pending proposals left after archive.
# No pending proposals left after the decided poll archives it.
self.assertEqual([], _sv.list_pending_proposals("dev"))
def test_pending_response_times_out_without_archive(self):
config = ServerConfig(
bottle_slug="dev",
response_timeout_seconds=0.05,
)
result = handle_tools_call(
result = _tools_call(
self.resolver,
{
"name": _sv.TOOL_EGRESS_ALLOW,
"arguments": {
@@ -433,9 +485,8 @@ class TestHandleToolsCall(unittest.TestCase):
"justification": "need egress",
},
},
config,
ServerConfig(response_timeout_seconds=0.05),
)
self.assertFalse(result["isError"]) # type: ignore[index]
text = result["content"][0]["text"] # type: ignore[index]
self.assertIn("status: pending", text)
@@ -510,7 +561,7 @@ class TestHttpEndToEnd(unittest.TestCase):
self.port = s.getsockname()[1]
s.close()
self.server = MCPServer(("127.0.0.1", self.port), MCPHandler)
self.server.config = ServerConfig(bottle_slug="dev")
self.server.config = ServerConfig()
self.thread = threading.Thread(
target=self.server.serve_forever, daemon=True,
)
@@ -552,23 +603,21 @@ class TestHttpEndToEnd(unittest.TestCase):
)
self.assertEqual(ERR_METHOD_NOT_FOUND, result["error"]["code"]) # type: ignore[index]
def test_internal_error_returns_err_internal_over_http(self):
with patch.object(
supervise_server._sv, "write_proposal",
side_effect=OSError("disk full"),
):
result = self._post_jsonrpc({
"jsonrpc": "2.0",
"id": 99,
"method": "tools/call",
"params": {
"name": _sv.TOOL_EGRESS_ALLOW,
"arguments": {
"routes_yaml": "routes:\n - host: example.com\n",
"justification": "x",
},
def test_no_resolver_fails_closed_over_http(self):
# The test server has no policy_resolver wired, so a proposal tools/call
# fails closed with ERR_INTERNAL rather than queuing anything.
result = self._post_jsonrpc({
"jsonrpc": "2.0",
"id": 99,
"method": "tools/call",
"params": {
"name": _sv.TOOL_EGRESS_ALLOW,
"arguments": {
"routes_yaml": "routes:\n - host: example.com\n",
"justification": "x",
},
})
},
})
self.assertIn("error", result)
self.assertEqual(ERR_INTERNAL, result["error"]["code"]) # type: ignore[index]
@@ -583,72 +632,23 @@ class TestHttpEndToEnd(unittest.TestCase):
conn.close()
class _FakeResolver:
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:
del identity_token
self.calls.append(source_ip)
if self._raises:
# Raise the exact class supervise_server catches (it imports
# policy_resolver flat inside the bundle, package-side in tests).
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
client address, enough to exercise `_attributed_config` off-socket."""
client address, enough to exercise the resolver-backed paths off-socket."""
h: MCPHandler = MCPHandler.__new__(MCPHandler)
h.server = types.SimpleNamespace(policy_resolver=resolver) # type: ignore[assignment]
h.client_address = ("10.0.0.7", 4321)
h.headers = {} # type: ignore[assignment]
return h
class TestAttributedConfig(unittest.TestCase):
"""Each proposal is attributed to the calling bottle by source IP (PRD
0070); a server without a resolver fails closed rather than queuing under an
unattributed slug."""
class TestResolverFailClosed(unittest.TestCase):
"""A dispatch without a resolver is a misconfiguration, not a tenancy mode
fail closed rather than queue (or list) anything (PRD 0070)."""
def test_missing_resolver_fails_closed(self) -> None:
def test_missing_resolver_raises(self) -> None:
with self.assertRaises(_RpcInternalError):
_handler(None)._attributed_config(ServerConfig(bottle_slug="dev"))
def test_consolidated_binds_source_ip_bottle(self) -> None:
r = _FakeResolver(bottle_id="bottle-x")
cfg = _handler(r)._attributed_config(ServerConfig(bottle_slug="ignored"))
self.assertEqual("bottle-x", cfg.bottle_slug) # resolved slug wins
self.assertEqual(["10.0.0.7"], r.calls)
def test_unattributed_source_fails_closed(self) -> None:
with self.assertRaises(_RpcInternalError):
_handler(_FakeResolver(bottle_id=None))._attributed_config(
ServerConfig(bottle_slug="x")
)
def test_resolver_error_fails_closed(self) -> None:
with self.assertRaises(_RpcInternalError):
_handler(_FakeResolver(raises=True))._attributed_config(
ServerConfig(bottle_slug="x")
)
_handler(None)._resolver_or_fail()
class TestResolvedRoutesPayload(unittest.TestCase):
@@ -664,7 +664,7 @@ class TestResolvedRoutesPayload(unittest.TestCase):
" - host: www.google.com\n"
)
payload = _handler(
_FakeResolver(bottle_id="b1", policy=policy)
_FakeSuperviseResolver(bottle_id="b1", policy=policy)
)._resolved_routes_payload()
assert payload is not None
self.assertFalse(payload["isError"]) # type: ignore[index]
@@ -676,7 +676,7 @@ class TestResolvedRoutesPayload(unittest.TestCase):
# resolve_client_context swallows resolver errors → deny-all (empty),
# never another bottle's routes.
payload = _handler(
_FakeResolver(raises=True)
_FakeSuperviseResolver(raises=True)
)._resolved_routes_payload()
assert payload is not None
data = json.loads(payload["content"][0]["text"]) # type: ignore[index]
@@ -698,7 +698,7 @@ class TestNonBlockingSupervise(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory(prefix="supervise-nonblock-test.")
self._home_patch = use_bottle_root(Path(self._tmp.name) / ".bot-bottle")
self.config = ServerConfig(bottle_slug="dev")
self.resolver = _FakeSuperviseResolver("dev")
_qs.QueueStore("dev").migrate()
_as.AuditStore().migrate()
@@ -717,9 +717,6 @@ class TestNonBlockingSupervise(unittest.TestCase):
_sv.write_proposal(p)
return p
def _check(self, proposal_id: str) -> dict[str, object]:
return handle_check_proposal({"arguments": {"proposal_id": proposal_id}}, self.config)
# --- pending response carries the id ---
def test_pending_text_includes_id_and_pointer(self):
@@ -730,12 +727,13 @@ class TestNonBlockingSupervise(unittest.TestCase):
def test_tools_call_timeout_returns_pending_with_id_and_stays_queued(self):
# No responder → the grace window expires → pending, not blocked forever.
result = handle_tools_call(
result = _tools_call(
self.resolver,
{
"name": _sv.TOOL_EGRESS_ALLOW,
"arguments": {"routes_yaml": self._ROUTES, "justification": "x"},
},
ServerConfig(bottle_slug="dev", response_timeout_seconds=0.05),
ServerConfig(response_timeout_seconds=0.05),
)
self.assertFalse(result["isError"]) # type: ignore[index]
text = result["content"][0]["text"] # type: ignore[index]
@@ -749,7 +747,7 @@ class TestNonBlockingSupervise(unittest.TestCase):
def test_check_returns_approved_and_archives(self):
p = self._seed_proposal()
_sv.write_response("dev", _sv.Response(proposal_id=p.id, status=_sv.STATUS_APPROVED, notes="ok"))
result = self._check(p.id)
result = _check(self.resolver, {"arguments": {"proposal_id": p.id}})
self.assertFalse(result["isError"])
text = result["content"][0]["text"] # type: ignore[index]
self.assertIn("status: approved", text)
@@ -760,13 +758,13 @@ class TestNonBlockingSupervise(unittest.TestCase):
def test_check_rejected_sets_isError(self):
p = self._seed_proposal()
_sv.write_response("dev", _sv.Response(proposal_id=p.id, status=_sv.STATUS_REJECTED, notes="no"))
result = self._check(p.id)
result = _check(self.resolver, {"arguments": {"proposal_id": p.id}})
self.assertTrue(result["isError"])
self.assertIn("status: rejected", result["content"][0]["text"]) # type: ignore[index]
def test_check_pending_when_no_decision_yet(self):
p = self._seed_proposal()
result = self._check(p.id)
result = _check(self.resolver, {"arguments": {"proposal_id": p.id}})
self.assertFalse(result["isError"])
text = result["content"][0]["text"] # type: ignore[index]
self.assertIn("status: pending", text)
@@ -774,40 +772,41 @@ class TestNonBlockingSupervise(unittest.TestCase):
self.assertEqual(1, len(_sv.list_pending_proposals("dev"))) # not archived
def test_check_unknown_id_is_error(self):
result = self._check("no-such-proposal")
result = _check(self.resolver, {"arguments": {"proposal_id": "no-such-proposal"}})
self.assertTrue(result["isError"])
self.assertIn("status: unknown", result["content"][0]["text"]) # type: ignore[index]
def test_check_missing_id_raises(self):
with self.assertRaises(_RpcClientError) as cm:
handle_check_proposal({"arguments": {}}, self.config)
_check(self.resolver, {"arguments": {}})
self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code)
def test_check_empty_id_raises(self):
with self.assertRaises(_RpcClientError) as cm:
handle_check_proposal({"arguments": {"proposal_id": " "}}, self.config)
_check(self.resolver, {"arguments": {"proposal_id": " "}})
self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code)
def test_check_arguments_must_be_object(self):
with self.assertRaises(_RpcClientError) as cm:
handle_check_proposal({"arguments": []}, self.config)
_check(self.resolver, {"arguments": []})
self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code)
def test_full_nonblocking_round_trip(self):
# 1. tools/call times out → pending with id
result = handle_tools_call(
result = _tools_call(
self.resolver,
{
"name": _sv.TOOL_EGRESS_ALLOW,
"arguments": {"routes_yaml": self._ROUTES, "justification": "x"},
},
ServerConfig(bottle_slug="dev", response_timeout_seconds=0.05),
ServerConfig(response_timeout_seconds=0.05),
)
pid = _sv.list_pending_proposals("dev")[0].id
self.assertIn(pid, result["content"][0]["text"]) # type: ignore[index]
# 2. operator decides out-of-band
_sv.write_response("dev", _sv.Response(proposal_id=pid, status=_sv.STATUS_APPROVED, notes="ok"))
# 3. agent resumes by polling — no re-proposing
poll = self._check(pid)
poll = _check(self.resolver, {"arguments": {"proposal_id": pid}})
self.assertFalse(poll["isError"])
self.assertIn("status: approved", poll["content"][0]["text"]) # type: ignore[index]
self.assertEqual([], _sv.list_pending_proposals("dev")) # resolved + archived