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
11 changes: 8 additions & 3 deletions docs/configuration.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Configuration Reference
# Configuration Reference

`cmcp-config.yaml` controls the gateway's attestation provider, policy enforcement behavior, network settings, and file paths. Environment variables override specific fields and control secrets that must not appear in config files.

Expand Down Expand Up @@ -55,6 +55,11 @@ policy_bundle_path: policies/
catalog_path: catalog.json

# Address and port the runtime listens on.
# Effective defaults:
# - tokenless CMCP_DEV_MODE=1: "127.0.0.1:8443"
# - authenticated or production operation: "0.0.0.0:8443" (unless explicitly configured)
# Tokenless development mode only accepts loopback addresses (e.g., 127.0.0.1:8443, localhost:8443, [::1]:8443).
# Wildcard, LAN, public, and non-loopback hostname binds require CMCP_BEARER_TOKEN.
listen_addr: "0.0.0.0:8443"

# Maximum size of a tool response payload in bytes. Responses larger than
Expand Down Expand Up @@ -98,7 +103,7 @@ All fields are optional as a group. If `path` is set, `trust_anchor_path` must a
|-------|------|---------|-------------|
| `policy_bundle_path` | string | `policies/` | Path to the Cedar policy bundle directory. Must contain `.cedar` files and a `manifest.json`. Path traversal (`..` components) is rejected. |
| `catalog_path` | string | `catalog.json` | Path to the JSON tool catalog. Path traversal (`..` components) is rejected. |
| `listen_addr` | string | `0.0.0.0:8443` | Address and port the gateway binds to. |
| `listen_addr` | string | `0.0.0.0:8443` | Address and port the gateway binds to. Default is `127.0.0.1:8443` in tokenless `CMCP_DEV_MODE=1`, otherwise `0.0.0.0:8443`. Tokenless dev mode requires loopback (e.g., `127.0.0.1:8443`, `localhost:8443`, `[::1]:8443`). Wildcard, LAN, public, and non-loopback hostname binds require `CMCP_BEARER_TOKEN`. |
| `max_response_size_bytes` | integer | `2097152` | Maximum tool response size in bytes (2MB). Must be a positive integer. Responses exceeding this limit are rejected before inspection. |
| `policy_reload_interval_seconds` | integer | `0` | Interval in seconds between automatic Cedar bundle reloads. `0` disables automatic reload. See note in the full example above. |

Expand All @@ -109,7 +114,7 @@ Environment variables control secrets and mode flags that must not appear in con
| Variable | Description | Overrides |
|----------|-------------|-----------|
| `CMCP_DEV_MODE=1` | Enables software-only attestation. No hardware TEE required. TRACE Claims will show `partially_verified` status. Required when `provider` is `software-only`. | `attestation.provider` (forces software-only) |
| `CMCP_BEARER_TOKEN` | Optional bearer token for runtime HTTP auth. If set, all requests to the runtime must include `Authorization: Bearer <token>`. If unset, no bearer auth is enforced. | none |
| `CMCP_BEARER_TOKEN` | Optional bearer token for runtime HTTP auth. If set, all requests to the runtime must include `Authorization: Bearer <token>`. If unset, no bearer auth is enforced. This token is required for non-loopback binds. | none |
| `OPAQUE_ATTESTATION_URL` | Enables the OPAQUE Managed Runtime provider. Must be set to the OPAQUE attestation service URL. Required when `provider` is `opaque` or `auto` on OPAQUE infrastructure. | enables `opaque` provider detection |
| `CMCP_POLICY_HASH` | SHA-256 hash of the approved policy bundle. Required in non-dev mode and checked by startup before Agent Manifest binding. The gateway fails closed at startup if this is unset and `CMCP_DEV_MODE` is not `1`. Format: `sha256:<hex>`. | none (startup policy integrity check) |
| `CMCP_CATALOG_HASH` | SHA-256 hash of the approved `catalog.json`. Required in non-dev mode. The gateway fails closed at startup if this is unset and `CMCP_DEV_MODE` is not `1`. Format: `sha256:<hex>`. | none (additional startup check) |
Expand Down
7 changes: 5 additions & 2 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ attestation:
enforcement_mode: enforcing
policy_bundle_path: ./policies/
catalog_path: ./catalog.json
listen_addr: "127.0.0.1:8443"
audit_db_path: ./audit.db
```

Expand Down Expand Up @@ -222,10 +223,12 @@ In dev mode the runtime uses a software-only TEE provider (no hardware required)
No hardware TEE detected. Running in development mode: attestation is not hardware-backed. ...
SPIFFE SVID not available (SPIRE agent socket not found ...) - gateway will use self-signed TLS for mTLS
CMCP_NRAS_API_KEY is not set -- skipping NRAS post-attestation appraisal. ...
cMCP Runtime starting: TEE: software-only, listen: 0.0.0.0:8443
INFO: Uvicorn running on http://0.0.0.0:8443 (Press CTRL+C to quit)
cMCP Runtime starting: TEE: software-only, listen: 127.0.0.1:8443
INFO: Uvicorn running on http://127.0.0.1:8443 (Press CTRL+C to quit)
```

Tokenless dev mode binds to loopback only. Reaching the gateway from a LAN, a container network, or the cloud requires setting `CMCP_BEARER_TOKEN`, so you never expose an unauthenticated gateway by accident.

The gateway now holds this terminal open. Leave it running and open a **second terminal** for the next steps. In that second terminal, `cd` back into `cmcp-quickstart` (and re-activate your Python environment if you use one) so the commands run from the right place.

---
Expand Down
2 changes: 1 addition & 1 deletion docs/tutorials/connecting-agent-frameworks.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ curl http://localhost:8443/health

## How the gateway looks to an agent

The gateway runs at `listen_addr` (default `0.0.0.0:8443`) and exposes a standard MCP over HTTP/SSE transport. Two endpoints matter for agent frameworks:
The gateway runs at `listen_addr` (default `127.0.0.1:8443` in dev mode, otherwise `0.0.0.0:8443`) and exposes a standard MCP over HTTP/SSE transport. Two endpoints matter for agent frameworks:

| Endpoint | Method | Auth | Purpose |
|---|---|---|---|
Expand Down
2 changes: 1 addition & 1 deletion examples/bfsi-demo/cmcp-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@ attestation:

policy_bundle_path: ./policies/
catalog_path: ./catalog.json
listen_addr: "0.0.0.0:8443"
listen_addr: "127.0.0.1:8443"
max_response_size_bytes: 2097152 # 2MB
audit_db_path: ./audit.db
4 changes: 2 additions & 2 deletions src/cmcp_runtime/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ def start(config: str, enforcement: str | None) -> None:
"""Start the cMCP Runtime."""
import uvicorn

from cmcp_runtime.config import parse_listen_addr
from cmcp_runtime.startup import run_startup

ctx = run_startup(config)
Expand All @@ -94,8 +95,7 @@ def start(config: str, enforcement: str | None) -> None:

server = build_server(ctx)

host, _, port_str = ctx.config.listen_addr.rpartition(":")
port = int(port_str)
host, port = parse_listen_addr(ctx.config.listen_addr)

click.echo(
f"cMCP Runtime starting: TEE: {ctx.attestation_report.provider},"
Expand Down
71 changes: 70 additions & 1 deletion src/cmcp_runtime/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import os
from dataclasses import dataclass, field
from enum import StrEnum
from ipaddress import ip_address
from pathlib import PurePosixPath, PureWindowsPath
from typing import Any

Expand Down Expand Up @@ -104,6 +105,59 @@ class Config:
_KNOWN_AGENT_MANIFEST_KEYS = {"path", "trust_anchor_path", "authenticated_subject"}


def parse_listen_addr(value: str) -> tuple[str, int]:
"""Parse a listen address into a host and port."""

if not isinstance(value, str):
raise ConfigError("listen_addr must be a string")

address = value.strip()
if not address:
raise ConfigError("listen_addr must not be empty")

if address.startswith("["):
closing_bracket = address.find("]")
if closing_bracket == -1 or address[closing_bracket + 1 : closing_bracket + 2] != ":":
raise ConfigError(
"listen_addr must use the format '[IPv6]:port'"
)

host = address[1:closing_bracket]
port_text = address[closing_bracket + 2 :]
else:
host, separator, port_text = address.rpartition(":")
if not separator:
raise ConfigError(
"listen_addr must use the format 'host:port'"
)

if not host:
raise ConfigError("listen_addr host must not be empty")

try:
port = int(port_text)
except ValueError as exc:
raise ConfigError("listen_addr port must be an integer") from exc

if not 1 <= port <= 65535:
raise ConfigError("listen_addr port must be between 1 and 65535")

return host, port


def _is_loopback_host(host: str) -> bool:
"""Return whether a host is explicitly local-only."""

if host.casefold() == "localhost":
return True

try:
return ip_address(host).is_loopback
except ValueError:
# Hostnames other than localhost are not guaranteed to remain local.
return False


def _check_no_traversal(field_name: str, path_str: str) -> None:
"""Reject paths that contain '..' components to prevent directory traversal (CONF-004)."""
for part in PurePosixPath(path_str).parts:
Expand Down Expand Up @@ -221,6 +275,21 @@ def load_config(path: str) -> Config:
dev_mode = DEV_MODE # TEE-002: use the frozen constant, never re-read from env
bearer_token = os.environ.get("CMCP_BEARER_TOKEN") or None

default_listen_addr = (
"127.0.0.1:8443"
if dev_mode and bearer_token is None
else "0.0.0.0:8443"
)
listen_addr = raw.get("listen_addr", default_listen_addr)
listen_host, _ = parse_listen_addr(listen_addr)

if dev_mode and bearer_token is None and not _is_loopback_host(listen_host):
raise ConfigError(
"Tokenless development mode may only bind to a loopback address. "
"Use 127.0.0.1:<port>, localhost:<port>, or [::1]:<port>, "
"or configure CMCP_BEARER_TOKEN for non-loopback access."
)

policy_bundle_path = raw.get("policy_bundle_path", "policy/")
catalog_path = raw.get("catalog_path", "catalog.json")
audit_db_path = raw.get("audit_db_path", "audit.db")
Expand Down Expand Up @@ -269,7 +338,7 @@ def load_config(path: str) -> Config:
),
policy_bundle_path=policy_bundle_path,
catalog_path=catalog_path,
listen_addr=raw.get("listen_addr", "0.0.0.0:8443"),
listen_addr=listen_addr,
max_response_size_bytes=max_bytes,
policy_reload_interval_seconds=policy_reload_interval,
audit_db_path=audit_db_path,
Expand Down
97 changes: 97 additions & 0 deletions tests/unit/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,3 +219,100 @@ def test_expected_measurement_non_string_rejected(config_file):
path = config_file("attestation:\n expected_measurement: 12345\n")
with pytest.raises(ConfigError, match="expected_measurement"):
load_config(path)


def test_tokenless_dev_mode_defaults_to_loopback(config_file, monkeypatch):
import cmcp_runtime.config as config_module

monkeypatch.setattr(config_module, "DEV_MODE", True)
monkeypatch.delenv("CMCP_BEARER_TOKEN", raising=False)

cfg = load_config(config_file(""))

assert cfg.listen_addr == "127.0.0.1:8443"


@pytest.mark.parametrize(
"listen_addr",
[
"127.0.0.1:8443",
"localhost:8443",
"::1:8443",
"[::1]:8443",
],
)
def test_tokenless_dev_mode_allows_loopback(
config_file,
monkeypatch,
listen_addr,
):
import cmcp_runtime.config as config_module

monkeypatch.setattr(config_module, "DEV_MODE", True)
monkeypatch.delenv("CMCP_BEARER_TOKEN", raising=False)

path = config_file(f'listen_addr: "{listen_addr}"\n')
cfg = load_config(path)

assert cfg.listen_addr == listen_addr


@pytest.mark.parametrize(
"listen_addr",
[
"0.0.0.0:8443",
"[::]:8443",
"192.168.1.20:8443",
"example.com:8443",
],
)
def test_tokenless_dev_mode_rejects_non_loopback(
config_file,
monkeypatch,
listen_addr,
):
import cmcp_runtime.config as config_module

monkeypatch.setattr(config_module, "DEV_MODE", True)
monkeypatch.delenv("CMCP_BEARER_TOKEN", raising=False)

path = config_file(f'listen_addr: "{listen_addr}"\n')

with pytest.raises(ConfigError, match="loopback"):
load_config(path)


@pytest.mark.parametrize(
"listen_addr",
[
"0.0.0.0:8443",
"[::]:8443",
"192.168.1.20:8443",
],
)
def test_dev_mode_with_bearer_token_allows_non_loopback(
config_file,
monkeypatch,
listen_addr,
):
import cmcp_runtime.config as config_module

monkeypatch.setattr(config_module, "DEV_MODE", True)
monkeypatch.setenv("CMCP_BEARER_TOKEN", "test-secret-token")

path = config_file(f'listen_addr: "{listen_addr}"\n')
cfg = load_config(path)

assert cfg.listen_addr == listen_addr
assert cfg.bearer_token == "test-secret-token"


def test_non_dev_mode_preserves_wildcard_default(config_file, monkeypatch):
import cmcp_runtime.config as config_module

monkeypatch.setattr(config_module, "DEV_MODE", False)
monkeypatch.delenv("CMCP_BEARER_TOKEN", raising=False)

cfg = load_config(config_file(""))

assert cfg.listen_addr == "0.0.0.0:8443"
1 change: 1 addition & 0 deletions tests/unit/test_startup.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ def test_startup_succeeds_in_dev_mode(complete_setup):
ctx = run_startup(complete_setup)
assert isinstance(ctx, RuntimeContext)
assert ctx.config.dev_mode is True
assert ctx.config.listen_addr == "127.0.0.1:8443"
assert ctx.signing_key is not None
assert ctx.policy_bundle is not None
assert ctx.catalog is not None
Expand Down