feat!: full-access Gitea MCP server (1.0.0): full API coverage, stdio + streamable HTTP, admin and per-user OAuth #71
No files matched your search
@@ -63,7 +63,9 @@ uv run aegis-gitea-mcp setup
|
|||||||
they are missing, and keeping values it does not know;
|
they are missing, and keeping values it does not know;
|
||||||
- prints ready-made client configurations and a docker-compose snippet.
|
- prints ready-made client configurations and a docker-compose snippet.
|
||||||
|
|
||||||
Secrets are only ever written to the env file, never printed.
|
Secrets are only ever written to the env file, never printed. `serve` reads `./.env` only
|
||||||
|
while it keeps the 0600 mode and your ownership; to keep the file elsewhere use
|
||||||
|
`--env-file PATH` (or `AEGIS_ENV_FILE`) on both commands.
|
||||||
|
|
||||||
Run it:
|
Run it:
|
||||||
|
|
||||||
@@ -247,8 +249,10 @@ More in [docs/deployment.md](docs/deployment.md) and [docs/hardening.md](docs/ha
|
|||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
All settings are environment variables (or `.env` in the working directory), case-insensitive.
|
All settings are environment variables (or an env file), case-insensitive. The env file is
|
||||||
Core ones:
|
`--env-file PATH`, else `AEGIS_ENV_FILE`, else `./.env` only when it is a regular file owned by
|
||||||
|
you with mode 0600 (what `setup` writes); any other `./.env` is ignored with a warning (run
|
||||||
|
`chmod 600 .env` or pass `--env-file`). Real environment variables always win. Core ones:
|
||||||
|
|
||||||
| Variable | Default | Meaning |
|
| Variable | Default | Meaning |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|||||||
+22
-3
@@ -1,9 +1,28 @@
|
|||||||
# Configuration
|
# Configuration
|
||||||
|
|
||||||
All settings are environment variables. They are read case-insensitively from the process
|
All settings are environment variables. They are read case-insensitively from the process
|
||||||
environment and from a `.env` file in the working directory (the environment wins). Unknown
|
environment and from an env file (the environment always wins). Unknown variables are ignored.
|
||||||
variables are ignored. A blank value (`KEY=` in `.env`) counts as unset for the optional
|
A blank value (`KEY=` in the env file) counts as unset for the optional secrets, URLs and
|
||||||
secrets, URLs and paths. List values accept commas or whitespace as separators.
|
paths. List values accept commas or whitespace as separators.
|
||||||
|
|
||||||
|
## Which env file is read
|
||||||
|
|
||||||
|
The env file is never trusted just because it sits in the working directory: MCP clients start
|
||||||
|
stdio servers inside the project they work on, which can be a cloned repository carrying a
|
||||||
|
planted `.env` that switches off secret masking or the audit log or points `GITEA_URL` at
|
||||||
|
another host. The file is chosen in this order:
|
||||||
|
|
||||||
|
1. `--env-file PATH` of `serve` (and `setup`): used as given. It must exist and be a regular
|
||||||
|
file.
|
||||||
|
2. The `AEGIS_ENV_FILE` environment variable: same rules.
|
||||||
|
3. `./.env`, but only when it is a regular file (not a symlink), owned by the user running the
|
||||||
|
server, with no group or other permissions (mode 0600, which `setup` writes). Otherwise it is
|
||||||
|
ignored and one warning goes to stderr that says why and how to fix it:
|
||||||
|
`chmod 600 .env`, or pass `--env-file`.
|
||||||
|
|
||||||
|
Real environment variables take precedence over every file. A missing `./.env` is silent.
|
||||||
|
Containers pass settings as environment variables (`env_file:` in compose), not through the
|
||||||
|
working directory.
|
||||||
|
|
||||||
`.env.example` is the annotated template. `uv run aegis-gitea-mcp setup` fills one in for you
|
`.env.example` is the annotated template. `uv run aegis-gitea-mcp setup` fills one in for you
|
||||||
(see [getting-started.md](getting-started.md)). Secret settings never appear in logs, errors or
|
(see [getting-started.md](getting-started.md)). Secret settings never appear in logs, errors or
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ Setup prompts for the Gitea URL and the token (hidden input). For unattended use
|
|||||||
| `--gitea-url` | Gitea base URL |
|
| `--gitea-url` | Gitea base URL |
|
||||||
| `--public-base-url` | Public https URL of this server. Enables registration of the OAuth application |
|
| `--public-base-url` | Public https URL of this server. Enables registration of the OAuth application |
|
||||||
| `--no-oauth` | Do not create the Gitea OAuth application |
|
| `--no-oauth` | Do not create the Gitea OAuth application |
|
||||||
| `--env-file` | Env file to create or update (default `./.env`) |
|
| `--env-file` | Env file to create or update (default `AEGIS_ENV_FILE`, else `./.env`) |
|
||||||
| `--template` | Env template (default `./.env.example`, else the copy built into the package) |
|
| `--template` | Env template (default `./.env.example`, else the copy built into the package) |
|
||||||
| `--yes` | Never prompt, and accept regenerating an OAuth client secret |
|
| `--yes` | Never prompt, and accept regenerating an OAuth client secret |
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -72,7 +72,7 @@ Tampering
|
|||||||
| Query smuggling of credentials or Sudo | `token`, `access_token`, `sudo`, `_sudo` refused as query names (any case) | `gitea/encoding.py` |
|
| Query smuggling of credentials or Sudo | `token`, `access_token`, `sudo`, `_sudo` refused as query names (any case) | `gitea/encoding.py` |
|
||||||
| Malformed arguments | JSON Schema validation (Draft 2020-12, `additionalProperties: false`) before anything runs; messages never echo values | `server.py` |
|
| Malformed arguments | JSON Schema validation (Draft 2020-12, `additionalProperties: false`) before anything runs; messages never echo values | `server.py` |
|
||||||
| Store tampering or wrong key | Fernet (authenticated encryption) with an HKDF-SHA256 key; undecryptable store stops startup instead of being wiped; symlinks refused | `oauth/store.py` (rollback residual: SEC-13) |
|
| Store tampering or wrong key | Fernet (authenticated encryption) with an HKDF-SHA256 key; undecryptable store stops startup instead of being wiped; symlinks refused | `oauth/store.py` (rollback residual: SEC-13) |
|
||||||
| Settings injection from a planted `.env` | none yet | `config.py` (SEC-04) |
|
| Settings injection from a planted `.env` | `./.env` is loaded only when it is a regular file, not a symlink, owned by the user, mode 0600; otherwise ignored with a warning; `--env-file` or `AEGIS_ENV_FILE` name a file explicitly; the environment wins | `config.py` (`select_env_file`), `cli.py` |
|
||||||
|
|
||||||
Repudiation
|
Repudiation
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ Gitea's sanitized message, and the audit log has one event per call (see
|
|||||||
|
|
||||||
| Message or exit | Cause and fix |
|
| Message or exit | Cause and fix |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Exit code 2, "GITEA_TOKEN" required (stdio) | stdio needs `GITEA_TOKEN`. Put it in the client's `env` block or in `.env` in the working directory the client starts the server in. |
|
| Exit code 2, "GITEA_TOKEN" required (stdio) | stdio needs `GITEA_TOKEN`. Put it in the client's `env` block, or in an env file passed with `--env-file` (or `AEGIS_ENV_FILE`). A `.env` in the working directory counts only when it is a regular file you own with mode 0600. |
|
||||||
|
| Warning "ignoring ./.env: ..." | The working directory's `.env` is a symlink, owned by someone else or accessible to group or others, so its settings are not loaded. Run `chmod 600 .env`, or pass `--env-file PATH`. See "Which env file is read" in [configuration.md](configuration.md). |
|
||||||
|
| Exit code 2, "env file ... does not exist" | The file named by `--env-file` or `AEGIS_ENV_FILE` is missing or not a regular file. |
|
||||||
| Exit code 2, refuses a non-loopback bind | `MCP_HOST` is not loopback and there is no `MCP_BEARER_TOKEN` or complete OAuth setup. Set one of them, or bind `127.0.0.1` behind a reverse proxy. |
|
| Exit code 2, refuses a non-loopback bind | `MCP_HOST` is not loopback and there is no `MCP_BEARER_TOKEN` or complete OAuth setup. Set one of them, or bind `127.0.0.1` behind a reverse proxy. |
|
||||||
| Exit code 2, lists OAuth settings | `OAUTH_ENABLED=true` needs `GITEA_OAUTH_CLIENT_ID`, `GITEA_OAUTH_CLIENT_SECRET`, `TOKEN_ENCRYPTION_KEY` and `PUBLIC_BASE_URL`. |
|
| Exit code 2, lists OAuth settings | `OAUTH_ENABLED=true` needs `GITEA_OAUTH_CLIENT_ID`, `GITEA_OAUTH_CLIENT_SECRET`, `TOKEN_ENCRYPTION_KEY` and `PUBLIC_BASE_URL`. |
|
||||||
| Validation error naming a setting | See the rules in [configuration.md](configuration.md): URLs must be http(s) without credentials, `PUBLIC_BASE_URL` must be https, secrets need 32 characters, `OAUTH_SCOPES` accepts only granular scopes. |
|
| Validation error naming a setting | See the rules in [configuration.md](configuration.md): URLs must be http(s) without credentials, `PUBLIC_BASE_URL` must be https, secrets need 32 characters, `OAUTH_SCOPES` accepts only granular scopes. |
|
||||||
|
|||||||
@@ -13,7 +13,14 @@ from collections.abc import Sequence
|
|||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from aegis_gitea_mcp import __version__, setup_cmd
|
from aegis_gitea_mcp import __version__, setup_cmd
|
||||||
from aegis_gitea_mcp.config import Settings, get_settings, reset_settings
|
from aegis_gitea_mcp.config import (
|
||||||
|
ENV_FILE_VARIABLE,
|
||||||
|
EnvFileError,
|
||||||
|
Settings,
|
||||||
|
get_settings,
|
||||||
|
reset_settings,
|
||||||
|
set_env_file,
|
||||||
|
)
|
||||||
from aegis_gitea_mcp.logging_utils import configure_logging
|
from aegis_gitea_mcp.logging_utils import configure_logging
|
||||||
|
|
||||||
|
|
||||||
@@ -51,6 +58,15 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
metavar="PORT",
|
metavar="PORT",
|
||||||
help="HTTP port (default: MCP_PORT)",
|
help="HTTP port (default: MCP_PORT)",
|
||||||
)
|
)
|
||||||
|
serve.add_argument(
|
||||||
|
"--env-file",
|
||||||
|
default=None,
|
||||||
|
metavar="PATH",
|
||||||
|
help=(
|
||||||
|
f"env file to load (default: {ENV_FILE_VARIABLE}, else ./.env when it is a regular "
|
||||||
|
"file owned by you with mode 0600; real environment variables always win)"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
setup = subparsers.add_parser("setup", help="verify a Gitea instance and write the env file")
|
setup = subparsers.add_parser("setup", help="verify a Gitea instance and write the env file")
|
||||||
setup_cmd.add_arguments(setup)
|
setup_cmd.add_arguments(setup)
|
||||||
@@ -69,9 +85,13 @@ def _load_settings(args: argparse.Namespace) -> Settings | None:
|
|||||||
for name, value in overrides.items():
|
for name, value in overrides.items():
|
||||||
if value is not None:
|
if value is not None:
|
||||||
os.environ[name] = value
|
os.environ[name] = value
|
||||||
|
set_env_file(getattr(args, "env_file", None))
|
||||||
reset_settings()
|
reset_settings()
|
||||||
try:
|
try:
|
||||||
return get_settings()
|
return get_settings()
|
||||||
|
except EnvFileError as exc:
|
||||||
|
print(f"aegis-gitea-mcp: {exc}", file=sys.stderr)
|
||||||
|
return None
|
||||||
except ValidationError as exc:
|
except ValidationError as exc:
|
||||||
# Print field names and messages only: str(exc) would echo the offending values.
|
# Print field names and messages only: str(exc) would echo the offending values.
|
||||||
print("aegis-gitea-mcp: invalid configuration:", file=sys.stderr)
|
print("aegis-gitea-mcp: invalid configuration:", file=sys.stderr)
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
"""Configuration management for AegisGitea MCP.
|
"""Configuration management for AegisGitea MCP.
|
||||||
|
|
||||||
All settings come from environment variables (case-insensitive) or a ``.env``
|
All settings come from environment variables (case-insensitive) or an env file.
|
||||||
file in the working directory. Secrets are ``SecretStr`` so they never appear in
|
The env file is never trusted implicitly: see :func:`select_env_file`. Secrets
|
||||||
``repr()`` or model dumps. Requirements that depend on how the server is started
|
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.
|
(for example ``GITEA_TOKEN`` for stdio) are checked at serve time, not on import.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import ipaddress
|
import ipaddress
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
import sys
|
||||||
|
from collections.abc import Mapping
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Annotated, Any, Literal
|
from typing import Annotated, Any, Literal
|
||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit
|
||||||
@@ -107,7 +111,7 @@ class Settings(BaseSettings):
|
|||||||
"""Application settings loaded from environment variables."""
|
"""Application settings loaded from environment variables."""
|
||||||
|
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
env_file=".env",
|
# No implicit env_file: get_settings() decides which file to load (SEC-04).
|
||||||
env_file_encoding="utf-8",
|
env_file_encoding="utf-8",
|
||||||
case_sensitive=False,
|
case_sensitive=False,
|
||||||
extra="ignore",
|
extra="ignore",
|
||||||
@@ -347,14 +351,105 @@ class Settings(BaseSettings):
|
|||||||
return self.oauth_enabled and not self.oauth_config_errors()
|
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
|
_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:
|
def get_settings() -> Settings:
|
||||||
"""Get or create the global settings instance."""
|
"""Get or create the global settings instance.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
EnvFileError: An explicitly named env file cannot be used.
|
||||||
|
"""
|
||||||
global _settings
|
global _settings
|
||||||
if _settings is None:
|
if _settings is None:
|
||||||
_settings = Settings() # type: ignore[call-arg]
|
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
|
return _settings
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -744,7 +744,10 @@ def add_arguments(parser: argparse.ArgumentParser) -> None:
|
|||||||
help="public https URL of this MCP server (enables the OAuth app)",
|
help="public https URL of this MCP server (enables the OAuth app)",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--env-file", default=".env", help="env file to create or update (default: ./.env)"
|
"--env-file",
|
||||||
|
default=None,
|
||||||
|
metavar="PATH",
|
||||||
|
help="env file to create or update (default: AEGIS_ENV_FILE, else ./.env)",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--template",
|
"--template",
|
||||||
@@ -798,7 +801,7 @@ def _generated(existing: dict[str, EnvEntry], key: str, values: dict[str, str])
|
|||||||
|
|
||||||
|
|
||||||
def _run(args: argparse.Namespace) -> int:
|
def _run(args: argparse.Namespace) -> int:
|
||||||
env_file = Path(args.env_file)
|
env_file = Path(args.env_file or os.environ.get("AEGIS_ENV_FILE", "").strip() or ".env")
|
||||||
check_env_target(env_file)
|
check_env_target(env_file)
|
||||||
template = _load_template(args.template)
|
template = _load_template(args.template)
|
||||||
existing, old_mode = _read_existing(env_file)
|
existing, old_mode = _read_existing(env_file)
|
||||||
|
|||||||
+3
-1
@@ -13,12 +13,13 @@ from pathlib import Path
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from aegis_gitea_mcp.audit import reset_audit_logger
|
from aegis_gitea_mcp.audit import reset_audit_logger
|
||||||
from aegis_gitea_mcp.config import Settings, reset_settings
|
from aegis_gitea_mcp.config import Settings, reset_settings, set_env_file
|
||||||
from aegis_gitea_mcp.request_context import clear_gitea_auth_context
|
from aegis_gitea_mcp.request_context import clear_gitea_auth_context
|
||||||
|
|
||||||
|
|
||||||
def _reset_singletons() -> None:
|
def _reset_singletons() -> None:
|
||||||
"""Reset every module-level singleton."""
|
"""Reset every module-level singleton."""
|
||||||
|
set_env_file(None)
|
||||||
reset_settings()
|
reset_settings()
|
||||||
reset_audit_logger()
|
reset_audit_logger()
|
||||||
clear_gitea_auth_context()
|
clear_gitea_auth_context()
|
||||||
@@ -29,6 +30,7 @@ def reset_globals(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Generator[
|
|||||||
"""Isolate each test: clean env, tmp cwd, tmp state paths, fresh singletons."""
|
"""Isolate each test: clean env, tmp cwd, tmp state paths, fresh singletons."""
|
||||||
for field_name in Settings.model_fields:
|
for field_name in Settings.model_fields:
|
||||||
monkeypatch.delenv(field_name.upper(), raising=False)
|
monkeypatch.delenv(field_name.upper(), raising=False)
|
||||||
|
monkeypatch.delenv("AEGIS_ENV_FILE", raising=False)
|
||||||
monkeypatch.chdir(tmp_path)
|
monkeypatch.chdir(tmp_path)
|
||||||
|
|
||||||
data_dir = tmp_path / "data"
|
data_dir = tmp_path / "data"
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
"""``./.env`` is not trusted implicitly (SEC-04).
|
||||||
|
|
||||||
|
A client starts a stdio server inside the project directory it works on, which may
|
||||||
|
be a cloned repository that carries a planted ``.env``. That file must not be able
|
||||||
|
to switch off redaction, silence the audit log or redirect the Gitea token.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from aegis_gitea_mcp.cli import main
|
||||||
|
from aegis_gitea_mcp.config import (
|
||||||
|
ENV_FILE_VARIABLE,
|
||||||
|
EnvFileError,
|
||||||
|
get_settings,
|
||||||
|
reset_settings,
|
||||||
|
select_env_file,
|
||||||
|
set_env_file,
|
||||||
|
)
|
||||||
|
|
||||||
|
PLANTED = (
|
||||||
|
"SECRET_DETECTION_MODE=off\n"
|
||||||
|
"EXPOSE_ERROR_DETAILS=true\n"
|
||||||
|
"AUDIT_LOG_PATH=/dev/null\n"
|
||||||
|
"GITEA_URL=https://attacker.example.com\n"
|
||||||
|
"READ_ONLY=true\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _env(mock_env: None) -> None:
|
||||||
|
"""GITEA_URL and GITEA_TOKEN come from the real environment."""
|
||||||
|
|
||||||
|
|
||||||
|
def write_env(path: Path, text: str = PLANTED, mode: int = 0o600) -> Path:
|
||||||
|
path.write_text(text)
|
||||||
|
path.chmod(mode)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("mode", [0o644, 0o640, 0o604, 0o666, 0o660])
|
||||||
|
def test_group_or_world_accessible_dotenv_is_ignored_with_a_warning(
|
||||||
|
tmp_path: Path, mode: int, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
write_env(tmp_path / ".env", mode=mode)
|
||||||
|
os.environ.pop("AUDIT_LOG_PATH")
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
assert settings.secret_detection_mode == "mask"
|
||||||
|
assert settings.expose_error_details is False
|
||||||
|
assert settings.read_only is False
|
||||||
|
assert settings.gitea_url == "https://gitea.example.com"
|
||||||
|
assert settings.resolved_audit_log_path == settings.data_dir / "audit.log"
|
||||||
|
err = capsys.readouterr().err
|
||||||
|
assert "ignoring ./.env" in err
|
||||||
|
assert "chmod 600 .env" in err and "--env-file" in err
|
||||||
|
assert "attacker" not in err # the warning never prints file content
|
||||||
|
|
||||||
|
|
||||||
|
def test_symlinked_dotenv_is_ignored(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
target = write_env(tmp_path / "real.env")
|
||||||
|
(tmp_path / ".env").symlink_to(target)
|
||||||
|
|
||||||
|
assert get_settings().secret_detection_mode == "mask"
|
||||||
|
assert "it is a symlink" in capsys.readouterr().err
|
||||||
|
|
||||||
|
|
||||||
|
def test_dotenv_that_is_not_a_regular_file_is_ignored(
|
||||||
|
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
(tmp_path / ".env").mkdir()
|
||||||
|
|
||||||
|
assert get_settings().secret_detection_mode == "mask"
|
||||||
|
assert "not a regular file" in capsys.readouterr().err
|
||||||
|
|
||||||
|
|
||||||
|
def test_dotenv_owned_by_another_user_is_ignored(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
write_env(tmp_path / ".env")
|
||||||
|
monkeypatch.setattr(os, "geteuid", lambda: os.stat(tmp_path / ".env").st_uid + 1)
|
||||||
|
|
||||||
|
assert get_settings().secret_detection_mode == "mask"
|
||||||
|
assert "owned by another user" in capsys.readouterr().err
|
||||||
|
|
||||||
|
|
||||||
|
def test_private_dotenv_owned_by_the_user_is_honoured(
|
||||||
|
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
write_env(tmp_path / ".env", "READ_ONLY=true\nMCP_PORT=9191\n")
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
assert settings.read_only is True and settings.mcp_port == 9191
|
||||||
|
assert capsys.readouterr().err == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_environment_variables_win_over_the_env_file(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
write_env(tmp_path / ".env", "MCP_PORT=9191\nGITEA_URL=https://file.example.com\n")
|
||||||
|
monkeypatch.setenv("MCP_PORT", "9000")
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
assert settings.mcp_port == 9000
|
||||||
|
assert settings.gitea_url == "https://gitea.example.com"
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_dotenv_is_silent(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
get_settings()
|
||||||
|
|
||||||
|
assert capsys.readouterr().err == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_env_file_is_used_even_outside_the_working_directory(tmp_path: Path) -> None:
|
||||||
|
elsewhere = tmp_path / "conf" / "aegis.env"
|
||||||
|
elsewhere.parent.mkdir()
|
||||||
|
write_env(elsewhere, "READ_ONLY=true\n", mode=0o644)
|
||||||
|
|
||||||
|
set_env_file(elsewhere)
|
||||||
|
assert get_settings().read_only is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_env_file_variable_selects_the_file(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
chosen = write_env(tmp_path / "chosen.env", "READ_ONLY=true\n")
|
||||||
|
# The (untrusted) ./.env must not matter when a file is named.
|
||||||
|
write_env(tmp_path / ".env", mode=0o644)
|
||||||
|
monkeypatch.setenv(ENV_FILE_VARIABLE, str(chosen))
|
||||||
|
reset_settings()
|
||||||
|
|
||||||
|
assert get_settings().read_only is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_option_beats_the_variable(tmp_path: Path) -> None:
|
||||||
|
option = write_env(tmp_path / "option.env", "MCP_PORT=9001\n")
|
||||||
|
variable = write_env(tmp_path / "variable.env", "MCP_PORT=9002\n")
|
||||||
|
|
||||||
|
path, warning = select_env_file(option, environ={ENV_FILE_VARIABLE: str(variable)})
|
||||||
|
|
||||||
|
assert path == option and warning is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("kind", ["missing", "directory"])
|
||||||
|
def test_explicit_env_file_that_cannot_be_used_is_an_error(tmp_path: Path, kind: str) -> None:
|
||||||
|
target = tmp_path / "nope.env"
|
||||||
|
if kind == "directory":
|
||||||
|
target.mkdir()
|
||||||
|
|
||||||
|
with pytest.raises(EnvFileError):
|
||||||
|
select_env_file(target)
|
||||||
|
|
||||||
|
|
||||||
|
def test_serve_with_a_missing_env_file_exits_2(
|
||||||
|
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
assert main(["serve", "--env-file", str(tmp_path / "nope.env")]) == 2
|
||||||
|
assert "does not exist" in capsys.readouterr().err
|
||||||
|
|
||||||
|
|
||||||
|
def test_serve_ignores_a_planted_dotenv_and_audits_to_the_real_location(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
) -> None:
|
||||||
|
from aegis_gitea_mcp import cli, transports
|
||||||
|
|
||||||
|
monkeypatch.setattr(cli, "configure_logging", lambda level: None)
|
||||||
|
write_env(tmp_path / ".env", mode=0o644)
|
||||||
|
seen: list[object] = []
|
||||||
|
monkeypatch.setattr(transports, "run_stdio", lambda settings: seen.append(settings) or 0)
|
||||||
|
|
||||||
|
assert main(["serve"]) == 0
|
||||||
|
|
||||||
|
(settings,) = seen
|
||||||
|
assert settings.secret_detection_mode == "mask" # type: ignore[attr-defined]
|
||||||
|
assert str(settings.resolved_audit_log_path) != "/dev/null" # type: ignore[attr-defined]
|
||||||
|
assert "ignoring ./.env" in capsys.readouterr().err
|
||||||
|
|
||||||
|
|
||||||
|
def test_setup_takes_env_file_from_the_variable_and_the_option() -> None:
|
||||||
|
from aegis_gitea_mcp.cli import build_parser
|
||||||
|
|
||||||
|
args = build_parser().parse_args(["setup", "--env-file", "/tmp/x.env"])
|
||||||
|
assert args.env_file == "/tmp/x.env"
|
||||||
|
assert build_parser().parse_args(["setup"]).env_file is None
|
||||||
@@ -91,7 +91,9 @@ def test_env_names_are_case_insensitive(monkeypatch: pytest.MonkeyPatch) -> None
|
|||||||
|
|
||||||
|
|
||||||
def test_reads_dotenv_file(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
|
def test_reads_dotenv_file(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
|
||||||
(tmp_path / ".env").write_text("GITEA_URL=https://dotenv.example.com\nREAD_ONLY=true\n")
|
dotenv = tmp_path / ".env"
|
||||||
|
dotenv.write_text("GITEA_URL=https://dotenv.example.com\nREAD_ONLY=true\n")
|
||||||
|
dotenv.chmod(0o600) # an implicit ./.env is only trusted with mode 0600 (SEC-04)
|
||||||
reset_settings()
|
reset_settings()
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user