From 4baeb9ab2397ebe1960c73269636ca0915e83b7d Mon Sep 17 00:00:00 2001 From: Oleg Date: Wed, 13 May 2026 00:21:35 -0700 Subject: [PATCH] feat(config): PINKY_DEPLOYMENT_MODE env + cached app.state.deployment_mode (#434) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR-1 of the #433 web-exposed hardening track. Establishes the single env-var switch downstream middleware will branch on (#436 LAN filter, #437 headers, #439 admin elevation, #441 boot guards, etc.) instead of each feature carrying its own knob. What ---- * New `pinky_daemon.config` module: - `DeploymentMode` enum: `trusted` (default, current Mac Mini), `lan`, `web`. - `resolve_deployment_mode()` reads `PINKY_DEPLOYMENT_MODE`, normalises case + whitespace, **fails closed** on invalid values (`InvalidDeploymentModeError`) — a typo like `prod` aborts startup rather than silently falling back to `trusted`. - `default_bind_host(mode)`: `0.0.0.0` for trusted/lan, `127.0.0.1` for web (assumes a reverse proxy in front). - `warn_if_insecure_exposure(mode, host)`: loud stderr warning when `trusted` + `0.0.0.0`, so accidental VPS exposure is obvious in logs while back-compat is preserved. * `pinky_daemon.__main__`: - `--host` default flipped to `None` so we can tell "operator picked 0.0.0.0" from "old hardcoded default" — needed by #436/#441. - On startup: resolve mode → resolve host → warn → log mode + host. - `InvalidDeploymentModeError` → `sys.exit(2)` with a useful message. * `pinky_daemon.api.create_api`: - New `deployment_mode` kwarg; if `None`, resolved from env so embedded callers and tests don't have to wire it through. - Cached on `app.state.deployment_mode` — single source of truth for the rest of #433. - `/api` payload exposes `deployment_mode` so VPS operators can confirm posture without scraping logs. Tests (26 new) -------------- * Resolver: empty/unset → trusted; each canonical value; case-insensitive; whitespace stripped; invalid value raises with a useful message; aliases like "production" / "dev" don't silently pass. * `default_bind_host`: trusted/lan → 0.0.0.0, web → 127.0.0.1. * `warn_if_insecure_exposure`: warns only on trusted + 0.0.0.0; quiet on lan/web and on specific bind addresses. * `create_api`: explicit mode cached on `app.state`; defaults resolve from env; `/api` exposes the mode for each posture. Existing test_api.py: 286 tests still green. Closes part of #433. Refs #434. 🤖 Authored by Barsik --- src/pinky_daemon/__main__.py | 38 ++++++- src/pinky_daemon/api.py | 29 ++++- src/pinky_daemon/config.py | 148 ++++++++++++++++++++++++ tests/test_deployment_mode.py | 209 ++++++++++++++++++++++++++++++++++ 4 files changed, 419 insertions(+), 5 deletions(-) create mode 100644 src/pinky_daemon/config.py create mode 100644 tests/test_deployment_mode.py diff --git a/src/pinky_daemon/__main__.py b/src/pinky_daemon/__main__.py index b2300de1..a78ae2b0 100644 --- a/src/pinky_daemon/__main__.py +++ b/src/pinky_daemon/__main__.py @@ -45,7 +45,14 @@ def main() -> None: default="api", help="Run mode: api (HTTP server) or poll (message polling daemon)", ) - parser.add_argument("--host", default="0.0.0.0", help="API server host") + parser.add_argument( + "--host", + default=None, + help=( + "API server host. Default depends on PINKY_DEPLOYMENT_MODE: " + "0.0.0.0 for trusted/lan, 127.0.0.1 for web." + ), + ) parser.add_argument("--port", type=int, default=8888, help="API server port") parser.add_argument( "--config", @@ -76,12 +83,36 @@ def _run_api(args) -> None: import uvicorn from pinky_daemon.api import create_api + from pinky_daemon.config import ( + InvalidDeploymentModeError, + default_bind_host, + resolve_deployment_mode, + warn_if_insecure_exposure, + ) + + # Resolve deployment posture first — fail fast on a typo'd env var so the + # operator doesn't think they have hardening applied when they don't. + try: + mode = resolve_deployment_mode() + except InvalidDeploymentModeError as exc: + print(f"[pinky] {exc}", file=sys.stderr) + sys.exit(2) + + # If --host wasn't passed, fall back to the mode-appropriate default. The + # asymmetry between "operator picked 0.0.0.0" and "old hardcoded default" + # matters for #436 / #441; argparse's default is now None so we can tell. + host = args.host if args.host is not None else default_bind_host(mode) + + warning = warn_if_insecure_exposure(mode, host) + if warning: + print(f"[pinky] {warning}", file=sys.stderr) working_dir = os.path.abspath(args.working_dir) print( f"[pinky] Starting API server\n" - f" Host: {args.host}:{args.port}\n" + f" Mode: {mode.value}\n" + f" Host: {host}:{args.port}\n" f" Working dir: {working_dir}\n" f" Max sessions: {args.max_sessions}", file=sys.stderr, @@ -90,9 +121,10 @@ def _run_api(args) -> None: app = create_api( max_sessions=args.max_sessions, default_working_dir=working_dir, + deployment_mode=mode, ) - uvicorn.run(app, host=args.host, port=args.port) + uvicorn.run(app, host=host, port=args.port) def _run_poll(args) -> None: diff --git a/src/pinky_daemon/api.py b/src/pinky_daemon/api.py index 47d1131b..44af0687 100644 --- a/src/pinky_daemon/api.py +++ b/src/pinky_daemon/api.py @@ -24,7 +24,10 @@ import uuid from pathlib import Path from types import SimpleNamespace -from typing import Literal +from typing import TYPE_CHECKING, Literal + +if TYPE_CHECKING: + from pinky_daemon.config import DeploymentMode from fastapi import ( FastAPI, @@ -622,14 +625,32 @@ def create_api( max_sessions: int = 50, default_working_dir: str = ".", db_path: str = "data/conversations.db", + deployment_mode: "DeploymentMode | None" = None, ) -> FastAPI: - """Create the FastAPI application.""" + """Create the FastAPI application. + + Args: + deployment_mode: Operator-declared posture (trusted/lan/web). When + ``None`` the value is resolved from ``PINKY_DEPLOYMENT_MODE`` so + that callers (tests, embedded use) don't have to wire it through. + Cached on ``app.state.deployment_mode`` and exposed under + ``/api`` so downstream hardening middleware can branch off a + single source of truth (#433). + """ + + from pinky_daemon.config import resolve_deployment_mode + + if deployment_mode is None: + deployment_mode = resolve_deployment_mode() app = FastAPI( title="Pinky", description="Stateful Claude Code session API", version="0.1.0", ) + # Cache on app.state for middleware/route consumers — single source of + # truth for the rest of the #433 hardening track. + app.state.deployment_mode = deployment_mode # ── CORS ────────────────────────────────────────────── # Allow origins from env (comma-separated) or default to same-origin only. @@ -2777,6 +2798,9 @@ async def api_info(): """Health check and server info (JSON).""" channel = os.environ.get("PINKYBOT_CHANNEL", "stable") rate_limits = _read_rate_limits() + # Surface the active posture so VPS operators can confirm hardening + # is applied without scraping startup logs (#434 review followup). + _mode = getattr(app.state, "deployment_mode", None) info = { "name": "pinky", "version": _pinky_version, @@ -2784,6 +2808,7 @@ async def api_info(): "git_hash": _git_hash, "git_branch": _git_branch, "channel": channel, + "deployment_mode": _mode.value if _mode is not None else None, "sessions": manager.count, "started_at": _server_started_at, } diff --git a/src/pinky_daemon/config.py b/src/pinky_daemon/config.py new file mode 100644 index 00000000..46900e5b --- /dev/null +++ b/src/pinky_daemon/config.py @@ -0,0 +1,148 @@ +"""Deployment posture configuration. + +Single source of truth for the security posture PinkyBot is running under. +Resolved once at startup, cached on ``app.state.deployment_mode``, and consumed +by the hardening middleware introduced in the rest of the #433 track: + +* #436 — LAN-only mode + private-CIDR middleware + bind defaults +* #437 — Security headers middleware +* #439 — Elevated-session protection for dangerous routes +* #441 — Refuse-to-boot guards for insecure web-mode configs + +Each downstream feature should branch on ``app.state.deployment_mode`` rather +than reading its own env knob, so operator intent is encoded in exactly one +place. + +Modes +----- +``trusted`` + Default. Matches today's Mac Mini deployment: trusted LAN, single shared + cookie session, no extra network filter, no bearer-token API auth. + +``lan`` + LAN appliance posture (Raspberry Pi, NAS, home server). Cookie auth still + required, plus a private-CIDR middleware to refuse non-private clients. + +``web`` + Internet-exposed VPS. Bind defaults to loopback (assumes a reverse proxy + in front), bearer-token API auth is enabled, admin routes require an + elevated session, full security headers, refuse-to-boot guards apply. + +Resolution rules +---------------- +* Source: ``PINKY_DEPLOYMENT_MODE`` env var. +* Empty/unset → ``trusted`` (back-compat). +* Case-insensitive, surrounding whitespace stripped. +* Invalid value → :class:`InvalidDeploymentModeError` (fail closed; do **not** + silently fall back to ``trusted``). The daemon entrypoint is expected to + catch this and abort startup with a useful message. + +Part of #433 (Hardening: PinkyBot for web-exposed deployments). Issue #434. +""" + +from __future__ import annotations + +import os +from enum import Enum + +__all__ = [ + "DEPLOYMENT_MODE_ENV", + "DeploymentMode", + "InvalidDeploymentModeError", + "default_bind_host", + "resolve_deployment_mode", + "warn_if_insecure_exposure", +] + +#: Canonical env var name. Imported by other modules so the spelling lives in +#: one place. +DEPLOYMENT_MODE_ENV = "PINKY_DEPLOYMENT_MODE" + + +class DeploymentMode(str, Enum): + """Operator-declared deployment posture.""" + + TRUSTED = "trusted" + LAN = "lan" + WEB = "web" + + def __str__(self) -> str: # pragma: no cover - cosmetic + return self.value + + +_VALID_VALUES = tuple(m.value for m in DeploymentMode) + + +class InvalidDeploymentModeError(ValueError): + """Raised when ``PINKY_DEPLOYMENT_MODE`` has an unrecognized value. + + Fail-closed posture: a typo like ``PINKY_DEPLOYMENT_MODE=prod`` must abort + startup, not silently fall back to ``trusted`` and hand the operator a + false sense of hardening. + """ + + +def resolve_deployment_mode(env: dict | None = None) -> DeploymentMode: + """Resolve the deployment mode from ``PINKY_DEPLOYMENT_MODE``. + + Pure function. Reads from the supplied mapping (defaults to ``os.environ``), + normalises case + whitespace, returns the matching :class:`DeploymentMode`. + + Raises: + InvalidDeploymentModeError: when the env var is set but doesn't match + one of the canonical values. No aliases are accepted (no "prod", + no "production"). Add aliases only if we commit to supporting them + long-term. + """ + source = env if env is not None else os.environ + raw = (source.get(DEPLOYMENT_MODE_ENV) or "").strip().lower() + if not raw: + return DeploymentMode.TRUSTED + try: + return DeploymentMode(raw) + except ValueError as e: + raise InvalidDeploymentModeError( + f"{DEPLOYMENT_MODE_ENV}={raw!r} is not a valid deployment mode. " + f"Expected one of: {', '.join(_VALID_VALUES)}." + ) from e + + +def default_bind_host(mode: DeploymentMode) -> str: + """Default ``--host`` for a given mode. + + Used by the daemon entrypoint when the operator did **not** pass an + explicit ``--host`` (i.e. ``--host`` was left at its sentinel ``None``). + The asymmetry between operator-supplied and inherited defaults matters for + #436 (bind defaults) and #441 (refuse-to-boot guards), which need to tell + "operator chose 0.0.0.0" from "old hardcoded default". + + * ``web`` → ``127.0.0.1`` — assumes a reverse proxy is in front. Refuses + to bind public interfaces by default. + * ``trusted`` / ``lan`` → ``0.0.0.0`` — back-compat with today's Mac Mini + and Pi-fleet deployments. + """ + if mode is DeploymentMode.WEB: + return "127.0.0.1" + return "0.0.0.0" + + +def warn_if_insecure_exposure(mode: DeploymentMode, host: str) -> str | None: + """Build a loud warning string when ``trusted`` mode binds all interfaces. + + Today's Mac Mini deployment runs ``trusted`` + ``0.0.0.0`` and there's no + intent to break that. But if PinkyBot ends up on an internet-reachable host + by accident (or a fresh operator copies the README config), the operator + should see a screaming line in the logs at startup rather than discovering + the exposure later. + + Returns ``None`` when no warning is needed. Caller is responsible for + surfacing the string (typically ``print(... , file=sys.stderr)``). + """ + if mode is DeploymentMode.TRUSTED and host == "0.0.0.0": + return ( + f"⚠️ PINKY_DEPLOYMENT_MODE={mode.value} + --host=0.0.0.0: " + "the API is bound to all interfaces under the trusted-LAN posture. " + "If this host is internet-reachable, set " + f"{DEPLOYMENT_MODE_ENV}=web and put PinkyBot behind a reverse proxy." + ) + return None diff --git a/tests/test_deployment_mode.py b/tests/test_deployment_mode.py new file mode 100644 index 00000000..dd3a4c06 --- /dev/null +++ b/tests/test_deployment_mode.py @@ -0,0 +1,209 @@ +"""Tests for deployment-posture configuration (#434). + +Covers the resolver, the bind-host helper, the insecure-exposure warning, and +the wiring through ``create_api`` so ``app.state.deployment_mode`` and the +``/api`` payload reflect the configured posture. +""" + +from __future__ import annotations + +import os +import tempfile +from unittest.mock import patch + +import pytest +from fastapi.testclient import TestClient + +from pinky_daemon.config import ( + DEPLOYMENT_MODE_ENV, + DeploymentMode, + InvalidDeploymentModeError, + default_bind_host, + resolve_deployment_mode, + warn_if_insecure_exposure, +) + +# ── resolve_deployment_mode ──────────────────────────────────── + + +class TestResolveDeploymentMode: + def test_unset_returns_trusted(self): + assert resolve_deployment_mode(env={}) is DeploymentMode.TRUSTED + + def test_empty_string_returns_trusted(self): + assert ( + resolve_deployment_mode(env={DEPLOYMENT_MODE_ENV: ""}) + is DeploymentMode.TRUSTED + ) + + def test_whitespace_only_returns_trusted(self): + assert ( + resolve_deployment_mode(env={DEPLOYMENT_MODE_ENV: " "}) + is DeploymentMode.TRUSTED + ) + + @pytest.mark.parametrize( + "value,expected", + [ + ("trusted", DeploymentMode.TRUSTED), + ("lan", DeploymentMode.LAN), + ("web", DeploymentMode.WEB), + ], + ) + def test_each_canonical_value(self, value, expected): + assert resolve_deployment_mode(env={DEPLOYMENT_MODE_ENV: value}) is expected + + def test_case_insensitive(self): + assert ( + resolve_deployment_mode(env={DEPLOYMENT_MODE_ENV: "WEB"}) + is DeploymentMode.WEB + ) + assert ( + resolve_deployment_mode(env={DEPLOYMENT_MODE_ENV: "Lan"}) + is DeploymentMode.LAN + ) + + def test_strips_whitespace(self): + assert ( + resolve_deployment_mode(env={DEPLOYMENT_MODE_ENV: " web "}) + is DeploymentMode.WEB + ) + + def test_invalid_value_raises(self): + with pytest.raises(InvalidDeploymentModeError) as excinfo: + resolve_deployment_mode(env={DEPLOYMENT_MODE_ENV: "prod"}) + # Error message should mention the env var, the bad value, and the + # valid options so the operator knows how to fix it. + msg = str(excinfo.value) + assert "PINKY_DEPLOYMENT_MODE" in msg + assert "prod" in msg + assert "trusted" in msg + assert "lan" in msg + assert "web" in msg + + def test_invalid_alias_does_not_silently_pass(self): + # No back-compat aliases — explicit only. + for alias in ["production", "development", "dev", "local", "staging"]: + with pytest.raises(InvalidDeploymentModeError): + resolve_deployment_mode(env={DEPLOYMENT_MODE_ENV: alias}) + + def test_reads_os_environ_by_default(self): + with patch.dict(os.environ, {DEPLOYMENT_MODE_ENV: "lan"}, clear=False): + assert resolve_deployment_mode() is DeploymentMode.LAN + + +# ── default_bind_host ────────────────────────────────────────── + + +class TestDefaultBindHost: + def test_trusted_binds_all(self): + # Back-compat with current Mac Mini deployment. + assert default_bind_host(DeploymentMode.TRUSTED) == "0.0.0.0" + + def test_lan_binds_all(self): + # LAN appliances need to be reachable on the local network. + assert default_bind_host(DeploymentMode.LAN) == "0.0.0.0" + + def test_web_binds_loopback(self): + # Web mode assumes a reverse proxy in front; refuse public bind. + assert default_bind_host(DeploymentMode.WEB) == "127.0.0.1" + + +# ── warn_if_insecure_exposure ────────────────────────────────── + + +class TestWarnIfInsecureExposure: + def test_trusted_with_all_interfaces_warns(self): + warning = warn_if_insecure_exposure(DeploymentMode.TRUSTED, "0.0.0.0") + assert warning is not None + assert "trusted" in warning + assert "0.0.0.0" in warning + # Tells operator what to do about it. + assert "PINKY_DEPLOYMENT_MODE" in warning + assert "web" in warning + + def test_trusted_with_loopback_no_warning(self): + assert warn_if_insecure_exposure(DeploymentMode.TRUSTED, "127.0.0.1") is None + + def test_trusted_with_lan_address_no_warning(self): + # A specific bind address means the operator made a deliberate choice. + assert warn_if_insecure_exposure(DeploymentMode.TRUSTED, "10.0.0.32") is None + + def test_lan_no_warning(self): + assert warn_if_insecure_exposure(DeploymentMode.LAN, "0.0.0.0") is None + + def test_web_no_warning(self): + # Web mode has its own enforcement (#436/#441); this helper only + # catches the silent-trusted-exposure case. + assert warn_if_insecure_exposure(DeploymentMode.WEB, "0.0.0.0") is None + assert warn_if_insecure_exposure(DeploymentMode.WEB, "127.0.0.1") is None + + +# ── DeploymentMode enum ──────────────────────────────────────── + + +class TestDeploymentModeEnum: + def test_str_returns_value(self): + assert str(DeploymentMode.TRUSTED) == "trusted" + assert str(DeploymentMode.LAN) == "lan" + assert str(DeploymentMode.WEB) == "web" + + def test_is_str_subclass(self): + # Useful for direct comparison and JSON serialisation. + assert isinstance(DeploymentMode.WEB, str) + assert DeploymentMode.WEB == "web" + + +# ── create_api wiring ────────────────────────────────────────── + + +@pytest.fixture +def tmp_db_path(): + """Throwaway DB path so create_api doesn't touch the project's data dir.""" + with tempfile.TemporaryDirectory() as tmpdir: + yield os.path.join(tmpdir, "test.db") + + +class TestCreateApiDeploymentMode: + def test_explicit_mode_cached_on_app_state(self, tmp_db_path): + from pinky_daemon.api import create_api + + app = create_api(db_path=tmp_db_path, deployment_mode=DeploymentMode.WEB) + assert app.state.deployment_mode is DeploymentMode.WEB + + def test_default_resolves_from_env(self, tmp_db_path): + from pinky_daemon.api import create_api + + with patch.dict( + os.environ, {DEPLOYMENT_MODE_ENV: "lan"}, clear=False + ): + app = create_api(db_path=tmp_db_path) + assert app.state.deployment_mode is DeploymentMode.LAN + + def test_default_when_env_unset(self, tmp_db_path): + from pinky_daemon.api import create_api + + # Strip the env var so resolution lands on TRUSTED. + env = {k: v for k, v in os.environ.items() if k != DEPLOYMENT_MODE_ENV} + with patch.dict(os.environ, env, clear=True): + app = create_api(db_path=tmp_db_path) + assert app.state.deployment_mode is DeploymentMode.TRUSTED + + def test_api_endpoint_exposes_mode(self, tmp_db_path): + from pinky_daemon.api import create_api + + app = create_api(db_path=tmp_db_path, deployment_mode=DeploymentMode.WEB) + client = TestClient(app) + resp = client.get("/api") + assert resp.status_code == 200 + body = resp.json() + assert body["deployment_mode"] == "web" + + def test_api_endpoint_exposes_mode_for_each_value(self, tmp_db_path): + from pinky_daemon.api import create_api + + for mode in DeploymentMode: + app = create_api(db_path=tmp_db_path, deployment_mode=mode) + client = TestClient(app) + resp = client.get("/api") + assert resp.json()["deployment_mode"] == mode.value