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:
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user