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:
@@ -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)
|
||||
|
||||
@@ -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}"]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user