ca91fc4d91
The control plane had no caller authentication — dispatch() routed every request straight through, and the only guards anywhere were the (source_ip, identity_token) checks inside /resolve and /attribute, which authenticate the *bottle* a request is about, not the *caller*. On the consolidated backends the agent shares a network with the control-plane port (macOS: the infra container runs 8099 on the agent's --internal network; docker: the orchestrator container sits on the shared gateway network), so a prompt-injected agent could GET /bottles (enumerate siblings + policies), PUT /bottles/<id>/policy (rewrite its own egress allowlist to allow-all), POST /resolve (read the injected upstream credentials it's never meant to see), and POST /supervise/respond (self-approve its own proposals). Every README guarantee collapsed to the agent's discretion. Fix: a per-host control-plane secret required on every route but GET /health, compared with hmac.compare_digest. It is held only by the trusted callers and never handed to an agent: - minted + persisted 0600 at <root>/control-plane-token (paths.host_control_plane_token); - injected as $BOT_BOTTLE_CONTROL_PLANE_TOKEN into the orchestrator + gateway containers via bare `--env NAME` (value inherited from the launch process, so it never lands on argv or in `container/docker inspect`); - presented by the gateway's PolicyResolver (reads the env) on /resolve, and by the host CLI's OrchestratorClient (reads the host file) on every call. The agent container is never given the env var or the host file, so from a bottle every /bottles*, /resolve, /attribute, and /supervise/* call now returns 401 — closing the enumeration, allowlist-rewrite, credential-lift, and self-approval. The existing (source_ip, identity_token) checks stay as defense-in-depth. Enforced when configured: macOS + docker inject the secret (→ enforced). With no secret set the server runs open and warns loudly at startup — a fail-visible fallback for the unit suite and for Firecracker, whose port-scoped nft already blocks agents from 8099 (wiring the secret into its infra-VM init is a clean fast-follow, left out here to avoid churning the prebuilt-artifact hash). Verified end-to-end on real Apple Container: infra comes up healthy, the host CLI (with the secret) lists bottles while an unauthenticated GET /bottles gets 401, all five issue-#400 attacks from inside the agent get 401, and egress policy still works (200 allowed / 403 denied) — proving the gateway authenticates to /resolve with the secret. 1829 unit tests pass, pyright clean, pylint 9.91. Refs #400. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
241 lines
9.6 KiB
Python
241 lines
9.6 KiB
Python
"""Host-side control-plane client (PRD 0070).
|
|
|
|
The launch path talks to the orchestrator over its HTTP control plane to
|
|
register, re-policy, and tear down bottles — the counterpart to the
|
|
gateway-side `PolicyResolver` (which only reads `/resolve`). Where
|
|
`PolicyResolver` is fail-closed and lives in the untrusted data plane, this
|
|
is the trusted control-plane caller: a non-success response is an error the
|
|
launch path must surface, not silently swallow.
|
|
|
|
Stdlib-only, so the CLI can drive the orchestrator without any dependency.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import urllib.error
|
|
import urllib.request
|
|
from dataclasses import dataclass
|
|
|
|
from ..paths import host_control_plane_token
|
|
from .control_plane import CONTROL_AUTH_HEADER
|
|
|
|
DEFAULT_TIMEOUT_SECONDS = 5.0
|
|
|
|
|
|
def _host_auth_token() -> str:
|
|
"""The per-host control-plane secret, or "" if it can't be read. "" means
|
|
'send no auth header' — correct against an open (unconfigured) control
|
|
plane, and harmlessly rejected by a secured one."""
|
|
try:
|
|
return host_control_plane_token()
|
|
except OSError:
|
|
return ""
|
|
|
|
|
|
class OrchestratorClientError(RuntimeError):
|
|
"""A control-plane call failed (unreachable, or an unexpected status)."""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RegisteredBottle:
|
|
"""What `POST /bottles` returns: the minted bottle id and the per-bottle
|
|
identity token the agent presents for app-layer attribution."""
|
|
|
|
bottle_id: str
|
|
identity_token: str
|
|
|
|
|
|
class OrchestratorClient:
|
|
"""Trusted host-side client for the orchestrator control plane.
|
|
|
|
Presents the per-host control-plane secret on every call (the header the
|
|
control plane requires on all routes but `/health`). The secret is read
|
|
from the host file — this client only ever runs host-side (CLI, launcher,
|
|
discovery), so it can read what an agent can't. `auth_token` is overridable
|
|
for tests; the default reads the host file, minting it on first use."""
|
|
|
|
def __init__(
|
|
self,
|
|
base_url: str,
|
|
*,
|
|
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
|
auth_token: str | None = None,
|
|
) -> None:
|
|
self._base = base_url.rstrip("/")
|
|
self._timeout = timeout
|
|
self._auth_token = auth_token if auth_token is not None else _host_auth_token()
|
|
|
|
def _request(
|
|
self, method: str, path: str, body: dict[str, object] | None = None,
|
|
) -> tuple[int, dict[str, object]]:
|
|
"""Send one request; return `(status, payload)`. Raises
|
|
`OrchestratorClientError` only when the orchestrator can't be reached
|
|
or returns malformed data — HTTP *status* codes are returned so
|
|
callers can treat 404 as a meaningful "no such bottle"."""
|
|
data = json.dumps(body).encode() if body is not None else None
|
|
headers = {"Content-Type": "application/json"} if data is not None else {}
|
|
if self._auth_token:
|
|
headers[CONTROL_AUTH_HEADER] = self._auth_token
|
|
req = urllib.request.Request(
|
|
f"{self._base}{path}", data=data, method=method, headers=headers,
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
|
|
raw = resp.read()
|
|
payload = json.loads(raw) if raw else {}
|
|
return resp.status, payload if isinstance(payload, dict) else {}
|
|
except urllib.error.HTTPError as e:
|
|
# A structured error response still carries a usable status.
|
|
try:
|
|
payload = json.loads(e.read() or b"{}")
|
|
except (ValueError, OSError):
|
|
payload = {}
|
|
return e.code, payload if isinstance(payload, dict) else {}
|
|
except (urllib.error.URLError, TimeoutError, OSError, ValueError) as e:
|
|
raise OrchestratorClientError(f"{method} {path}: {e}") from e
|
|
|
|
def _ok(self, method: str, path: str, body: dict[str, object] | None = None) -> dict[str, object]:
|
|
"""`_request` that requires a 2xx, raising otherwise."""
|
|
status, payload = self._request(method, path, body)
|
|
if not 200 <= status < 300:
|
|
detail = payload.get("error", "")
|
|
raise OrchestratorClientError(f"{method} {path}: HTTP {status} {detail}".rstrip())
|
|
return payload
|
|
|
|
def health(self) -> bool:
|
|
"""True iff the control plane answers `GET /health` with 200."""
|
|
try:
|
|
status, _ = self._request("GET", "/health")
|
|
except OrchestratorClientError:
|
|
return False
|
|
return status == 200
|
|
|
|
def register_bottle(
|
|
self,
|
|
source_ip: str,
|
|
*,
|
|
image_ref: str = "",
|
|
metadata: str = "",
|
|
policy: str = "",
|
|
tokens: dict[str, str] | None = None,
|
|
) -> RegisteredBottle:
|
|
"""Register a bottle and broker its launch (`POST /bottles`). `tokens`
|
|
are the per-bottle egress auth values (env_name -> value) the
|
|
orchestrator holds in memory for the gateway to inject. Returns the
|
|
minted id + identity token."""
|
|
payload = self._ok("POST", "/bottles", {
|
|
"source_ip": source_ip,
|
|
"image_ref": image_ref,
|
|
"metadata": metadata,
|
|
"policy": policy,
|
|
"tokens": tokens or {},
|
|
})
|
|
bottle_id = payload.get("bottle_id")
|
|
token = payload.get("identity_token")
|
|
if not isinstance(bottle_id, str) or not isinstance(token, str):
|
|
raise OrchestratorClientError("register: response missing bottle_id/identity_token")
|
|
return RegisteredBottle(bottle_id=bottle_id, identity_token=token)
|
|
|
|
def teardown_bottle(self, bottle_id: str) -> bool:
|
|
"""Tear a bottle down (`DELETE /bottles/<id>`). False if the
|
|
orchestrator didn't know it (404) — idempotent for cleanup paths."""
|
|
status, _ = self._request("DELETE", f"/bottles/{bottle_id}")
|
|
if status == 404:
|
|
return False
|
|
if not 200 <= status < 300:
|
|
raise OrchestratorClientError(f"teardown {bottle_id}: HTTP {status}")
|
|
return True
|
|
|
|
def set_policy(self, bottle_id: str, policy: str) -> bool:
|
|
"""Live-reload a bottle's policy (`PUT /bottles/<id>/policy`). False on
|
|
404 (unknown bottle)."""
|
|
status, _ = self._request("PUT", f"/bottles/{bottle_id}/policy", {"policy": policy})
|
|
if status == 404:
|
|
return False
|
|
if not 200 <= status < 300:
|
|
raise OrchestratorClientError(f"set_policy {bottle_id}: HTTP {status}")
|
|
return True
|
|
|
|
def list_bottles(self) -> list[dict[str, object]]:
|
|
"""Every registered bottle's redacted record (`GET /bottles`)."""
|
|
payload = self._ok("GET", "/bottles")
|
|
bottles = payload.get("bottles")
|
|
return bottles if isinstance(bottles, list) else []
|
|
|
|
# --- supervise queue (operator TUI) ------------------------------------
|
|
|
|
def supervise_pending(self) -> list[dict[str, object]]:
|
|
"""Pending supervise proposals across all bottles
|
|
(`GET /supervise/proposals`)."""
|
|
payload = self._ok("GET", "/supervise/proposals")
|
|
proposals = payload.get("proposals")
|
|
return proposals if isinstance(proposals, list) else []
|
|
|
|
def supervise_respond(
|
|
self,
|
|
proposal_id: str,
|
|
*,
|
|
bottle_slug: str,
|
|
decision: str,
|
|
notes: str = "",
|
|
final_file: str | None = None,
|
|
) -> None:
|
|
"""Record an operator decision (`POST /supervise/respond`). `decision`
|
|
is approve/modify/reject. Raises `OrchestratorClientError` if the
|
|
proposal is gone or the bottle can no longer be applied to (409)."""
|
|
body: dict[str, object] = {
|
|
"proposal_id": proposal_id,
|
|
"bottle_slug": bottle_slug,
|
|
"decision": decision,
|
|
"notes": notes,
|
|
}
|
|
if final_file is not None:
|
|
body["final_file"] = final_file
|
|
self._ok("POST", "/supervise/respond", body)
|
|
|
|
|
|
def discover_orchestrator_url(*, timeout: float = 2.0) -> str:
|
|
"""The URL of the one running per-host orchestrator control plane, probing
|
|
the backends' well-known control-plane addresses (both on port 8099):
|
|
docker publishes it on loopback; the firecracker infra VM serves it on the
|
|
orchestrator TAP. Returns the first that answers `/health`; raises if none
|
|
do (no orchestrator up — launch a bottle first)."""
|
|
candidates: list[str] = []
|
|
try: # docker: loopback-published control plane
|
|
from .lifecycle import DEFAULT_PORT as _DOCKER_PORT
|
|
candidates.append(f"http://127.0.0.1:{_DOCKER_PORT}")
|
|
except Exception: # noqa: BLE001 — backend optional
|
|
candidates.append("http://127.0.0.1:8099")
|
|
try: # firecracker: infra VM control plane on the orchestrator TAP
|
|
from ..backend.firecracker import netpool
|
|
from ..backend.firecracker.infra_vm import CONTROL_PLANE_PORT
|
|
candidates.append(
|
|
f"http://{netpool.orch_slot().guest_ip}:{CONTROL_PLANE_PORT}")
|
|
except Exception: # noqa: BLE001 — backend optional / not firecracker
|
|
pass
|
|
try: # macOS: infra container control plane on its host-only address
|
|
from ..backend.macos_container.infra import probe_control_plane_url
|
|
url = probe_control_plane_url()
|
|
if url:
|
|
candidates.append(url)
|
|
except Exception: # noqa: BLE001 — backend optional / not macOS
|
|
pass
|
|
for url in candidates:
|
|
if OrchestratorClient(url, timeout=timeout).health():
|
|
return url
|
|
raise OrchestratorClientError(
|
|
"no running orchestrator control plane found (tried "
|
|
+ ", ".join(candidates)
|
|
+ "); launch a bottle first"
|
|
)
|
|
|
|
|
|
__all__ = [
|
|
"OrchestratorClient",
|
|
"OrchestratorClientError",
|
|
"RegisteredBottle",
|
|
"DEFAULT_TIMEOUT_SECONDS",
|
|
"discover_orchestrator_url",
|
|
]
|