initial commit after one day coding agent session

This commit is contained in:
Hans Aschauer 2026-02-07 07:45:57 +01:00
commit 372af75b90
88 changed files with 22694 additions and 0 deletions

View file

@ -0,0 +1,17 @@
"""Custom environment builder components."""
from .package_validator import PackageValidator, SecurityError, ApprovalRequiredError
from .uv_installer import UVInstaller
from .image_builder import ImageBuilder, BuildResult
from .environment_builder import EnvironmentBuilder, BuildRateLimiter
__all__ = [
"PackageValidator",
"SecurityError",
"ApprovalRequiredError",
"UVInstaller",
"ImageBuilder",
"BuildResult",
"EnvironmentBuilder",
"BuildRateLimiter",
]

View file

@ -0,0 +1,315 @@
"""Environment builder orchestration with security validation."""
import re
import shutil
import threading
from typing import List, Optional, Dict, Set
from pathlib import Path
from datetime import datetime, timedelta
from mcp_forge.config.schema import EnvironmentBuilderConfig
from mcp_forge.podman.client import PodmanClient
from mcp_forge.security.audit import AuditLogger, AuditEventType, AuditSeverity
from mcp_forge.builder.package_validator import PackageValidator
from mcp_forge.builder.uv_installer import UVInstaller
from mcp_forge.builder.image_builder import ImageBuilder, BuildResult
class BuildRateLimiter:
"""Rate limiter for build requests."""
def __init__(self, max_requests: int, period_seconds: int):
"""
Initialize rate limiter.
Args:
max_requests: Maximum requests per period
period_seconds: Period length in seconds
"""
self.max_requests = max_requests
self.period_seconds = period_seconds
self.requests: Dict[str, List[datetime]] = {}
self.lock = threading.Lock()
def check_rate_limit(self, user_id: str) -> None:
"""
Check if user is within rate limit.
Args:
user_id: User identifier
Raises:
RuntimeError: If rate limit exceeded
"""
with self.lock:
now = datetime.now()
# Initialize user's request list if needed
if user_id not in self.requests:
self.requests[user_id] = []
# Clean up old requests
self._cleanup_old_requests(user_id, now)
# Check if at limit
if len(self.requests[user_id]) >= self.max_requests:
raise RuntimeError(
f"Rate limit exceeded: {self.max_requests} requests "
f"per {self.period_seconds} seconds"
)
# Record this request
self.requests[user_id].append(now)
def _cleanup_old_requests(self, user_id: str, now: datetime) -> None:
"""
Remove requests older than period.
Args:
user_id: User identifier
now: Current time
"""
cutoff = now - timedelta(seconds=self.period_seconds)
self.requests[user_id] = [
req_time for req_time in self.requests[user_id]
if req_time > cutoff
]
class EnvironmentBuilder:
"""Builds custom Python environments with security validation."""
def __init__(
self,
config: EnvironmentBuilderConfig,
podman_client: PodmanClient,
audit_logger: AuditLogger
):
"""
Initialize environment builder.
Args:
config: Environment builder configuration
podman_client: Podman API client
audit_logger: Audit logging instance
"""
self.config = config
self.podman = podman_client
self.audit_logger = audit_logger
# Initialize sub-components
self.package_validator = PackageValidator(
config.package_validation
)
self.uv_installer = UVInstaller(config.uv_cache_path)
self.image_builder = ImageBuilder(
podman_client, config, audit_logger
)
# Rate limiting and concurrency control
self.rate_limiter = BuildRateLimiter(
max_requests=config.build_rate_limit['requests'],
period_seconds=config.build_rate_limit['period']
)
self.active_builds: Set[str] = set()
self.active_builds_lock = threading.Lock()
def build_custom_environment(
self,
name: str,
packages: List[str],
base_image: str = "python:3.11-slim",
python_version: str = "3.11",
description: str = "",
user_id: str = "default"
) -> BuildResult:
"""
Build custom environment with packages.
Process:
1. Check rate limit
2. Check concurrent builds limit
3. Validate environment name
4. Validate package count
5. Validate package names (allowlist/blocklist)
6. Generate build context with UV
7. Build image
8. Cleanup build context
Args:
name: Environment name (alphanumeric + hyphens)
packages: List of package specifications
base_image: Base image to build from
python_version: Python version
description: Optional description
user_id: User ID for rate limiting
Returns:
BuildResult
Raises:
ValueError: If validation fails
SecurityError: If security check fails
RuntimeError: If rate limit or concurrency exceeded
"""
# Check rate limit
self.rate_limiter.check_rate_limit(user_id)
# Check concurrent builds
self._check_concurrent_builds()
# Validate environment name
if not re.match(r'^[a-zA-Z0-9-]+$', name):
raise ValueError(
f"Environment name '{name}' must contain only alphanumeric characters and hyphens"
)
# Validate package count
if len(packages) > self.config.max_packages:
raise ValueError(
f"Package count {len(packages)} exceeds maximum {self.config.max_packages}"
)
# Log build start
self.audit_logger.log(
event_type=AuditEventType.BUILD_REQUEST,
severity=AuditSeverity.INFO,
message=f"Building environment: {name}",
details={
"packages": packages,
"base_image": base_image,
"user_id": user_id
}
)
build_context = None
try:
# Register build as active
self._register_build_start(name)
# Validate packages
self.package_validator.validate_packages(packages)
# Generate build context
build_context = self.uv_installer.create_build_context(
packages=packages,
base_image=base_image,
python_version=python_version
)
# Build image
result = self.image_builder.build_image(
name=name,
build_context=build_context,
base_image=base_image,
packages=packages
)
# Log completion
self.audit_logger.log(
event_type=AuditEventType.BUILD_COMPLETE,
severity=AuditSeverity.INFO,
message=f"Environment built: {name}",
details={
"image_id": result.image_id,
"build_time": result.build_time,
"user_id": user_id
}
)
return result
finally:
# Always cleanup
self._register_build_complete(name)
if build_context and build_context.exists():
shutil.rmtree(build_context, ignore_errors=True)
def build_from_template(
self,
template_name: str,
additional_packages: Optional[List[str]] = None,
name: Optional[str] = None,
user_id: str = "default"
) -> BuildResult:
"""
Build environment from template.
Expands template packages and adds additional packages.
Args:
template_name: Name of template to use
additional_packages: Optional additional packages
name: Optional custom name (uses template name if not provided)
user_id: User ID for rate limiting
Returns:
BuildResult
Raises:
ValueError: If template not found
"""
# Validate template exists
if template_name not in self.config.templates:
raise ValueError(f"Template '{template_name}' not found")
template = self.config.templates[template_name]
# Combine template and additional packages
packages = template["packages"].copy()
if additional_packages:
packages.extend(additional_packages)
# Use template name if no custom name provided
if name is None:
name = template_name
# Build with combined package list
return self.build_custom_environment(
name=name,
packages=packages,
description=template.get("description", ""),
user_id=user_id
)
def list_templates(self) -> Dict[str, dict]:
"""
List available templates.
Returns:
Dictionary of template name to template metadata
"""
return self.config.templates
def _check_concurrent_builds(self) -> None:
"""
Check concurrent builds limit.
Raises:
RuntimeError: If at max concurrent builds
"""
with self.active_builds_lock:
if len(self.active_builds) >= self.config.max_concurrent_builds:
raise RuntimeError(
f"Maximum concurrent builds ({self.config.max_concurrent_builds}) reached"
)
def _register_build_start(self, name: str) -> None:
"""
Register build as started.
Args:
name: Environment name
"""
with self.active_builds_lock:
self.active_builds.add(name)
def _register_build_complete(self, name: str) -> None:
"""
Register build as completed.
Args:
name: Environment name
"""
with self.active_builds_lock:
self.active_builds.discard(name)

View file

@ -0,0 +1,315 @@
"""Container image builder with security validation."""
import json
import time
import hashlib
import re
from typing import List, Optional
from pathlib import Path
from dataclasses import dataclass
from mcp_forge.podman.client import PodmanClient
from mcp_forge.config.schema import EnvironmentBuilderConfig
from mcp_forge.security.audit import AuditLogger, AuditEventType, AuditSeverity
from mcp_forge.security.resource_limits import parse_memory_string
@dataclass
class BuildResult:
"""Result of image build."""
success: bool
image_name: str
image_id: str
build_time: float
size_bytes: int
cache_hit: bool
installed_packages: List[str]
error: Optional[str] = None
def to_dict(self) -> dict:
"""Convert to dictionary for JSON serialization."""
return {
"success": self.success,
"image_name": self.image_name,
"image_id": self.image_id,
"build_time": self.build_time,
"size_bytes": self.size_bytes,
"cache_hit": self.cache_hit,
"installed_packages": self.installed_packages,
"error": self.error
}
class ImageBuilder:
"""Builds container images with security validation."""
def __init__(
self,
podman_client: PodmanClient,
config: EnvironmentBuilderConfig,
audit_logger: AuditLogger
):
"""
Initialize image builder.
Args:
podman_client: Podman API client
config: Environment builder configuration
audit_logger: Audit logging instance
"""
self.podman = podman_client
self.config = config
self.audit_logger = audit_logger
def build_image(
self,
name: str,
build_context: Path,
base_image: str,
packages: List[str],
timeout: Optional[int] = None
) -> BuildResult:
"""
Build container image from build context.
Process:
1. Validate build context
2. Generate image tag
3. Build image with Podman
4. Validate image size
5. Extract installed packages
6. Cleanup build artifacts
Args:
name: Environment name (user-provided)
build_context: Path to build context directory
base_image: Base image to build from
packages: List of packages being installed
timeout: Build timeout (uses config default if None)
Returns:
BuildResult
Raises:
ValueError: If timeout exceeds max or validation fails
RuntimeError: If build fails
"""
# Validate build context
self._validate_build_context(build_context)
# Use default timeout if not specified
timeout = timeout if timeout is not None else self.config.build_timeout
# Validate timeout against maximum
if timeout > self.config.max_build_timeout:
raise ValueError(
f"Timeout {timeout} exceeds maximum {self.config.max_build_timeout}"
)
# Generate image tag
tag = self.generate_tag(name)
# Calculate cache hash
cache_hash = self.calculate_cache_hash(packages)
# Log build start
self.audit_logger.log(
event_type=AuditEventType.IMAGE_BUILD_START,
severity=AuditSeverity.INFO,
message=f"Building image: {tag}",
details={
"base_image": base_image,
"packages": packages,
"cache_hash": cache_hash
}
)
start_time = time.time()
try:
# Build image with Podman
image, build_logs = self.podman.images.build(
path=str(build_context),
tag=tag,
timeout=timeout,
rm=True, # Remove intermediate containers
pull=False # Don't pull base image (assume it exists)
)
build_time = time.time() - start_time
# Validate image size
size_bytes = self.validate_image_size(image.id)
# Extract installed packages
installed_packages = self.extract_installed_packages(image.id)
# Log success
self.audit_logger.log(
event_type=AuditEventType.IMAGE_BUILD_SUCCESS,
severity=AuditSeverity.INFO,
message=f"Image built successfully: {tag}",
details={
"image_id": image.id,
"build_time": build_time,
"size_bytes": size_bytes,
"installed_packages": len(installed_packages)
}
)
return BuildResult(
success=True,
image_name=tag,
image_id=image.id,
build_time=build_time,
size_bytes=size_bytes,
cache_hit=False, # TODO: implement cache checking
installed_packages=installed_packages
)
except Exception as e:
build_time = time.time() - start_time
# Log failure
self.audit_logger.log(
event_type=AuditEventType.IMAGE_BUILD_FAILURE,
severity=AuditSeverity.ERROR,
message=f"Image build failed: {tag}",
error=str(e),
details={"build_time": build_time}
)
raise RuntimeError(f"Build failed: {e}") from e
def generate_tag(self, name: str) -> str:
"""
Generate image tag.
Format: mcp-forge/custom:{name}
Validates name is alphanumeric + hyphens only.
Args:
name: Environment name
Returns:
Full image tag
Raises:
ValueError: If name contains invalid characters
"""
# Validate name (alphanumeric + hyphens only)
if not re.match(r'^[a-zA-Z0-9-]+$', name):
raise ValueError(
f"Environment name '{name}' must contain only alphanumeric characters and hyphens"
)
return f"mcp-forge/custom:{name}"
def validate_image_size(self, image_id: str) -> int:
"""
Validate image size against maximum.
Args:
image_id: Image ID to validate
Returns:
Size in bytes
Raises:
ValueError: If image exceeds max size
"""
image = self.podman.images.get(image_id)
size_bytes = image.attrs.get("Size", 0)
max_size_bytes = parse_memory_string(self.config.max_image_size)
if size_bytes > max_size_bytes:
raise ValueError(
f"Image size {size_bytes} bytes exceeds maximum {max_size_bytes} bytes"
)
return size_bytes
def extract_installed_packages(self, image_id: str) -> List[str]:
"""
Extract list of installed packages from image.
Runs: pip list --format=json in container
Args:
image_id: Image ID to inspect
Returns:
List of package specifications (name==version)
"""
try:
# Run pip list in container
container = self.podman.containers.run(
image=image_id,
command=["pip", "list", "--format=json"],
remove=False,
detach=False
)
exit_code, output = container.exec_run(
["pip", "list", "--format=json"]
)
if exit_code != 0:
return []
# Parse JSON output
packages_data = json.loads(output.decode('utf-8'))
# Format as name==version
packages = [
f"{pkg['name']}=={pkg['version']}"
for pkg in packages_data
]
return packages
except Exception:
# Return empty list on error
return []
def calculate_cache_hash(self, packages: List[str]) -> str:
"""
Calculate hash of package list for cache key.
Hash is order-independent (sorts packages first).
Args:
packages: List of package specifications
Returns:
SHA256 hash hex string
"""
# Sort packages for order-independent hash
sorted_packages = sorted(packages)
# Join and hash
packages_str = '\n'.join(sorted_packages)
hash_obj = hashlib.sha256(packages_str.encode('utf-8'))
return hash_obj.hexdigest()
def _validate_build_context(self, build_context: Path) -> None:
"""
Validate build context exists and contains Containerfile.
Args:
build_context: Path to build context
Raises:
ValueError: If validation fails
"""
if not build_context.exists():
raise ValueError(f"Build context does not exist: {build_context}")
if not build_context.is_dir():
raise ValueError(f"Build context is not a directory: {build_context}")
containerfile = build_context / "Containerfile"
if not containerfile.exists():
raise ValueError(f"Containerfile not found in build context: {build_context}")

View file

@ -0,0 +1,199 @@
"""Package validation against security policies."""
import re
from typing import List, Set, Optional, Pattern
from pathlib import Path
from mcp_forge.config.schema import PackageValidationConfig
class SecurityError(Exception):
"""Raised when package is blocked by security policy."""
pass
class ApprovalRequiredError(Exception):
"""Raised when package requires manual approval."""
pass
class PackageValidator:
"""Validates package names against security policy."""
def __init__(self, config: PackageValidationConfig):
"""
Initialize package validator.
Args:
config: Package validation configuration
"""
self.config = config
self.allowlist: Set[str] = self._load_allowlist()
self.blocklist: Set[str] = self._load_blocklist()
self.approval_patterns: List[Pattern] = self._compile_patterns()
def validate_packages(
self,
packages: List[str],
max_packages: Optional[int] = None
) -> None:
"""
Validate list of package specifications.
Args:
packages: List of package specs (e.g., ["numpy>=1.24", "pandas"])
max_packages: Maximum number of packages allowed
Raises:
ValueError: If too many packages
SecurityError: If package is blocklisted
ApprovalRequiredError: If package requires approval
"""
# Check package count limit
if max_packages is not None and len(packages) > max_packages:
raise ValueError(
f"Maximum {max_packages} packages allowed, got {len(packages)}"
)
# Validate each package
for package_spec in packages:
self.validate_package(package_spec)
def validate_package(self, package_spec: str) -> None:
"""
Validate single package specification.
Extracts package name from spec (handles >=, ==, <=, etc.)
Checks against blocklist, allowlist, and approval patterns.
Args:
package_spec: Package specification (e.g., "numpy>=1.24.0")
Raises:
SecurityError: If package is blocklisted or not in allowlist
ApprovalRequiredError: If package requires manual approval
"""
# Extract clean package name
package_name = self.extract_package_name(package_spec)
# Check blocklist first (highest priority)
if package_name in self.blocklist:
raise SecurityError(f"Package '{package_name}' is blocklisted")
# Check approval patterns
for pattern in self.approval_patterns:
if pattern.match(package_name):
raise ApprovalRequiredError(
f"Package '{package_name}' requires manual approval"
)
# Check allowlist if enabled
if self.config.use_allowlist:
if package_name not in self.allowlist:
raise SecurityError(
f"Package '{package_name}' is not in allowlist"
)
def extract_package_name(self, package_spec: str) -> str:
"""
Extract package name from specification.
Handles version specifiers, extras, and whitespace.
Examples:
"numpy>=1.24.0" "numpy"
"requests==2.28.0" "requests"
"pandas[excel]" "pandas"
" numpy " "numpy"
Args:
package_spec: Package specification string
Returns:
Clean package name
"""
# Remove leading/trailing whitespace
spec = package_spec.strip()
# Remove version specifiers (>=, ==, <=, ~=, !=, <, >)
# Pattern matches: package-name[extras]>=version,<version
# We want to extract just the package-name part
# First remove extras like [security] or [excel,sql]
if '[' in spec:
spec = spec.split('[')[0]
# Then remove version specifiers
# Match any of: >= == <= ~= != < > ,
spec = re.split(r'[><=!~,]', spec)[0]
return spec.strip()
def _load_allowlist(self) -> Set[str]:
"""
Load allowlist from file.
Returns:
Set of allowed package names
Raises:
FileNotFoundError: If allowlist file doesn't exist when use_allowlist is True
"""
if not self.config.use_allowlist or not self.config.allowlist_path:
return set()
path = Path(self.config.allowlist_path)
if not path.exists():
raise FileNotFoundError(f"Allowlist file not found: {path}")
allowlist = set()
with open(path, 'r') as f:
for line in f:
line = line.strip()
# Skip comments and empty lines
if line and not line.startswith('#'):
allowlist.add(line)
return allowlist
def _load_blocklist(self) -> Set[str]:
"""
Load blocklist from file.
Returns:
Set of blocked package names
"""
if not self.config.blocklist_path:
return set()
path = Path(self.config.blocklist_path)
if not path.exists():
return set()
blocklist = set()
with open(path, 'r') as f:
for line in f:
line = line.strip()
# Skip comments and empty lines
if line and not line.startswith('#'):
blocklist.add(line)
return blocklist
def _compile_patterns(self) -> List[Pattern]:
"""
Compile approval requirement patterns.
Returns:
List of compiled regex patterns
"""
patterns = []
for pattern_str in self.config.require_approval_patterns:
try:
pattern = re.compile(pattern_str)
patterns.append(pattern)
except re.error:
# Log warning but continue
pass
return patterns

View file

@ -0,0 +1,158 @@
"""UV-based package installer for custom environments."""
import tempfile
import shutil
from typing import List
from pathlib import Path
class UVInstaller:
"""Manages UV-based package installation in containers."""
def __init__(self, cache_path: Path):
"""
Initialize UV installer.
Args:
cache_path: Path to UV cache directory
"""
self.cache_path = Path(cache_path)
self._ensure_cache_dir()
def generate_requirements(self, packages: List[str]) -> str:
"""
Generate requirements.txt content.
One package per line with version specifiers preserved.
Args:
packages: List of package specifications
Returns:
requirements.txt content
"""
if not packages:
return ""
return '\n'.join(packages)
def generate_containerfile(
self,
base_image: str,
packages: List[str],
python_version: str = "3.11"
) -> str:
"""
Generate Containerfile for building custom environment.
Containerfile structure optimizes layer caching:
1. Base image
2. Install UV (cached layer)
3. Create non-root user
4. Copy requirements.txt (cache-friendly)
5. Install packages with UV
6. Set working directory
Args:
base_image: Base container image
packages: List of package specifications
python_version: Python version (for reference)
Returns:
Containerfile content
"""
has_packages = bool(packages)
containerfile = f"""FROM {base_image}
# Install UV for fast package installation
RUN pip install --no-cache-dir uv
# Create non-root user for security
RUN useradd -m -u 1000 -s /bin/bash forge && \\
mkdir -p /home/forge/.cache/uv && \\
chown -R forge:forge /home/forge
# Switch to non-root user
USER forge
WORKDIR /home/forge
# Copy requirements for layer caching
COPY --chown=forge:forge requirements.txt /home/forge/requirements.txt
"""
if has_packages:
containerfile += """# Install packages with UV
RUN uv pip install --system -r requirements.txt
"""
containerfile += """# Set working directory
WORKDIR /home/forge/workspace
# Default command
CMD ["/bin/bash"]
"""
return containerfile
def create_build_context(
self,
base_image: str,
packages: List[str],
python_version: str = "3.11"
) -> Path:
"""
Create temporary build context directory.
Contains:
- Containerfile
- requirements.txt
Args:
base_image: Base container image
packages: List of package specifications
python_version: Python version
Returns:
Path to build context directory (caller must cleanup)
"""
# Create temporary directory
context_dir = Path(tempfile.mkdtemp(prefix="mcp-forge-build-"))
try:
# Generate Containerfile
containerfile_content = self.generate_containerfile(
base_image, packages, python_version
)
containerfile_path = context_dir / "Containerfile"
containerfile_path.write_text(containerfile_content)
# Generate requirements.txt
requirements_content = self.generate_requirements(packages)
requirements_path = context_dir / "requirements.txt"
requirements_path.write_text(requirements_content)
return context_dir
except Exception:
# Cleanup on error
shutil.rmtree(context_dir, ignore_errors=True)
raise
def get_cache_volume_mount(self) -> dict:
"""
Get volume mount configuration for UV cache.
Returns:
Volume mount dict for Podman
"""
return {
"bind": "/home/forge/.cache/uv",
"mode": "rw"
}
def _ensure_cache_dir(self) -> None:
"""Ensure UV cache directory exists."""
self.cache_path.mkdir(parents=True, exist_ok=True)