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>
147 lines
7.0 KiB
Python
147 lines
7.0 KiB
Python
"""Gateway-side per-client policy resolver (PRD 0070).
|
|
|
|
The consolidated gateway serves every bottle from one process, so for each
|
|
request it must apply the *calling* bottle's policy, selected by the source
|
|
IP the attribution invariant makes unspoofable. This resolves that policy
|
|
from the orchestrator's control plane (`POST /resolve`), keyed on the
|
|
`(source_ip, identity_token)` pair.
|
|
|
|
**Always fresh — no cache.** The resolver is called rarely enough that a
|
|
round-trip doesn't matter, and correctness matters more than speed: every
|
|
resolve reflects the orchestrator's *current* view, so a revocation, a
|
|
policy change, or a bottle teardown the orchestrator knows about is honored
|
|
immediately rather than lingering for a cache TTL. (If this ever becomes a
|
|
hot path, add caching with orchestrator-driven invalidation — not a blind
|
|
TTL.)
|
|
|
|
**Fail-closed:** an unattributed client (the orchestrator answers `403`)
|
|
resolves to `None`, and the caller (the egress addon, git-gate) must then
|
|
deny — exactly as an unknown bottle should be treated. Orchestrator
|
|
*errors* (unreachable / unexpected status) raise, so the caller can fail
|
|
closed too rather than silently serving stale or empty policy.
|
|
|
|
The resolved value is the policy blob the orchestrator stores verbatim; the
|
|
consumer parses it (e.g. the egress addon's `load_config`). This module is
|
|
stdlib-only and free of bot-bottle imports so it can be COPYed flat into
|
|
the gateway.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
DEFAULT_TIMEOUT_SECONDS = 2.0
|
|
|
|
# The control-plane secret this gateway presents on every /resolve call, read
|
|
# from the env the launcher injects into the gateway container. The control
|
|
# plane requires it (orchestrator/control_plane.py). Constant + env-var name are
|
|
# duplicated here rather than imported because this module is COPYed flat into
|
|
# the gateway image, free of bot-bottle imports — same rationale as
|
|
# IDENTITY_HEADER in egress_addon / git_http_backend.
|
|
CONTROL_AUTH_HEADER = "x-bot-bottle-control-auth"
|
|
CONTROL_PLANE_TOKEN_ENV = "BOT_BOTTLE_CONTROL_PLANE_TOKEN"
|
|
|
|
|
|
def _control_auth_headers() -> dict[str, str]:
|
|
"""The auth header to send, or {} when no secret is configured (an open
|
|
control plane, e.g. Firecracker behind its nft boundary — sending nothing
|
|
is correct there and harmlessly ignored)."""
|
|
token = os.environ.get(CONTROL_PLANE_TOKEN_ENV, "").strip()
|
|
return {CONTROL_AUTH_HEADER: token} if token else {}
|
|
|
|
|
|
class PolicyResolveError(RuntimeError):
|
|
"""The orchestrator was unreachable or returned an unexpected status —
|
|
distinct from a clean `403` (unattributed), which returns None."""
|
|
|
|
|
|
class PolicyResolver:
|
|
"""Resolves each client's policy from the orchestrator, fresh per call."""
|
|
|
|
def __init__(self, base_url: str, *, timeout: float = DEFAULT_TIMEOUT_SECONDS) -> None:
|
|
self._base = base_url.rstrip("/")
|
|
self._timeout = timeout
|
|
|
|
def _post_resolve(self, source_ip: str, identity_token: str) -> dict[str, object] | None:
|
|
"""The orchestrator's `/resolve` payload for this client, or None if
|
|
unattributed (a clean `403`). Raises `PolicyResolveError` on an
|
|
unreachable / unexpected-status / malformed response so every caller
|
|
can fail closed. Shared by `resolve` and `resolve_bottle_id`."""
|
|
body = json.dumps(
|
|
{"source_ip": source_ip, "identity_token": identity_token}
|
|
).encode()
|
|
req = urllib.request.Request(
|
|
f"{self._base}/resolve", data=body, method="POST",
|
|
headers={"Content-Type": "application/json", **_control_auth_headers()},
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
|
|
payload = json.loads(resp.read())
|
|
except urllib.error.HTTPError as e:
|
|
if e.code == 403:
|
|
return None # unattributed → fail closed (caller denies)
|
|
raise PolicyResolveError(f"/resolve returned HTTP {e.code}") from e
|
|
except (urllib.error.URLError, TimeoutError, OSError, ValueError) as e:
|
|
raise PolicyResolveError(f"/resolve unreachable or malformed: {e}") from e
|
|
return payload if isinstance(payload, dict) else {}
|
|
|
|
def resolve(self, source_ip: str, identity_token: str = "") -> str | None:
|
|
"""The calling bottle's policy blob, or None if unattributed. Always
|
|
fetches from the orchestrator so revocations / changes / teardowns
|
|
are honored immediately.
|
|
|
|
`identity_token` is optional *transitionally* — omitting it resolves
|
|
by source IP alone (the network-layer attribution, sound by
|
|
construction on Firecracker's `/31`+nft, weaker elsewhere). The
|
|
consolidated end state makes the token mandatory so the app-layer
|
|
defense is always enforced, not silently degraded; that flips at the
|
|
`/resolve` boundary once identity-token *delivery* lands (a PRD 0070
|
|
open question — we can't require what the agent can't yet present).
|
|
Raises `PolicyResolveError` if the orchestrator can't be reached."""
|
|
payload = self._post_resolve(source_ip, identity_token)
|
|
if payload is None:
|
|
return None
|
|
policy = payload.get("policy")
|
|
return policy if isinstance(policy, str) else ""
|
|
|
|
def resolve_bottle_id(self, source_ip: str, identity_token: str = "") -> str | None:
|
|
"""The calling bottle's id, or None if unattributed. This is the
|
|
source-IP-keyed identity the git-gate uses to select the bottle's
|
|
repo namespace (there is no per-bottle policy blob to parse — the
|
|
bottle *is* the namespace). Same fail-closed contract as `resolve`."""
|
|
payload = self._post_resolve(source_ip, identity_token)
|
|
if payload is None:
|
|
return None
|
|
bottle_id = payload.get("bottle_id")
|
|
return bottle_id if isinstance(bottle_id, str) and bottle_id else None
|
|
|
|
def resolve_policy_and_bottle_id(
|
|
self, source_ip: str, identity_token: str = "",
|
|
) -> tuple[str | None, str | None, dict[str, str]]:
|
|
"""The policy blob, bottle id, **and per-bottle egress auth tokens** in
|
|
a single `/resolve` — so the egress addon gets everything it needs
|
|
(policy for routing, bottle id for the supervise queue/safelist, tokens
|
|
to inject upstream auth) in one round-trip. Returns `(None, None, {})`
|
|
when unattributed (a clean `403`). Raises `PolicyResolveError` if the
|
|
orchestrator can't be reached, so the caller still fails closed."""
|
|
payload = self._post_resolve(source_ip, identity_token)
|
|
if payload is None:
|
|
return None, None, {}
|
|
policy = payload.get("policy")
|
|
bottle_id = payload.get("bottle_id")
|
|
raw = payload.get("tokens")
|
|
tokens = {
|
|
k: v for k, v in raw.items() if isinstance(k, str) and isinstance(v, str)
|
|
} if isinstance(raw, dict) else {}
|
|
return (
|
|
policy if isinstance(policy, str) else "",
|
|
bottle_id if isinstance(bottle_id, str) and bottle_id else None,
|
|
tokens,
|
|
)
|
|
|
|
|
|
__all__ = ["PolicyResolver", "PolicyResolveError"]
|