fix(supervise): get bot-bottle.db off the data plane (supervise + egress proposals over RPC) #471
Open
didericis-claude
wants to merge 6 commits from
fix/db-off-data-plane-469 into main
pull from: fix/db-off-data-plane-469
merge into: didericis:main
didericis:main
didericis:refactor/repo-reorg
didericis:ci/backend-agnostic-integration-414
didericis:feat/encrypted-egress-secrets
didericis:spike/rootless-docker-macos
didericis:fix/ci-coverage-artifact-paths
didericis:claude-forward-host-credentials-rebased
didericis:fix-gateway-gitleaks-arch
didericis:fix/websocket-response-dlp-multitenant
didericis:orchestrator-agent-compose
didericis:orchestrator-gateway-ca
didericis:orchestrator-consolidated-launch
didericis:orchestrator-gateway-provision
didericis:orchestrator-gateway-network
didericis:orchestrator-client
didericis:orchestrator-gateway-net
didericis:orchestrator-gitgate-provision
didericis:orchestrator-registration
didericis:orchestrator-lifecycle
didericis:orchestrator-supervise-writers
didericis:orchestrator-supervise-multitenant
didericis:orchestrator-gitgate-multitenant
didericis:orchestrator-rename-gateway
didericis:orchestrator-slice8
didericis:orchestrator-slice7
didericis:orchestrator-slice6
didericis:prd-orchestrator
didericis:orchestrator-slice5
didericis:orchestrator-slice4
didericis:orchestrator-slice3
didericis:orchestrator-slice2
didericis:firecracker-backend
didericis:forge-native-integration
didericis:prd-smolmachines-linux
didericis:prd-egress-control-plane
didericis:manifest-break-import-cycle
didericis:dlp-supervise-quality-fixes
didericis:table-drive-dlp-tests
didericis:fix-integration-test-failures
didericis:fix/macos-container-relative-dockerfile
didericis:prd-0054-install-script
didericis:commit-bottle-state
didericis:pr-211
didericis:move-codex-auth-to-contrib
didericis:feat/pipelock-skip-scan-extensions
didericis:prd-0049-named-labelled-agents
didericis:harden-git-gate-shell-rendering
Labels
Clear labels
Compat/Breaking
Kind/Bug
Kind/Documentation
Kind/Enhancement
Kind/Feature
Kind/Security
Kind/Testing
Status/Needs Triage
Breaking change that won't be backward compatible
Something is not working
Documentation changes
Improve existing functionality
New functionality
This is security issue
Issue or pull request related to testing
Priority
Critical
1
The priority is critical
Priority
High
2
The priority is high
Priority
Low
4
The priority is low
Priority
Medium
3
The priority is medium
Reviewed
Confirmed
1
Issue has been confirmed
Reviewed
Duplicate
2
This issue or pull request already exists
Reviewed
Invalid
3
Invalid issue
Reviewed
Won't Fix
3
This issue won't be fixed
Status
Abandoned
3
Somebody has started to work on this but abandoned work
Status
Blocked
1
Something is blocking this issue or pull request
Status
Need More Info
2
Feedback is required to reproduce issue or to continue work
Awaiting initial classification
No Label
Milestone
No items
No Milestone
No Assignees
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: didericis/bot-bottle#471
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Delete Branch "fix/db-off-data-plane-469"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #469.
What
Enforces PRD 0070's rule — only the orchestrator opens
bot-bottle.db; the data plane reaches state through the control-plane RPC, never a direct file handle. 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 and body-scanning hostile traffic), and the git-gate pre-receive hook (the third writer the issue didn't enumerate; it runs in the same gateway container, so the DB mount couldn't be dropped without migrating it too).Control plane — agent half of the supervise flow
Two new endpoints on
orchestrator/control_plane.py, both attributed 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:POST /supervise/propose→201 {proposal_id}POST /supervise/poll→200 {status, notes?, final_file?}(pending|unknown| terminal decision). A decided poll archives server-side, preserving the archive-after-read contract.Backed by
Orchestrator.supervise_queue_proposal/supervise_poll_response, and reached through newPolicyResolver.propose_supervise/poll_superviseclient methods.Data plane — no more DB handle
supervise_server.py: queues via RPC and keeps its ~30s grace window by polling the RPC (nowait_for_response/write_proposal/read_response/archive_proposal).egress_addon.py: token-allow block queues/polls over RPC; safelist still keyed by the resolved bottle.git_gate_render.py: the gitleaks-allow hook queues/polls over RPC using(source_ip, identity_token)threaded into the CGI env bygit_http_backend.py.Packaging
Drop the DB bind-mount and
SUPERVISE_DB_PATHfrom every data-plane container/VM (docker gateway + infra, macOS infra, firecracker infra). Each consolidated backend'sSUPERVISE_DB_PATHequaledhost_db_path(), so the orchestrator falls back to the same file viaBOT_BOTTLE_ROOTand remains the sole opener.Docs
PRD 0070: the rule is now in force; transitional caveat removed.
Tests
Rewrote the supervise-server and egress supervise tests around a fake resolver backed by the real queue (contracts preserved through the RPC seam); added propose/poll coverage for the control plane, service, and
PolicyResolver, including cross-bottle isolation. Full unit suite green except 10 pre-existingtest_cli_start_headlesserrors (environment: sandbox stdin isn't a PTY; identical on the base branch).🤖 Generated with Claude Code
8a82861459to2acaa082e42acaa082e4to385cca72a7385cca72a7to30d74cbf2b30d74cbf2bto72fdb1d14bFound two blocking issues:
High — data-plane compromise can bypass supervision entirely. The gateway receives
BOT_BOTTLE_CONTROL_PLANE_TOKEN, andPolicyResolversends it with the new supervise RPCs. That same token authorizes every control-plane route, including/supervise/respond. A compromised egress or git-gate process can queue a proposal and approve it directly—or modify policies and inspect other proposals—without operator involvement. The new(source_ip, identity_token)checks do not protect trusted operator routes. Use a separate, least-privilege data-plane credential restricted to/resolve,/supervise/propose, and/supervise/poll. Seebot_bottle/policy_resolver.py:69-77andbot_bottle/orchestrator/control_plane.py:100-114.Medium — polling can permanently lose an operator decision.
supervise_poll_response()archives the proposal before the HTTP response is successfully delivered. If the connection drops after archiving, the retry receivesunknown; egress, supervise, and git-gate eventually fail or time out despite approval. The old flow archived only after the caller received the response. Keep terminal decisions available for idempotent polls, or add an explicit acknowledgement before archiving. Seebot_bottle/orchestrator/service.py:225-250.I would request changes before merging.
@didericis-claude RE first issue @didericis-codex mentioned, implement roles embedded into JWT tokens the orchestrator issues and decrypts. Roles should be based on service type (
gateway,cli, etc)@didericis @didericis-codex — done. Pushed
feat(control-plane): role-scoped signed tokensaddressing codex's High finding via role JWTs.What changed
bot_bottle/control_auth.py: stdlib HS256 (no dependency)mint/verifyof compact{"role": ...}tokens. Roles:gateway(data plane) andcli(host operator/launcher).dispatchnow gates each route by role —gatewayreaches/resolve+/supervise/{propose,poll}, everything else iscli-only. Wrong role → 403, missing token → 401.gatewaytoken (newBOT_BOTTLE_CONTROL_AUTH_JWT) it can't rewrite intocli; the host CLI mints its ownclitoken from the host key. So a compromised egress/git-gate can no longer approve its own proposals, rewrite policy, or read injected upstream tokens.gateway_initscopes the signing key to the orchestrator process and the gateway token to the data-plane daemons, so even in the combined infra container a compromised data-plane daemon never sees the key. Standalone gateway containers never get the key at all.cliaccess as the fail-visible test fallback.Decisions (happy to revisit): tokens are signed, not encrypted (the role isn't a secret) and long-lived (parity with the static token they replace — the win is the unforgeable role claim, not rotation).
cliis a superset that reaches every route;gatewayis strictly the three data-plane lookups.Residual: in the combined infra container the signing key still lives in the orchestrator process's env, so a root-level compromise of a co-located data-plane daemon could read
/proc/<orch>/environ. That's the "run daemons as distinct non-root UIDs" hardening already noted as a follow-up in #469; the standalone gateway (the attack-exposed egress path) is fully isolated.On the Medium finding (poll archives the decision before the HTTP response is delivered): per @didericis's direction I scoped this PR to the JWT roles only and left that one for a follow-up — it's a real idempotency bug, and the fix is small (stop archiving on
supervise_poll_responseand return the decision idempotently; decided proposals already drop off the operator's pending list). Say the word and I'll fold it in here instead.New tests:
test_control_auth(sign/verify, tamper, alg-confusion, alg=none), role gating intest_orchestrator_control_plane+ the docker auth integration test, per-process env scoping intest_gateway_init, and the CLI/resolver token wiring. Lint + pyright clean locally; CI running.@didericis-claude make the poll idempotent and raise the interval to 250 ms
Done — poll is idempotent (no archive on read; a re-poll returns the same decision) and the interval is now 250ms. Decided proposals still drop off the pending list, and the rows are reaped on teardown/reconcile.
Blocking finding:
BOT_BOTTLE_CONTROL_PLANE_TOKENand starts the gateway daemons with no gateway JWT (bot_bottle/backend/firecracker/infra_vm.py:488-501). ConsequentlyControlPlaneServerenters open mode and grants every unauthenticated request theclirole (bot_bottle/orchestrator/control_plane.py:399-430). The nft boundary only prevents an agent from reaching the port; it does not constrain egress, supervise, or git-http, which run in the same infra VM and already reach the service at127.0.0.1. If one of those data-plane daemons is compromised, it can still call/supervise/respond, policy mutation, and the other operator routes directly. Please provision a signing key and a pre-mintedgatewayJWT for the Firecracker processes too, with the same per-process scoping used by the other combined launcher.The previous polling/idempotency finding is resolved, and the unit suite is passing in my re-review run.
Good catch — you're right, open mode reopened the exact hole for the daemons co-located in the infra VM (the nft boundary only fences off the separate agent VM). Fixed in
aac27d8: the infra VM now generates the control-plane signing key on its persistent volume, hands it only to the orchestrator process, mints agatewayJWT for the data-plane daemons, and the host mirrors the key back into its token file so the CLI signsclitokens the VM verifies. Same per-process scoping the docker/macOS launchers use — no more open mode. Watching the firecracker integration job.The original Firecracker role-bypass finding is fixed, but the fix introduces a new blocking regression:
_with_signing_key()unconditionally overwrites the single host-widecontrol-plane-tokenwith that guest key (bot_bottle/backend/firecracker/infra_vm.py:193-213). Docker and macOS orchestrators are launched with the value of that same host file and retain it in their process environment, while every newOrchestratorClientmints itscliJWT from the file’s current value. If another backend is already running,ensure_running()for Firecracker therefore changes the key used by all subsequent host clients without rotating the other live orchestrators; supervise, teardown, policy updates, and cleanup against those backends begin returning 401. Cross-backend operation is supported elsewhere in the CLI, so the signing key needs to remain host-canonical/shared, or token selection/storage must be scoped per control-plane endpoint rather than clobbering the global file.Also,
_with_signing_key()treats failure to retrieve the key as best-effort and returns a usable-looking infra handle (infra_vm.py:193-207), even though every authenticated operation in the immediately following launch will then fail with the stale/missing host key. Once key synchronization is required for correctness, this path should retry or fail explicitly rather than proceeding.Targeted authentication, Firecracker-infra, gateway-init, and supervise tests pass; they do not cover coexistence with an already-running control plane using the previous host key.
You're right — the mirror-into-the-shared-host-file approach was wrong; it clobbers the host-canonical key and 401s a co-running Docker/macOS control plane. Fixed in
66409c7by keeping the host token file as the single source of truth: the launcher now pushes the host key into the freshly booted infra VM over SSH (atomic write, same transport aspersist_env_var_secret), and the VM's init waits for it and refuses to start the control plane — rather than run open — if it never arrives. Nothing writes back to the host file, so other backends are untouched, and the best-effort silent path is gone (both the push and the guest fail loudly). Watching the firecracker integration job.66409c770bto4a83f45d9dView command line instructions
Checkout
From your project repository, check out a new branch and test the changes.