From d6b214351bd2272d278f344bada8812a1e0b290f Mon Sep 17 00:00:00 2001 From: Rick Hightower Date: Fri, 5 Jun 2026 15:38:31 -0500 Subject: [PATCH 1/2] feat(server): API key auth on REST routers + startup gate (#179) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the critical-severity unauthenticated REST API surface raised in issue #179. Adds an opt-in AGENT_BRAIN_API_KEY enforced via the X-API-Key header by a single `verify_api_key` FastAPI dependency, applied at the router level on all data routers (index, cache, folders, jobs, query, graph). The health router stays exempt by design. Startup gate refuses to bind any non-loopback host without a key set (exit 2 to match uvicorn's port-in-use convention so process managers can distinguish a misconfiguration from a crash). Loopback + no key keeps working with a loud warning to preserve single-user dev UX. When AGENT_BRAIN_API_KEY is set AND DEBUG=false, /docs, /redoc, and /openapi.json are mounted as None — there's no point requiring a key to hit endpoints if the schema describing them is publicly browsable. DEBUG=true keeps the docs open so developer workflows are unaffected. Constant-time compare via `secrets.compare_digest` for the key check. Tests: - tests/unit/api/test_security.py (6) — verify_api_key in isolation - tests/unit/api/test_auth_enforcement.py (20) — structural + behavioral (401/200) across all 6 gated routers, with a health- router exemption check - tests/unit/api/test_startup_gate.py (9) — exit-2 on non-loopback, warn on loopback, accept localhost/127.0.0.1/::1 aliases, /docs gating matrix across (key set/unset × DEBUG true/false) Server suite: 1304 passed / 28 skipped (was 1275 before this commit). Plan: docs/plans/2026-06-05-issue-179-api-key-auth.md Next: extend RuntimeState + propagate the key through CLI and MCP clients so enabling AGENT_BRAIN_API_KEY doesn't break either layer. Co-Authored-By: Claude Opus 4.7 --- .../agent_brain_server/api/main.py | 95 +++++++++-- .../agent_brain_server/api/routers/cache.py | 5 +- .../agent_brain_server/api/routers/folders.py | 5 +- .../agent_brain_server/api/routers/graph.py | 5 +- .../agent_brain_server/api/routers/index.py | 5 +- .../agent_brain_server/api/routers/jobs.py | 5 +- .../agent_brain_server/api/routers/query.py | 5 +- .../agent_brain_server/api/security.py | 38 +++++ .../agent_brain_server/config/settings.py | 8 + .../tests/unit/api/test_auth_enforcement.py | 149 ++++++++++++++++++ .../tests/unit/api/test_security.py | 115 ++++++++++++++ .../tests/unit/api/test_startup_gate.py | 133 ++++++++++++++++ .../2026-06-05-issue-179-api-key-auth.md | 134 ++++++++++++++++ 13 files changed, 678 insertions(+), 24 deletions(-) create mode 100644 agent-brain-server/agent_brain_server/api/security.py create mode 100644 agent-brain-server/tests/unit/api/test_auth_enforcement.py create mode 100644 agent-brain-server/tests/unit/api/test_security.py create mode 100644 agent-brain-server/tests/unit/api/test_startup_gate.py create mode 100644 docs/plans/2026-06-05-issue-179-api-key-auth.md diff --git a/agent-brain-server/agent_brain_server/api/main.py b/agent-brain-server/agent_brain_server/api/main.py index 12d9a870..d744c251 100644 --- a/agent-brain-server/agent_brain_server/api/main.py +++ b/agent-brain-server/agent_brain_server/api/main.py @@ -12,6 +12,7 @@ import logging import os import socket +import sys from collections.abc import AsyncIterator from contextlib import asynccontextmanager from pathlib import Path @@ -669,19 +670,82 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: logger.info(f"Released lock and cleaned up state in {state_dir}") +def _check_api_key_startup_gate(resolved_host: str) -> None: + """Refuse to start when binding non-loopback without an API key (Issue #179). + + Loopback (``127.0.0.1`` / ``localhost`` / ``::1``) + empty key emits a + loud warning and proceeds. Any other bind host without a key exits with + code 2 — the same code uvicorn uses for "port in use" — so process + managers can distinguish a misconfiguration from a server crash. + + Refreshes the settings lru_cache first so env vars set by the CLI + subprocess wrapper (and tests) are picked up before the check fires. + """ + from agent_brain_server.config.settings import get_settings + + get_settings.cache_clear() + current_settings = get_settings() + + loopback_hosts = {"127.0.0.1", "localhost", "::1"} + api_key_set = bool(current_settings.AGENT_BRAIN_API_KEY) + + if api_key_set: + return + + if resolved_host in loopback_hosts: + logger.warning( + "Starting on %s with no AGENT_BRAIN_API_KEY set — endpoints are " + "unauthenticated. Safe for single-user dev only. Set " + "AGENT_BRAIN_API_KEY before exposing the server beyond loopback.", + resolved_host, + ) + return + + logger.critical( + "Refusing to start on %s without AGENT_BRAIN_API_KEY. The default " + "no-auth posture is only allowed on loopback (127.0.0.1, localhost, " + "::1). Set AGENT_BRAIN_API_KEY in the environment or bind to " + "127.0.0.1. (Issue #179)", + resolved_host, + ) + sys.exit(2) + + +def _build_app() -> FastAPI: + """Construct the FastAPI app, gating /docs and /openapi.json when configured. + + When ``AGENT_BRAIN_API_KEY`` is set AND ``DEBUG`` is False, the interactive + docs (``/docs``, ``/redoc``) and the OpenAPI schema (``/openapi.json``) are + disabled — there's no point requiring an API key to read endpoints if the + schema describing them is publicly browsable. In DEBUG mode the docs stay + open so developers keep their normal workflow. + + Extracted as a factory so tests can build alternate apps under a + monkeypatched environment without re-importing the module. + """ + from agent_brain_server.config.settings import get_settings + + get_settings.cache_clear() + current_settings = get_settings() + docs_gated = ( + bool(current_settings.AGENT_BRAIN_API_KEY) and not current_settings.DEBUG + ) + return FastAPI( + title="Agent Brain RAG API", + description=( + "RAG-based document indexing and semantic search API. " + "Index documents from folders and query them using natural language." + ), + version=__version__, + lifespan=lifespan, + docs_url=None if docs_gated else "/docs", + redoc_url=None if docs_gated else "/redoc", + openapi_url=None if docs_gated else "/openapi.json", + ) + + # Create FastAPI application -app = FastAPI( - title="Agent Brain RAG API", - description=( - "RAG-based document indexing and semantic search API. " - "Index documents from folders and query them using natural language." - ), - version=__version__, - lifespan=lifespan, - docs_url="/docs", - redoc_url="/redoc", - openapi_url="/openapi.json", -) +app = _build_app() # Add CORS middleware app.add_middleware( @@ -745,6 +809,13 @@ def run( resolved_host = host or settings.API_HOST resolved_port = port if port is not None else settings.API_PORT + # Issue #179: refuse to start on non-loopback without an API key. + # Default-no-auth is only safe on 127.0.0.1; binding 0.0.0.0 or any + # routable interface without a key is a hard failure (exit 2). Loopback + # without a key keeps working with a loud warning so single-user dev + # workflows are unaffected. + _check_api_key_startup_gate(resolved_host) + # Handle port 0: find a free port if resolved_port == 0: resolved_port = _find_free_port() diff --git a/agent-brain-server/agent_brain_server/api/routers/cache.py b/agent-brain-server/agent_brain_server/api/routers/cache.py index 17419de7..f95a50ad 100644 --- a/agent-brain-server/agent_brain_server/api/routers/cache.py +++ b/agent-brain-server/agent_brain_server/api/routers/cache.py @@ -17,13 +17,14 @@ import logging from typing import Any -from fastapi import APIRouter, HTTPException, Request +from fastapi import APIRouter, Depends, HTTPException, Request +from agent_brain_server.api.security import verify_api_key from agent_brain_server.services.embedding_cache import get_embedding_cache logger = logging.getLogger(__name__) -router = APIRouter() +router = APIRouter(dependencies=[Depends(verify_api_key)]) async def _cache_status_impl(request: Request) -> dict[str, Any]: diff --git a/agent-brain-server/agent_brain_server/api/routers/folders.py b/agent-brain-server/agent_brain_server/api/routers/folders.py index 857aa29d..93f596af 100644 --- a/agent-brain-server/agent_brain_server/api/routers/folders.py +++ b/agent-brain-server/agent_brain_server/api/routers/folders.py @@ -10,8 +10,9 @@ import logging from pathlib import Path -from fastapi import APIRouter, HTTPException, Request, status +from fastapi import APIRouter, Depends, HTTPException, Request, status +from agent_brain_server.api.security import verify_api_key from agent_brain_server.models.folders import ( FolderDeleteRequest, FolderDeleteResponse, @@ -21,7 +22,7 @@ logger = logging.getLogger(__name__) -router = APIRouter() +router = APIRouter(dependencies=[Depends(verify_api_key)]) @router.get( diff --git a/agent-brain-server/agent_brain_server/api/routers/graph.py b/agent-brain-server/agent_brain_server/api/routers/graph.py index 0e8701c7..a4c001f0 100644 --- a/agent-brain-server/agent_brain_server/api/routers/graph.py +++ b/agent-brain-server/agent_brain_server/api/routers/graph.py @@ -39,8 +39,9 @@ import logging -from fastapi import APIRouter, HTTPException, Request, status +from fastapi import APIRouter, Depends, HTTPException, Request, status +from agent_brain_server.api.security import verify_api_key from agent_brain_server.config.provider_config import load_provider_settings from agent_brain_server.config.settings import settings from agent_brain_server.models import ENTITY_TYPES, GraphEntityRecord @@ -51,7 +52,7 @@ logger = logging.getLogger(__name__) -router = APIRouter() +router = APIRouter(dependencies=[Depends(verify_api_key)]) # Frozen at module import for predictable 400-body content. The 17 SCHEMA-01 diff --git a/agent-brain-server/agent_brain_server/api/routers/index.py b/agent-brain-server/agent_brain_server/api/routers/index.py index b5c477dd..9aa9a495 100644 --- a/agent-brain-server/agent_brain_server/api/routers/index.py +++ b/agent-brain-server/agent_brain_server/api/routers/index.py @@ -7,8 +7,9 @@ from pathlib import Path from typing import Any -from fastapi import APIRouter, HTTPException, Query, Request, status +from fastapi import APIRouter, Depends, HTTPException, Query, Request, status +from agent_brain_server.api.security import verify_api_key from agent_brain_server.config import settings from agent_brain_server.config.provider_config import load_provider_settings from agent_brain_server.models import IndexRequest, IndexResponse @@ -27,7 +28,7 @@ def _graphrag_enabled() -> bool: logger = logging.getLogger(__name__) -router = APIRouter() +router = APIRouter(dependencies=[Depends(verify_api_key)]) # Maximum queue length for backpressure MAX_QUEUE_LENGTH = settings.AGENT_BRAIN_MAX_QUEUE diff --git a/agent-brain-server/agent_brain_server/api/routers/jobs.py b/agent-brain-server/agent_brain_server/api/routers/jobs.py index adb15481..19b34774 100644 --- a/agent-brain-server/agent_brain_server/api/routers/jobs.py +++ b/agent-brain-server/agent_brain_server/api/routers/jobs.py @@ -2,12 +2,13 @@ from typing import Any -from fastapi import APIRouter, HTTPException, Query, Request, status +from fastapi import APIRouter, Depends, HTTPException, Query, Request, status +from agent_brain_server.api.security import verify_api_key from agent_brain_server.job_queue.job_service import JobQueueService from agent_brain_server.models.job import JobDetailResponse, JobListResponse -router = APIRouter() +router = APIRouter(dependencies=[Depends(verify_api_key)]) @router.get( diff --git a/agent-brain-server/agent_brain_server/api/routers/query.py b/agent-brain-server/agent_brain_server/api/routers/query.py index 12bff606..83c306eb 100644 --- a/agent-brain-server/agent_brain_server/api/routers/query.py +++ b/agent-brain-server/agent_brain_server/api/routers/query.py @@ -2,14 +2,15 @@ import logging -from fastapi import APIRouter, HTTPException, Request, status +from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi.responses import JSONResponse +from agent_brain_server.api.security import verify_api_key from agent_brain_server.models import ChunkRecord, QueryRequest, QueryResponse logger = logging.getLogger(__name__) -router = APIRouter() +router = APIRouter(dependencies=[Depends(verify_api_key)]) @router.post( diff --git a/agent-brain-server/agent_brain_server/api/security.py b/agent-brain-server/agent_brain_server/api/security.py new file mode 100644 index 00000000..bfc338d7 --- /dev/null +++ b/agent-brain-server/agent_brain_server/api/security.py @@ -0,0 +1,38 @@ +"""API key authentication dependency for protected routers (Issue #179). + +When ``settings.AGENT_BRAIN_API_KEY`` is empty, ``verify_api_key`` is a no-op +so single-user loopback dev keeps its current zero-config experience. When the +setting is non-empty, the dependency requires an ``X-API-Key`` request header +whose value matches the setting in constant time. + +The startup gate in :mod:`agent_brain_server.api.main` enforces that +``AGENT_BRAIN_API_KEY`` is set whenever the server binds to anything other than +``127.0.0.1``; this module only enforces the per-request check. +""" + +from __future__ import annotations + +import secrets + +from fastapi import Header, HTTPException, status + +from agent_brain_server.config.settings import get_settings + + +async def verify_api_key( + x_api_key: str | None = Header(default=None, alias="X-API-Key"), +) -> None: + """FastAPI dependency that gates a router on an API key match. + + No-op when ``AGENT_BRAIN_API_KEY`` is empty (loopback dev default). + """ + expected = get_settings().AGENT_BRAIN_API_KEY + if not expected: + return + + if x_api_key is None or not secrets.compare_digest(x_api_key, expected): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or missing API key", + headers={"WWW-Authenticate": "X-API-Key"}, + ) diff --git a/agent-brain-server/agent_brain_server/config/settings.py b/agent-brain-server/agent_brain_server/config/settings.py index f94f3c42..90c8966c 100644 --- a/agent-brain-server/agent_brain_server/config/settings.py +++ b/agent-brain-server/agent_brain_server/config/settings.py @@ -19,6 +19,14 @@ class Settings(BaseSettings): API_PORT: int = 8000 DEBUG: bool = False + # API Authentication (Issue #179) + # When empty (default), data endpoints are unauthenticated — safe only on + # loopback for single-user dev. The startup gate in api/main.py refuses to + # start when API_HOST != "127.0.0.1" and this is unset. + # When set, requests to gated routers must carry the value in the + # X-API-Key header; /health stays open; /docs is gated when DEBUG is False. + AGENT_BRAIN_API_KEY: str = "" + # OpenAI Configuration OPENAI_API_KEY: str = "" EMBEDDING_MODEL: str = "text-embedding-3-large" diff --git a/agent-brain-server/tests/unit/api/test_auth_enforcement.py b/agent-brain-server/tests/unit/api/test_auth_enforcement.py new file mode 100644 index 00000000..cd2abab6 --- /dev/null +++ b/agent-brain-server/tests/unit/api/test_auth_enforcement.py @@ -0,0 +1,149 @@ +"""Integration tests proving each non-health router gates on ``verify_api_key``. + +Two layers of evidence: + +1. **Structural** — ``router.dependencies`` contains a ``Depends(verify_api_key)`` + for every non-health router. Cheap regression guard against someone deleting + the dependency from a router file. +2. **Behavioral** — a minimal FastAPI app mounts a stub endpoint underneath each + router's dependency stack, and a TestClient verifies that requests without + the ``X-API-Key`` header return 401 while requests with the right header + reach the stub handler. This exercises the full FastAPI dependency-resolution + chain (the unit tests in ``test_security.py`` only cover the dependency in + isolation). +""" + +from __future__ import annotations + +from collections.abc import Generator + +import pytest +from fastapi import APIRouter, FastAPI +from fastapi.testclient import TestClient + +from agent_brain_server.api.routers import ( + cache, + folders, + graph, + index, + jobs, + query, +) +from agent_brain_server.api.routers import health as health_router +from agent_brain_server.api.security import verify_api_key +from agent_brain_server.config.settings import get_settings + + +GATED_ROUTERS = [ + ("cache", cache.router), + ("folders", folders.router), + ("graph", graph.router), + ("index", index.router), + ("jobs", jobs.router), + ("query", query.router), +] + + +@pytest.fixture +def reset_settings_cache() -> Generator[None, None, None]: + get_settings.cache_clear() + yield + get_settings.cache_clear() + + +# --------------------------------------------------------------------------- +# Structural layer +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("name,router", GATED_ROUTERS) +def test_each_gated_router_declares_verify_api_key(name: str, router: object) -> None: + """``router.dependencies`` must include a ``Depends(verify_api_key)`` entry.""" + deps = getattr(router, "dependencies", []) + assert any( + getattr(d, "dependency", None) is verify_api_key for d in deps + ), f"router '{name}' is missing Depends(verify_api_key)" + + +def test_health_router_is_intentionally_unauthenticated() -> None: + """Health endpoints must stay open even when API key is configured.""" + deps = getattr(health_router.router, "dependencies", []) + assert not any( + getattr(d, "dependency", None) is verify_api_key for d in deps + ), "health router should NOT carry verify_api_key — it must stay open" + + +# --------------------------------------------------------------------------- +# Behavioral layer +# --------------------------------------------------------------------------- + + +def _make_stub_app(router_under_test: object) -> FastAPI: + """Mount a stub endpoint under a clone of the router's dependency stack. + + We build a fresh ``APIRouter(dependencies=...)`` that mirrors the router + under test, then attach only the stub endpoint to it. This isolates the + dependency contract from each router's actual path table (which would + otherwise match stub paths like ``/{job_id}`` before ``/__authcheck``). + """ + app = FastAPI() + deps = list(getattr(router_under_test, "dependencies", [])) + stub_router = APIRouter(dependencies=deps) + + @stub_router.get("/__authcheck") + async def _stub() -> dict[str, str]: + return {"ok": "true"} + + app.include_router(stub_router) + return app + + +@pytest.mark.parametrize("name,router", GATED_ROUTERS) +def test_gated_router_returns_401_without_header( + monkeypatch: pytest.MonkeyPatch, + reset_settings_cache: None, + name: str, + router: object, +) -> None: + monkeypatch.setenv("AGENT_BRAIN_API_KEY", "auth-test-key") + client = TestClient(_make_stub_app(router)) + + response = client.get("/__authcheck") + + assert response.status_code == 401, ( + f"router '{name}' did not return 401 without X-API-Key" + ) + + +@pytest.mark.parametrize("name,router", GATED_ROUTERS) +def test_gated_router_returns_200_with_correct_header( + monkeypatch: pytest.MonkeyPatch, + reset_settings_cache: None, + name: str, + router: object, +) -> None: + monkeypatch.setenv("AGENT_BRAIN_API_KEY", "auth-test-key") + client = TestClient(_make_stub_app(router)) + + response = client.get("/__authcheck", headers={"X-API-Key": "auth-test-key"}) + + assert response.status_code == 200, ( + f"router '{name}' rejected the correct X-API-Key" + ) + assert response.json() == {"ok": "true"} + + +def test_health_router_stays_open_when_key_is_set( + monkeypatch: pytest.MonkeyPatch, reset_settings_cache: None +) -> None: + """A real /health/ endpoint must respond 200 with no header even when key set.""" + monkeypatch.setenv("AGENT_BRAIN_API_KEY", "auth-test-key") + + # Mount only the health router; /health/ does not require app.state. + app = FastAPI() + app.include_router(health_router.router, prefix="/health") + client = TestClient(app) + + response = client.get("/health/") + + assert response.status_code == 200 diff --git a/agent-brain-server/tests/unit/api/test_security.py b/agent-brain-server/tests/unit/api/test_security.py new file mode 100644 index 00000000..e33d737b --- /dev/null +++ b/agent-brain-server/tests/unit/api/test_security.py @@ -0,0 +1,115 @@ +"""Unit tests for the ``verify_api_key`` FastAPI dependency (Issue #179). + +Verifies the dependency in isolation against a minimal FastAPI app so the +``Header(...)`` resolver and ``HTTPException`` plumbing exercise the same path +they take in production routers. The router-wiring tests in +``test_auth_enforcement.py`` cover the integration side. +""" + +from __future__ import annotations + +from collections.abc import Generator + +import pytest +from fastapi import Depends, FastAPI +from fastapi.testclient import TestClient + +from agent_brain_server.api.security import verify_api_key +from agent_brain_server.config.settings import get_settings + + +@pytest.fixture +def reset_settings_cache() -> Generator[None, None, None]: + """Reset ``get_settings``'s lru_cache around each test. + + The Settings class reads env vars at construction; without clearing the + cache, a monkeypatched env var won't show up in ``settings.AGENT_BRAIN_API_KEY``. + """ + get_settings.cache_clear() + yield + get_settings.cache_clear() + + +def _make_app() -> FastAPI: + app = FastAPI() + + @app.get("/protected", dependencies=[Depends(verify_api_key)]) + async def protected() -> dict[str, str]: + return {"ok": "true"} + + return app + + +def test_noop_when_setting_empty_and_no_header( + monkeypatch: pytest.MonkeyPatch, reset_settings_cache: None +) -> None: + monkeypatch.setenv("AGENT_BRAIN_API_KEY", "") + client = TestClient(_make_app()) + + response = client.get("/protected") + + assert response.status_code == 200 + assert response.json() == {"ok": "true"} + + +def test_noop_when_setting_empty_even_with_random_header( + monkeypatch: pytest.MonkeyPatch, reset_settings_cache: None +) -> None: + monkeypatch.setenv("AGENT_BRAIN_API_KEY", "") + client = TestClient(_make_app()) + + response = client.get("/protected", headers={"X-API-Key": "ignored"}) + + assert response.status_code == 200 + + +def test_401_when_setting_present_and_header_missing( + monkeypatch: pytest.MonkeyPatch, reset_settings_cache: None +) -> None: + monkeypatch.setenv("AGENT_BRAIN_API_KEY", "secret-token") + client = TestClient(_make_app()) + + response = client.get("/protected") + + assert response.status_code == 401 + assert response.json()["detail"] == "Invalid or missing API key" + assert response.headers["WWW-Authenticate"] == "X-API-Key" + + +def test_401_when_setting_present_and_header_wrong( + monkeypatch: pytest.MonkeyPatch, reset_settings_cache: None +) -> None: + monkeypatch.setenv("AGENT_BRAIN_API_KEY", "secret-token") + client = TestClient(_make_app()) + + response = client.get("/protected", headers={"X-API-Key": "wrong"}) + + assert response.status_code == 401 + + +def test_200_when_setting_present_and_header_matches( + monkeypatch: pytest.MonkeyPatch, reset_settings_cache: None +) -> None: + monkeypatch.setenv("AGENT_BRAIN_API_KEY", "secret-token") + client = TestClient(_make_app()) + + response = client.get("/protected", headers={"X-API-Key": "secret-token"}) + + assert response.status_code == 200 + assert response.json() == {"ok": "true"} + + +def test_header_is_case_insensitive_per_http_spec( + monkeypatch: pytest.MonkeyPatch, reset_settings_cache: None +) -> None: + """HTTP header names are case-insensitive; FastAPI's Header() honors that. + + Documenting the behavior with a test so a future refactor doesn't + accidentally tighten the matcher. + """ + monkeypatch.setenv("AGENT_BRAIN_API_KEY", "secret-token") + client = TestClient(_make_app()) + + response = client.get("/protected", headers={"x-api-key": "secret-token"}) + + assert response.status_code == 200 diff --git a/agent-brain-server/tests/unit/api/test_startup_gate.py b/agent-brain-server/tests/unit/api/test_startup_gate.py new file mode 100644 index 00000000..2e59a865 --- /dev/null +++ b/agent-brain-server/tests/unit/api/test_startup_gate.py @@ -0,0 +1,133 @@ +"""Tests for the API key startup gate and /docs gating in ``api/main.py``. + +Covers Issue #179 acceptance: + +- ``API_HOST=0.0.0.0`` + empty key → process exits with code 2. +- ``API_HOST=127.0.0.1`` + empty key → warning logged, no exit. +- ``API_HOST=0.0.0.0`` + non-empty key → no exit, no warning. +- App built with ``AGENT_BRAIN_API_KEY`` set and ``DEBUG=false`` exposes + ``docs_url=None`` and ``openapi_url=None`` (Swagger UI hidden when + the schema would otherwise leak the protected surface). +- App built with ``DEBUG=true`` always keeps ``/docs`` mounted regardless + of whether a key is configured. +""" + +from __future__ import annotations + +import logging +from collections.abc import Generator + +import pytest + +from agent_brain_server.api.main import _build_app, _check_api_key_startup_gate +from agent_brain_server.config.settings import get_settings + + +@pytest.fixture +def reset_settings_cache() -> Generator[None, None, None]: + get_settings.cache_clear() + yield + get_settings.cache_clear() + + +# --------------------------------------------------------------------------- +# Startup gate +# --------------------------------------------------------------------------- + + +def test_startup_gate_exits_on_non_loopback_without_key( + monkeypatch: pytest.MonkeyPatch, reset_settings_cache: None +) -> None: + monkeypatch.setenv("AGENT_BRAIN_API_KEY", "") + + with pytest.raises(SystemExit) as exc_info: + _check_api_key_startup_gate("0.0.0.0") + + assert exc_info.value.code == 2 + + +def test_startup_gate_warns_on_loopback_without_key( + monkeypatch: pytest.MonkeyPatch, + reset_settings_cache: None, + caplog: pytest.LogCaptureFixture, +) -> None: + monkeypatch.setenv("AGENT_BRAIN_API_KEY", "") + + with caplog.at_level(logging.WARNING, logger="agent_brain_server.api.main"): + _check_api_key_startup_gate("127.0.0.1") + + assert any( + "AGENT_BRAIN_API_KEY" in record.message and record.levelno == logging.WARNING + for record in caplog.records + ) + + +@pytest.mark.parametrize("host", ["127.0.0.1", "localhost", "::1"]) +def test_startup_gate_accepts_all_loopback_aliases( + monkeypatch: pytest.MonkeyPatch, + reset_settings_cache: None, + host: str, +) -> None: + monkeypatch.setenv("AGENT_BRAIN_API_KEY", "") + # Should not raise SystemExit for any loopback alias + _check_api_key_startup_gate(host) + + +def test_startup_gate_silent_when_key_set_even_on_non_loopback( + monkeypatch: pytest.MonkeyPatch, + reset_settings_cache: None, + caplog: pytest.LogCaptureFixture, +) -> None: + monkeypatch.setenv("AGENT_BRAIN_API_KEY", "configured-key") + + with caplog.at_level(logging.WARNING, logger="agent_brain_server.api.main"): + _check_api_key_startup_gate("0.0.0.0") + + # Neither warning nor critical when a key is configured + assert not any( + record.levelno >= logging.WARNING for record in caplog.records + ) + + +# --------------------------------------------------------------------------- +# /docs gating +# --------------------------------------------------------------------------- + + +def test_docs_gated_when_key_set_and_debug_false( + monkeypatch: pytest.MonkeyPatch, reset_settings_cache: None +) -> None: + monkeypatch.setenv("AGENT_BRAIN_API_KEY", "docs-test-key") + monkeypatch.setenv("DEBUG", "false") + + app = _build_app() + + assert app.docs_url is None + assert app.redoc_url is None + assert app.openapi_url is None + + +def test_docs_open_when_key_set_but_debug_true( + monkeypatch: pytest.MonkeyPatch, reset_settings_cache: None +) -> None: + monkeypatch.setenv("AGENT_BRAIN_API_KEY", "docs-test-key") + monkeypatch.setenv("DEBUG", "true") + + app = _build_app() + + assert app.docs_url == "/docs" + assert app.redoc_url == "/redoc" + assert app.openapi_url == "/openapi.json" + + +def test_docs_open_when_key_unset( + monkeypatch: pytest.MonkeyPatch, reset_settings_cache: None +) -> None: + monkeypatch.setenv("AGENT_BRAIN_API_KEY", "") + monkeypatch.setenv("DEBUG", "false") + + app = _build_app() + + assert app.docs_url == "/docs" + assert app.redoc_url == "/redoc" + assert app.openapi_url == "/openapi.json" diff --git a/docs/plans/2026-06-05-issue-179-api-key-auth.md b/docs/plans/2026-06-05-issue-179-api-key-auth.md new file mode 100644 index 00000000..90dc563d --- /dev/null +++ b/docs/plans/2026-06-05-issue-179-api-key-auth.md @@ -0,0 +1,134 @@ +# Plan: Issue #179 — REST API Key Authentication (server + CLI + MCP) + +**Branch:** `security/issue-179-api-key-auth` +**Target milestone:** v10.3-prep (hotfix-class security work; can ship as v10.2.1 patch) +**Issue:** [#179 server exposes all endpoints with no authentication (critical)](https://github.com/SpillwaveSolutions/agent-brain/issues/179) + +--- + +## Context + +`agent-brain-server`'s FastAPI app currently has **zero authentication** on all data endpoints. `127.0.0.1` binding is not an authorization control — any local process can read or wipe the index. This is the only open issue marked **critical** in its title and was explicitly deferred during v10.2 ("Bearer-token API auth mid-flight", surfaced in Phase 50 design doc). + +The fix introduces an optional `AGENT_BRAIN_API_KEY` with an `X-API-Key` header enforcement, default-no-auth on loopback to preserve the single-user dev UX, and a strict fail-fast when bound non-loopback without a key. CLI and MCP clients are updated in lockstep so enabling auth doesn't silently break the MCP layer. + +**Locked decisions:** + +1. **Default mode (strict-on-non-loopback):** No-auth on `127.0.0.1` if `AGENT_BRAIN_API_KEY` is empty. Refuse to start (`exit_code=2`) if `API_HOST != 127.0.0.1` and key unset. Loud warn-and-continue if loopback + no key. +2. **Scope:** server + CLI + MCP in this PR. CORS wildcard (`allow_origins=["*"]`) tightening filed as separate follow-up issue. +3. **Docs gating:** `/docs`, `/redoc`, `/openapi.json` gated when `AGENT_BRAIN_API_KEY` set AND `DEBUG=false`. Stays open in DEBUG. + +--- + +## Files Affected + +### `agent-brain-server` — enforcement side + +| File | Change | +|---|---| +| `agent_brain_server/config/settings.py` (lines 14–154) | Add `AGENT_BRAIN_API_KEY: str = ""` field next to OPENAI/ANTHROPIC keys. Pydantic v2 style, no prefix, matching existing convention. | +| `agent_brain_server/api/security.py` (**new**, ~50 LOC) | Implements `verify_api_key` FastAPI dependency reading `X-API-Key` header. Returns no-op when settings key empty; raises `HTTPException(401)` on missing/mismatched when key is set. Uses `secrets.compare_digest` for constant-time compare. | +| `agent_brain_server/api/routers/{index,cache,folders,jobs,query,graph}.py` (6 files) | Add module-level `router = APIRouter(dependencies=[Depends(verify_api_key)])` so every endpoint inherits the dependency. **Health router is intentionally exempt.** | +| `agent_brain_server/api/main.py` (lines 672–702 + lifespan ~766–779) | Startup gate: in lifespan, if `settings.API_HOST != "127.0.0.1"` and `settings.AGENT_BRAIN_API_KEY == ""` → log critical + `sys.exit(2)` with message. Else if loopback + no key → loud warning via logger. Gate `/docs`, `/redoc`, `/openapi.json` by passing `docs_url=None, redoc_url=None, openapi_url=None` to `FastAPI()` when key set + DEBUG false; re-mount under `Depends(verify_api_key)` in that case. | +| `agent_brain_server/runtime.py` (lines 16–34, 37–47) | Extend `RuntimeState` with `api_key: str \| None = None`. `write_runtime()` writes the file with `Path.chmod(0o600)` after write (defense-in-depth — state_dir is already user-owned). | +| `.env.example` | Document `AGENT_BRAIN_API_KEY=` with a one-line comment. | + +### `agent-brain-cli` — client side + +| File | Change | +|---|---| +| `agent_brain_cli/client/api_client.py` (line 155–265, `DocServeClient`) | Add `api_key: str \| None = None` to `__init__`. When set, pass `headers={"X-API-Key": api_key}` to `httpx.Client(...)`. `from_httpx` honors a separate `api_key` kwarg for UDS path. | +| `agent_brain_cli/client/transport.py` (lines 24–54, `open_client`) | After resolving base_url/socket_path, resolve API key via: `AGENT_BRAIN_API_KEY` env → `runtime.json::api_key` (via existing `read_runtime`) → `None`. Pass to `DocServeClient` constructor. | +| `agent_brain_cli/commands/init.py` (lines 1–79) | On `agent-brain init`: generate `secrets.token_urlsafe(32)` → write to `runtime.json::api_key` (or staged config if runtime.json doesn't exist yet, since `init` precedes `start`). Print a one-line note: "API key generated and stored in `.agent-brain/runtime.json` (mode 600)." Add `--no-api-key` flag for opt-out (single-user devs who want the legacy no-auth behavior). | +| `agent_brain_cli/config.py` (~line 245–251) | If extending env resolution, mirror server-side env var name to keep ops simple. | + +### `agent-brain-mcp` — client side (the gap the issue missed) + +| File | Change | +|---|---| +| `agent_brain_mcp/client.py` (`ApiClient`, lines 22–79) | No internal change — `ApiClient` already accepts a pre-configured `httpx.Client`. Caller must inject the header. | +| `agent_brain_mcp/config.py` (lines 1–24, backend URL resolution + `MCPSubscriptionSettings`) | Extend backend resolution order with API key lookup parallel to URL: `AGENT_BRAIN_MCP_API_KEY` env → `AGENT_BRAIN_API_KEY` env → `runtime.json::api_key` → `None`. Surface as `backend_api_key` on the resolved config dataclass/model. | +| The MCP entrypoint that constructs the httpx.Client for `ApiClient` (find via grep `ApiClient(`) | When `backend_api_key` is set, build `httpx.Client(headers={"X-API-Key": ...})`. | + +--- + +## Test Strategy + +Tests live in their respective packages; coverage gate is ≥80% per package (matches v10.2 floors). + +### `agent-brain-server/tests/` + +- **New fixture** in `tests/conftest.py`: `app_with_api_key(monkeypatch)` — sets `AGENT_BRAIN_API_KEY="test-key-123"` via `monkeypatch.setenv`, clears `get_settings` lru_cache, returns `TestClient`. Also returns the key so tests can header-inject. +- **Per-router test class** in `tests/unit/api/test_auth_enforcement.py` (new) — for each of the 6 gated routers, parametrized over one representative endpoint: `(no header → 401)`, `(wrong key → 401)`, `(correct key → 200)`. Plus a `health` parametrization confirming health stays open even with key set. +- **Existing tests stay green** via the empty-key default — verify by running unchanged. +- **Startup-gate test** in `tests/unit/test_api_main_startup.py` (likely new): subtest 1 — `API_HOST=0.0.0.0` + no key → process exits with code 2. Subtest 2 — `API_HOST=127.0.0.1` + no key → warning logged, app starts. Subtest 3 — `API_HOST=0.0.0.0` + key set → app starts cleanly. +- **/docs gating tests**: when `AGENT_BRAIN_API_KEY` set AND `DEBUG=false` → `GET /docs` returns 401 without header, 200 with. With `DEBUG=true` → `/docs` always 200. + +### `agent-brain-cli/tests/` + +- **`api_client` header injection test**: instantiate `DocServeClient(base_url="http://test", api_key="k")` → check `self._client.headers["X-API-Key"] == "k"`. +- **`transport.open_client` resolution test**: monkeypatch env + runtime.json → verify resolution order. +- **`init` command test**: invoke via `CliRunner`, assert generated `runtime.json` contains `api_key` field of 32-byte urlsafe length; assert `--no-api-key` skips generation. + +### `agent-brain-mcp/tests/` + +- **Layer 1 contract** (`tests/contract/test_layer1_*.py`): the existing `fake_httpx_client` fixture must accept and pass through `X-API-Key`; one new test confirms a tool round-trip with the header set. +- **Layer 2 SDK contract** (`tests/contract/test_layer2_*.py`): one smoke test that starts the MCP subprocess with `AGENT_BRAIN_API_KEY` env and confirms it propagates to the backend httpx client. +- **Config resolution test**: env precedence and runtime.json fallback for `backend_api_key`. + +### Reference test files (for style matching) +- `agent-brain-server/tests/unit/api/test_health_config.py` (lines 22–50) — router-level mock pattern with `_create_app()` helper + `TestClient`. +- `agent-brain-server/tests/conftest.py` (lines 78–112) — `isolate_provider_settings` autouse pattern + how `get_settings` cache is cleared. + +--- + +## Implementation Sequence + +1. **Branch:** `git checkout -b security/issue-179-api-key-auth` (clean from `main`). +2. **Server settings + security module:** add `AGENT_BRAIN_API_KEY` field + `api/security.py` + unit tests for `verify_api_key` dependency in isolation (mock settings). +3. **Server router wiring:** add `dependencies=[Depends(verify_api_key)]` to the 6 non-health routers + per-router auth tests. Existing tests stay green. +4. **Startup gate + /docs gating** in `api/main.py` + tests for the 3 startup matrix cases and the /docs DEBUG behavior. +5. **Runtime schema bump:** `RuntimeState.api_key` field + `write_runtime` chmod 0o600 + tests. +6. **CLI client header propagation:** `DocServeClient` + `transport.open_client` + tests. +7. **CLI init key generation:** `commands/init.py` + `--no-api-key` flag + tests. +8. **MCP config + client header injection:** `config.py` resolution + entrypoint httpx headers + contract tests for both layers. +9. **Docs:** update `.env.example`, README quickstart blurb ("for shared hosts, set `AGENT_BRAIN_API_KEY`"), CHANGELOG entry. +10. **Self-review:** run the auth path end-to-end manually against a local server (start without key on loopback → succeeds with warning; set key + start → all CLI calls work; unset CLI key → 401). + +--- + +## Verification (mandatory before push, per CLAUDE.md) + +1. `task before-push` from repo root — **must exit 0**. This runs format/lint/typecheck/test across all packages including the DR-5 monorepo integration (agent-brain-mcp + agent-brain-uds). Per memory: this catches silent regressions cross-package. +2. `task pr-qa-gate` from repo root — must exit 0. +3. Per-package coverage gate: agent-brain-server ≥80%, agent-brain-cli ≥80%, agent-brain-mcp ≥80% (matches v10.2 floors). +4. Manual smoke (recorded in PR description): + - `agent-brain-serve` on default loopback, no key → starts with warning, `curl http://127.0.0.1:8000/health` → 200, `curl http://127.0.0.1:8000/query` POST → 200 (no auth required). + - Set `AGENT_BRAIN_API_KEY=xxx`, restart → `curl /query` without header → 401, with `-H "X-API-Key: xxx"` → 200, `/health` → 200 either way. + - `API_HOST=0.0.0.0 agent-brain-serve` without key → exits 2 with clear log line. + - `agent-brain query "test"` end-to-end via CLI with `runtime.json::api_key` present → 200 (CLI injects header). + - `agent-brain-mcp --transport stdio` with `AGENT_BRAIN_API_KEY` env set → search_documents tool succeeds against authed backend. + +--- + +## Out of Scope (file as follow-up issues) + +- **CORS wildcard tightening** — `agent_brain_server/api/main.py:687-693` uses `allow_origins=["*"]` with `allow_credentials=True`. Tracked separately per the issue's own carve-out. +- **Authorization: Bearer / OAuth 2.1** — covered by future MCP v4 (#188). `X-API-Key` is the API-key idiom; `Bearer` semantics are for OAuth tokens. +- **/mcp/subscriptions debug endpoint (#194)** — orthogonal v3-labeled improvement. + +--- + +## Risks & Mitigations + +- **Risk:** Enabling `AGENT_BRAIN_API_KEY` on an existing deployment breaks every running CLI/MCP that lacks the new key. + **Mitigation:** Default-no-auth on loopback means existing single-user dev installs keep working unchanged. Multi-user/shared hosts must set the key and rotate clients together — surfaced in CHANGELOG `BREAKING (if you bind non-loopback)` section. + +- **Risk:** Layer 2 SDK contract test for MCP starts a subprocess; passing the key via env is straightforward but the existing harness may need a small addition to set env before spawn. + **Mitigation:** Implement env passthrough in the existing subprocess fixture; add the fixture extension as the first MCP commit. + +- **Risk:** `get_settings()` is `@lru_cache`-decorated; tests changing env via `monkeypatch.setenv` won't see the new value unless cache is cleared. + **Mitigation:** Test fixture explicitly calls `get_settings.cache_clear()` (pattern already used elsewhere in conftest — verify and reuse). + +- **Risk:** Phase 51's `MIN_BACKEND_VERSION` check in MCP — bumping server to a new version may force MCP to bump too. + **Mitigation:** This is an auth feature, not a protocol change. Server version bumps to 10.2.1 (patch); `MIN_BACKEND_VERSION` stays at 10.2.0 unless we want to require auth-capable backend (we don't, since default is no-auth). No version-pin change needed. From a3905d62293d50b39124b9edaa8f7abec82bccf3 Mon Sep 17 00:00:00 2001 From: Rick Hightower Date: Fri, 5 Jun 2026 15:53:30 -0500 Subject: [PATCH 2/2] feat(cli,mcp): propagate API key to all REST clients (#179) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes out the lockstep client-side work for issue #179: the agent-brain CLI and agent-brain-mcp both auto-resolve and send the X-API-Key header so that enabling AGENT_BRAIN_API_KEY on the server doesn't silently break either layer. RuntimeState extended: - `api_key: str | None = None` field — the running server publishes the configured key into `runtime.json` so other consumers can discover it without re-reading env / config. - `write_runtime()` chmods runtime.json to 0o600 since it may now carry a secret. - main.py `run()` populates the new field from `settings.AGENT_BRAIN_API_KEY` when constructing RuntimeState. CLI changes: - `DocServeClient(api_key=...)` and `DocServeClient.from_httpx(api_key=...)` inject `X-API-Key` into the underlying httpx.Client default headers. - `transport.open_client` calls a new `resolve_api_key()` that walks the precedence chain env → runtime.json → config.json → None and passes the result into both the HTTP and UDS construction paths. - `agent-brain init` now generates `secrets.token_urlsafe(32)` and writes it into project-local `.agent-brain/config.json` (chmod 0o600). A `--no-api-key` flag opts out for single-user loopback workflows. - `agent-brain start` exports the config.json key as AGENT_BRAIN_API_KEY in the server subprocess env (unless already set externally), so the server gates appropriately on first start without operator intervention. MCP changes: - New `_resolve_api_key(state_dir)` with precedence AGENT_BRAIN_MCP_API_KEY env → AGENT_BRAIN_API_KEY env → runtime.json::api_key → config.json::api_key → None. - `open_backend_client` injects the resolved key into both the HTTP and UDS httpx.Client default headers so the backend round-trip works against an authed Agent Brain server identically to the CLI. Documentation: - `.env.example` (repo root + agent-brain-server) document AGENT_BRAIN_API_KEY with the startup-gate / docs-gate semantics. - README quickstart calls out the new auto-generated key, the --no-api-key opt-out, and the non-loopback fail-fast. - CHANGELOG [Unreleased] entry under Security captures the full design, the BREAKING note for non-loopback users, the inventory of touched files, and the deferred CORS / OAuth follow-ups. Tests added across the three packages: - agent-brain-server/tests/unit/test_runtime.py — 3 new tests (chmod 0o600, api_key round-trip, default-None contract). - agent-brain-cli/tests/test_api_key_propagation.py — 11 tests covering DocServeClient + from_httpx header injection, resolve_api_key precedence (5 cases), and end-to-end through open_client. - agent-brain-cli/tests/test_init_api_key.py — 6 tests covering init key generation, --no-api-key opt-out, config.json 0o600, cross-project key uniqueness, and the post-init / pre-start config.json fallback for resolve_api_key. - agent-brain-mcp/tests/test_api_key_propagation.py — 8 tests covering MCP precedence chain (5 cases) and HTTP / UDS header injection. Verification (repo root): - `task before-push` — all checks passed, exit 0 (~3min). - `task pr-qa-gate` — passed, MCP coverage 91.89% (above 80% floor). Server suite: 1304 passed / 28 skipped. CLI suite: 433 passed. MCP suite: 468 passed / 96 deselected. Plan: docs/plans/2026-06-05-issue-179-api-key-auth.md Out of scope (separate follow-ups): CORS wildcard tightening, Authorization: Bearer / OAuth 2.1 (deferred to MCP v4 #188). Co-Authored-By: Claude Opus 4.7 --- .env.example | 15 ++ README.md | 11 +- .../agent_brain_cli/client/api_client.py | 18 +- .../agent_brain_cli/client/transport.py | 14 +- .../agent_brain_cli/commands/init.py | 33 +++- .../agent_brain_cli/commands/start.py | 6 + agent-brain-cli/agent_brain_cli/config.py | 52 ++++++ .../tests/test_api_key_propagation.py | 155 ++++++++++++++++++ agent-brain-cli/tests/test_init_api_key.py | 137 ++++++++++++++++ agent-brain-mcp/agent_brain_mcp/config.py | 63 ++++++- .../tests/test_api_key_propagation.py | 148 +++++++++++++++++ agent-brain-server/.env.example | 6 + .../agent_brain_server/api/main.py | 5 +- .../agent_brain_server/runtime.py | 14 ++ .../tests/unit/api/test_auth_enforcement.py | 13 +- .../tests/unit/api/test_startup_gate.py | 4 +- agent-brain-server/tests/unit/test_runtime.py | 29 ++++ docs/CHANGELOG.md | 4 +- 18 files changed, 700 insertions(+), 27 deletions(-) create mode 100644 agent-brain-cli/tests/test_api_key_propagation.py create mode 100644 agent-brain-cli/tests/test_init_api_key.py create mode 100644 agent-brain-mcp/tests/test_api_key_propagation.py diff --git a/.env.example b/.env.example index abed9fe9..5dfbc230 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,21 @@ API_HOST=127.0.0.1 API_PORT=8000 DEBUG=false +# ============================================================================= +# API Authentication (Issue #179) +# ============================================================================= +# When set, every data endpoint (/query, /index, /index/*, /graph, etc.) +# requires the value in the `X-API-Key` request header; /health stays open. +# When empty (default), endpoints are unauthenticated — safe ONLY on the +# 127.0.0.1 loopback for single-user dev. The server REFUSES TO START +# (exit 2) when API_HOST is anything other than 127.0.0.1/localhost/::1 +# and AGENT_BRAIN_API_KEY is unset. `agent-brain init` auto-generates a +# urlsafe 32-byte key into project-local .agent-brain/config.json so the +# CLI and MCP discover it automatically; pass --no-api-key to opt out. +# When DEBUG=false AND a key is set, /docs and /redoc are also hidden +# (the schema would otherwise leak the protected surface). +AGENT_BRAIN_API_KEY= + # ============================================================================= # Path Containment (Issue #180) # ============================================================================= diff --git a/README.md b/README.md index 1748d2d3..f6bbf6c0 100644 --- a/README.md +++ b/README.md @@ -209,7 +209,7 @@ While the plugin is the recommended interface, you can also use the CLI directly pip install agent-brain-rag agent-brain-cli # Initialize and start -agent-brain init +agent-brain init # auto-generates a per-project API key agent-brain start --daemon # Index and query @@ -217,6 +217,15 @@ agent-brain index /path/to/docs --include-code agent-brain query "authentication" --mode hybrid ``` +> **Note on authentication.** As of the next release, every data endpoint +> requires `X-API-Key`. `agent-brain init` writes a 32-byte urlsafe key +> into `.agent-brain/config.json` (mode `0o600`); the CLI and +> `agent-brain-mcp` pick it up automatically. Pass `--no-api-key` to +> `agent-brain init` for the legacy no-auth experience on `127.0.0.1`. +> The server refuses to start on any non-loopback bind without a key +> set. See [`docs/CHANGELOG.md`](docs/CHANGELOG.md) under the unreleased +> entry for the full design (Issue #179). + ## Development ### Prerequisites diff --git a/agent-brain-cli/agent_brain_cli/client/api_client.py b/agent-brain-cli/agent_brain_cli/client/api_client.py index 46be99ae..35e479e9 100644 --- a/agent-brain-cli/agent_brain_cli/client/api_client.py +++ b/agent-brain-cli/agent_brain_cli/client/api_client.py @@ -159,6 +159,7 @@ def __init__( self, base_url: str = "http://127.0.0.1:8000", timeout: float = 30.0, + api_key: str | None = None, ): """ Initialize the client. @@ -166,13 +167,21 @@ def __init__( Args: base_url: Server base URL. timeout: Request timeout in seconds. + api_key: Optional X-API-Key value (Issue #179). When supplied, + every outbound request carries the header. ``None`` means + no auth (legacy single-user loopback dev path). """ self.base_url = base_url.rstrip("/") self.timeout = timeout - self._client = httpx.Client(timeout=timeout) + headers = {"X-API-Key": api_key} if api_key else None + self._client = httpx.Client(timeout=timeout, headers=headers) @classmethod - def from_httpx(cls, client: httpx.Client) -> "DocServeClient": + def from_httpx( + cls, + client: httpx.Client, + api_key: str | None = None, + ) -> "DocServeClient": """Build a DocServeClient that uses a pre-constructed httpx.Client. Used by the transport selector to inject a UDS-backed client @@ -183,6 +192,9 @@ def from_httpx(cls, client: httpx.Client) -> "DocServeClient": Args: client: An already-configured ``httpx.Client``. The wrapper takes ownership and will close it on ``__exit__``. + api_key: Optional X-API-Key to merge into the client's + default headers (Issue #179). Caller may also have set + the header on ``client`` directly; both paths work. Returns: A DocServeClient backed by ``client``. @@ -191,6 +203,8 @@ def from_httpx(cls, client: httpx.Client) -> "DocServeClient": instance.base_url = "" # inner client carries the real base_url timeout = client.timeout instance.timeout = timeout.read or 30.0 + if api_key: + client.headers["X-API-Key"] = api_key instance._client = client return instance diff --git a/agent-brain-cli/agent_brain_cli/client/transport.py b/agent-brain-cli/agent_brain_cli/client/transport.py index ffc8b1f9..7479509d 100644 --- a/agent-brain-cli/agent_brain_cli/client/transport.py +++ b/agent-brain-cli/agent_brain_cli/client/transport.py @@ -17,7 +17,7 @@ import click -from ..config import resolve_transport +from ..config import resolve_api_key, resolve_transport from .api_client import DocServeClient @@ -41,14 +41,20 @@ def open_client(ctx: click.Context, *, timeout: float = 30.0) -> DocServeClient: base_url_override=obj.get("base_url_override"), socket_path_override=obj.get("socket_path_override"), ) + # Issue #179: resolve the API key alongside the transport so the same + # CLI invocation works against an authed and an unauthed server. + api_key = resolve_api_key() if obj.get("debug_transport"): - click.echo(f"[debug-transport] {transport} -> {target}", err=True) + auth_marker = "with X-API-Key" if api_key else "no auth" + click.echo( + f"[debug-transport] {transport} -> {target} ({auth_marker})", err=True + ) if transport == "http": - return DocServeClient(base_url=target, timeout=timeout) + return DocServeClient(base_url=target, timeout=timeout, api_key=api_key) # UDS: import lazily so HTTP-only invocations don't pay the cost. from agent_brain_uds import make_client inner = make_client(socket_path=Path(target), timeout=timeout) - return DocServeClient.from_httpx(inner) + return DocServeClient.from_httpx(inner, api_key=api_key) diff --git a/agent-brain-cli/agent_brain_cli/commands/init.py b/agent-brain-cli/agent_brain_cli/commands/init.py index 6f92dcd6..51670d85 100644 --- a/agent-brain-cli/agent_brain_cli/commands/init.py +++ b/agent-brain-cli/agent_brain_cli/commands/init.py @@ -1,6 +1,7 @@ """Init command for initializing an Agent Brain project.""" import json +import secrets from pathlib import Path import click @@ -69,6 +70,14 @@ type=click.Path(file_okay=False, resolve_path=True), help="Custom state directory for index data (default: .agent-brain)", ) +@click.option( + "--no-api-key", + is_flag=True, + help=( + "Skip auto-generating an API key (Issue #179). Use for single-user " + "loopback dev when no auth is desired. Server still starts without auth." + ), +) def init_command( path: str | None, host: str, @@ -76,6 +85,7 @@ def init_command( force: bool, json_output: bool, state_dir: str | None, + no_api_key: bool, ) -> None: """Initialize a new Agent Brain project. @@ -145,8 +155,21 @@ def init_command( config["port"] = port config["auto_port"] = False - # Write configuration + # Issue #179: auto-generate an API key so the server boots with auth + # by default. Stored in config.json (project-local); the `start` + # command exports it as AGENT_BRAIN_API_KEY for the server + # subprocess, and `resolve_api_key` reads it for the CLI side. Opt + # out with --no-api-key for single-user loopback workflows. + if not no_api_key: + config["api_key"] = secrets.token_urlsafe(32) + + # Write configuration with mode 0o600 since it may carry a secret. config_path.write_text(json.dumps(config, indent=2)) + try: + config_path.chmod(0o600) + except OSError: + # Best-effort; filesystems without POSIX modes (FAT) still work. + pass if json_output: click.echo( @@ -162,12 +185,18 @@ def init_command( ) ) else: + api_key_note = ( + f"[bold]API Key:[/] generated ({config_path.name}, mode 0o600)" + if not no_api_key + else "[bold]API Key:[/] [yellow]disabled[/] (--no-api-key)" + ) console.print( Panel( f"[green]Project initialized successfully![/]\n\n" f"[bold]Project Root:[/] {project_root}\n" f"[bold]State Directory:[/] {resolved_state_dir}\n" - f"[bold]Configuration:[/] {config_path}", + f"[bold]Configuration:[/] {config_path}\n" + f"{api_key_note}", title="Agent Brain Initialized", border_style="green", ) diff --git a/agent-brain-cli/agent_brain_cli/commands/start.py b/agent-brain-cli/agent_brain_cli/commands/start.py index fb9cf187..92f99f83 100644 --- a/agent-brain-cli/agent_brain_cli/commands/start.py +++ b/agent-brain-cli/agent_brain_cli/commands/start.py @@ -368,6 +368,12 @@ def start_command( env = os.environ.copy() env["AGENT_BRAIN_PROJECT_ROOT"] = str(project_root) env["AGENT_BRAIN_STATE_DIR"] = str(state_dir) + # Issue #179: propagate the project-local API key from config.json + # into the server subprocess. Existing env value wins so operators + # can override the file-stored key without re-running init. + config_api_key = config.get("api_key") + if config_api_key and not env.get("AGENT_BRAIN_API_KEY"): + env["AGENT_BRAIN_API_KEY"] = str(config_api_key) if strict: env["AGENT_BRAIN_STRICT_MODE"] = "true" if enable_uds: diff --git a/agent-brain-cli/agent_brain_cli/config.py b/agent-brain-cli/agent_brain_cli/config.py index f7e748df..b826dd4f 100644 --- a/agent-brain-cli/agent_brain_cli/config.py +++ b/agent-brain-cli/agent_brain_cli/config.py @@ -414,6 +414,58 @@ def get_server_url(config: AgentBrainConfig | None = None) -> str: return config.server.url +def resolve_api_key(state_dir: Path | None = None) -> str | None: + """Resolve the X-API-Key value the CLI should send (Issue #179). + + Precedence (first non-empty wins): + 1. ``AGENT_BRAIN_API_KEY`` environment variable + 2. ``runtime.json::api_key`` for the resolved state directory + (set by the running server) + 3. ``config.json::api_key`` for the resolved state directory + (set by ``agent-brain init``, used when the server hasn't + started yet) + + Returns ``None`` when no source provides a value, which is the + correct behavior for a server running in default no-auth loopback + mode — the client sends no header and the server's no-op dependency + accepts the request. + + Args: + state_dir: Optional state directory to read from. Defaults to + ``get_state_dir()`` so callers in CLI commands don't need + to thread the path through. + + Returns: + The resolved API key or ``None``. + """ + import json + + env_key = os.getenv("AGENT_BRAIN_API_KEY") + if env_key: + return env_key + + if state_dir is None: + try: + state_dir = get_state_dir() + except Exception: + return None + + for filename in ("runtime.json", "config.json"): + candidate = state_dir / filename + if not candidate.exists(): + continue + try: + with open(candidate) as f: + payload = json.load(f) + except (json.JSONDecodeError, OSError): + continue + api_key = payload.get("api_key") + if api_key: + return str(api_key) + + return None + + def resolve_transport( *, transport_hint: str | None = None, diff --git a/agent-brain-cli/tests/test_api_key_propagation.py b/agent-brain-cli/tests/test_api_key_propagation.py new file mode 100644 index 00000000..b1033032 --- /dev/null +++ b/agent-brain-cli/tests/test_api_key_propagation.py @@ -0,0 +1,155 @@ +"""Tests for X-API-Key propagation through CLI client + transport (Issue #179). + +Covers three independent surfaces: + +1. ``DocServeClient(api_key=...)`` — header set on the underlying httpx.Client. +2. ``DocServeClient.from_httpx(client, api_key=...)`` — header injected into + a pre-built UDS client. +3. ``resolve_api_key`` — env > runtime.json > None precedence chain. +4. ``open_client(ctx)`` — end-to-end: env-provided key reaches the client's + default headers. +""" + +from __future__ import annotations + +import json +import os +from collections.abc import Generator +from pathlib import Path + +import click +import httpx +import pytest + +from agent_brain_cli.client.api_client import DocServeClient +from agent_brain_cli.config import resolve_api_key + + +@pytest.fixture +def clean_env() -> Generator[None, None, None]: + keys = [k for k in os.environ if k.startswith("AGENT_BRAIN_")] + saved = {k: os.environ.pop(k) for k in keys} + try: + yield + finally: + for k in keys: + os.environ.pop(k, None) + os.environ.update(saved) + + +# --------------------------------------------------------------------------- +# DocServeClient header injection +# --------------------------------------------------------------------------- + + +class TestDocServeClientHeader: + def test_api_key_added_to_httpx_default_headers(self) -> None: + client = DocServeClient(base_url="http://test", api_key="secret-token") + try: + assert client._client.headers["X-API-Key"] == "secret-token" + finally: + client.close() + + def test_no_header_when_api_key_omitted(self) -> None: + client = DocServeClient(base_url="http://test") + try: + assert "X-API-Key" not in client._client.headers + finally: + client.close() + + def test_empty_string_api_key_treated_as_no_auth(self) -> None: + """Truthy check prevents an empty string from setting an empty header.""" + client = DocServeClient(base_url="http://test", api_key="") + try: + assert "X-API-Key" not in client._client.headers + finally: + client.close() + + +class TestFromHttpxHeaderInjection: + def test_api_key_injected_onto_existing_httpx_client(self) -> None: + inner = httpx.Client(base_url="http://uds-target") + wrapper = DocServeClient.from_httpx(inner, api_key="uds-secret") + try: + assert inner.headers["X-API-Key"] == "uds-secret" + # The wrapper hands ownership to the inner client + assert wrapper._client is inner + finally: + wrapper.close() + + def test_no_header_when_api_key_omitted(self) -> None: + inner = httpx.Client(base_url="http://uds-target") + wrapper = DocServeClient.from_httpx(inner) + try: + assert "X-API-Key" not in inner.headers + finally: + wrapper.close() + + +# --------------------------------------------------------------------------- +# resolve_api_key precedence +# --------------------------------------------------------------------------- + + +class TestResolveApiKeyPrecedence: + def test_env_var_wins( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + monkeypatch.setenv("AGENT_BRAIN_API_KEY", "env-key") + (tmp_path / "runtime.json").write_text(json.dumps({"api_key": "file-key"})) + + assert resolve_api_key(tmp_path) == "env-key" + + def test_runtime_json_used_when_env_empty( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + monkeypatch.delenv("AGENT_BRAIN_API_KEY", raising=False) + (tmp_path / "runtime.json").write_text(json.dumps({"api_key": "file-key"})) + + assert resolve_api_key(tmp_path) == "file-key" + + def test_returns_none_when_no_source_set( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + monkeypatch.delenv("AGENT_BRAIN_API_KEY", raising=False) + # No runtime.json + assert resolve_api_key(tmp_path) is None + + def test_returns_none_when_runtime_json_omits_key( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + monkeypatch.delenv("AGENT_BRAIN_API_KEY", raising=False) + (tmp_path / "runtime.json").write_text(json.dumps({"base_url": "x"})) + + assert resolve_api_key(tmp_path) is None + + def test_corrupt_runtime_json_returns_none( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + monkeypatch.delenv("AGENT_BRAIN_API_KEY", raising=False) + (tmp_path / "runtime.json").write_text("{not valid json") + + assert resolve_api_key(tmp_path) is None + + +# --------------------------------------------------------------------------- +# End-to-end through open_client +# --------------------------------------------------------------------------- + + +class TestOpenClientEndToEnd: + def test_env_api_key_reaches_http_client(self, clean_env: None) -> None: + from agent_brain_cli.client.transport import open_client + + os.environ["AGENT_BRAIN_API_KEY"] = "e2e-secret" + cmd = click.Command("test") + ctx = click.Context(cmd) + ctx.obj = { + "transport_hint": "http", + "base_url_override": "http://127.0.0.1:9001", + } + client = open_client(ctx) + try: + assert client._client.headers["X-API-Key"] == "e2e-secret" + finally: + client.close() diff --git a/agent-brain-cli/tests/test_init_api_key.py b/agent-brain-cli/tests/test_init_api_key.py new file mode 100644 index 00000000..7cfc7c69 --- /dev/null +++ b/agent-brain-cli/tests/test_init_api_key.py @@ -0,0 +1,137 @@ +"""Tests for ``agent-brain init`` API key generation (Issue #179). + +Covers: + +- Default flow: ``init`` writes ``api_key`` to ``config.json``. +- ``--no-api-key`` opt-out: no ``api_key`` field in ``config.json``. +- Generated key has the expected entropy (urlsafe, 32-byte source). +- ``config.json`` is chmod'd to ``0o600`` since it carries a secret. +- ``resolve_api_key`` falls back to ``config.json`` when no env var or + runtime.json is present (the post-init / pre-start window). +""" + +from __future__ import annotations + +import json +import os +import stat +from collections.abc import Generator +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from agent_brain_cli.commands.init import init_command +from agent_brain_cli.config import resolve_api_key + + +@pytest.fixture +def runner() -> CliRunner: + return CliRunner() + + +@pytest.fixture +def temp_project(tmp_path: Path) -> Generator[Path, None, None]: + """Create a fresh project root for init to populate.""" + project = tmp_path / "project" + project.mkdir() + yield project + + +@pytest.fixture +def clean_env() -> Generator[None, None, None]: + keys = [k for k in os.environ if k.startswith("AGENT_BRAIN_")] + saved = {k: os.environ.pop(k) for k in keys} + try: + yield + finally: + for k in keys: + os.environ.pop(k, None) + os.environ.update(saved) + + +class TestInitGeneratesApiKey: + def test_default_init_writes_api_key_to_config( + self, runner: CliRunner, temp_project: Path + ) -> None: + result = runner.invoke(init_command, ["--path", str(temp_project)]) + + assert result.exit_code == 0, result.output + + config_path = temp_project / ".agent-brain" / "config.json" + config = json.loads(config_path.read_text()) + + assert "api_key" in config + assert isinstance(config["api_key"], str) + # secrets.token_urlsafe(32) produces ~43 char base64-urlsafe string. + assert len(config["api_key"]) >= 32 + + def test_no_api_key_flag_omits_field( + self, runner: CliRunner, temp_project: Path + ) -> None: + result = runner.invoke( + init_command, ["--path", str(temp_project), "--no-api-key"] + ) + + assert result.exit_code == 0, result.output + + config_path = temp_project / ".agent-brain" / "config.json" + config = json.loads(config_path.read_text()) + + assert "api_key" not in config + + def test_config_json_chmod_0600( + self, runner: CliRunner, temp_project: Path + ) -> None: + result = runner.invoke(init_command, ["--path", str(temp_project)]) + assert result.exit_code == 0, result.output + + config_path = temp_project / ".agent-brain" / "config.json" + mode = stat.S_IMODE(config_path.stat().st_mode) + assert mode == 0o600, f"config.json mode is {oct(mode)}, expected 0o600" + + def test_two_init_runs_produce_different_keys( + self, runner: CliRunner, tmp_path: Path + ) -> None: + """No key reuse across projects — each init should be independent.""" + project_a = tmp_path / "project-a" + project_b = tmp_path / "project-b" + project_a.mkdir() + project_b.mkdir() + + runner.invoke(init_command, ["--path", str(project_a)]) + runner.invoke(init_command, ["--path", str(project_b)]) + + key_a = json.loads((project_a / ".agent-brain" / "config.json").read_text())[ + "api_key" + ] + key_b = json.loads((project_b / ".agent-brain" / "config.json").read_text())[ + "api_key" + ] + + assert key_a != key_b + + +class TestResolveApiKeyConfigFallback: + def test_falls_back_to_config_json_when_no_env_or_runtime( + self, + clean_env: None, + tmp_path: Path, + ) -> None: + """Post-init / pre-start window: config.json has it, runtime.json doesn't.""" + (tmp_path / "config.json").write_text( + json.dumps({"api_key": "config-stored-key", "bind_host": "127.0.0.1"}) + ) + + assert resolve_api_key(tmp_path) == "config-stored-key" + + def test_runtime_json_still_takes_precedence_over_config_json( + self, + clean_env: None, + tmp_path: Path, + ) -> None: + """When server writes runtime.json, that wins over config.json.""" + (tmp_path / "config.json").write_text(json.dumps({"api_key": "config-key"})) + (tmp_path / "runtime.json").write_text(json.dumps({"api_key": "runtime-key"})) + + assert resolve_api_key(tmp_path) == "runtime-key" diff --git a/agent-brain-mcp/agent_brain_mcp/config.py b/agent-brain-mcp/agent_brain_mcp/config.py index d1315ae5..675b1bd8 100644 --- a/agent-brain-mcp/agent_brain_mcp/config.py +++ b/agent-brain-mcp/agent_brain_mcp/config.py @@ -170,6 +170,47 @@ def _resolve_state_dir(state_dir: Path | None) -> Path | None: return candidate if candidate.exists() else None +def _resolve_api_key(state_dir: Path | None) -> str | None: + """Resolve the X-API-Key the MCP server should send to the backend (Issue #179). + + Precedence (first non-empty wins): + 1. ``AGENT_BRAIN_MCP_API_KEY`` env (MCP-specific override) + 2. ``AGENT_BRAIN_API_KEY`` env (shared with the CLI) + 3. ``runtime.json::api_key`` for the resolved state dir + (set by a running server) + 4. ``config.json::api_key`` for the resolved state dir + (set by ``agent-brain init``, used when the server has not + started yet) + + Returns ``None`` when no source provides a value. The server's + ``verify_api_key`` dependency is a no-op in that case, so unauthed + loopback workflows keep working. + """ + env_key = os.environ.get("AGENT_BRAIN_MCP_API_KEY") or os.environ.get( + "AGENT_BRAIN_API_KEY" + ) + if env_key: + return env_key + + sdir = _resolve_state_dir(state_dir) + if sdir is None: + return None + + for filename in ("runtime.json", "config.json"): + candidate = sdir / filename + if not candidate.exists(): + continue + try: + payload = json.loads(candidate.read_text()) + except (json.JSONDecodeError, OSError): + continue + api_key = payload.get("api_key") + if api_key: + return str(api_key) + + return None + + def _resolve_http_url( backend_url: str | None, state_dir: Path | None, @@ -221,6 +262,7 @@ def _open_uds_client( socket_path: Path | None, state_dir: Path | None, timeout: float, + api_key: str | None = None, ) -> httpx.Client: if socket_path is None: env_path = os.environ.get("AGENT_BRAIN_UDS_PATH") @@ -232,11 +274,16 @@ def _open_uds_client( ) validate_socket(socket_path) client: httpx.Client = make_uds_client(socket_path=socket_path, timeout=timeout) + if api_key: + client.headers["X-API-Key"] = api_key return client -def _open_http_client(backend_url: str, timeout: float) -> httpx.Client: - return httpx.Client(base_url=backend_url, timeout=timeout) +def _open_http_client( + backend_url: str, timeout: float, api_key: str | None = None +) -> httpx.Client: + headers = {"X-API-Key": api_key} if api_key else None + return httpx.Client(base_url=backend_url, timeout=timeout, headers=headers) def open_backend_client( @@ -251,19 +298,23 @@ def open_backend_client( Auto mode tries UDS validation first; on any :class:`AgentBrainUdsError` falls back to HTTP transparently. + + Resolves the X-API-Key once and injects it into whichever client wins + (Issue #179) so MCP can talk to an authed backend just like the CLI. """ chosen = (backend or os.environ.get("AGENT_BRAIN_MCP_BACKEND") or "auto").lower() + api_key = _resolve_api_key(state_dir) if chosen == "http": url = _resolve_http_url(backend_url, state_dir) - return ("http", _open_http_client(url, timeout)) + return ("http", _open_http_client(url, timeout, api_key)) if chosen == "uds": - return ("uds", _open_uds_client(socket_path, state_dir, timeout)) + return ("uds", _open_uds_client(socket_path, state_dir, timeout, api_key)) # auto try: - return ("uds", _open_uds_client(socket_path, state_dir, timeout)) + return ("uds", _open_uds_client(socket_path, state_dir, timeout, api_key)) except (AgentBrainUdsError, OSError, FileNotFoundError): url = _resolve_http_url(backend_url, state_dir) - return ("http", _open_http_client(url, timeout)) + return ("http", _open_http_client(url, timeout, api_key)) diff --git a/agent-brain-mcp/tests/test_api_key_propagation.py b/agent-brain-mcp/tests/test_api_key_propagation.py new file mode 100644 index 00000000..59842254 --- /dev/null +++ b/agent-brain-mcp/tests/test_api_key_propagation.py @@ -0,0 +1,148 @@ +"""Tests for X-API-Key propagation through the MCP backend client (Issue #179). + +Covers the precedence chain in ``_resolve_api_key`` and verifies the +resolved key reaches the httpx.Client default headers for both HTTP and +UDS transports. +""" + +from __future__ import annotations + +import json +import os +import socket +import tempfile +from collections.abc import Generator +from pathlib import Path + +import pytest + +from agent_brain_mcp.config import ( + _resolve_api_key, + open_backend_client, +) + + +@pytest.fixture +def clean_env() -> Generator[None, None, None]: + keys = [k for k in os.environ if k.startswith("AGENT_BRAIN_")] + saved = {k: os.environ.pop(k) for k in keys} + try: + yield + finally: + for k in keys: + os.environ.pop(k, None) + os.environ.update(saved) + + +@pytest.fixture +def short_state_dir() -> Generator[Path, None, None]: + base = Path(tempfile.mkdtemp(prefix="absrv-mcp-auth-")) + os.chmod(base, 0o700) + try: + yield base + finally: + import shutil + + shutil.rmtree(base, ignore_errors=True) + + +class TestResolveApiKeyPrecedence: + def test_mcp_specific_env_wins( + self, clean_env: None, short_state_dir: Path + ) -> None: + os.environ["AGENT_BRAIN_MCP_API_KEY"] = "mcp-env-key" + os.environ["AGENT_BRAIN_API_KEY"] = "shared-env-key" + (short_state_dir / "runtime.json").write_text( + json.dumps({"api_key": "runtime-key"}) + ) + (short_state_dir / "config.json").write_text( + json.dumps({"api_key": "config-key"}) + ) + + assert _resolve_api_key(short_state_dir) == "mcp-env-key" + + def test_shared_env_used_when_mcp_specific_absent( + self, clean_env: None, short_state_dir: Path + ) -> None: + os.environ["AGENT_BRAIN_API_KEY"] = "shared-env-key" + (short_state_dir / "runtime.json").write_text( + json.dumps({"api_key": "runtime-key"}) + ) + + assert _resolve_api_key(short_state_dir) == "shared-env-key" + + def test_runtime_json_used_when_no_env( + self, clean_env: None, short_state_dir: Path + ) -> None: + (short_state_dir / "runtime.json").write_text( + json.dumps({"api_key": "runtime-key"}) + ) + (short_state_dir / "config.json").write_text( + json.dumps({"api_key": "config-key"}) + ) + + assert _resolve_api_key(short_state_dir) == "runtime-key" + + def test_config_json_fallback_when_runtime_absent( + self, clean_env: None, short_state_dir: Path + ) -> None: + (short_state_dir / "config.json").write_text( + json.dumps({"api_key": "config-key"}) + ) + + assert _resolve_api_key(short_state_dir) == "config-key" + + def test_returns_none_when_no_source( + self, clean_env: None, short_state_dir: Path + ) -> None: + assert _resolve_api_key(short_state_dir) is None + + +class TestHttpClientHeaderInjection: + def test_api_key_added_to_http_client_default_headers( + self, clean_env: None + ) -> None: + os.environ["AGENT_BRAIN_API_KEY"] = "http-test-key" + + transport, client = open_backend_client( + backend="http", backend_url="http://127.0.0.1:9000" + ) + try: + assert transport == "http" + assert client.headers["X-API-Key"] == "http-test-key" + finally: + client.close() + + def test_no_header_when_no_key_resolved(self, clean_env: None) -> None: + transport, client = open_backend_client( + backend="http", backend_url="http://127.0.0.1:9000" + ) + try: + assert "X-API-Key" not in client.headers + finally: + client.close() + + +class TestUdsClientHeaderInjection: + def test_api_key_added_to_uds_client_default_headers( + self, clean_env: None, short_state_dir: Path + ) -> None: + socket_path = short_state_dir / "agent-brain.sock" + s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + s.bind(str(socket_path)) + os.chmod(socket_path, 0o600) + os.chmod(short_state_dir, 0o700) + + os.environ["AGENT_BRAIN_API_KEY"] = "uds-test-key" + + try: + transport, client = open_backend_client( + backend="uds", socket_path=socket_path + ) + try: + assert transport == "uds" + assert client.headers["X-API-Key"] == "uds-test-key" + finally: + client.close() + finally: + s.close() diff --git a/agent-brain-server/.env.example b/agent-brain-server/.env.example index ead4ec49..55071d9e 100644 --- a/agent-brain-server/.env.example +++ b/agent-brain-server/.env.example @@ -26,6 +26,12 @@ API_PORT=8000 # Debug mode (set to true for development) DEBUG=false +# API authentication (Issue #179). Empty = no auth (loopback dev only). +# When set, every data endpoint requires the value in the X-API-Key +# header; /health stays open; /docs is hidden when DEBUG=false. Server +# refuses to start (exit 2) if API_HOST is non-loopback and this is empty. +AGENT_BRAIN_API_KEY= + # ====================== # AI MODEL CONFIGURATION # ====================== diff --git a/agent-brain-server/agent_brain_server/api/main.py b/agent-brain-server/agent_brain_server/api/main.py index d744c251..771b011b 100644 --- a/agent-brain-server/agent_brain_server/api/main.py +++ b/agent-brain-server/agent_brain_server/api/main.py @@ -833,7 +833,9 @@ def run( env_root = os.environ.get("AGENT_BRAIN_PROJECT_ROOT") _project_root = env_root or str(_state_dir.parent.parent.parent) - # Create runtime state + # Create runtime state — surface the configured API key so the CLI + # and MCP clients can discover it from runtime.json (Issue #179). + # write_runtime() chmods the file to 0o600. _runtime_state = RuntimeState( mode="project", project_root=_project_root, @@ -841,6 +843,7 @@ def run( port=resolved_port, pid=os.getpid(), base_url=f"http://{resolved_host}:{resolved_port}", + api_key=settings.AGENT_BRAIN_API_KEY or None, ) # Write runtime.json before starting server diff --git a/agent-brain-server/agent_brain_server/runtime.py b/agent-brain-server/agent_brain_server/runtime.py index 20c6818d..34174c35 100644 --- a/agent-brain-server/agent_brain_server/runtime.py +++ b/agent-brain-server/agent_brain_server/runtime.py @@ -32,11 +32,21 @@ class RuntimeState(BaseModel): active_projects: list[str] | None = None # UDS transport (plan §4.3 — present when --uds, None otherwise) socket_path: str | None = None + # API key for X-API-Key auth (Issue #179). Optional — None means the + # server is running without auth (only safe on loopback). CLI and MCP + # clients read this field to authenticate their requests; the file is + # chmod'd to 0o600 by write_runtime so the secret stays user-only. + api_key: str | None = None def write_runtime(state_dir: Path, state: RuntimeState) -> None: """Write runtime state to state directory. + The file is chmod'd to 0o600 (owner read/write only) because it may + contain an ``api_key`` secret. The chmod is best-effort — on + filesystems that don't support POSIX mode bits (e.g., FAT) the write + still succeeds but the mode flag has no effect. + Args: state_dir: Path to the state directory. state: Runtime state to write. @@ -44,6 +54,10 @@ def write_runtime(state_dir: Path, state: RuntimeState) -> None: state_dir.mkdir(parents=True, exist_ok=True) runtime_path = state_dir / "runtime.json" runtime_path.write_text(state.model_dump_json(indent=2)) + try: + runtime_path.chmod(0o600) + except OSError as exc: + logger.warning("Failed to chmod runtime.json to 0o600: %s", exc) logger.info(f"Runtime state written to {runtime_path}") diff --git a/agent-brain-server/tests/unit/api/test_auth_enforcement.py b/agent-brain-server/tests/unit/api/test_auth_enforcement.py index cd2abab6..cdf81600 100644 --- a/agent-brain-server/tests/unit/api/test_auth_enforcement.py +++ b/agent-brain-server/tests/unit/api/test_auth_enforcement.py @@ -33,7 +33,6 @@ from agent_brain_server.api.security import verify_api_key from agent_brain_server.config.settings import get_settings - GATED_ROUTERS = [ ("cache", cache.router), ("folders", folders.router), @@ -110,9 +109,9 @@ def test_gated_router_returns_401_without_header( response = client.get("/__authcheck") - assert response.status_code == 401, ( - f"router '{name}' did not return 401 without X-API-Key" - ) + assert ( + response.status_code == 401 + ), f"router '{name}' did not return 401 without X-API-Key" @pytest.mark.parametrize("name,router", GATED_ROUTERS) @@ -127,9 +126,9 @@ def test_gated_router_returns_200_with_correct_header( response = client.get("/__authcheck", headers={"X-API-Key": "auth-test-key"}) - assert response.status_code == 200, ( - f"router '{name}' rejected the correct X-API-Key" - ) + assert ( + response.status_code == 200 + ), f"router '{name}' rejected the correct X-API-Key" assert response.json() == {"ok": "true"} diff --git a/agent-brain-server/tests/unit/api/test_startup_gate.py b/agent-brain-server/tests/unit/api/test_startup_gate.py index 2e59a865..90ad882e 100644 --- a/agent-brain-server/tests/unit/api/test_startup_gate.py +++ b/agent-brain-server/tests/unit/api/test_startup_gate.py @@ -84,9 +84,7 @@ def test_startup_gate_silent_when_key_set_even_on_non_loopback( _check_api_key_startup_gate("0.0.0.0") # Neither warning nor critical when a key is configured - assert not any( - record.levelno >= logging.WARNING for record in caplog.records - ) + assert not any(record.levelno >= logging.WARNING for record in caplog.records) # --------------------------------------------------------------------------- diff --git a/agent-brain-server/tests/unit/test_runtime.py b/agent-brain-server/tests/unit/test_runtime.py index 170afc9a..faff7340 100644 --- a/agent-brain-server/tests/unit/test_runtime.py +++ b/agent-brain-server/tests/unit/test_runtime.py @@ -71,6 +71,35 @@ def test_creates_directory(self, tmp_path): assert state_dir.exists() assert (state_dir / "runtime.json").exists() + def test_chmods_file_to_0600(self, tmp_path): + """runtime.json may carry an api_key — must be owner-only (Issue #179).""" + import stat + + state = RuntimeState(api_key="secret-token") + write_runtime(tmp_path, state) + + runtime_path = tmp_path / "runtime.json" + mode = stat.S_IMODE(runtime_path.stat().st_mode) + assert mode == 0o600, f"runtime.json mode is {oct(mode)}, expected 0o600" + + def test_api_key_round_trips(self, tmp_path): + """api_key field persists across write/read.""" + state = RuntimeState(api_key="secret-token", port=8080) + write_runtime(tmp_path, state) + + result = read_runtime(tmp_path) + assert result is not None + assert result.api_key == "secret-token" + + def test_api_key_defaults_to_none(self, tmp_path): + """Omitting api_key produces None (existing runtime.json files keep working).""" + state = RuntimeState(port=8080) + write_runtime(tmp_path, state) + + result = read_runtime(tmp_path) + assert result is not None + assert result.api_key is None + class TestReadRuntime: """Tests for read_runtime function.""" diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 1634c1a0..48a3823c 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -13,7 +13,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -(nothing yet) +### Security + +- **REST API key authentication on data endpoints** (`agent_brain_server/api/security.py`, `agent_brain_server/api/routers/*.py`, `agent_brain_server/api/main.py`, `agent_brain_server/config/settings.py`, `agent_brain_server/runtime.py`, `agent_brain_cli/client/api_client.py`, `agent_brain_cli/client/transport.py`, `agent_brain_cli/config.py`, `agent_brain_cli/commands/init.py`, `agent_brain_cli/commands/start.py`, `agent_brain_mcp/agent_brain_mcp/config.py`, `.env.example` × 2). Closes the critical-severity unauthenticated REST API surface raised in [#179](https://github.com/SpillwaveSolutions/agent-brain/issues/179). A new `AGENT_BRAIN_API_KEY` setting is enforced via the `X-API-Key` header by a single `verify_api_key` FastAPI dependency wired at the router level on all data routers (`/index`, `/index/cache`, `/index/folders`, `/index/jobs`, `/query`, `/graph`). The health router stays exempt by design. **BREAKING when binding non-loopback:** the server refuses to start (exit code 2) when `API_HOST` is anything other than `127.0.0.1`/`localhost`/`::1` and `AGENT_BRAIN_API_KEY` is unset, so any deployment that previously bound `0.0.0.0` without auth must set the env var before upgrading. Loopback workflows are unaffected: empty key + loopback emits a loud warning and starts as before, preserving the single-user dev UX. When `AGENT_BRAIN_API_KEY` is set AND `DEBUG=false`, `/docs`, `/redoc`, and `/openapi.json` are also gated (there's no point requiring a key to hit endpoints if the schema describing them is publicly browsable); `DEBUG=true` keeps them open. `agent-brain init` now auto-generates a urlsafe 32-byte key into project-local `.agent-brain/config.json` (chmod'd to `0o600`), `agent-brain start` exports it as `AGENT_BRAIN_API_KEY` for the server subprocess, and both the CLI (`DocServeClient`) and `agent-brain-mcp` (`open_backend_client`) auto-resolve the key from `env → runtime.json → config.json → None` and inject `X-API-Key` on every request. Opt out at init time with `--no-api-key` for single-user loopback dev. `RuntimeState.api_key` carries the value across IPC; `write_runtime()` chmods `runtime.json` to `0o600`. Constant-time compare via `secrets.compare_digest`. Tests: 6 unit tests for the dependency in isolation (`agent-brain-server/tests/unit/api/test_security.py`), 20 structural + behavioral tests across the 6 gated routers plus the health-router exemption (`test_auth_enforcement.py`), 9 tests for the startup gate and docs gating matrix (`test_startup_gate.py`), 11 tests for CLI header propagation and the resolve_api_key precedence chain (`agent-brain-cli/tests/test_api_key_propagation.py`), 6 tests for `init` key generation, chmod, and the post-init/pre-start config.json fallback (`test_init_api_key.py`), 8 tests for MCP backend client header injection across HTTP and UDS transports (`agent-brain-mcp/tests/test_api_key_propagation.py`). Out of scope and tracked separately: CORS wildcard tightening (currently `allow_origins=["*"]`), `Authorization: Bearer` / OAuth 2.1 (covered by future MCP v4 #188). ---