Skip to content
Merged
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
15 changes: 15 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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)
# =============================================================================
Expand Down
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,14 +209,23 @@ 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
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
Expand Down
18 changes: 16 additions & 2 deletions agent-brain-cli/agent_brain_cli/client/api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,20 +159,29 @@ def __init__(
self,
base_url: str = "http://127.0.0.1:8000",
timeout: float = 30.0,
api_key: str | None = None,
):
"""
Initialize the client.

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
Expand All @@ -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``.
Expand All @@ -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

Expand Down
14 changes: 10 additions & 4 deletions agent-brain-cli/agent_brain_cli/client/transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

import click

from ..config import resolve_transport
from ..config import resolve_api_key, resolve_transport
from .api_client import DocServeClient


Expand All @@ -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)
33 changes: 31 additions & 2 deletions agent-brain-cli/agent_brain_cli/commands/init.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Init command for initializing an Agent Brain project."""

import json
import secrets
from pathlib import Path

import click
Expand Down Expand Up @@ -69,13 +70,22 @@
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,
port: int | None,
force: bool,
json_output: bool,
state_dir: str | None,
no_api_key: bool,
) -> None:
"""Initialize a new Agent Brain project.

Expand Down Expand Up @@ -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(
Expand All @@ -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",
)
Expand Down
6 changes: 6 additions & 0 deletions agent-brain-cli/agent_brain_cli/commands/start.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
52 changes: 52 additions & 0 deletions agent-brain-cli/agent_brain_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading