Create standalone pod_executor package

Created a standalone code execution package independent of MCP-Forge:

Structure:
- pod_executor/security/ - Resource limits, audit protocols, validation
- pod_executor/containers/ - Podman client and container management
- pod_executor/simple/ - Stateless code executor
- pod_executor/jupyter/ - Stateful Jupyter backend with sessions

Key changes:
- Removed ForgeConfig dependency - all parameters explicit
- Audit logger now a protocol with NullAuditLogger/SimpleFileAuditLogger
- Validator now a protocol with NoOpValidator/BasicValidator
- All imports updated to pod_executor namespace
- Audit calls use simple strings instead of enums

Benefits:
- Standalone package usable without MCP-Forge
- Clear separation between execution engine and MCP protocol
- Easier testing and development
- Reusable in other projects
This commit is contained in:
Hans Aschauer 2026-02-07 10:11:59 +01:00
parent 8b6b237be9
commit db75b822f4
14 changed files with 2842 additions and 0 deletions

View file

@ -0,0 +1,33 @@
"""Security components for pod_executor."""
from pod_executor.security.resource_limits import (
ResourceLimits,
parse_memory_string,
parse_cpu_quota,
parse_storage_string,
)
from pod_executor.security.audit import (
AuditLoggerProtocol,
NullAuditLogger,
SimpleFileAuditLogger,
)
from pod_executor.security.validation import (
SecurityError,
OperationValidatorProtocol,
NoOpValidator,
BasicValidator,
)
__all__ = [
"ResourceLimits",
"parse_memory_string",
"parse_cpu_quota",
"parse_storage_string",
"AuditLoggerProtocol",
"NullAuditLogger",
"SimpleFileAuditLogger",
"SecurityError",
"OperationValidatorProtocol",
"NoOpValidator",
"BasicValidator",
]

View file

@ -0,0 +1,132 @@
"""
Audit logger protocol for pod_executor.
Provides a protocol (interface) for audit logging that can be implemented
by consuming applications. A default no-op implementation is provided.
"""
from typing import Protocol, Any, Optional, Dict
from pathlib import Path
class AuditLoggerProtocol(Protocol):
"""Protocol for audit logging (optional dependency)."""
def log(
self,
event_type: str,
severity: str,
message: str,
session_id: Optional[str] = None,
user_id: Optional[str] = None,
details: Optional[Dict[str, Any]] = None,
error: Optional[str] = None,
**kwargs: Any
) -> None:
"""
Log an audit event.
Args:
event_type: Type of event (e.g., "container.create", "execution.request")
severity: Severity level ("info", "warning", "error", "critical")
message: Human-readable message describing the event
session_id: Optional session ID associated with event
user_id: Optional user ID associated with event
details: Optional dictionary of additional details
error: Optional error message if event represents an error
**kwargs: Additional keyword arguments for extensibility
"""
...
class NullAuditLogger:
"""No-op audit logger for standalone usage without audit requirements."""
def log(
self,
event_type: str = "",
severity: str = "info",
message: str = "",
session_id: Optional[str] = None,
user_id: Optional[str] = None,
details: Optional[Dict[str, Any]] = None,
error: Optional[str] = None,
**kwargs: Any
) -> None:
"""Do nothing - audit logging disabled."""
pass
class SimpleFileAuditLogger:
"""
Simple file-based audit logger for basic use cases.
Logs events to a JSON Lines file (one JSON object per line).
Thread-safe via file locking.
"""
def __init__(self, log_path: Path):
"""
Initialize file audit logger.
Args:
log_path: Path to audit log file
"""
self.log_path = Path(log_path)
self._ensure_log_file()
def _ensure_log_file(self) -> None:
"""Ensure log file and directory exist."""
self.log_path.parent.mkdir(parents=True, exist_ok=True)
if not self.log_path.exists():
self.log_path.touch()
def log(
self,
event_type: str = "",
severity: str = "info",
message: str = "",
session_id: Optional[str] = None,
user_id: Optional[str] = None,
details: Optional[Dict[str, Any]] = None,
error: Optional[str] = None,
**kwargs: Any
) -> None:
"""
Log an audit event to JSON Lines file.
Args:
event_type: Type of event
severity: Severity level
message: Human-readable message
session_id: Optional session ID
user_id: Optional user ID
details: Optional details dictionary
error: Optional error message
**kwargs: Additional fields
"""
import json
from datetime import datetime, timezone
entry = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"event_type": event_type,
"severity": severity,
"message": message,
}
if session_id is not None:
entry["session_id"] = session_id
if user_id is not None:
entry["user_id"] = user_id
if details is not None:
entry["details"] = details
if error is not None:
entry["error"] = error
# Add any additional kwargs
entry.update(kwargs)
# Write to file (append mode, file locking via 'a' mode)
with open(self.log_path, 'a') as f:
f.write(json.dumps(entry) + '\n')

View file

@ -0,0 +1,150 @@
"""
Resource limit parser and validator.
Parses and validates resource limit strings (memory, CPU, storage).
All values must be positive and within reasonable limits.
"""
import re
from typing import Dict, Any
def parse_memory_string(memory: str) -> int:
"""
Parse memory string to bytes.
Supports: k, m, g suffixes (case-insensitive)
Examples: "512m" 536870912, "2g" 2147483648
Args:
memory: Memory string with suffix (e.g., "512m", "2g", "1024k")
Returns:
Memory in bytes
Raises:
ValueError: If format is invalid or value is <= 0
"""
memory = memory.strip()
# Pattern: optional sign, number (int or float), suffix (k/m/g)
pattern = r'^(-?\d+(?:\.\d+)?)\s*([kmgKMG])$'
match = re.match(pattern, memory)
if not match:
raise ValueError(
f"Invalid memory format: '{memory}'. "
f"Expected format: <number><k|m|g> (e.g., '512m', '2g')"
)
value_str, suffix = match.groups()
value = float(value_str)
if value <= 0:
raise ValueError(
f"Memory value must be positive, got: {value}"
)
# Convert to bytes
suffix_lower = suffix.lower()
multipliers = {
'k': 1024,
'm': 1024 * 1024,
'g': 1024 * 1024 * 1024,
}
bytes_value = int(value * multipliers[suffix_lower])
return bytes_value
def parse_cpu_quota(cpu_quota: int) -> int:
"""
Validate CPU quota value.
CPU quota is in microseconds per 100ms period.
100000 = 100% of one CPU core
Args:
cpu_quota: CPU quota in microseconds (e.g., 50000 for 50% of one core)
Returns:
Validated CPU quota value
Raises:
ValueError: If quota <= 0 or > 1000000 (10 cores max)
"""
if cpu_quota <= 0:
raise ValueError(
f"CPU quota must be positive, got: {cpu_quota}"
)
# Maximum of 10 cores (1000000 microseconds)
if cpu_quota > 1000000:
raise ValueError(
f"CPU quota exceeds maximum of 1000000 (10 cores), got: {cpu_quota}"
)
return cpu_quota
def parse_storage_string(storage: str) -> int:
"""
Parse storage string to bytes (same as memory).
Args:
storage: Storage string with suffix (e.g., "1g", "512m")
Returns:
Storage in bytes
Raises:
ValueError: If format is invalid or value is <= 0
"""
return parse_memory_string(storage)
class ResourceLimits:
"""
Resource limits with validation.
Encapsulates memory, storage, CPU, and timeout limits with validation.
Provides conversion to Podman container parameters.
"""
def __init__(
self,
memory: str,
storage: str,
cpu_quota: int,
timeout: int = 300
):
"""
Initialize resource limits with validation.
Args:
memory: Memory limit string (e.g., "512m", "2g")
storage: Storage limit string (e.g., "1g", "10g")
cpu_quota: CPU quota in microseconds per 100ms period
timeout: Execution timeout in seconds (default: 300)
Raises:
ValueError: If any limit is invalid
"""
self.memory_bytes = parse_memory_string(memory)
self.storage_bytes = parse_storage_string(storage)
self.cpu_quota = parse_cpu_quota(cpu_quota)
self.timeout = timeout
def to_podman_params(self) -> Dict[str, Any]:
"""
Convert to Podman container create parameters.
Returns:
Dictionary of parameters suitable for Podman container creation
"""
return {
"mem_limit": str(self.memory_bytes), # Podman expects string
"cpu_quota": self.cpu_quota
# Note: storage_bytes tracked internally but not passed to Podman (not supported)
}

View file

@ -0,0 +1,232 @@
"""
Security validation protocol for pod_executor.
Provides protocols (interfaces) for security validation that can be implemented
by consuming applications. Default implementations are provided.
"""
from typing import Protocol, Set, Optional, Dict, Any
class SecurityError(Exception):
"""Raised when security policy is violated."""
pass
class OperationValidatorProtocol(Protocol):
"""Protocol for validating container operations."""
session_containers: Set[str]
def validate_container_create(
self,
image: str,
params: Dict[str, Any],
session_id: Optional[str] = None
) -> None:
"""
Validate container creation parameters against security policy.
Args:
image: Container image name
params: Container creation parameters
session_id: Optional session ID for volume validation
Raises:
SecurityError: If any security policy is violated
"""
...
def validate_container_start(self, container_id: str) -> None:
"""
Validate container start.
Args:
container_id: Container ID to start
Raises:
SecurityError: If operation is not allowed
"""
...
def validate_container_stop(self, container_id: str) -> None:
"""
Validate container stop.
Args:
container_id: Container ID to stop
Raises:
SecurityError: If operation is not allowed
"""
...
def validate_operation(self, operation: str, target: str) -> tuple[bool, Optional[str]]:
"""
Validate generic operation.
Args:
operation: Operation type
target: Operation target
Returns:
(allowed, reason) tuple
"""
...
def register_session_container(self, container_id: str) -> None:
"""Register a container as belonging to a session."""
...
def unregister_session_container(self, container_id: str) -> None:
"""Unregister a session container."""
...
class NoOpValidator:
"""
No-op validator that allows all operations.
WARNING: This validator provides NO SECURITY. Only use for testing
or in fully trusted environments.
"""
def __init__(self):
"""Initialize with empty session container set."""
self.session_containers: Set[str] = set()
def validate_container_create(
self,
image: str,
params: Dict[str, Any],
session_id: Optional[str] = None
) -> None:
"""Allow all container creations."""
pass
def validate_container_start(self, container_id: str) -> None:
"""Allow all container starts."""
pass
def validate_container_stop(self, container_id: str) -> None:
"""Allow all container stops."""
pass
def validate_operation(self, operation: str, target: str) -> tuple[bool, Optional[str]]:
"""Allow all operations."""
return (True, None)
def register_session_container(self, container_id: str) -> None:
"""Track session container."""
self.session_containers.add(container_id)
def unregister_session_container(self, container_id: str) -> None:
"""Untrack session container."""
self.session_containers.discard(container_id)
class BasicValidator:
"""
Basic validator with minimal security checks.
Enforces:
- Allowed image patterns
- Required security parameters
- Forbidden dangerous parameters
- Session container tracking
"""
# Allowed container images with wildcard support
DEFAULT_ALLOWED_IMAGES = [
"python:3.11*",
"python:3.12*",
"jupyter/*",
"mcp-forge/*",
]
# Parameters that are forbidden
FORBIDDEN_PARAMS = [
"privileged",
"cap_add",
"devices",
"pid_mode",
]
def __init__(self, allowed_images: Optional[list[str]] = None):
"""
Initialize basic validator.
Args:
allowed_images: List of allowed image patterns (supports wildcards)
"""
self.allowed_images = allowed_images or self.DEFAULT_ALLOWED_IMAGES
self.session_containers: Set[str] = set()
def validate_container_create(
self,
image: str,
params: Dict[str, Any],
session_id: Optional[str] = None
) -> None:
"""
Validate container creation.
Args:
image: Container image name
params: Container creation parameters
session_id: Optional session ID
Raises:
SecurityError: If validation fails
"""
# Validate image is allowed
if not self._is_image_allowed(image):
raise SecurityError(
f"Image '{image}' not in allowlist. "
f"Allowed patterns: {self.allowed_images}"
)
# Check for forbidden parameters
for forbidden in self.FORBIDDEN_PARAMS:
if forbidden in params:
raise SecurityError(
f"Forbidden parameter '{forbidden}' in container creation"
)
# Ensure security_opt includes no-new-privileges
security_opts = params.get("security_opt", [])
if "no-new-privileges" not in security_opts:
raise SecurityError(
"Container must include security_opt=['no-new-privileges']"
)
def validate_container_start(self, container_id: str) -> None:
"""Validate container start."""
if container_id not in self.session_containers:
raise SecurityError(
f"Container {container_id} is not a registered session container"
)
def validate_container_stop(self, container_id: str) -> None:
"""Validate container stop."""
if container_id not in self.session_containers:
raise SecurityError(
f"Container {container_id} is not a registered session container"
)
def validate_operation(self, operation: str, target: str) -> tuple[bool, Optional[str]]:
"""Validate generic operation."""
return (True, None) # Allow by default
def register_session_container(self, container_id: str) -> None:
"""Register session container."""
self.session_containers.add(container_id)
def unregister_session_container(self, container_id: str) -> None:
"""Unregister session container."""
self.session_containers.discard(container_id)
def _is_image_allowed(self, image: str) -> bool:
"""Check if image matches any allowed pattern."""
import fnmatch
return any(fnmatch.fnmatch(image, pattern) for pattern in self.allowed_images)