Get bot-bottle.db off the data plane (supervise + egress proposals over RPC) #469

Open
opened 2026-07-23 20:56:49 -04:00 by didericis-claude · 0 comments
Collaborator

Context

PRD 0070 states the rule plainly:

only the orchestrator (control plane) opens bot-bottle.db; 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.

That rule is not in force. Two data-plane daemons hold a direct read-write handle on the shared SQLite file today:

  • supervise_server.py_sv.write_proposal (L377) and _sv.read_response (L434) for MCP tool calls.
  • egress_addon.py — the same two calls (L592, L645) on the DLP on_match: supervise path.

The PRD's own transitional caveat only names supervise. The egress addon — the most attack-exposed process in the system, TLS-bumping and body-scanning hostile traffic — is also a direct writer, and that isn't recorded anywhere.

The mount is the whole DB directory, read-write, via orchestrator/lifecycle.py (_host_db_dir()/run/supervise, plus the host root at /bot-bottle-root).

Why it's like this

Purely historical. The supervise plane (PRD 0013) predates the orchestrator: in the per-bottle model there was no control plane, and a bind-mounted host DB was the only channel that got a proposal from inside the gateway to the operator on the host. Reasonable then.

PRD 0070 built the control plane but migrated only the operator half of the flow — GET /supervise/proposals and POST /supervise/respond exist. The agent half (queue a proposal, poll for its response) was never added, so those four call sites kept doing what they always did.

Why it matters

Security. bot-bottle.db co-tenants the orchestrator registry, and orchestrator_bottles.identity_token is a plaintext column. An RCE in egress therefore reads every bottle's identity token, then calls /resolve as any of them to collect that bottle's upstream credentials. This is the single step that converts one compromised process into fleet-wide credential access, and it's the concrete form of the "secret concentration" risk PRD 0070's security review flags. Note the daemons all run as root — no USER directive in Dockerfile.gateway, Dockerfile.orchestrator, or Dockerfile.infra.

Scaling. TLS interception plus per-byte DLP scanning (gzip inflate, entropy, regex over bodies) is the expensive path, and mitmproxy is one GIL-bound asyncio process. The fix is N egress workers, which the resolver-only design otherwise permits almost for free — egress holds no policy and no cross-connection state, and source-IP attribution means bottles can be sharded across workers at launch rather than load-balanced, so attribution keeps working unchanged. The direct DB handle is what blocks it: N workers writing one SQLite file is survivable inside one kernel where fcntl serializes them, and is exactly the incoherent-locking corruption case that forced the single-container design on macOS the moment those workers live in separate guests.

The change

  • Control plane (orchestrator/control_plane.py): add the agent-side half of the supervise flow — an endpoint to queue a proposal and one to poll for its response. Both must attribute the caller by (source_ip, identity_token) like /resolve, so a bottle can only ever queue or read its own proposals.
  • Data plane: swap the four _sv.write_proposal / _sv.read_response call sites in supervise_server.py and egress_addon.py for HTTP calls through the existing resolver client.
  • Packaging: drop the DB bind-mount from the gateway/infra container once nothing in the data plane opens the file, and drop SUPERVISE_DB_PATH from the data-plane env.
  • Remove the transitional caveat from PRD 0070 and update the "gateway writes become RPC calls" note.

The blocking-poll semantics already map cleanly onto a request: the existing contract is a ~30s grace window, then return pending with a proposal_id, with check-proposal to resume. No new state machine.

Notes

  • Not blocked on anything. Independent of #355 (SecretProvider), which addresses the residual concentration risk that survives this fix.
  • Worth pairing with two adjacent cheap hardening steps, either here or as follow-ups: scoping the control-plane token by role (the gateway needs /resolve only, not the mutating routes), and running the daemons as distinct non-root UIDs.
## Context PRD 0070 states the rule plainly: > **only the orchestrator (control plane) opens `bot-bottle.db`; 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. That rule is not in force. Two data-plane daemons hold a direct read-write handle on the shared SQLite file today: - [`supervise_server.py`](https://gitea.dideric.is/didericis/bot-bottle/src/branch/main/bot_bottle/supervise_server.py) — `_sv.write_proposal` (L377) and `_sv.read_response` (L434) for MCP tool calls. - [`egress_addon.py`](https://gitea.dideric.is/didericis/bot-bottle/src/branch/main/bot_bottle/egress_addon.py) — the same two calls (L592, L645) on the DLP `on_match: supervise` path. The PRD's own transitional caveat only names supervise. The egress addon — the most attack-exposed process in the system, TLS-bumping and body-scanning hostile traffic — is also a direct writer, and that isn't recorded anywhere. The mount is the whole DB directory, read-write, via [`orchestrator/lifecycle.py`](https://gitea.dideric.is/didericis/bot-bottle/src/branch/main/bot_bottle/orchestrator/lifecycle.py) (`_host_db_dir()` → `/run/supervise`, plus the host root at `/bot-bottle-root`). ## Why it's like this Purely historical. The supervise plane (PRD 0013) predates the orchestrator: in the per-bottle model there was no control plane, and a bind-mounted host DB was the only channel that got a proposal from inside the gateway to the operator on the host. Reasonable then. PRD 0070 built the control plane but migrated only the **operator** half of the flow — `GET /supervise/proposals` and `POST /supervise/respond` exist. The **agent** half (queue a proposal, poll for its response) was never added, so those four call sites kept doing what they always did. ## Why it matters **Security.** `bot-bottle.db` co-tenants the orchestrator registry, and `orchestrator_bottles.identity_token` is a plaintext column. An RCE in egress therefore reads every bottle's identity token, then calls `/resolve` as any of them to collect that bottle's upstream credentials. This is the single step that converts one compromised process into fleet-wide credential access, and it's the concrete form of the "secret concentration" risk PRD 0070's security review flags. Note the daemons all run as root — no `USER` directive in `Dockerfile.gateway`, `Dockerfile.orchestrator`, or `Dockerfile.infra`. **Scaling.** TLS interception plus per-byte DLP scanning (gzip inflate, entropy, regex over bodies) is the expensive path, and mitmproxy is one GIL-bound asyncio process. The fix is N egress workers, which the resolver-only design otherwise permits almost for free — egress holds no policy and no cross-connection state, and source-IP attribution means bottles can be *sharded* across workers at launch rather than load-balanced, so attribution keeps working unchanged. The direct DB handle is what blocks it: N workers writing one SQLite file is survivable inside one kernel where `fcntl` serializes them, and is exactly the incoherent-locking corruption case that forced the single-container design on macOS the moment those workers live in separate guests. ## The change - **Control plane** ([`orchestrator/control_plane.py`](https://gitea.dideric.is/didericis/bot-bottle/src/branch/main/bot_bottle/orchestrator/control_plane.py)): add the agent-side half of the supervise flow — an endpoint to queue a proposal and one to poll for its response. Both must attribute the caller by `(source_ip, identity_token)` like `/resolve`, so a bottle can only ever queue or read *its own* proposals. - **Data plane**: swap the four `_sv.write_proposal` / `_sv.read_response` call sites in `supervise_server.py` and `egress_addon.py` for HTTP calls through the existing resolver client. - **Packaging**: drop the DB bind-mount from the gateway/infra container once nothing in the data plane opens the file, and drop `SUPERVISE_DB_PATH` from the data-plane env. - Remove the transitional caveat from PRD 0070 and update the "gateway writes become RPC calls" note. The blocking-poll semantics already map cleanly onto a request: the existing contract is a ~30s grace window, then return `pending` with a `proposal_id`, with `check-proposal` to resume. No new state machine. ## Notes - Not blocked on anything. Independent of #355 (`SecretProvider`), which addresses the residual concentration risk that survives this fix. - Worth pairing with two adjacent cheap hardening steps, either here or as follow-ups: scoping the control-plane token by role (the gateway needs `/resolve` only, not the mutating routes), and running the daemons as distinct non-root UIDs.
didericis-claude added the Kind/Security
Priority
High
2
labels 2026-07-23 20:56:49 -04:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: didericis/bot-bottle#469