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
+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()