fix: v0.2.9 — review welle 1 (C-1, C-2, M-3)

C-1: __version__ now derived from package metadata via
importlib.metadata.version() so pyproject.toml is the single source of
truth. Previously stuck at "0.1.0" since the initial release.

C-2: Backfill CHANGELOG entries for 0.2.7 and 0.2.8 (both releases had
shipped without changelog updates) and add a 0.2.9 entry covering this
welle.

M-3: Reject project names containing path separators or other unsafe
characters before they reach _find_compose_path. Previously a name like
"../../etc" could traverse out of compose_base_path when the project was
not yet registered with Container Manager. Adds _validate_project_name
(regex ^[a-zA-Z0-9_-]+$, applied in read_compose, update_compose,
update_image_tag, update_env_var) plus parametrized tests for valid and
unsafe names and one rejection test per tool. 236 tests pass.

Also: ruff format autofix on three pre-existing files (cli.py,
config.py, test_config.py) — cosmetic only.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 09:07:00 +02:00
parent a1a9388d88
commit 661460bfd9
9 changed files with 246 additions and 8 deletions
+6 -1
View File
@@ -1,3 +1,8 @@
"""MCP server for Synology Container Manager."""
__version__ = "0.1.0"
from importlib.metadata import PackageNotFoundError, version
try:
__version__ = version("mcp-synology-container")
except PackageNotFoundError:
__version__ = "0.0.0+unknown"
+1
View File
@@ -291,6 +291,7 @@ def serve(config_path: str | None) -> None:
# and anyio.run() ensures the correct backend context is set up.
# asyncio.run() can cause issues on Windows (ProactorEventLoop + anyio).
import anyio
anyio.run(_run_serve, config_path)
+1 -4
View File
@@ -132,10 +132,7 @@ def _validate_config(raw: dict[str, Any]) -> AppConfig:
"""
schema_version = raw.get("schema_version")
if schema_version != CURRENT_SCHEMA_VERSION:
msg = (
f"Config schema_version is {schema_version!r}, "
f"expected {CURRENT_SCHEMA_VERSION}."
)
msg = f"Config schema_version is {schema_version!r}, expected {CURRENT_SCHEMA_VERSION}."
raise ValueError(msg)
conn_raw = raw.get("connection", {})
@@ -25,6 +25,11 @@ logger = logging.getLogger(__name__)
_VOLUME_PREFIX_RE = re.compile(r"^/volume\d+")
# Project names are used as path components for FileStation lookups when the
# project is not yet registered with Container Manager. Restrict to a safe
# subset so a malicious name like "../../etc" cannot escape compose_base_path.
_PROJECT_NAME_RE = re.compile(r"^[a-zA-Z0-9_-]+$")
def _to_filestation_path(path: str) -> str:
"""Strip /volumeN prefix so paths work with the FileStation API.
@@ -35,6 +40,22 @@ def _to_filestation_path(path: str) -> str:
return _VOLUME_PREFIX_RE.sub("", path)
def _validate_project_name(project_name: str) -> str | None:
"""Return an error message if project_name is unsafe, else None.
Allowed characters: letters, digits, underscore, hyphen. Anything else
(including '/', '\\', '..', whitespace, empty string) is rejected because
the name flows into FileStation path construction when the project is
not yet known to Container Manager.
"""
if not project_name or not _PROJECT_NAME_RE.match(project_name):
return (
f"Error: invalid project name {project_name!r}. "
"Allowed: letters, digits, '_' and '-' (no slashes, dots, or spaces)."
)
return None
# Recognized compose file names (in priority order)
_COMPOSE_FILENAMES = [
"docker-compose.yml",
@@ -68,6 +89,8 @@ def register_compose(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None
@mcp.tool()
async def read_compose(project_name: str):
"""Read the compose file (YAML) for a project."""
if (err := _validate_project_name(project_name)) is not None:
return err
path = await _find_compose_path(client, config, project_name)
if path is None:
project = await _find_project(client, project_name)
@@ -97,6 +120,8 @@ def register_compose(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None
confirmed: bool = False,
):
"""Update a service's image tag in the compose file. Requires confirmed=True."""
if (err := _validate_project_name(project_name)) is not None:
return err
path = await _find_compose_path(client, config, project_name)
if path is None:
return f"No compose file found for project '{project_name}'."
@@ -216,6 +241,8 @@ def register_compose(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None
confirmed: bool = False,
):
"""Add or update an env var in a service's compose definition. Requires confirmed=True."""
if (err := _validate_project_name(project_name)) is not None:
return err
path = await _find_compose_path(client, config, project_name)
if path is None:
return f"No compose file found for project '{project_name}'."
@@ -311,6 +338,8 @@ def register_compose(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None
confirmed: bool = False,
):
"""Replace the entire compose file with new YAML content. Requires confirmed=True."""
if (err := _validate_project_name(project_name)) is not None:
return err
# Validate YAML before anything else
try:
parsed = yaml.safe_load(new_content)