311 lines
9.6 KiB
Python
311 lines
9.6 KiB
Python
"""Per-bottle supervise plane (PRD 0013).
|
|
|
|
The supervise plane is the per-bottle MCP sidecar plus its host-side
|
|
queue/audit support. The sidecar (bot_bottle.supervise_server)
|
|
sits on the bottle's internal network and exposes MCP tools the agent
|
|
calls when it needs an operator-reviewed egress change:
|
|
|
|
* egress-block / allow — agent proposes a new routes.yaml
|
|
|
|
Each tool call: the agent passes the full proposed file plus a
|
|
justification text. The sidecar validates the proposal syntactically,
|
|
writes it to the host SQLite queue table, and holds the tool-call
|
|
connection open. The operator's supervise TUI
|
|
(bot_bottle.cli.supervise) sees the proposal, accepts
|
|
approve / modify / reject, and writes a response row. The sidecar sees
|
|
the response and returns `{status, notes}` to the agent.
|
|
|
|
This module defines the host-side library: dataclasses for the queue
|
|
record shapes, queue read/write helpers, the audit log writer, and the
|
|
diff renderer. The in-container sidecar lives in
|
|
bot_bottle/supervise_server.py; the supervise daemon's container
|
|
lifecycle is owned by the sidecar bundle (PRD 0024).
|
|
|
|
For 0013 the supervisor's approval handlers are deliberately no-ops:
|
|
on approval the audit log is written and the response file is
|
|
delivered to the agent, but no host-side config change happens. The
|
|
remediation engines that wire real config changes land in PRDs 0014,
|
|
0015, and 0016.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import difflib
|
|
import hashlib
|
|
import time
|
|
from abc import ABC
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
try:
|
|
from .supervise_types import (
|
|
ACTION_OPERATOR_EDIT,
|
|
AuditEntry,
|
|
HOST_DB_FILENAME,
|
|
Proposal,
|
|
Response,
|
|
STATUSES,
|
|
STATUS_APPROVED,
|
|
STATUS_MODIFIED,
|
|
STATUS_REJECTED,
|
|
TOOLS,
|
|
TOOL_EGRESS_ALLOW,
|
|
TOOL_EGRESS_BLOCK,
|
|
TOOL_EGRESS_TOKEN_ALLOW,
|
|
TOOL_GITLEAKS_ALLOW,
|
|
TOOL_LIST_EGRESS_ROUTES,
|
|
bot_bottle_root,
|
|
)
|
|
except ImportError:
|
|
from supervise_types import ( # type: ignore[import-not-found,no-redef] # pylint: disable=import-error,no-name-in-module
|
|
ACTION_OPERATOR_EDIT,
|
|
AuditEntry,
|
|
HOST_DB_FILENAME,
|
|
Proposal,
|
|
Response,
|
|
STATUSES,
|
|
STATUS_APPROVED,
|
|
STATUS_MODIFIED,
|
|
STATUS_REJECTED,
|
|
TOOLS,
|
|
TOOL_EGRESS_ALLOW,
|
|
TOOL_EGRESS_BLOCK,
|
|
TOOL_EGRESS_TOKEN_ALLOW,
|
|
TOOL_GITLEAKS_ALLOW,
|
|
TOOL_LIST_EGRESS_ROUTES,
|
|
bot_bottle_root,
|
|
)
|
|
|
|
|
|
SUPERVISE_HOSTNAME = "supervise"
|
|
SUPERVISE_PORT = 9100
|
|
|
|
# The supervise sidecar uses these to query egress's
|
|
# introspection endpoint for the `list-egress-routes` MCP
|
|
# tool. The hostname + port match egress's docker network
|
|
# listen port (see backend.docker.egress.EGRESS_PORT). The supervise
|
|
# daemon runs inside the sidecar bundle alongside egress, so loopback
|
|
# is the stable address across docker, smolmachines, and Apple
|
|
# Container backends.
|
|
EGRESS_FORWARD_PROXY = "http://127.0.0.1:9099"
|
|
EGRESS_INTROSPECT_URL = "http://_egress.local/allowlist"
|
|
|
|
COMPONENT_FOR_TOOL: dict[str, str] = {
|
|
TOOL_EGRESS_ALLOW: "egress",
|
|
TOOL_EGRESS_BLOCK: "egress",
|
|
}
|
|
|
|
DB_PATH_IN_CONTAINER = "/run/supervise/bot-bottle.db"
|
|
DEFAULT_POLL_INTERVAL_SEC = 0.5
|
|
|
|
|
|
# --- Paths -----------------------------------------------------------------
|
|
|
|
|
|
def audit_dir() -> Path:
|
|
return bot_bottle_root() / "audit"
|
|
|
|
|
|
def host_db_path() -> Path:
|
|
# Calls bot_bottle_root() through this module's globals so that patches
|
|
# on supervise.bot_bottle_root propagate to callers going through
|
|
# supervise.host_db_path().
|
|
return bot_bottle_root() / HOST_DB_FILENAME
|
|
|
|
|
|
def audit_log_path(component: str, slug: str) -> Path:
|
|
return audit_dir() / f"{component}-{slug}.log"
|
|
|
|
|
|
try:
|
|
from .queue_store import QueueStore
|
|
from .audit_store import AuditStore
|
|
from .store_manager import StoreManager
|
|
except ImportError:
|
|
# Sidecar bundle: files are flat-copied under /app, not a package.
|
|
from queue_store import QueueStore # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
|
|
from audit_store import AuditStore # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
|
|
from store_manager import StoreManager # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
|
|
|
|
|
|
# --- Queue I/O -------------------------------------------------------------
|
|
|
|
|
|
def write_proposal(proposal: Proposal) -> Path:
|
|
"""Persist `proposal` in the queue database, mode 0o600.
|
|
Directory is created if missing."""
|
|
return QueueStore(proposal.bottle_slug).write_proposal(proposal)
|
|
|
|
|
|
def read_proposal(bottle_slug: str, proposal_id: str) -> Proposal:
|
|
return QueueStore(bottle_slug).read_proposal(proposal_id)
|
|
|
|
|
|
def list_pending_proposals(bottle_slug: str) -> list[Proposal]:
|
|
"""All proposals for `bottle_slug` that do not yet have a matching
|
|
response. Sorted by `arrival_timestamp` so the operator
|
|
sees the queue FIFO."""
|
|
return QueueStore(bottle_slug).list_pending_proposals()
|
|
|
|
|
|
def list_all_pending_proposals() -> list[Proposal]:
|
|
"""All pending proposals across bottles, sorted FIFO."""
|
|
return QueueStore("").list_all_pending_proposals()
|
|
|
|
|
|
def write_response(bottle_slug: str, response: Response) -> Path:
|
|
return QueueStore(bottle_slug).write_response(response)
|
|
|
|
|
|
def read_response(bottle_slug: str, proposal_id: str) -> Response:
|
|
return QueueStore(bottle_slug).read_response(proposal_id)
|
|
|
|
|
|
def wait_for_response(
|
|
bottle_slug: str,
|
|
proposal_id: str,
|
|
*,
|
|
poll_interval: float = DEFAULT_POLL_INTERVAL_SEC,
|
|
deadline: float | None = None,
|
|
) -> Response:
|
|
"""Block until a response file appears for `proposal_id`, then
|
|
return it. `deadline` is an absolute time.monotonic() value after
|
|
which the wait raises TimeoutError. None waits forever — the
|
|
natural shape, since the operator's response time is unbounded.
|
|
|
|
Polls SQLite so the implementation stays portable and stdlib-only."""
|
|
store = QueueStore(bottle_slug)
|
|
while True:
|
|
try:
|
|
return store.read_response(proposal_id)
|
|
except FileNotFoundError:
|
|
pass
|
|
if deadline is not None and time.monotonic() >= deadline:
|
|
raise TimeoutError(f"no response for proposal {proposal_id!r}")
|
|
time.sleep(poll_interval)
|
|
|
|
|
|
def archive_proposal(bottle_slug: str, proposal_id: str) -> None:
|
|
"""Mark both proposal and response rows processed.
|
|
Idempotent — missing rows are silently skipped."""
|
|
QueueStore(bottle_slug).archive_proposal(proposal_id)
|
|
|
|
|
|
# --- Audit log -------------------------------------------------------------
|
|
|
|
|
|
def write_audit_entry(entry: AuditEntry) -> Path:
|
|
"""Append `entry` to the host supervise audit table."""
|
|
return AuditStore().write_audit_entry(entry)
|
|
|
|
|
|
def read_audit_entries(component: str, slug: str) -> list[AuditEntry]:
|
|
"""Load all audit entries for the given component+slug."""
|
|
return AuditStore().read_audit_entries(component, slug)
|
|
|
|
|
|
# --- Diff rendering --------------------------------------------------------
|
|
|
|
|
|
def render_diff(before: str, after: str, *, label: str = "config") -> str:
|
|
"""Unified diff suitable for the audit log + TUI. Empty diff (no
|
|
changes) renders as the empty string."""
|
|
diff = difflib.unified_diff(
|
|
before.splitlines(keepends=True),
|
|
after.splitlines(keepends=True),
|
|
fromfile=f"{label} (current)",
|
|
tofile=f"{label} (proposed)",
|
|
lineterm="",
|
|
)
|
|
parts = list(diff)
|
|
if not parts:
|
|
return ""
|
|
return "".join(p if p.endswith("\n") else p + "\n" for p in parts).rstrip("\n")
|
|
|
|
|
|
def sha256_hex(content: str) -> str:
|
|
return hashlib.sha256(content.encode("utf-8")).hexdigest()
|
|
|
|
|
|
# --- Sidecar plan + abstract lifecycle -------------------------------------
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SupervisePlan:
|
|
"""Output of Supervise.prepare; consumed by .start.
|
|
|
|
`db_path` is the host database bind-mounted into the sidecar at
|
|
/run/supervise/bot-bottle.db. `internal_network` is empty at
|
|
prepare time; the backend's launch step fills it via
|
|
dataclasses.replace before calling .start."""
|
|
|
|
slug: str
|
|
db_path: Path
|
|
internal_network: str = ""
|
|
|
|
|
|
class Supervise(ABC):
|
|
"""Per-bottle supervise sidecar. Encapsulates host-side database
|
|
staging; the sidecar's start/stop lifecycle is backend-specific."""
|
|
|
|
def prepare(
|
|
self,
|
|
slug: str,
|
|
stage_dir: Path,
|
|
) -> SupervisePlan:
|
|
"""Stage the host database. Returns the plan; `internal_network`
|
|
must be set by the launch step before .start runs."""
|
|
del stage_dir
|
|
mgr = StoreManager.instance()
|
|
mgr.migrate()
|
|
return SupervisePlan(
|
|
slug=slug,
|
|
db_path=mgr.db_path,
|
|
)
|
|
|
|
|
|
__all__ = [
|
|
"ACTION_OPERATOR_EDIT",
|
|
"AuditEntry",
|
|
"AuditStore",
|
|
"COMPONENT_FOR_TOOL",
|
|
"DEFAULT_POLL_INTERVAL_SEC",
|
|
"DB_PATH_IN_CONTAINER",
|
|
"Proposal",
|
|
"QueueStore",
|
|
"Response",
|
|
"StoreManager",
|
|
"STATUSES",
|
|
"STATUS_APPROVED",
|
|
"STATUS_MODIFIED",
|
|
"STATUS_REJECTED",
|
|
"SUPERVISE_HOSTNAME",
|
|
"SUPERVISE_PORT",
|
|
"Supervise",
|
|
"SupervisePlan",
|
|
"TOOLS",
|
|
"EGRESS_FORWARD_PROXY",
|
|
"EGRESS_INTROSPECT_URL",
|
|
"TOOL_EGRESS_ALLOW",
|
|
"TOOL_EGRESS_BLOCK",
|
|
"TOOL_GITLEAKS_ALLOW",
|
|
"TOOL_EGRESS_TOKEN_ALLOW",
|
|
"TOOL_LIST_EGRESS_ROUTES",
|
|
"archive_proposal",
|
|
"audit_dir",
|
|
"audit_log_path",
|
|
"bot_bottle_root",
|
|
"host_db_path",
|
|
"list_pending_proposals",
|
|
"list_all_pending_proposals",
|
|
"read_audit_entries",
|
|
"read_proposal",
|
|
"read_response",
|
|
"render_diff",
|
|
"sha256_hex",
|
|
"wait_for_response",
|
|
"write_audit_entry",
|
|
"write_proposal",
|
|
"write_response",
|
|
]
|