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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user