Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 35 additions & 3 deletions src/pinky_daemon/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand All @@ -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:
Expand Down
29 changes: 27 additions & 2 deletions src/pinky_daemon/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Check notice

Code scanning / CodeQL

Unused import Note

Import of 'DeploymentMode' is not used.

from fastapi import (
FastAPI,
Expand Down Expand Up @@ -622,14 +625,32 @@
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.
Expand Down Expand Up @@ -2777,13 +2798,17 @@
"""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,
"claude_version": _claude_version,
"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,
}
Expand Down
148 changes: 148 additions & 0 deletions src/pinky_daemon/config.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading