Skip to content

Latest commit

 

History

History
731 lines (546 loc) · 21.4 KB

File metadata and controls

731 lines (546 loc) · 21.4 KB

How It Works: Architecture & Security

This document provides an in-depth explanation of the code-interpreter service architecture, execution environments, and security model.

Table of Contents

Overview

The code-interpreter service is a secure FastAPI-based platform for executing untrusted Python code in isolated environments. It uses a pluggable executor architecture that supports different isolation backends (Docker, Kubernetes) while maintaining consistent security guarantees.

Architecture

Layered Design

The service follows a clean separation of concerns across four layers:

┌─────────────────────────────────────────────────────┐
│  API Layer (FastAPI)                                │
│  - Request validation (Pydantic)                    │
│  - HTTP routing                                     │
│  - Error handling                                   │
└──────────────────┬──────────────────────────────────┘
                   │
┌──────────────────▼──────────────────────────────────┐
│  Service Layer                                      │
│  - Executor factory (backend selection)             │
│  - File management                                  │
│  - Business logic                                   │
└──────────────────┬──────────────────────────────────┘
                   │
┌──────────────────▼──────────────────────────────────┐
│  Executor Abstraction Layer                         │
│  - BaseExecutor (abstract interface)                │
│  - ExecutorProtocol (type interface)                │
│  - Common utilities (output truncation, etc.)       │
└──────────────────┬──────────────────────────────────┘
                   │
        ┌──────────┴──────────┐
        │                     │
┌───────▼───────┐    ┌────────▼────────┐
│ DockerExecutor│    │KubernetesExecutor│
│               │    │                  │
└───────────────┘    └──────────────────┘

Layer Responsibilities:

  • API Layer: Validates requests, enforces API contracts, handles HTTP concerns
  • Service Layer: Orchestrates business logic, manages executor lifecycle
  • Executor Layer: Provides unified interface for different execution backends
  • Backend Implementations: Handle environment-specific isolation and execution

Execution Flow

  1. Request Reception: FastAPI receives POST request at /v1/execute
  2. Validation: Pydantic models validate code, files, and execution parameters
  3. Executor Selection: Factory pattern selects backend based on EXECUTOR_BACKEND env var
  4. File Preparation: Files encoded as base64 are decoded and prepared for injection
  5. Code Wrapping: If last_line_interactive=true, code is wrapped to auto-print last expression
  6. Environment Creation: Ephemeral isolated environment is created
  7. Code Execution: Python code runs in isolation with resource limits enforced
  8. Output Collection: stdout, stderr, exit code, and execution time are captured
  9. Workspace Extraction: Generated files are extracted from the workspace
  10. Cleanup: Execution environment is destroyed
  11. Response: Results are returned to client

Execution Environments

Docker Executor

The Docker executor (executor_docker.py) provides strong isolation using Linux containers.

Security Architecture

Container Lifecycle:

1. Create ephemeral container (--rm, --network none)
2. Start with sleep command (detached mode)
3. Inject code + files via tar archive
4. Execute Python as unprivileged user (uid:gid 65532:65532)
5. Extract workspace artifacts
6. Kill and cleanup container

Security Controls:

Control Implementation Purpose
Network Isolation --network none No network access (prevents data exfiltration)
Capability Dropping --cap-drop ALL, --cap-add CHOWN Minimal privileges (CHOWN only for workspace setup)
No New Privileges --security-opt no-new-privileges Prevents privilege escalation
Process Limits --pids-limit 64 Prevents fork bombs
Unprivileged Execution Run as user 65532:65532 Non-root execution
Read-Only Root Workspace as tmpfs Prevents filesystem tampering
Ephemeral Containers --rm flag Automatic cleanup
Memory Limits --memory, --memory-swap Prevents memory exhaustion
CPU Limits --ulimit cpu Prevents CPU exhaustion

File System Isolation:

Container Filesystem Layout:

/               (read-only root filesystem)
├── tmp/        (tmpfs, 64MB, writable)
│   └── matplotlib/  (matplotlib cache dir)
├── workspace/  (tmpfs, 100MB, owned by 65532:65532)
│   ├── __main__.py   (injected user code)
│   └── <user-files>  (injected via tar)
└── opt/
    └── executor-venv/  (pre-installed Python packages)
  • Workspace Injection: Code and files are injected via tar archive streaming
  • Ownership: All workspace files owned by unprivileged user (65532:65532)
  • Isolation: Workspace is ephemeral tmpfs (memory-backed, no disk persistence)

Resource Limits:

# Memory (configurable via MEMORY_LIMIT_MB)
--memory 256m --memory-swap 256m  # Hard limit, no swap

# CPU Time (configurable via CPU_TIME_LIMIT_SEC)
--ulimit cpu=5:5  # SIGKILL after 5 seconds of CPU time

# Process Count
--pids-limit 64  # Maximum 64 processes

# Filesystem
--tmpfs /tmp:size=64m        # 64MB temp storage
--tmpfs /workspace:size=100m  # 100MB workspace

Execution Model:

  1. Container starts with sleep command (keeps container alive)
  2. Tar archive with code + files streamed via stdin to docker exec tar -x
  3. Python execution via docker exec -u 65532:65532 python __main__.py
  4. Timeout enforcement via subprocess.communicate(timeout=...)
  5. On timeout: pkill -9 python inside container, then kill container

Docker-out-of-Docker (Recommended)

The recommended deployment mode mounts the host's Docker socket:

docker run --rm -it \
  --user root \
  -p 8000:8000 \
  -v /var/run/docker.sock:/var/run/docker.sock \
  code-interpreter

Security Considerations:

  • Privilege Requirement: Requires root inside API container to access Docker socket. Note that the container actually running arbitrary python, does not use the root user. Also does not require --privileged.
  • Isolation Trade-off: Executor containers run on host Docker daemon.
  • Attack Vector: Compromised API container could spawn malicious containers
  • Mitigation: API container itself should be isolated (network policies, resource limits)

Advantages:

  • Simpler deployment (no Docker-in-Docker)
  • Better performance (no nested virtualization)
  • Standard Docker security controls apply

Docker-in-Docker (Alternative)

Alternatively, enable nested Docker:

docker build -f code-interpreter/Dockerfile .
docker run --privileged code-interpreter

Trade-offs:

  • ⚠️ Requires --privileged flag
  • Stronger isolation between API and executor containers
  • More complex setup, potential stability issues

Kubernetes Executor

The Kubernetes executor (executor_kubernetes.py) provides cloud-native, scalable isolation using Kubernetes Pods.

Security Architecture

Pod Lifecycle:

1. Create Pod manifest with security constraints
2. Submit Pod to Kubernetes API
3. Wait for Pod to reach Running state
4. Inject code + files via kubectl exec tar -x
5. Execute Python via kubectl exec python
6. Extract workspace artifacts via kubectl exec tar -c
7. Delete Pod (grace period 0)

Security Controls:

Control Implementation Purpose
RunAsNonRoot securityContext.runAsNonRoot: true Enforces non-root execution
User/Group runAsUser: 65532, runAsGroup: 65532 Unprivileged execution
No Privilege Escalation allowPrivilegeEscalation: false Prevents setuid/setgid
Capability Dropping drop: ["ALL"] Zero Linux capabilities
Network Policy (Cluster-configurable) Can restrict network access
Resource Limits limits.memory, limits.cpu Prevents resource exhaustion
Ephemeral Storage emptyDir volumes No persistent storage
ServiceAccount Minimal or none Restricts Kubernetes API access

Pod Security Context:

securityContext:
  runAsNonRoot: true
  runAsUser: 65532
  runAsGroup: 65532
  fsGroup: 65532
  allowPrivilegeEscalation: false
  capabilities:
    drop: ["ALL"]

Resource Limits:

resources:
  limits:
    memory: "256Mi"     # Configurable via MEMORY_LIMIT_MB
    cpu: "5"            # Configurable via CPU_TIME_LIMIT_SEC
  requests:
    memory: "64Mi"      # Request 25% of limit
    cpu: "100m"         # Request minimal CPU

File System Layout:

Pod Filesystem:

/               (read-only container filesystem)
├── tmp/        (emptyDir, 64Mi limit)
│   └── matplotlib/
├── workspace/  (emptyDir, 100Mi limit, uid:gid 65532:65532)
│   ├── __main__.py
│   └── <user-files>
└── opt/
    └── executor-venv/

Execution Model:

  1. Pod created with sleep 3600 command
  2. Wait up to 30 seconds for Pod to reach Running state
  3. Stream tar archive via kubectl exec -i tar -x
  4. Execute Python via kubectl exec python __main__.py
  5. Read output via WebSocket streams (stdout, stderr, error channel)
  6. Timeout via client-side timer (kill Python process with pkill -9 on timeout)
  7. Extract files via kubectl exec tar -c
  8. Delete Pod with grace_period_seconds=0

Kubernetes-Specific Security:

  • Pod Security Standards: Can enforce restricted, baseline, or privileged policies
  • Network Policies: Can isolate Pods from cluster network
  • Resource Quotas: Cluster-level limits on compute resources
  • RBAC: ServiceAccount with minimal permissions
  • Admission Controllers: Can enforce additional security constraints (e.g., PSP, OPA)

Advantages Over Docker:

  • Native cloud orchestration
  • Multi-tenancy support (namespaces)
  • Built-in resource management
  • Audit logging (Kubernetes API server)
  • Integration with cloud-native security tools

Trade-offs:

  • More complex deployment (requires Kubernetes cluster)
  • Higher latency (Pod creation overhead ~1-5 seconds)
  • Requires cluster permissions (create/delete Pods)

Security Model

Isolation Mechanisms

Process Isolation

User Namespace Isolation:

  • All code execution happens as UID/GID 65532 (unprivileged user)
  • User 65532 is the "nobody" user with zero permissions
  • No ability to interact with host processes

PID Namespace Isolation:

  • Processes cannot see host processes
  • Docker: Enforced by container runtime
  • Kubernetes: Enforced by container runtime + Pod isolation

Network Isolation

Docker:

  • --network none: Complete network stack removal
  • No loopback except localhost
  • No external connectivity (blocks data exfiltration)

Kubernetes:

  • Default: Pod has network access (cluster networking)
  • Recommended: Apply NetworkPolicy to deny all egress
  • Can allow specific destinations if needed (e.g., package registries)

Example NetworkPolicy for zero network access:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: code-interpreter-deny-all
spec:
  podSelector:
    matchLabels:
      component: executor
  policyTypes:
  - Ingress
  - Egress
  # Empty ingress/egress = deny all

Filesystem Isolation

Read-Only Root Filesystem:

  • Container base filesystem is read-only (Docker default)
  • Prevents tampering with Python interpreter, libraries

Ephemeral Storage:

  • Workspace is tmpfs/emptyDir (memory-backed)
  • Data deleted on container/Pod termination
  • No persistence = no cross-execution contamination

Path Validation:

def _validate_relative_path(self, path_str: str) -> Path:
    # Prevents path traversal attacks
    # Blocks: absolute paths, "..", empty paths
    # Ensures files stay within workspace

Resource Limits

Memory Limits

Docker:

--memory 256m --memory-swap 256m
  • Hard limit enforced by cgroups
  • OOM killer terminates process if exceeded
  • No swap = prevents disk thrashing

Kubernetes:

resources:
  limits:
    memory: "256Mi"
  • Hard limit enforced by kubelet
  • OOM killed if exceeded

CPU Limits

Docker:

--ulimit cpu=5:5
  • SIGKILL sent after 5 seconds of CPU time
  • Measures actual CPU consumption (not wall time)
  • Prevents infinite loops, crypto mining

Kubernetes:

resources:
  limits:
    cpu: "5"
  • Throttling applied via CFS (Completely Fair Scheduler)
  • Less strict than Docker ulimit (throttling, not killing)
  • Recommendation: Combine with client-side timeout

Timeout Enforcement

Wall Clock Timeout:

timeout_ms = 60_000  # Default 60 seconds
proc.communicate(timeout=timeout_ms / 1000.0)
  • Enforced by parent process
  • On timeout: SIGKILL sent to Python process
  • Protects against sleep, network waits, blocking I/O

Combined Strategy:

  • Wall clock timeout: Protects against blocking operations
  • CPU time limit: Protects against infinite loops
  • Memory limit: Protects against memory bombs

Process Limits

--pids-limit 64  # Docker only
  • Prevents fork bombs
  • Maximum 64 processes/threads per container
  • Kubernetes: Enforced by PID cgroup controller (if enabled)

Output Limits

MAX_OUTPUT_BYTES = 1_000_000  # 1 MB default
  • Prevents memory exhaustion in API server
  • Truncates stdout/stderr at limit
  • Suffix: \n...[truncated]

Attack Surface Mitigation

Code Injection

Protection:

  • Code is written to file (__main__.py), not passed via command-line
  • No shell execution (shell=False in subprocess)
  • No string interpolation into commands

File Injection:

# Files validated before injection
validated_path = self._validate_relative_path(file_path)
# Injected via tar archive (binary safe)
tar.addfile(file_info, io.BytesIO(content))

Privilege Escalation

Docker:

  • --security-opt no-new-privileges: Blocks setuid/setgid
  • --cap-drop ALL: No Linux capabilities
  • User 65532: No sudo, no setuid binaries

Kubernetes:

  • allowPrivilegeEscalation: false: Blocks setuid/setgid
  • capabilities.drop: ["ALL"]: No Linux capabilities
  • runAsNonRoot: true: Enforced by Kubernetes

Resource Exhaustion

Multi-Layer Defense:

Attack Vector Defense
Fork Bomb --pids-limit 64
Memory Bomb --memory 256m
CPU Exhaustion --ulimit cpu=5
Disk Fill tmpfs with size limits
Infinite Loop Wall clock timeout + CPU limit
Output Flood Output truncation (1MB)

Container Escape

Docker:

  • User namespace isolation (UID 65532 inside = UID 65532 outside)
  • No capabilities (can't mount, modify networking, etc.)
  • No new privileges (can't escalate)
  • Kernel exploit still possible (use Docker-in-Docker for defense-in-depth)

Kubernetes:

  • Pod Security Standards (enforce restricted profile)
  • Runtime hardening (e.g., gVisor, Kata Containers)
  • Node isolation (dedicated node pools for untrusted workloads)

Data Exfiltration

Docker:

  • --network none: Complete network isolation
  • No DNS, no HTTP, no external connectivity

Kubernetes:

  • NetworkPolicy: Deny all egress (recommended)
  • Without NetworkPolicy: Risk of data exfiltration via network

Recommendation for Production:

Always apply NetworkPolicy in Kubernetes:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: executor-deny-egress
spec:
  podSelector:
    matchLabels:
      component: executor
  policyTypes:
  - Egress
  egress: []  # Deny all egress

Last-Line Interactive Mode

The service supports a Jupyter-like REPL behavior where the last expression's value is automatically printed.

Implementation

Code Wrapping:

def wrap_last_line_interactive(code: str) -> str:
    """
    Wraps user code to execute the last line in Python's 'single' mode.
    This mimics Jupyter notebook behavior: bare expressions print their value.
    """
    # Parses AST, executes all but last line normally
    # Last line: if it's an expression, compile with mode='single' and exec
    # Python's 'single' mode auto-prints expression values

Example:

# User code
x = 10
y = 20
x + y  # Last line is an expression

# Wrapped code (simplified)
tree = ast.parse(code)
for node in tree.body[:-1]:
    exec(compile(node))  # Execute normally

last_node = tree.body[-1]
if isinstance(last_node, ast.Expr):
    exec(compile(last_node, mode='single'))  # Auto-prints value

Result:

stdout: "30\n"

Behavior:

  • Only the last line is affected
  • Earlier expressions are not printed
  • Statements (assignments, imports, etc.) don't print anything
  • Matches Jupyter notebook UX

Control:

{
  "last_line_interactive": true  // Enable (default)
}

Set to false for traditional script behavior (no auto-printing).

File Management

File Injection

Request Format:

{
  "code": "import pandas as pd\ndf = pd.read_csv('data.csv')\ndf.head()",
  "files": [
    {
      "path": "data.csv",
      "content": "base64-encoded-content"
    }
  ]
}

Process:

  1. Validation: Path validated (no .., no absolute paths, no __main__.py)
  2. Directory Creation: Parent directories created in tar archive
  3. Tar Injection: Files added to tar with correct ownership (65532:65532)
  4. Extraction: Tar streamed into container/Pod workspace
  5. Execution: Code can access files via relative paths

File Extraction

After execution, any files created in /workspace (except __main__.py) are extracted.

Process:

  1. Tar Creation: tar -c --exclude=__main__.py -C /workspace .
  2. Streaming: Tar archive streamed out via stdout
  3. Extraction: Files extracted from tar, content captured
  4. Response: Files returned as WorkspaceEntry[] with base64-encoded content

Response Format:

{
  "files": [
    {
      "path": "output.png",
      "kind": "file",
      "content": "base64-encoded-image"
    },
    {
      "path": "results/",
      "kind": "directory",
      "content": null
    }
  ]
}

File Storage API

The service also provides a file storage API for managing uploaded files.

Upload:

POST /v1/files
Content-Type: multipart/form-data

Storage:

  • Files stored in FILE_STORAGE_DIR (default: /tmp/code-interpreter-files)
  • UUIDs used as file identifiers
  • TTL-based cleanup (default: 3600 seconds)
  • Size limits enforced (default: 100MB per file)

Usage in Execution:

{
  "code": "import pandas as pd\ndf = pd.read_csv('data.csv')",
  "files": [
    {
      "path": "data.csv",
      "file_id": "uuid-from-upload"
    }
  ]
}

Security:

  • Path validation prevents directory traversal
  • Size limits prevent disk exhaustion
  • TTL prevents unbounded storage growth
  • Files isolated per request (no cross-request access)

Summary

The code-interpreter service provides secure, isolated Python execution through:

  1. Strong Isolation: Docker/Kubernetes containers with restricted capabilities
  2. Resource Limits: Memory, CPU, process, and output limits prevent exhaustion
  3. Minimal Privileges: All code runs as unprivileged user (UID 65532)
  4. Network Isolation: No network access by default (Docker) or via NetworkPolicy (Kubernetes)
  5. Ephemeral Storage: No persistent data, preventing cross-execution contamination
  6. Defense in Depth: Multiple overlapping security controls at every layer

Recommended Deployment:

  • Development: Docker executor with Docker-out-of-Docker
  • Production: Kubernetes executor with:
    • NetworkPolicy (deny all egress)
    • Pod Security Standards (restricted profile)
    • Dedicated node pools for untrusted workloads
    • Resource quotas and limits
    • Monitoring and alerting on execution metrics

This architecture balances security, performance, and operational simplicity while providing strong guarantees against malicious or buggy code.