Files
AegisGitea-MCP/src/aegis_gitea_mcp/config.py
T

460 lines
17 KiB
Python

"""Configuration management for AegisGitea MCP.
All settings come from environment variables (case-insensitive) or an env file.
The env file is never trusted implicitly: see :func:`select_env_file`. Secrets
are ``SecretStr`` so they never appear in ``repr()`` or model dumps. Requirements that depend on how the server is started
(for example ``GITEA_TOKEN`` for stdio) are checked at serve time, not on import.
"""
from __future__ import annotations
import ipaddress
import os
import stat
import sys
from collections.abc import Mapping
from pathlib import Path
from typing import Annotated, Any, Literal
from urllib.parse import urlsplit
from pydantic import Field, SecretStr, field_validator
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
ALLOWED_LOG_LEVELS = frozenset({"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"})
#: Tool domains accepted by ``TOOLSETS`` (plus the ``all`` shortcut).
TOOL_DOMAINS = frozenset(
{
"admin",
"repo",
"issue",
"pull",
"release",
"wiki",
"org",
"team",
"user",
"actions",
"hook",
"package",
"notification",
"misc",
}
)
#: Granular Gitea OAuth scopes. Gitea grants full access for unknown scope
#: strings, so anything outside this set is rejected up front.
GITEA_SCOPE_CATEGORIES = (
"activitypub",
"admin",
"issue",
"misc",
"notification",
"organization",
"package",
"repository",
"user",
)
GITEA_SCOPES = frozenset(
f"{access}:{category}" for access in ("read", "write") for category in GITEA_SCOPE_CATEGORIES
)
DEFAULT_OAUTH_SCOPES = ",".join(f"write:{category}" for category in GITEA_SCOPE_CATEGORIES)
_MIN_SECRET_CHARS = 32
_LOOPBACK_NAMES = frozenset({"localhost"})
def default_data_dir() -> Path:
"""Return the default state directory (``~/.local/state/aegis-gitea-mcp``)."""
return Path("~/.local/state/aegis-gitea-mcp").expanduser()
def is_loopback_host(host: str) -> bool:
"""Return True when ``host`` is localhost or a loopback IP (127.0.0.0/8, ::1)."""
candidate = host.strip().strip("[]").lower()
if candidate in _LOOPBACK_NAMES:
return True
try:
return ipaddress.ip_address(candidate).is_loopback
except ValueError:
return False
def _split_list(value: Any) -> Any:
"""Split a comma or whitespace separated string into a list of stripped items."""
if isinstance(value, str):
return [item for item in value.replace(",", " ").split() if item]
return value
def _normalize_http_url(value: str, *, field: str) -> str:
"""Validate an http(s) URL and return it without query, fragment or trailing slash."""
candidate = value.strip()
parts = urlsplit(candidate)
if parts.scheme not in {"http", "https"}:
raise ValueError(f"{field} must start with http:// or https://")
if not parts.hostname:
raise ValueError(f"{field} must include a host")
if parts.username is not None or parts.password is not None:
raise ValueError(f"{field} must not contain credentials (user:password@)")
if parts.query or parts.fragment or "?" in candidate or "#" in candidate:
raise ValueError(f"{field} must not contain a query string or fragment")
try:
parts.port # noqa: B018 (accessing .port validates it)
except ValueError as exc:
raise ValueError(f"{field} has an invalid port") from exc
return candidate.rstrip("/")
class Settings(BaseSettings):
"""Application settings loaded from environment variables."""
model_config = SettingsConfigDict(
# No implicit env_file: get_settings() decides which file to load (SEC-04).
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
)
# Gitea
gitea_url: str = Field(..., description="Base URL of the Gitea instance")
gitea_token: SecretStr | None = Field(
default=None,
description="Gitea token for stdio and admin-token mode (checked at serve time)",
)
# MCP transport
mcp_transport: Literal["stdio", "http"] = Field(default="stdio")
mcp_host: str = Field(default="127.0.0.1", min_length=1)
mcp_port: int = Field(default=8080, ge=1, le=65535)
mcp_path: str = Field(default="/mcp")
mcp_bearer_token: SecretStr | None = Field(default=None)
public_base_url: str | None = Field(default=None)
allowed_hosts: Annotated[list[str], NoDecode] = Field(default_factory=list)
forwarded_allow_ips: str = Field(default="127.0.0.1")
# Per-user OAuth (HTTP only)
oauth_enabled: bool = Field(default=False)
gitea_oauth_client_id: str | None = Field(default=None)
gitea_oauth_client_secret: SecretStr | None = Field(default=None)
token_encryption_key: SecretStr | None = Field(default=None)
oauth_scopes: Annotated[list[str], NoDecode] = Field(
default_factory=lambda: DEFAULT_OAUTH_SCOPES.split(",")
)
# State
data_dir: Path = Field(default_factory=default_data_dir)
audit_log_path: Path | None = Field(default=None)
# Tool exposure
read_only: bool = Field(default=False)
toolsets: Annotated[list[str], NoDecode] = Field(default_factory=lambda: ["all"])
disabled_tools: Annotated[list[str], NoDecode] = Field(default_factory=list)
# Limits and safety
request_timeout_seconds: int = Field(default=30, ge=1, le=600)
max_tool_response_items: int = Field(default=200, ge=1)
max_tool_response_chars: int = Field(default=60_000, ge=1)
max_binary_bytes: int = Field(default=5_242_880, ge=1)
secret_detection_mode: Literal["off", "mask", "block"] = Field(default="mask")
log_level: str = Field(default="INFO")
expose_error_details: bool = Field(default=False)
rate_limit_per_minute: int = Field(default=120, ge=1)
# Validators
@field_validator(
"gitea_token",
"mcp_bearer_token",
"public_base_url",
"gitea_oauth_client_id",
"gitea_oauth_client_secret",
"token_encryption_key",
"audit_log_path",
mode="before",
)
@classmethod
def _blank_is_none(cls, value: Any) -> Any:
"""Treat an empty or whitespace-only value (``KEY=`` in .env) as unset."""
if isinstance(value, str) and not value.strip():
return None
return value
@field_validator("gitea_url")
@classmethod
def _validate_gitea_url(cls, value: str) -> str:
"""Normalize the Gitea base URL; a trailing ``/api/v1`` is accepted and removed."""
normalized = _normalize_http_url(value, field="GITEA_URL")
if normalized.endswith("/api/v1"):
normalized = normalized[: -len("/api/v1")].rstrip("/")
if not urlsplit(normalized).hostname: # pragma: no cover - defensive
raise ValueError("GITEA_URL must include a host")
return normalized
@field_validator("public_base_url")
@classmethod
def _validate_public_base_url(cls, value: str | None) -> str | None:
"""Require https for the public URL unless it points at a loopback host."""
if value is None:
return None
normalized = _normalize_http_url(value, field="PUBLIC_BASE_URL")
parts = urlsplit(normalized)
if parts.scheme != "https" and not is_loopback_host(parts.hostname or ""):
raise ValueError("PUBLIC_BASE_URL must use https unless the host is loopback")
return normalized
@field_validator("forwarded_allow_ips", mode="before")
@classmethod
def _validate_forwarded_allow_ips(cls, value: Any) -> Any:
"""Accept ``*`` or a comma list of IP addresses and networks (blank means the default)."""
if not isinstance(value, str):
return value
items = [item.strip() for item in value.split(",") if item.strip()]
if not items:
return "127.0.0.1"
if "*" in items and len(items) > 1:
raise ValueError("FORWARDED_ALLOW_IPS: '*' cannot be combined with other entries")
for item in items:
if item == "*":
continue
try:
ipaddress.ip_network(item, strict=False)
except ValueError:
raise ValueError(
"FORWARDED_ALLOW_IPS must be '*' or a comma list of IP addresses or networks"
) from None
return ",".join(items)
@field_validator("mcp_path")
@classmethod
def _validate_mcp_path(cls, value: str) -> str:
"""Require an absolute path without query or trailing slash."""
candidate = value.strip()
if not candidate.startswith("/") or candidate.startswith("//"):
raise ValueError("MCP_PATH must start with a single '/'")
if "?" in candidate or "#" in candidate or " " in candidate:
raise ValueError("MCP_PATH must be a plain path")
candidate = candidate.rstrip("/")
if not candidate:
raise ValueError("MCP_PATH must not be '/'")
return candidate
@field_validator("mcp_bearer_token", "token_encryption_key")
@classmethod
def _validate_min_secret_length(cls, value: SecretStr | None, info: Any) -> SecretStr | None:
"""Reject short shared secrets."""
if value is not None and len(value.get_secret_value()) < _MIN_SECRET_CHARS:
raise ValueError(
f"{info.field_name.upper()} must be at least {_MIN_SECRET_CHARS} chars"
)
return value
@field_validator("allowed_hosts", "oauth_scopes", "toolsets", "disabled_tools", mode="before")
@classmethod
def _parse_list(cls, value: Any) -> Any:
"""Accept comma or whitespace separated strings for list settings."""
return _split_list(value)
@field_validator("oauth_scopes")
@classmethod
def _validate_oauth_scopes(cls, value: list[str]) -> list[str]:
"""Accept only granular Gitea scopes (unknown scopes make Gitea grant full access)."""
unknown = sorted({scope for scope in value if scope not in GITEA_SCOPES})
if unknown:
raise ValueError(
f"OAUTH_SCOPES contains unknown scope(s): {', '.join(unknown)}. "
"Gitea grants full access for unknown scopes, so only granular "
"read:/write: scopes are accepted"
)
if not value:
raise ValueError("OAUTH_SCOPES must not be empty")
return list(dict.fromkeys(value))
@field_validator("toolsets")
@classmethod
def _validate_toolsets(cls, value: list[str]) -> list[str]:
"""Accept known tool domains and ``all``."""
lowered = [item.lower() for item in value]
unknown = sorted({item for item in lowered if item != "all" and item not in TOOL_DOMAINS})
if unknown:
allowed = ", ".join(sorted(TOOL_DOMAINS | {"all"}))
raise ValueError(
f"TOOLSETS contains unknown domain(s): {', '.join(unknown)}; use {allowed}"
)
if not lowered:
raise ValueError("TOOLSETS must not be empty (use 'all')")
return list(dict.fromkeys(lowered))
@field_validator("disabled_tools")
@classmethod
def _normalize_disabled_tools(cls, value: list[str]) -> list[str]:
"""Lowercase tool names and drop duplicates."""
return list(dict.fromkeys(item.lower() for item in value))
@field_validator("allowed_hosts")
@classmethod
def _normalize_allowed_hosts(cls, value: list[str]) -> list[str]:
"""Drop duplicates, keep order."""
return list(dict.fromkeys(value))
@field_validator("log_level")
@classmethod
def _validate_log_level(cls, value: str) -> str:
"""Normalize and validate the logging level."""
upper = value.strip().upper()
if upper not in ALLOWED_LOG_LEVELS:
raise ValueError(f"LOG_LEVEL must be one of {sorted(ALLOWED_LOG_LEVELS)}")
return upper
@field_validator("data_dir", "audit_log_path")
@classmethod
def _expand_user(cls, value: Path | None) -> Path | None:
"""Expand a leading ``~`` in paths."""
return value.expanduser() if value is not None else None
# Derived values and helpers
@property
def gitea_api_url(self) -> str:
"""Base URL of the Gitea REST API (``<GITEA_URL>/api/v1``)."""
return f"{self.gitea_url}/api/v1"
@property
def resolved_audit_log_path(self) -> Path:
"""Audit log path, defaulting to ``DATA_DIR/audit.log``."""
return self.audit_log_path or (self.data_dir / "audit.log")
def is_loopback_bind(self) -> bool:
"""Return True when ``MCP_HOST`` is a loopback address (127.0.0.0/8, ::1, localhost)."""
return is_loopback_host(self.mcp_host)
def oauth_config_errors(self) -> list[str]:
"""List missing settings required by OAuth mode (empty when OAuth is off or complete)."""
if not self.oauth_enabled:
return []
errors: list[str] = []
if not self.gitea_oauth_client_id:
errors.append("GITEA_OAUTH_CLIENT_ID is required when OAUTH_ENABLED=true")
if self.gitea_oauth_client_secret is None:
errors.append("GITEA_OAUTH_CLIENT_SECRET is required when OAUTH_ENABLED=true")
if self.token_encryption_key is None:
errors.append("TOKEN_ENCRYPTION_KEY is required when OAUTH_ENABLED=true")
if not self.public_base_url:
errors.append("PUBLIC_BASE_URL is required when OAUTH_ENABLED=true")
return errors
def http_auth_configured(self) -> bool:
"""Return True when the HTTP endpoint has a bearer token or a complete OAuth setup."""
if self.mcp_bearer_token is not None:
return True
return self.oauth_enabled and not self.oauth_config_errors()
#: Environment variable naming an env file (same as ``--env-file``).
ENV_FILE_VARIABLE = "AEGIS_ENV_FILE"
DEFAULT_ENV_FILE = ".env"
class EnvFileError(ValueError):
"""An explicitly requested env file cannot be used (the message is safe to print)."""
def env_file_problem(path: Path) -> str | None:
"""Return why the implicit env file ``path`` is not trusted, or None when it may be loaded.
Trusted means: a regular file (not a symlink) owned by the current user that
group and others cannot access (mode 0600, which ``setup`` writes). A missing
file is not a problem here.
"""
try:
info = os.lstat(path)
except FileNotFoundError:
return None
except OSError as exc:
return f"it cannot be inspected ({exc.strerror or type(exc).__name__})"
if stat.S_ISLNK(info.st_mode):
return "it is a symlink"
if not stat.S_ISREG(info.st_mode):
return "it is not a regular file"
if info.st_uid != getattr(os, "geteuid", lambda: info.st_uid)():
return "it is owned by another user"
mode = stat.S_IMODE(info.st_mode)
if mode & 0o077:
return f"its permissions are {mode:04o} (group or others have access)"
return None
def select_env_file(
explicit: str | Path | None = None,
*,
environ: Mapping[str, str] | None = None,
directory: Path | None = None,
) -> tuple[Path | None, str | None]:
"""Choose the env file to load; returns ``(path, warning)``.
Order: ``explicit`` (``--env-file``), then ``AEGIS_ENV_FILE``, then ``./.env``.
A file named explicitly is the operator's choice and is used as is. The
implicit ``./.env`` is loaded only when :func:`env_file_problem` finds nothing
wrong; otherwise it is ignored and ``warning`` says why and how to fix it. A
working directory is not trustworthy by itself: clients start stdio servers
inside the project (possibly a cloned repository) they are working on, and a
planted ``.env`` could switch off redaction or audit, or redirect the token.
Real environment variables always take precedence over the file.
Raises:
EnvFileError: An explicitly named file is missing or not a regular file.
"""
env = os.environ if environ is None else environ
named = explicit or env.get(ENV_FILE_VARIABLE, "").strip() or None
if named is not None:
path = Path(named).expanduser()
try:
regular = stat.S_ISREG(os.stat(path).st_mode)
except OSError:
regular = False
if not regular:
raise EnvFileError(f"env file {path} does not exist or is not a regular file")
return path, None
path = (directory or Path.cwd()) / DEFAULT_ENV_FILE
problem = env_file_problem(path)
if problem is not None:
return None, (
f"ignoring ./{DEFAULT_ENV_FILE}: {problem}. Run 'chmod 600 {DEFAULT_ENV_FILE}' "
f"(and make sure you own it), or pass --env-file PATH (or set {ENV_FILE_VARIABLE}) "
"to load a file explicitly"
)
return (path if path.exists() else None), None
_settings: Settings | None = None
_env_file_choice: str | Path | None = None
def set_env_file(path: str | Path | None) -> None:
"""Choose the env file for the next :func:`get_settings` call (``--env-file``)."""
global _env_file_choice
_env_file_choice = path
reset_settings()
def get_settings() -> Settings:
"""Get or create the global settings instance.
Raises:
EnvFileError: An explicitly named env file cannot be used.
"""
global _settings
if _settings is None:
path, warning = select_env_file(_env_file_choice)
if warning:
print(f"aegis-gitea-mcp: warning: {warning}", file=sys.stderr)
_settings = Settings(_env_file=path) # type: ignore[call-arg]
return _settings
def reset_settings() -> None:
"""Reset the global settings instance (primarily for tests)."""
global _settings
_settings = None