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:
parent
8b6b237be9
commit
db75b822f4
14 changed files with 2842 additions and 0 deletions
33
src/pod_executor/security/__init__.py
Normal file
33
src/pod_executor/security/__init__.py
Normal 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",
|
||||
]
|
||||
132
src/pod_executor/security/audit.py
Normal file
132
src/pod_executor/security/audit.py
Normal 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')
|
||||
150
src/pod_executor/security/resource_limits.py
Normal file
150
src/pod_executor/security/resource_limits.py
Normal 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)
|
||||
}
|
||||
232
src/pod_executor/security/validation.py
Normal file
232
src/pod_executor/security/validation.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue