diff --git a/README.md b/README.md index 029b726..66acbda 100644 --- a/README.md +++ b/README.md @@ -377,6 +377,12 @@ Serwer FastAPI z endpointami (poza MCP STDIO): - `POST /refactor/recommend` - Rekomendacje refaktoryzacji - `GET /health` - Healthcheck +Synchronizacja i zapis cache repozytoriów wymagają ustawienia +`MCP_SKILLS_ALLOW_SYNC=1`. Uruchamianie narzędzi oraz reDSL (także przez HTTP) +wymaga osobnego `MCP_SKILLS_ALLOW_EXECUTE=1`. Obie możliwości są domyślnie +wyłączone, a wszystkie `repo_id`, ścieżki fragmentów i archiwa są ograniczone +do `SKILLS_REPO_BASE`. + ## MCP Skills - Narzędzia ### analyze_code_structure @@ -420,6 +426,20 @@ Najważniejsze endpointy `mcp-git-proxy`: - `POST /repos/{repo_id}/run-tests` - test commitu przed pushem - `POST /repos/{repo_id}/push` - push po pozytywnych testach +Proxy uruchamia się w trybie tylko do odczytu. Lokalne zmiany i synchronizacja +wymagają `GIT_PROXY_ALLOW_MUTATION=1`, dowolne polecenia testowe wymagają +`GIT_PROXY_ALLOW_EXECUTE=1`, a push i tworzenie repozytoriów na GitHubie są +osobno chronione przez `GIT_PROXY_ALLOW_REMOTE_WRITE=1`. Identyfikatory repo, +ścieżki zmian, checkpointy i importowane archiwa pozostają wewnątrz +skonfigurowanych katalogów proxy. Port proxy jest domyślnie publikowany tylko +na `127.0.0.1`; jawna zmiana `GIT_PROXY_BIND_HOST` rozszerza tę granicę sieciową. + +`gh2mcp` nie udostępnia pełnego PAT przez HTTP. Zapis `.env` wymaga +`GH2MCP_ALLOW_MUTATION=1`, a formularze sekretów w testowym WebUI wymagają +`MCP_WEBUI_ALLOW_SECRET_WRITE=1`. Synchronizacja repo i wywołania modeli w +WebUI mają osobne bramki `MCP_WEBUI_ALLOW_MUTATION=1` i +`MCP_WEBUI_ALLOW_EXECUTE=1`; oba porty są domyślnie tylko na loopbacku. + ## Przykłady Użycia ### Analiza lokalnego repozytorium diff --git a/docker-compose.yml b/docker-compose.yml index 0ec7df5..ce18a74 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,13 +21,16 @@ services: environment: - GIT_PROXY_REPO_ROOT=/git-repos - GIT_PROXY_CACHE_ROOT=/git-cache + - GIT_PROXY_ALLOW_MUTATION=${GIT_PROXY_ALLOW_MUTATION:-false} + - GIT_PROXY_ALLOW_EXECUTE=${GIT_PROXY_ALLOW_EXECUTE:-false} + - GIT_PROXY_ALLOW_REMOTE_WRITE=${GIT_PROXY_ALLOW_REMOTE_WRITE:-false} volumes: - git-repo-storage:/git-repos - git-cache-storage:/git-cache - ./repos:/host-repos:ro - ..:/host-semcod:ro ports: - - "${PORT_GIT_PROXY:-8081}:8080" + - "${GIT_PROXY_BIND_HOST:-127.0.0.1}:${PORT_GIT_PROXY:-8081}:8080" healthcheck: test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health', timeout=3)"] interval: 20s @@ -45,7 +48,8 @@ services: container_name: gh2mcp-agent environment: - GH2MCP_ENV_FILE=/app/.env - - GH2MCP_SYNC_ON_START=${GH2MCP_SYNC_ON_START:-true} + - GH2MCP_ALLOW_MUTATION=${GH2MCP_ALLOW_MUTATION:-false} + - GH2MCP_SYNC_ON_START=${GH2MCP_SYNC_ON_START:-false} - GH2MCP_SYNC_INTERVAL=${GH2MCP_SYNC_INTERVAL:-0} - GH_TOKEN=${GH_TOKEN:-} - GITHUB_PAT=${GITHUB_PAT:-} @@ -54,7 +58,7 @@ services: - ./.env:/app/.env - ${HOME}/.config/gh:/root/.config/gh:ro ports: - - "${PORT_GH2MCP:-8079}:8079" + - "${GH2MCP_BIND_HOST:-127.0.0.1}:${PORT_GH2MCP:-8079}:8079" healthcheck: test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8079/health', timeout=3)"] interval: 20s @@ -81,6 +85,8 @@ services: - MCP_SKILLS_TRANSPORT=http - MCP_SKILLS_HTTP_HOST=0.0.0.0 - MCP_SKILLS_HTTP_PORT=8080 + - MCP_SKILLS_ALLOW_SYNC=${MCP_SKILLS_ALLOW_SYNC:-false} + - MCP_SKILLS_ALLOW_EXECUTE=${MCP_SKILLS_ALLOW_EXECUTE:-false} - GITHUB_PAT=${GITHUB_PAT:-} - GH_TOKEN=${GH_TOKEN:-} volumes: @@ -218,10 +224,13 @@ services: - GIT_PROXY_URL=http://mcp-git-proxy:8080 - WEBUI_API_KEY=${WEBUI_API_KEY:-sk-mcp-default-dev-key} - GH2MCP_URL=http://gh2mcp-agent:8079 + - MCP_WEBUI_ALLOW_SECRET_WRITE=${MCP_WEBUI_ALLOW_SECRET_WRITE:-false} + - MCP_WEBUI_ALLOW_MUTATION=${MCP_WEBUI_ALLOW_MUTATION:-false} + - MCP_WEBUI_ALLOW_EXECUTE=${MCP_WEBUI_ALLOW_EXECUTE:-false} volumes: - ./.env:/app/.env ports: - - "${PORT_WEBUI:-8092}:8090" + - "${MCP_WEBUI_BIND_HOST:-127.0.0.1}:${PORT_WEBUI:-8092}:8090" depends_on: mcp-gateway: condition: service_healthy diff --git a/env2mcp/env2mcp/config.py b/env2mcp/env2mcp/config.py index 0d6aa87..cbd0e95 100644 --- a/env2mcp/env2mcp/config.py +++ b/env2mcp/env2mcp/config.py @@ -5,7 +5,7 @@ import os import re from pathlib import Path -from typing import Dict, Optional +from typing import Dict class EnvConfig: @@ -74,14 +74,19 @@ def _format_value(self, key: str, value: str) -> str: def save(self, create_backup: bool = True) -> None: """Save configuration to .env file.""" + if self.env_path.is_symlink(): + raise ValueError(f"Refusing to write secrets through symlink: {self.env_path}") if create_backup and self.env_path.exists(): backup_path = self.env_path.with_suffix(".env.backup") + if backup_path.is_symlink(): + raise ValueError(f"Refusing to write backup through symlink: {backup_path}") backup_path.write_text(self.env_path.read_text(), encoding="utf-8") + backup_path.chmod(0o600) lines = [] # Add header - lines.append(f"# Generated by env2mcp v0.1.0") + lines.append("# Generated by env2mcp v0.1.0") lines.append(f"# {self.env_path.absolute()}") lines.append("") @@ -124,7 +129,18 @@ def save(self, create_backup: bool = True) -> None: lines.append(f'{key}={self._format_value(key, value)}') lines.append("") - self.env_path.write_text("\n".join(lines), encoding="utf-8") + flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + fd = os.open(self.env_path, flags, 0o600) + try: + os.fchmod(fd, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as handle: + fd = -1 + handle.write("\n".join(lines)) + finally: + if fd >= 0: + os.close(fd) def __contains__(self, key: str) -> bool: return key in self._data or key in os.environ diff --git a/gh2mcp/README.md b/gh2mcp/README.md index c66b57a..af7ed24 100644 --- a/gh2mcp/README.md +++ b/gh2mcp/README.md @@ -29,6 +29,11 @@ Kontener uruchamia API: - `POST /sync/token` - `POST /repo/last-pushed` +API nigdy nie zwraca pełnego tokenu. Zapis tokenu i organizacji jest domyślnie +wyłączony i wymaga `GH2MCP_ALLOW_MUTATION=1`; automatyczna synchronizacja przy +starcie wymaga dodatkowo `GH2MCP_SYNC_ON_START=true`. Port kontenera jest +domyślnie związany z `127.0.0.1`. + ## License diff --git a/gh2mcp/gh2mcp/server.py b/gh2mcp/gh2mcp/server.py index 72f1681..cd66188 100644 --- a/gh2mcp/gh2mcp/server.py +++ b/gh2mcp/gh2mcp/server.py @@ -3,15 +3,16 @@ import asyncio import os -from fastapi import FastAPI -from fastapi import Query +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse from pydantic import BaseModel from .sync import GitHubTokenSyncService ENV_FILE = os.getenv("GH2MCP_ENV_FILE", "/app/.env") -SYNC_ON_START = os.getenv("GH2MCP_SYNC_ON_START", "true").lower() in {"1", "true", "yes"} +SYNC_ON_START = os.getenv("GH2MCP_SYNC_ON_START", "false").lower() in {"1", "true", "yes"} SYNC_INTERVAL = int(os.getenv("GH2MCP_SYNC_INTERVAL", "0")) +_MUTATION_ENV = "GH2MCP_ALLOW_MUTATION" app = FastAPI(title="gh2mcp", version="0.1.0") service = GitHubTokenSyncService(ENV_FILE) @@ -19,7 +20,6 @@ class SyncTokenRequest(BaseModel): force_gh_cli: bool = False - include_token: bool = False class SetOrgRequest(BaseModel): @@ -44,8 +44,28 @@ class RecentReposRequest(BaseModel): _sync_task: asyncio.Task | None = None +def _mutation_enabled() -> bool: + return os.getenv(_MUTATION_ENV, "").strip().lower() in {"1", "true", "yes", "on"} + + +def _require_mutation(action: str) -> None: + if not _mutation_enabled(): + raise PermissionError( + f"gh2mcp mutation '{action}' is disabled; start the service with {_MUTATION_ENV}=1" + ) + + +@app.exception_handler(PermissionError) +async def permission_error_handler( + _request: Request, + exc: PermissionError, +) -> JSONResponse: + return JSONResponse(status_code=403, content={"detail": str(exc)}) + + async def _periodic_sync() -> None: while True: + _require_mutation("periodic_sync") service.sync_token(force_gh_cli=False) await asyncio.sleep(SYNC_INTERVAL) @@ -53,10 +73,10 @@ async def _periodic_sync() -> None: @app.on_event("startup") async def on_startup() -> None: global _sync_task - if SYNC_ON_START: + if SYNC_ON_START and _mutation_enabled(): service.sync_token(force_gh_cli=False) - if SYNC_INTERVAL > 0: + if SYNC_INTERVAL > 0 and _mutation_enabled(): _sync_task = asyncio.create_task(_periodic_sync()) @@ -74,20 +94,22 @@ def health() -> dict: @app.get("/status") -def status(include_token: bool = Query(False)) -> dict: - return service.get_status(include_token=include_token) +def status() -> dict: + return service.get_status(include_token=False) @app.post("/sync/token") def sync_token(payload: SyncTokenRequest) -> dict: + _require_mutation("sync_token") return service.sync_token( force_gh_cli=payload.force_gh_cli, - include_token=payload.include_token, + include_token=False, ) @app.post("/org/set") def set_org(payload: SetOrgRequest) -> dict: + _require_mutation("set_org") return service.set_org(org=payload.org) diff --git a/gh2mcp/tests/test_gh2mcp.py b/gh2mcp/tests/test_gh2mcp.py index 549d61b..1cf4316 100644 --- a/gh2mcp/tests/test_gh2mcp.py +++ b/gh2mcp/tests/test_gh2mcp.py @@ -1,15 +1,31 @@ from __future__ import annotations +import os from pathlib import Path +import stat import sys +import pytest + ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(Path(__file__).resolve().parents[1])) sys.modules.pop("env2mcp", None) sys.path.insert(0, str(ROOT / "env2mcp")) -import gh2mcp.sync as sync_module -from gh2mcp.sync import GitHubTokenSyncService +import gh2mcp.sync as sync_module # noqa: E402 +from gh2mcp.sync import GitHubTokenSyncService # noqa: E402 + + +@pytest.fixture(autouse=True) +def restore_github_environment(): + keys = ("GITHUB_PAT", "GITHUB_TOKEN", "GITHUB_USER", "GITHUB_ORG") + original = {key: os.environ.get(key) for key in keys} + yield + for key, value in original.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value class _GhUnavailable: @@ -84,7 +100,70 @@ def test_sync_token_saves_from_env_and_reads_back(monkeypatch, tmp_path: Path): assert status["token"] == "ghp_env_token_123456" assert status["token_hint"].startswith("ghp_env_") assert env_path.exists() - assert 'GITHUB_PAT="ghp_env_token_123456"' in env_path.read_text(encoding="utf-8") + assert stat.S_IMODE(env_path.stat().st_mode) == 0o600 + assert "GITHUB_PAT=ghp_env_token_123456" in env_path.read_text(encoding="utf-8") + + +def test_http_api_never_returns_raw_token(monkeypatch, tmp_path: Path): + import gh2mcp.server as server_module + from fastapi.testclient import TestClient + + monkeypatch.setenv("GITHUB_PAT", "ghp_http_secret_123456") + monkeypatch.setenv("GH2MCP_ALLOW_MUTATION", "1") + monkeypatch.setattr(sync_module, "GitHubCLI", _GhUnavailable) + monkeypatch.setattr( + server_module, + "service", + GitHubTokenSyncService(tmp_path / ".env"), + ) + client = TestClient(server_module.app) + + status = client.get("/status?include_token=true") + assert status.status_code == 200 + assert "token" not in status.json() + + synced = client.post( + "/sync/token", + json={"force_gh_cli": False, "include_token": True}, + ) + assert synced.status_code == 200 + assert synced.json()["success"] is True + assert "token" not in synced.json() + + +def test_http_mutations_require_operator_capability(monkeypatch, tmp_path: Path): + import gh2mcp.server as server_module + from fastapi.testclient import TestClient + + monkeypatch.delenv("GH2MCP_ALLOW_MUTATION", raising=False) + monkeypatch.setattr( + server_module, + "service", + GitHubTokenSyncService(tmp_path / ".env"), + ) + client = TestClient(server_module.app) + + synced = client.post("/sync/token", json={"force_gh_cli": False}) + assert synced.status_code == 403 + assert "GH2MCP_ALLOW_MUTATION" in synced.json()["detail"] + + org = client.post("/org/set", json={"org": "semcod"}) + assert org.status_code == 403 + assert not (tmp_path / ".env").exists() + + +def test_env_config_rejects_secret_symlink(tmp_path: Path): + target = tmp_path / "target.env" + target.write_text("SAFE=1\n", encoding="utf-8") + env_path = tmp_path / ".env" + env_path.symlink_to(target) + + cfg = sync_module.EnvConfig(env_path) + cfg["GITHUB_PAT"] = "ghp_should_not_write" + + with pytest.raises(ValueError, match="symlink"): + cfg.save() + assert target.read_text(encoding="utf-8") == "SAFE=1\n" def test_sync_token_reads_from_env_file_when_env_missing(monkeypatch, tmp_path: Path): @@ -128,7 +207,7 @@ def test_set_org_defaults_to_gh_username(monkeypatch, tmp_path: Path): result = service.set_org(org=None) assert result["success"] is True assert result["org"] == "alice" - assert 'GITHUB_ORG="alice"' in env_path.read_text(encoding="utf-8") + assert "GITHUB_ORG=alice" in env_path.read_text(encoding="utf-8") def test_list_orgs_and_repos(monkeypatch, tmp_path: Path): diff --git a/git2mcp/git2mcp/proxy.py b/git2mcp/git2mcp/proxy.py index 2ccb8c1..d5c89b8 100644 --- a/git2mcp/git2mcp/proxy.py +++ b/git2mcp/git2mcp/proxy.py @@ -6,21 +6,64 @@ import subprocess import tarfile from pathlib import Path -from urllib.parse import urlparse, unquote +from urllib.parse import unquote, urlparse, urlunparse from git import Repo, Actor from git.remote import PushInfo class GitProxyManager: + _MAX_ARCHIVE_BYTES = 100 * 1024 * 1024 + _MAX_ARCHIVE_MEMBERS = 100_000 + _MAX_ARCHIVE_UNPACKED_BYTES = 500 * 1024 * 1024 + def __init__(self, base_dir: str = "/git-repos", cache_dir: str = "/git-cache"): - self.base_dir = Path(base_dir) - self.cache_dir = Path(cache_dir) + self.base_dir = Path(base_dir).expanduser().resolve() + self.cache_dir = Path(cache_dir).expanduser().resolve() self.base_dir.mkdir(parents=True, exist_ok=True) self.cache_dir.mkdir(parents=True, exist_ok=True) + @staticmethod + def _path_within(root: Path, relative: str, *, label: str, allow_root: bool = False) -> Path: + if not isinstance(relative, str) or not relative.strip(): + raise ValueError(f"Invalid {label}: {relative!r}") + rel_path = Path(relative) + if rel_path.is_absolute(): + raise ValueError(f"Invalid {label}: {relative!r}") + root = root.resolve() + candidate = (root / rel_path).resolve() + try: + candidate.relative_to(root) + except ValueError as exc: + raise ValueError(f"{label} escapes configured storage root: {relative!r}") from exc + if not allow_root and candidate == root: + raise ValueError(f"Invalid {label}: {relative!r}") + return candidate + + @staticmethod + def _validate_git_arg(value: str, *, label: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"Invalid {label}: {value!r}") + if value.startswith("-") or any(char in value for char in ("\x00", "\n", "\r")): + raise ValueError(f"Invalid {label}: {value!r}") + return value + + @staticmethod + def _validate_checkpoint_id(value: str) -> str: + if ( + not isinstance(value, str) + or not value + or len(value) > 128 + or any(char not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-" for char in value) + ): + raise ValueError(f"Invalid checkpoint id: {value!r}") + return value + def _repo_path(self, repo_id: str) -> Path: - return self.base_dir / repo_id + return self._path_within(self.base_dir, repo_id, label="repo_id") + + def _worktree_path(self, repo_path: Path, path: str) -> Path: + return self._path_within(repo_path, path, label="worktree path") def _ensure_parent(self, path: Path) -> None: path.parent.mkdir(parents=True, exist_ok=True) @@ -55,6 +98,18 @@ def _allow_local_repo_url(self, repo_url: str | None) -> None: text=True, ) + @staticmethod + def redact_url_credentials(repo_url: str | None) -> str | None: + if not repo_url or "://" not in repo_url: + return repo_url + parsed = urlparse(repo_url) + if parsed.username is None and parsed.password is None: + return repo_url + host = parsed.hostname or "" + if parsed.port: + host = f"{host}:{parsed.port}" + return urlunparse(parsed._replace(netloc=host)) + def list_repos(self) -> list[dict]: repos = [] for dot_git in self.base_dir.glob("**/.git"): @@ -79,6 +134,7 @@ def sync_repo( branch: str = "main", ) -> dict: repo_path = self._repo_path(repo_id) + branch = self._validate_git_arg(branch, label="branch") if source_path: source = Path(source_path) if not source.exists(): @@ -119,6 +175,9 @@ def sync_repo( self._allow_local_repo_url(repo_url) Repo.clone_from(repo_url, str(repo_path), branch=branch) repo = Repo(repo_path) + sanitized_url = self.redact_url_credentials(repo_url) + if sanitized_url and sanitized_url != repo_url: + repo.remote("origin").set_url(sanitized_url) else: raise ValueError("Either repo_url or source_path must be provided") @@ -130,6 +189,7 @@ def sync_repo( def export_package(self, repo_id: str, ref: str = "HEAD") -> dict: repo_path = self._repo_path(repo_id) + ref = self._validate_git_arg(ref, label="ref") if not repo_path.exists(): raise FileNotFoundError(f"Repo not found: {repo_id}") @@ -159,6 +219,7 @@ def export_package(self, repo_id: str, ref: str = "HEAD") -> dict: def export_fragments(self, repo_id: str, ref: str = "HEAD", max_fragment_bytes: int = 200_000) -> dict: repo_path = self._repo_path(repo_id) + ref = self._validate_git_arg(ref, label="ref") if not repo_path.exists(): raise FileNotFoundError(f"Repo not found: {repo_id}") @@ -218,6 +279,31 @@ def export_fragments(self, repo_id: str, ref: str = "HEAD", max_fragment_bytes: "fragments": fragments, } + def import_package(self, repo_id: str, archive_bytes: bytes) -> dict: + repo_path = self._repo_path(repo_id) + if len(archive_bytes) > self._MAX_ARCHIVE_BYTES: + raise ValueError("Archive exceeds compressed size limit") + + with tarfile.open(fileobj=io.BytesIO(archive_bytes), mode="r:gz") as tar: + members = tar.getmembers() + if len(members) > self._MAX_ARCHIVE_MEMBERS: + raise ValueError("Archive contains too many members") + if sum(member.size for member in members) > self._MAX_ARCHIVE_UNPACKED_BYTES: + raise ValueError("Archive exceeds unpacked size limit") + for member in members: + if member.issym() or member.islnk() or member.isdev() or member.isfifo(): + raise ValueError(f"Unsupported archive member: {member.name!r}") + self._path_within( + repo_path, + member.name, + label="archive member", + allow_root=True, + ) + repo_path.mkdir(parents=True, exist_ok=True) + tar.extractall(repo_path, members=members) + + return {"repo_id": repo_id, "imported_to": str(repo_path)} + def commit_changes( self, repo_id: str, @@ -231,21 +317,26 @@ def commit_changes( raise FileNotFoundError(f"Repo not found: {repo_id}") repo = Repo(repo_path) + prepared_changes: list[tuple[dict, Path, str]] = [] for change in changes: path = change["path"] + absolute = self._worktree_path(repo_path, path) + relative = str(absolute.relative_to(repo_path)) + prepared_changes.append((change, absolute, relative)) + + for change, absolute, relative in prepared_changes: content = change.get("content", "") mode = change.get("mode", "update") - absolute = repo_path / path self._ensure_parent(absolute) if mode == "delete": if absolute.exists(): absolute.unlink() - repo.index.remove([path], working_tree=True, ignore_unmatch=True) + repo.index.remove([relative], working_tree=True, ignore_unmatch=True) continue absolute.write_text(content, encoding="utf-8") - repo.index.add([path]) + repo.index.add([relative]) actor = Actor(author_name, author_email) commit = repo.index.commit(message, author=actor, committer=actor) @@ -257,12 +348,14 @@ def commit_changes( def push(self, repo_id: str, remote: str = "origin", branch: str | None = None) -> dict: repo_path = self._repo_path(repo_id) + remote = self._validate_git_arg(remote, label="remote") if not repo_path.exists(): raise FileNotFoundError(f"Repo not found: {repo_id}") repo = Repo(repo_path) if branch is None: branch = repo.active_branch.name + branch = self._validate_git_arg(branch, label="branch") remote_ref = repo.remote(remote) remote_urls = list(remote_ref.urls) @@ -293,18 +386,14 @@ def worktree_write(self, repo_id: str, path: str, content: str, encoding: str = repo_path = self._repo_path(repo_id) if not repo_path.exists(): raise FileNotFoundError(f"Repo not found: {repo_id}") - target = (repo_path / path).resolve() - if not str(target).startswith(str(repo_path.resolve())): - raise ValueError("Path traversal outside repo is not allowed") + target = self._worktree_path(repo_path, path) self._ensure_parent(target) target.write_text(content, encoding=encoding) return {"repo_id": repo_id, "path": path, "bytes": len(content.encode(encoding))} def worktree_read(self, repo_id: str, path: str, encoding: str = "utf-8") -> dict: repo_path = self._repo_path(repo_id) - target = (repo_path / path).resolve() - if not str(target).startswith(str(repo_path.resolve())): - raise ValueError("Path traversal outside repo is not allowed") + target = self._worktree_path(repo_path, path) if not target.exists(): raise FileNotFoundError(f"File not found: {path}") return {"repo_id": repo_id, "path": path, "content": target.read_text(encoding=encoding)} @@ -343,7 +432,11 @@ def stage(self, repo_id: str, paths: list[str] | None = None) -> dict: raise FileNotFoundError(f"Repo not found: {repo_id}") repo = Repo(repo_path) if paths: - repo.index.add(paths) + safe_paths = [ + str(self._worktree_path(repo_path, path).relative_to(repo_path)) + for path in paths + ] + repo.index.add(safe_paths) else: repo.git.add(A=True) return {"repo_id": repo_id, "staged": paths or "all"} @@ -376,7 +469,9 @@ def branch_draft(self, repo_id: str, name: str, base: str | None = None) -> dict raise FileNotFoundError(f"Repo not found: {repo_id}") repo = Repo(repo_path) full_name = name if name.startswith("draft/") else f"draft/{name}" + full_name = self._validate_git_arg(full_name, label="draft branch") if base: + base = self._validate_git_arg(base, label="base ref") repo.git.checkout("-B", full_name, base) else: repo.git.checkout("-B", full_name) @@ -386,10 +481,12 @@ def checkpoint_create(self, repo_id: str, label: str | None = None) -> dict: repo_path = self._repo_path(repo_id) if not repo_path.exists(): raise FileNotFoundError(f"Repo not found: {repo_id}") - ckpt_dir = self.cache_dir / "checkpoints" / repo_id + ckpt_root = self.cache_dir / "checkpoints" + ckpt_dir = self._path_within(ckpt_root, repo_id, label="checkpoint repo_id") ckpt_dir.mkdir(parents=True, exist_ok=True) from datetime import datetime, timezone ckpt_id = label or datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%f") + ckpt_id = self._validate_checkpoint_id(ckpt_id) archive = ckpt_dir / f"{ckpt_id}.tar" with tarfile.open(archive, "w") as tar: for entry in repo_path.iterdir(): @@ -402,7 +499,10 @@ def checkpoint_restore(self, repo_id: str, checkpoint_id: str) -> dict: repo_path = self._repo_path(repo_id) if not repo_path.exists(): raise FileNotFoundError(f"Repo not found: {repo_id}") - archive = self.cache_dir / "checkpoints" / repo_id / f"{checkpoint_id}.tar" + checkpoint_id = self._validate_checkpoint_id(checkpoint_id) + ckpt_root = self.cache_dir / "checkpoints" + ckpt_dir = self._path_within(ckpt_root, repo_id, label="checkpoint repo_id") + archive = ckpt_dir / f"{checkpoint_id}.tar" if not archive.exists(): raise FileNotFoundError(f"Checkpoint not found: {checkpoint_id}") for entry in repo_path.iterdir(): @@ -418,6 +518,7 @@ def checkpoint_restore(self, repo_id: str, checkpoint_id: str) -> dict: def reset(self, repo_id: str, ref: str = "HEAD~1", mode: str = "hard") -> dict: repo_path = self._repo_path(repo_id) + ref = self._validate_git_arg(ref, label="ref") if not repo_path.exists(): raise FileNotFoundError(f"Repo not found: {repo_id}") diff --git a/git2mcp/tests/test_git2mcp.py b/git2mcp/tests/test_git2mcp.py index 24d054d..c54f0d5 100644 --- a/git2mcp/tests/test_git2mcp.py +++ b/git2mcp/tests/test_git2mcp.py @@ -1,7 +1,10 @@ from __future__ import annotations +import base64 +import io import importlib.util import os +import tarfile from pathlib import Path from uuid import uuid4 @@ -16,6 +19,9 @@ def _load_proxy_app(repo_root: Path, cache_root: Path): os.environ["GIT_PROXY_REPO_ROOT"] = str(repo_root) os.environ["GIT_PROXY_CACHE_ROOT"] = str(cache_root) + os.environ["GIT_PROXY_ALLOW_MUTATION"] = "1" + os.environ["GIT_PROXY_ALLOW_EXECUTE"] = "1" + os.environ["GIT_PROXY_ALLOW_REMOTE_WRITE"] = "1" module_name = f"mcp_git_proxy_server_{uuid4().hex}" spec = importlib.util.spec_from_file_location(module_name, PROXY_SERVER_PATH) @@ -37,6 +43,58 @@ def _create_sample_repo_source(source: Path) -> None: (pkg / "util.py").write_text("VALUE = 42\n", encoding="utf-8") +def test_git_proxy_capabilities_are_disabled_by_default(tmp_path, monkeypatch): + app = _load_proxy_app(tmp_path / "git-repos", tmp_path / "git-cache") + client = TestClient(app) + monkeypatch.delenv("GIT_PROXY_ALLOW_MUTATION", raising=False) + monkeypatch.delenv("GIT_PROXY_ALLOW_EXECUTE", raising=False) + monkeypatch.delenv("GIT_PROXY_ALLOW_REMOTE_WRITE", raising=False) + + sync = client.post("/repos/sync", json={"repo_id": "team/repo", "source_path": "/tmp"}) + assert sync.status_code == 403 + assert "GIT_PROXY_ALLOW_MUTATION" in sync.json()["detail"] + + tests = client.post("/repos/team/repo/run-tests", json={"command": "true"}) + assert tests.status_code == 403 + assert "GIT_PROXY_ALLOW_EXECUTE" in tests.json()["detail"] + + push = client.post("/repos/team/repo/push", json={}) + assert push.status_code == 403 + assert "GIT_PROXY_ALLOW_REMOTE_WRITE" in push.json()["detail"] + + +def test_git_proxy_rejects_repo_and_archive_traversal(tmp_path): + repo_root = tmp_path / "git-repos" + app = _load_proxy_app(repo_root, tmp_path / "git-cache") + client = TestClient(app) + source_repo = tmp_path / "source" + _create_sample_repo_source(source_repo) + + escaped_repo = client.post( + "/repos/sync", + json={"repo_id": "../escaped", "source_path": str(source_repo)}, + ) + assert escaped_repo.status_code == 400 + assert not (tmp_path / "escaped").exists() + + payload = io.BytesIO() + with tarfile.open(fileobj=payload, mode="w:gz") as archive: + member = tarfile.TarInfo("../../archive-escape.txt") + content = b"unsafe" + member.size = len(content) + archive.addfile(member, io.BytesIO(content)) + + imported = client.post( + "/packages/import", + json={ + "repo_id": "team/imported", + "archive_b64": base64.b64encode(payload.getvalue()).decode("ascii"), + }, + ) + assert imported.status_code == 400 + assert not (tmp_path / "archive-escape.txt").exists() + + def test_git_proxy_e2e_sync_export_commit_and_tests(tmp_path): repo_root = tmp_path / "git-repos" cache_root = tmp_path / "git-cache" @@ -139,9 +197,25 @@ def test_git_proxy_local_operations(tmp_path): # path traversal must be blocked bad = client.post( f"/repos/{repo_id}/worktree/write", - json={"path": "../escape.txt", "content": "x"}, + json={"path": "../local-ops-evil/escape.txt", "content": "x"}, ) assert bad.status_code == 400 + assert not (repo_root / "team" / "local-ops-evil" / "escape.txt").exists() + + bad_commit = client.post( + f"/repos/{repo_id}/commit", + json={ + "message": "unsafe", + "changes": [{"path": "../local-ops-evil/commit.txt", "content": "x"}], + }, + ) + assert bad_commit.status_code == 400 + + bad_checkpoint = client.post( + f"/repos/{repo_id}/checkpoint", + json={"label": "../../checkpoint-escape"}, + ) + assert bad_checkpoint.status_code == 400 # checkpoint create -> modify -> restore ckpt = client.post(f"/repos/{repo_id}/checkpoint", json={"label": "before"}) diff --git a/mcp-git-proxy/server.py b/mcp-git-proxy/server.py index 50fa08e..e9ff6a2 100644 --- a/mcp-git-proxy/server.py +++ b/mcp-git-proxy/server.py @@ -3,19 +3,32 @@ import base64 import os import subprocess -import tarfile -from io import BytesIO -from pathlib import Path import urllib.request import urllib.error import json as _json -from fastapi import FastAPI, HTTPException +from fastapi import FastAPI, HTTPException, Request +from fastapi.responses import JSONResponse from pydantic import BaseModel, Field from git2mcp.proxy import GitProxyManager +_MUTATION_ENV = "GIT_PROXY_ALLOW_MUTATION" +_EXECUTE_ENV = "GIT_PROXY_ALLOW_EXECUTE" +_REMOTE_WRITE_ENV = "GIT_PROXY_ALLOW_REMOTE_WRITE" + + +def _env_enabled(name: str) -> bool: + return os.getenv(name, "").strip().lower() in {"1", "true", "yes", "on"} + + +def _require_capability(env_name: str, action: str) -> None: + if not _env_enabled(env_name): + raise PermissionError( + f"Git proxy action '{action}' is disabled; start the service with {env_name}=1" + ) + class SyncRepoRequest(BaseModel): repo_id: str @@ -49,6 +62,7 @@ class PushRequest(BaseModel): class RunTestsRequest(BaseModel): command: str = "python3 -m compileall -q ." + timeout_seconds: int = Field(default=600, ge=1, le=3600) class ResetRequest(BaseModel): @@ -123,6 +137,14 @@ class CreateGithubRepoRequest(BaseModel): ) +@app.exception_handler(PermissionError) +async def permission_error_handler( + _request: Request, + exc: PermissionError, +) -> JSONResponse: + return JSONResponse(status_code=403, content={"detail": str(exc)}) + + @app.get("/health") def health(): return {"status": "ok", "service": "mcp-git-proxy"} @@ -135,6 +157,7 @@ def list_repos(): @app.post("/repos/sync") def sync_repo(request: SyncRepoRequest): + _require_capability(_MUTATION_ENV, "sync_repo") try: return manager.sync_repo( repo_id=request.repo_id, @@ -143,7 +166,11 @@ def sync_repo(request: SyncRepoRequest): branch=request.branch, ) except Exception as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc + detail = str(exc) + if request.repo_url: + safe_url = manager.redact_url_credentials(request.repo_url) or "" + detail = detail.replace(request.repo_url, safe_url) + raise HTTPException(status_code=400, detail=detail) from exc @app.post("/packages/export-fragments") @@ -168,18 +195,17 @@ def export_package(request: ExportPackageRequest): @app.post("/packages/import") def import_package(request: ImportPackageRequest): - repo_path = Path(os.getenv("GIT_PROXY_REPO_ROOT", "/git-repos")) / request.repo_id - repo_path.mkdir(parents=True, exist_ok=True) - - archive = base64.b64decode(request.archive_b64) - with tarfile.open(fileobj=BytesIO(archive), mode="r:gz") as tar: - tar.extractall(repo_path) - - return {"repo_id": request.repo_id, "imported_to": str(repo_path)} + _require_capability(_MUTATION_ENV, "import_package") + try: + archive = base64.b64decode(request.archive_b64, validate=True) + return manager.import_package(request.repo_id, archive) + except Exception as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc @app.post("/repos/{repo_id:path}/commit") def commit(repo_id: str, request: CommitRequest): + _require_capability(_MUTATION_ENV, "commit") try: return manager.commit_changes( repo_id=repo_id, @@ -194,6 +220,7 @@ def commit(repo_id: str, request: CommitRequest): @app.post("/repos/{repo_id:path}/push") def push(repo_id: str, request: PushRequest): + _require_capability(_REMOTE_WRITE_ENV, "push") try: return manager.push(repo_id, remote=request.remote, branch=request.branch) except Exception as exc: @@ -202,6 +229,7 @@ def push(repo_id: str, request: PushRequest): @app.post("/repos/{repo_id:path}/reset") def reset(repo_id: str, request: ResetRequest): + _require_capability(_MUTATION_ENV, "reset") try: return manager.reset(repo_id=repo_id, ref=request.ref, mode=request.mode) except Exception as exc: @@ -210,6 +238,7 @@ def reset(repo_id: str, request: ResetRequest): @app.post("/repos/{repo_id:path}/worktree/write") def worktree_write(repo_id: str, request: WorktreeWriteRequest): + _require_capability(_MUTATION_ENV, "worktree_write") try: return manager.worktree_write(repo_id, request.path, request.content, request.encoding) except Exception as exc: @@ -236,6 +265,8 @@ def worktree_diff(repo_id: str, request: WorktreeDiffRequest): @app.post("/repos/{repo_id:path}/patch/apply") def patch_apply(repo_id: str, request: PatchApplyRequest): + if not request.check_only: + _require_capability(_MUTATION_ENV, "patch_apply") try: return manager.patch_apply(repo_id, request.patch, check_only=request.check_only) except Exception as exc: @@ -244,6 +275,7 @@ def patch_apply(repo_id: str, request: PatchApplyRequest): @app.post("/repos/{repo_id:path}/stage") def stage(repo_id: str, request: StageRequest): + _require_capability(_MUTATION_ENV, "stage") try: return manager.stage(repo_id, paths=request.paths) except Exception as exc: @@ -252,6 +284,7 @@ def stage(repo_id: str, request: StageRequest): @app.post("/repos/{repo_id:path}/stash/save") def stash_save(repo_id: str, request: StashSaveRequest): + _require_capability(_MUTATION_ENV, "stash_save") try: return manager.stash_save(repo_id, message=request.message) except Exception as exc: @@ -260,6 +293,7 @@ def stash_save(repo_id: str, request: StashSaveRequest): @app.post("/repos/{repo_id:path}/stash/pop") def stash_pop(repo_id: str): + _require_capability(_MUTATION_ENV, "stash_pop") try: return manager.stash_pop(repo_id) except Exception as exc: @@ -268,6 +302,7 @@ def stash_pop(repo_id: str): @app.post("/repos/{repo_id:path}/branch/draft") def branch_draft(repo_id: str, request: BranchDraftRequest): + _require_capability(_MUTATION_ENV, "branch_draft") try: return manager.branch_draft(repo_id, name=request.name, base=request.base) except Exception as exc: @@ -276,6 +311,7 @@ def branch_draft(repo_id: str, request: BranchDraftRequest): @app.post("/repos/{repo_id:path}/checkpoint") def checkpoint_create(repo_id: str, request: CheckpointCreateRequest): + _require_capability(_MUTATION_ENV, "checkpoint_create") try: return manager.checkpoint_create(repo_id, label=request.label) except Exception as exc: @@ -284,6 +320,7 @@ def checkpoint_create(repo_id: str, request: CheckpointCreateRequest): @app.post("/repos/{repo_id:path}/checkpoint/restore") def checkpoint_restore(repo_id: str, request: CheckpointRestoreRequest): + _require_capability(_MUTATION_ENV, "checkpoint_restore") try: return manager.checkpoint_restore(repo_id, checkpoint_id=request.checkpoint_id) except FileNotFoundError as exc: @@ -294,23 +331,38 @@ def checkpoint_restore(repo_id: str, request: CheckpointRestoreRequest): @app.post("/repos/{repo_id:path}/run-tests") def run_tests(repo_id: str, request: RunTestsRequest): - repo_path = Path(os.getenv("GIT_PROXY_REPO_ROOT", "/git-repos")) / repo_id + _require_capability(_EXECUTE_ENV, "run_tests") + try: + repo_path = manager._repo_path(repo_id) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc if not repo_path.exists(): raise HTTPException(status_code=404, detail=f"Repo not found: {repo_id}") - process = subprocess.run( - request.command, - shell=True, - cwd=repo_path, - capture_output=True, - text=True, - ) + try: + process = subprocess.run( + request.command, + shell=True, + cwd=repo_path, + capture_output=True, + text=True, + timeout=request.timeout_seconds, + ) + except subprocess.TimeoutExpired as exc: + return { + "repo_id": repo_id, + "command": request.command, + "returncode": None, + "stdout": (exc.stdout or "")[-65_536:] if isinstance(exc.stdout, str) else "", + "stderr": f"timeout after {request.timeout_seconds}s", + "ok": False, + } return { "repo_id": repo_id, "command": request.command, "returncode": process.returncode, - "stdout": process.stdout, - "stderr": process.stderr, + "stdout": process.stdout[-65_536:], + "stderr": process.stderr[-65_536:], "ok": process.returncode == 0, } @@ -318,6 +370,7 @@ def run_tests(repo_id: str, request: RunTestsRequest): @app.post("/github/create-repo") def github_create_repo(request: CreateGithubRepoRequest): """Create a new repository on GitHub via REST API, then optionally clone it locally.""" + _require_capability(_REMOTE_WRITE_ENV, "github_create_repo") token = request.github_token or os.getenv("GITHUB_PAT") or os.getenv("GITHUB_TOKEN") if not token: raise HTTPException(status_code=400, detail="No GitHub token available. Set GITHUB_PAT or pass github_token.") @@ -361,15 +414,28 @@ def github_create_repo(request: CreateGithubRepoRequest): if request.auto_clone: clone_url = repo_data.get("clone_url", "") + authenticated_clone_url = clone_url if clone_url.startswith("https://"): - clone_url = clone_url.replace("https://", f"https://{token}@") + authenticated_clone_url = clone_url.replace("https://", f"https://{token}@") repo_id = request.name try: - manager.sync_repo(repo_id=repo_id, repo_url=clone_url, branch=request.branch) + manager.sync_repo( + repo_id=repo_id, + repo_url=authenticated_clone_url, + branch=request.branch, + ) + repo = manager._repo_path(repo_id) + subprocess.run( + ["git", "-C", str(repo), "remote", "set-url", "origin", clone_url], + check=True, + capture_output=True, + text=True, + timeout=30, + ) result["cloned_locally"] = True result["repo_id"] = repo_id except Exception as exc: - result["clone_error"] = str(exc) + result["clone_error"] = str(exc).replace(token, "***") return result @@ -377,7 +443,12 @@ def github_create_repo(request: CreateGithubRepoRequest): @app.post("/repos/{repo_id:path}/sync-pull") def sync_pull(repo_id: str, request: SyncPullRequest): """Pull updates from remote for an existing repository.""" - repo_path = Path(os.getenv("GIT_PROXY_REPO_ROOT", "/git-repos")) / repo_id + _require_capability(_MUTATION_ENV, "sync_pull") + try: + repo_path = manager._repo_path(repo_id) + branch = manager._validate_git_arg(request.branch, label="branch") + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc if not repo_path.exists(): raise HTTPException(status_code=404, detail=f"Repo not found: {repo_id}") @@ -403,15 +474,20 @@ def sync_pull(repo_id: str, request: SyncPullRequest): # Checkout and pull the requested branch checkout_result = subprocess.run( - ["git", "checkout", request.branch], + ["git", "checkout", branch], cwd=repo_path, capture_output=True, text=True, timeout=30 ) + if checkout_result.returncode != 0: + raise HTTPException( + status_code=400, + detail=f"Checkout failed: {checkout_result.stderr}", + ) pull_result = subprocess.run( - ["git", "pull", "origin", request.branch], + ["git", "pull", "origin", branch], cwd=repo_path, capture_output=True, text=True, @@ -429,9 +505,9 @@ def sync_pull(repo_id: str, request: SyncPullRequest): return { "repo_id": repo_id, - "branch": request.branch, + "branch": branch, "commit": commit, - "message": f"Pulled latest changes from origin/{request.branch}", + "message": f"Pulled latest changes from origin/{branch}", "pull_output": pull_result.stdout, "pull_stderr": pull_result.stderr, "success": pull_result.returncode == 0 diff --git a/mcp-skills/server.py b/mcp-skills/server.py index ef45d39..4836158 100644 --- a/mcp-skills/server.py +++ b/mcp-skills/server.py @@ -14,28 +14,88 @@ import tarfile from io import BytesIO from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, Dict import httpx import uvicorn -from fastapi import FastAPI, HTTPException +from fastapi import FastAPI, HTTPException, Request +from fastapi.responses import JSONResponse from mcp.server import Server from mcp.server import NotificationOptions from mcp.server.models import InitializationOptions from mcp.types import ( - CallToolRequestParams, ListToolsResult, TextContent, Tool, - INVALID_PARAMS, - INTERNAL_ERROR, ) from pydantic import BaseModel, Field logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) +_SYNC_ENV = "MCP_SKILLS_ALLOW_SYNC" +_EXECUTE_ENV = "MCP_SKILLS_ALLOW_EXECUTE" + + +def _env_enabled(name: str) -> bool: + return os.getenv(name, "").strip().lower() in {"1", "true", "yes", "on"} + + +def _require_sync(action: str) -> None: + if not _env_enabled(_SYNC_ENV): + raise PermissionError( + f"Repository sync '{action}' is disabled; start the server with {_SYNC_ENV}=1" + ) + + +def _require_execute(action: str) -> None: + if not _env_enabled(_EXECUTE_ENV): + raise PermissionError( + f"Tool execution '{action}' is disabled; start the server with {_EXECUTE_ENV}=1" + ) + + +def _resolve_descendant(root: Path, relative: str, *, label: str) -> Path: + root = root.expanduser().resolve() + if not isinstance(relative, str) or not relative.strip(): + raise ValueError(f"Invalid {label}: {relative!r}") + rel_path = Path(relative) + if rel_path.is_absolute(): + raise ValueError(f"Invalid {label}: {relative!r}") + candidate = (root / rel_path).resolve() + try: + candidate.relative_to(root) + except ValueError as exc: + raise ValueError(f"{label} escapes configured repository root: {relative!r}") from exc + return candidate + + +def _resolve_repo_path( + configured_base: Path, + repo_id: str, + requested_base: str | None = None, +) -> Path: + if not isinstance(repo_id, str) or not repo_id.strip(): + raise ValueError("repo_id is required") + configured_root = configured_base.expanduser().resolve() + base = Path(requested_base).expanduser().resolve() if requested_base else configured_root + try: + base.relative_to(configured_root) + except ValueError as exc: + raise ValueError("base_path must be inside the configured SKILLS_REPO_BASE") from exc + return _resolve_descendant(base, repo_id, label="repo_id") + + +def _extract_archive_safely(archive_bytes: bytes, target_repo: Path) -> None: + with tarfile.open(fileobj=BytesIO(archive_bytes), mode="r:gz") as tar: + members = tar.getmembers() + for member in members: + if member.issym() or member.islnk() or member.isdev() or member.isfifo(): + raise ValueError(f"Unsupported archive member: {member.name!r}") + _resolve_descendant(target_repo, member.name, label="archive member") + tar.extractall(target_repo, members=members) + class MCPSkillsServer: """Serwer MCP Skills z narzędziami do analizy kodu""" @@ -49,7 +109,8 @@ def __init__(self, repo_base: str = "/repos"): self._setup_handlers() async def _sync_from_git_proxy(self, repo_id: str, ref: str = "HEAD") -> Dict[str, Any]: - target_repo = self.repo_base / repo_id + _require_sync("sync_repo_from_git_proxy") + target_repo = _resolve_repo_path(self.repo_base, repo_id) target_repo.mkdir(parents=True, exist_ok=True) existing_files = { @@ -86,7 +147,11 @@ async def _sync_from_git_proxy(self, repo_id: str, ref: str = "HEAD") -> Dict[st if not rel_path or content_b64 is None: continue incoming_paths.add(rel_path) - file_path = target_repo / rel_path + file_path = _resolve_descendant( + target_repo, + rel_path, + label="fragment path", + ) file_path.parent.mkdir(parents=True, exist_ok=True) incoming_bytes = base64.b64decode(content_b64) @@ -127,8 +192,7 @@ async def _sync_from_git_proxy(self, repo_id: str, ref: str = "HEAD") -> Dict[st raise ValueError("Missing archive_b64 in git proxy response") archive_bytes = base64.b64decode(archive_b64) - with tarfile.open(fileobj=BytesIO(archive_bytes), mode="r:gz") as tar: - tar.extractall(target_repo) + _extract_archive_safely(archive_bytes, target_repo) return { "repo_id": repo_id, @@ -300,11 +364,11 @@ async def _analyze_code_structure(self, arguments: dict) -> list: if not repo_id or not paths: raise ValueError("repo_id and paths are required") - repo_path = Path(base_path) / repo_id + repo_path = _resolve_repo_path(self.repo_base, repo_id, base_path) results = [] for rel_path in paths: - full_path = repo_path / rel_path + full_path = _resolve_descendant(repo_path, rel_path, label="analysis path") if not full_path.exists(): results.append({ "path": rel_path, @@ -364,7 +428,7 @@ async def _compute_metrics_for_repo(self, arguments: dict) -> list: if not repo_id: raise ValueError("repo_id is required") - repo_path = Path(base_path) / repo_id + repo_path = _resolve_repo_path(self.repo_base, repo_id, base_path) if not repo_path.exists(): return [TextContent(type="text", text=json.dumps({ @@ -380,8 +444,17 @@ async def _compute_metrics_for_repo(self, arguments: dict) -> list: file_metrics = [] for ext in extensions: - for file_path in repo_path.rglob(f"*{ext}"): - if ".git" in str(file_path): + for discovered_path in repo_path.rglob(f"*{ext}"): + if ".git" in str(discovered_path): + continue + try: + rel_path = discovered_path.relative_to(repo_path) + file_path = _resolve_descendant( + repo_path, + str(rel_path), + label="metrics path", + ) + except ValueError: continue total_files += 1 @@ -407,7 +480,6 @@ async def _compute_metrics_for_repo(self, arguments: dict) -> list: total_classes += classes # Relatywna ścieżka - rel_path = file_path.relative_to(repo_path) file_metrics.append({ "path": str(rel_path), "lines": line_count, @@ -439,9 +511,8 @@ async def _detect_code_patterns(self, arguments: dict) -> list: """Wykrywanie wzorców kodu i antywzorców""" repo_id = arguments.get("repo_id") base_path = arguments.get("base_path", str(self.repo_base)) - pattern_types = arguments.get("pattern_types", ["complexity", "imports"]) - repo_path = Path(base_path) / repo_id + repo_path = _resolve_repo_path(self.repo_base, repo_id, base_path) if not repo_path.exists(): return [TextContent(type="text", text=json.dumps({ @@ -458,8 +529,17 @@ async def _detect_code_patterns(self, arguments: dict) -> list: all_imports = {} - for file_path in repo_path.rglob("*.py"): - if ".git" in str(file_path): + for discovered_path in repo_path.rglob("*.py"): + if ".git" in str(discovered_path): + continue + try: + rel_path = discovered_path.relative_to(repo_path) + file_path = _resolve_descendant( + repo_path, + str(rel_path), + label="pattern path", + ) + except ValueError: continue try: @@ -533,11 +613,10 @@ async def _sync_repo_tool(self, arguments: dict) -> list: async def _recommend_refactoring(self, arguments: dict) -> list: """Generowanie rekomendacji refaktoryzacji""" repo_id = arguments.get("repo_id") - target_paths = arguments.get("target_paths", []) goal = arguments.get("goal", "maintainability") base_path = arguments.get("base_path", str(self.repo_base)) - repo_path = Path(base_path) / repo_id + repo_path = _resolve_repo_path(self.repo_base, repo_id, base_path) if not repo_path.exists(): return [TextContent(type="text", text=json.dumps({ @@ -1040,12 +1119,13 @@ async def _run_tool_against_repo(request: ToolRunRequest) -> dict[str, Any]: if not repo_id: raise HTTPException(status_code=400, detail="repo_id or repo_url is required") - base = Path(request.base_path or str(skills_server.repo_base)) - repo_path = base / repo_id + _require_execute("tools_run") + repo_path = _resolve_repo_path(skills_server.repo_base, repo_id, request.base_path) # 1. Materialize the repo locally. sync_info: dict[str, Any] = {"strategy": None, "ok": False} if request.repo_url: + _require_sync("tools_run_clone") sync_info = _git_clone_or_update(request.repo_url, repo_path, request.ref) sync_info["strategy"] = "git_clone" elif request.use_git_proxy: @@ -1286,6 +1366,14 @@ def _run_redsl_refactor(project_path: Path, max_actions: int, dry_run: bool) -> app = FastAPI(title="mcp-skills", version="0.1.0") +@app.exception_handler(PermissionError) +async def permission_error_handler( + _request: Request, + exc: PermissionError, +) -> JSONResponse: + return JSONResponse(status_code=403, content={"detail": str(exc)}) + + @app.get("/health") async def health() -> dict[str, Any]: return { @@ -1357,12 +1445,18 @@ async def redsl_refactor(request: RedslRefactorRequest) -> Any: 2. Uruchom `redsl refactor --dry-run` (lub z --execute jeśli execute=True) 3. Sparsuj i zwróć wynik z metrykami, decyzjami i rekomendacjami """ - base = Path(request.base_path or str(skills_server.repo_base)) - repo_path = base / request.repo_id + _require_execute("redsl_refactor") + repo_path = _resolve_repo_path( + skills_server.repo_base, + request.repo_id, + request.base_path, + ) # 1. Synchronizacja z git-proxy try: sync_result = await skills_server._sync_from_git_proxy(request.repo_id) + except PermissionError: + raise except Exception as exc: raise HTTPException(status_code=502, detail=f"git-proxy sync failed: {exc}") from exc diff --git a/mcp-skills/test_tools_run.py b/mcp-skills/test_tools_run.py index 78a31e1..07a821c 100644 --- a/mcp-skills/test_tools_run.py +++ b/mcp-skills/test_tools_run.py @@ -37,6 +37,12 @@ def server_module(tmp_path_factory): return server +@pytest.fixture(autouse=True) +def allow_test_capabilities(monkeypatch): + monkeypatch.setenv("MCP_SKILLS_ALLOW_SYNC", "1") + monkeypatch.setenv("MCP_SKILLS_ALLOW_EXECUTE", "1") + + def test_derive_repo_id_from_url(server_module): derive = server_module._derive_repo_id_from_url assert derive("https://github.com/owner/repo") == "owner/repo" @@ -63,6 +69,42 @@ def test_collect_output_files_reads_small_text(server_module, tmp_path): assert result[0]["binary"] is False +def test_operator_capabilities_are_disabled_by_default(server_module, monkeypatch): + monkeypatch.delenv("MCP_SKILLS_ALLOW_SYNC", raising=False) + monkeypatch.delenv("MCP_SKILLS_ALLOW_EXECUTE", raising=False) + + with pytest.raises(PermissionError, match="MCP_SKILLS_ALLOW_SYNC"): + server_module._require_sync("sync") + with pytest.raises(PermissionError, match="MCP_SKILLS_ALLOW_EXECUTE"): + server_module._require_execute("run") + + +def test_repo_paths_are_confined_to_configured_base(server_module, tmp_path): + base = server_module.skills_server.repo_base + assert server_module._resolve_repo_path(base, "owner/repo") == (base / "owner/repo").resolve() + + with pytest.raises(ValueError, match="escapes"): + server_module._resolve_repo_path(base, "../../outside") + with pytest.raises(ValueError, match="base_path"): + server_module._resolve_repo_path(base, "owner/repo", str(tmp_path)) + + +def test_archive_rejects_path_traversal(server_module, tmp_path): + import io + import tarfile + + payload = io.BytesIO() + with tarfile.open(fileobj=payload, mode="w:gz") as archive: + member = tarfile.TarInfo("../escaped.txt") + content = b"unsafe" + member.size = len(content) + archive.addfile(member, io.BytesIO(content)) + + with pytest.raises(ValueError, match="escapes"): + server_module._extract_archive_safely(payload.getvalue(), tmp_path / "repo") + assert not (tmp_path / "escaped.txt").exists() + + @pytest.mark.asyncio async def test_run_tool_against_repo_unsupported(server_module): from fastapi import HTTPException @@ -77,7 +119,7 @@ async def test_run_tool_against_repo_unsupported(server_module): @pytest.mark.asyncio async def test_run_tool_against_repo_happy_path(server_module, tmp_path, monkeypatch): """Simulate: repo already materialized + tool already installed + stubbed run.""" - base = tmp_path / "base" + base = server_module.skills_server.repo_base / "happy" base.mkdir() repo_path = base / "owner/repo" repo_path.mkdir(parents=True) @@ -121,7 +163,7 @@ def __init__(self): @pytest.mark.asyncio async def test_run_tool_against_repo_install_fails(server_module, tmp_path, monkeypatch): - base = tmp_path / "base" + base = server_module.skills_server.repo_base / "install-fails" base.mkdir() repo_path = base / "owner/repo" repo_path.mkdir(parents=True) diff --git a/mcp-webui/server.py b/mcp-webui/server.py index 8a5a779..5b1affa 100644 --- a/mcp-webui/server.py +++ b/mcp-webui/server.py @@ -13,10 +13,9 @@ import os from pathlib import Path -from urllib.parse import urlparse import httpx -from fastapi import FastAPI, Form, Request +from fastapi import FastAPI, Form, HTTPException, Request from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from fastapi.templating import Jinja2Templates @@ -34,6 +33,9 @@ GIT_PROXY_URL = os.getenv("GIT_PROXY_URL", "http://mcp-git-proxy:8080") WEBUI_API_KEY = os.getenv("WEBUI_API_KEY", "sk-mcp-default-dev-key") GH2MCP_URL = os.getenv("GH2MCP_URL", "http://gh2mcp-agent:8079") +_SECRET_WRITE_ENV = "MCP_WEBUI_ALLOW_SECRET_WRITE" +_MUTATION_ENV = "MCP_WEBUI_ALLOW_MUTATION" +_EXECUTE_ENV = "MCP_WEBUI_ALLOW_EXECUTE" TEMPLATES_DIR = Path(__file__).parent / "templates" templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) @@ -41,6 +43,15 @@ app = FastAPI(title="mcp-webui", version="0.1.0") +def _require_capability(env_name: str, action: str) -> None: + enabled = os.getenv(env_name, "").strip().lower() in {"1", "true", "yes", "on"} + if not enabled: + raise HTTPException( + status_code=403, + detail=f"WebUI action '{action}' is disabled; set {env_name}=1", + ) + + def gateway_headers() -> dict[str, str]: return {"Authorization": f"Bearer {WEBUI_API_KEY}", "Content-Type": "application/json"} @@ -66,13 +77,14 @@ async def repos_page(request: Request): async with httpx.AsyncClient(timeout=10.0) as client: try: repos = (await client.get(f"{GIT_PROXY_URL}/repos")).json() - except Exception as exc: + except Exception: repos = [] return templates.TemplateResponse("repos.html", {"request": request, "repos": repos}) @app.post("/repos/sync") async def repos_sync(repo_id: str = Form(...), source_path: str = Form(...), branch: str = Form("main")): + _require_capability(_MUTATION_ENV, "repos_sync") async with httpx.AsyncClient(timeout=120.0) as client: await client.post( f"{GIT_PROXY_URL}/repos/sync", @@ -112,6 +124,7 @@ async def skills_run( repo_id: str = Form(""), source_path: str = Form(""), ): + _require_capability(_EXECUTE_ENV, "skills_run") payload = { "model": model, "messages": [{"role": "user", "content": prompt}], @@ -256,6 +269,7 @@ async def github_page(request: Request): @app.post("/github/configure") async def github_configure(request: Request, token: str = Form(""), action: str = Form("")): """Configure or clear GitHub token.""" + _require_capability(_SECRET_WRITE_ENV, "github_configure") env_path = Path(__file__).parent.parent / ".env" if action == "clear": @@ -298,15 +312,16 @@ async def github_configure(request: Request, token: str = Form(""), action: str @app.post("/github/fetch-token-from-cli") async def github_fetch_token_from_cli(request: Request): """Read GitHub token from gh CLI (via env2mcp) and save to .env.""" + _require_capability(_SECRET_WRITE_ENV, "github_fetch_token_from_cli") env_path = Path(__file__).parent.parent / ".env" - result = {"success": False, "error": None, "user": None, "token_hint": None, "token": None} + result = {"success": False, "error": None, "user": None, "token_hint": None} if GH2MCP_URL: try: async with httpx.AsyncClient(timeout=10.0) as client: response = await client.post( f"{GH2MCP_URL}/sync/token", - json={"force_gh_cli": True, "include_token": True}, + json={"force_gh_cli": True}, ) if response.status_code == 200: data = response.json() @@ -314,7 +329,6 @@ async def github_fetch_token_from_cli(request: Request): result["success"] = True result["user"] = data.get("user") result["token_hint"] = data.get("token_hint") - result["token"] = data.get("token") else: result["error"] = data.get("error") or "gh2mcp sync failed" else: @@ -344,7 +358,7 @@ async def github_fetch_token_from_cli(request: Request): "sync_result": None, "create_result": None, "cli_fetch_result": result, - "prefill_token": result.get("token") or "", + "prefill_token": "", } ) @@ -373,7 +387,6 @@ async def github_fetch_token_from_cli(request: Request): result["success"] = True result["user"] = user result["token_hint"] = token[:8] + "..." - result["token"] = token except Exception as exc: result["error"] = str(exc) @@ -398,7 +411,7 @@ async def github_fetch_token_from_cli(request: Request): "sync_result": None, "create_result": None, "cli_fetch_result": result, - "prefill_token": result.get("token") or "", + "prefill_token": "", } ) @@ -452,6 +465,7 @@ async def github_clone( branch: str = Form("main") ): """Clone a repository from GitHub.""" + _require_capability(_MUTATION_ENV, "github_clone") clone_url = _normalize_github_url(repo_url) # Add token to URL if available @@ -517,6 +531,7 @@ async def github_create_repo( auto_clone: bool = Form(True), ): """Create a new repository on GitHub.""" + _require_capability(_MUTATION_ENV, "github_create_repo") token = _resolve_github_token() result = {"success": False, "error": None, "html_url": None, "repo_id": None} @@ -577,6 +592,7 @@ async def github_sync( branch: str = Form("main") ): """Sync/pull updates for an existing repository.""" + _require_capability(_MUTATION_ENV, "github_sync") result = {"success": False, "error": None, "message": ""} try: diff --git a/mcp-webui/test_security.py b/mcp-webui/test_security.py new file mode 100644 index 0000000..57aba6e --- /dev/null +++ b/mcp-webui/test_security.py @@ -0,0 +1,47 @@ +"""Security defaults for the local MCP WebUI.""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path +from uuid import uuid4 + +from fastapi.testclient import TestClient + + +def _load_webui(): + server_path = Path(__file__).with_name("server.py") + module_name = f"mcp_webui_server_{uuid4().hex}" + spec = importlib.util.spec_from_file_location(module_name, server_path) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + return module + + +def test_mutating_webui_actions_are_disabled_by_default(monkeypatch): + monkeypatch.delenv("MCP_WEBUI_ALLOW_SECRET_WRITE", raising=False) + monkeypatch.delenv("MCP_WEBUI_ALLOW_MUTATION", raising=False) + monkeypatch.delenv("MCP_WEBUI_ALLOW_EXECUTE", raising=False) + module = _load_webui() + client = TestClient(module.app) + + secret = client.post("/github/configure", data={"token": "secret"}) + assert secret.status_code == 403 + assert "MCP_WEBUI_ALLOW_SECRET_WRITE" in secret.json()["detail"] + + sync = client.post( + "/repos/sync", + data={"repo_id": "team/repo", "source_path": "/tmp/repo", "branch": "main"}, + ) + assert sync.status_code == 403 + assert "MCP_WEBUI_ALLOW_MUTATION" in sync.json()["detail"] + + execute = client.post( + "/skills/run", + data={"model": "mcp-skills/refactor", "prompt": "run"}, + ) + assert execute.status_code == 403 + assert "MCP_WEBUI_ALLOW_EXECUTE" in execute.json()["detail"]