Skip to main content
Lesson 7 of 14 · AI Security and Red Teaming

Sandboxing and isolation

Lesson 7 of 14 Updated July 28, 2026 Advanced
On this page
  1. Why isolate AI agents?
  2. Level 1: Restrict resource access
  3. Level 2: Control outbound traffic
  4. Level 3: Execute code without trusting it with the machine
  5. Choosing the right level
  6. Key takeaways

Why isolate AI agents?

An AI agent that executes code or calls tools is fundamentally different from a simple chatbot. A compromised chatbot produces, at worst, an embarrassing response; a compromised agent acts—it can run system commands, read sensitive files, or communicate with external services. All the defenses covered so far aimed to prevent compromise. Sandboxing changes the posture: it assumes compromise will eventually happen and focuses on limiting what an attacker can do once inside. This is a fundamental shift, which is why sandboxing doesn’t replace any of the previous layers.

Level 1: Restrict resource access

The first level applies the principle of least privilege to files and commands. The key technical point is using resolve() before comparison: without it, a path like /data/app/uploads/../../etc/passwd starts with the allowed directory and would pass a naive prefix check. Resolving the real path defeats this traversal, which remains one of the most common attacks against agents that manipulate files.

import os
from pathlib import Path

class SandboxPermissions:
    """Controls access permissions to resources."""

    def __init__(self, allowed_folder: str, allowed_commands: list[str]):
        self.allowed_folder = Path(allowed_folder).resolve()
        self.allowed_commands = set(allowed_commands)

    def verify_path(self, path: str) -> bool:
        """Verifies that a path is within the allowed folder."""
        resolved_path = Path(path).resolve()
        try:
            resolved_path.relative_to(self.allowed_folder)
            return True
        except ValueError:
            return False

    def verify_command(self, command: str) -> bool:
        """Verifies that a command is in the allowlist."""
        cmd_base = command.split()[0] if command else ""
        return cmd_base in self.allowed_commands

    def read_file(self, path: str) -> str:
        """Secure reading with path verification."""
        if not self.verify_path(path):
            raise PermissionError(
                f"Access denied: {path} is outside the allowed folder"
            )
        return Path(path).read_text()

# Usage
sandbox = SandboxPermissions(
    allowed_folder="/data/app/uploads",
    allowed_commands=["ls", "cat", "wc"],
)

# OK: file in allowed folder
try:
    content = sandbox.read_file("/data/app/uploads/report.txt")
except PermissionError as e:
    print(f"Blocked: {e}")

# Blocked: path traversal attempt
try:
    content = sandbox.read_file("/data/app/uploads/../../etc/passwd")
except PermissionError as e:
    print(f"Blocked: {e}")

Commands follow the same logic, using an allowlist rather than a blocklist. The asymmetry is decisive: a blocklist forces you to imagine every dangerous command—an exercise you will lose—whereas an allowlist only requires you to enumerate what the agent actually needs, often just three or four commands.

Level 2: Control outbound traffic

Network isolation addresses the exfiltration scenario discussed earlier. As long as the agent can send an HTTP request to any domain, any data it touches can leave your perimeter, and the outbound traffic will look like ordinary application traffic. Restricting destinations to an explicit list turns an exfiltration attempt into a logged exception.

import subprocess

class NetworkSandbox:
    """Controls the agent's network communications."""

    def __init__(self, allowed_domains: list[str]):
        self.allowed_domains = set(allowed_domains)

    def is_allowed_request(self, url: str) -> bool:
        """Verifies that a URL is in the allowlist."""
        from urllib.parse import urlparse
        domain = urlparse(url).hostname
        return domain in self.allowed_domains

    def secure_fetch(self, url: str) -> str:
        """HTTP request with domain verification."""
        if not self.is_allowed_request(url):
            raise PermissionError(f"Domain not allowed: {url}")
        import urllib.request
        with urllib.request.urlopen(url, timeout=10) as resp:
            return resp.read().decode()

sandbox_net = NetworkSandbox(
    allowed_domains=["api.openai.com", "api.anthropic.com"]
)

# OK
try:
    sandbox_net.secure_fetch("https://api.openai.com/v1/models")
except PermissionError as e:
    print(e)

# Blocked: exfiltration to an external domain
try:
    sandbox_net.secure_fetch("https://attacker.com/collect")
except PermissionError as e:
    print(f"Blocked: {e}")

Level 3: Execute code without trusting it with the machine

Running code written by a model is the most delicate case. The implementation below combines three protections: import checking before execution, memory and CPU time limits set within the script itself, and an environment reduced to the bare minimum when launching the subprocess.

A warning is necessary regarding import verification. It relies on text search, and a careful attacker will bypass it—__import__("os") does not contain the searched string. Treat this verification as a guardrail against mistakes and naive attempts, never as a security boundary. The real boundary is the isolated process with its resource limits.

import subprocess
import tempfile
import os

class ExecutionSandbox:
    """Executes model-generated code in an isolated environment."""

    def __init__(self, timeout: int = 10, max_memory_mb: int = 256):
        self.timeout = timeout
        self.max_memory = max_memory_mb

    def execute_python(self, code: str) -> dict:
        """Executes Python code in an isolated process."""
        # Security checks before execution
        dangerous_imports = ["os", "subprocess", "shutil", "socket", "requests"]
        for imp in dangerous_imports:
            if f"import {imp}" in code or f"from {imp}" in code:
                return {
                    "success": False,
                    "error": f"Forbidden import: {imp}",
                    "output": "",
                }

        # Create a temporary file
        with tempfile.NamedTemporaryFile(
            mode="w", suffix=".py", delete=False
        ) as f:
            # Add restrictions at the start of the script
            wrapper = f"""
import resource
import sys

# Limit memory
resource.setrlimit(resource.RLIMIT_AS, ({self.max_memory * 1024 * 1024}, {self.max_memory * 1024 * 1024}))

# Limit CPU time
resource.setrlimit(resource.RLIMIT_CPU, ({self.timeout}, {self.timeout}))

# User code
{code}
"""
            f.write(wrapper)
            temp_file = f.name

        try:
            result = subprocess.run(
                ["python3", temp_file],
                capture_output=True,
                text=True,
                timeout=self.timeout,
                env={"PATH": "/usr/bin", "HOME": "/tmp"},
            )
            return {
                "success": result.returncode == 0,
                "output": result.stdout[:5000],
                "error": result.stderr[:2000] if result.returncode != 0 else "",
            }
        except subprocess.TimeoutExpired:
            return {"success": False, "error": "Timeout exceeded", "output": ""}
        finally:
            os.unlink(temp_file)

# Usage
sandbox = ExecutionSandbox(timeout=5, max_memory_mb=128)

# OK: simple calculation
result = sandbox.execute_python("print(sum(range(100)))")
print(result)  # {"success": True, "output": "4950\n", "error": ""}

# Blocked: system access attempt
result = sandbox.execute_python("import os; os.listdir('/')")
print(result)  # {"success": False, "error": "Forbidden import: os"}

For production cases, Docker provides more robust isolation because it no longer relies on the cooperation of the executed code. Each command option closes a specific door: --network=none removes any possibility of network exfiltration, --read-only prevents persistence, --memory and --cpus cap consumption, --tmpfs grants volatile and capped workspace, and --security-opt=no-new-privileges forbids escalation during execution. The --rm flag destroys the container at the end, guaranteeing that no state survives from one execution to the next.

class DockerSandbox:
    """Executes code in an ephemeral Docker container."""

    def __init__(self, image: str = "python:3.12-slim"):
        self.image = image

    def execute(self, code: str, timeout: int = 10) -> dict:
        """Executes code in an isolated container."""
        cmd = [
            "docker", "run", "--rm",
            "--network=none",          # No network
            "--memory=256m",           # Memory limit
            "--cpus=0.5",              # CPU limit
            "--read-only",             # Read-only filesystem
            "--tmpfs=/tmp:size=50m",   # Limited temporary space
            "--security-opt=no-new-privileges",
            self.image,
            "python3", "-c", code,
        ]
        try:
            result = subprocess.run(
                cmd, capture_output=True, text=True, timeout=timeout
            )
            return {
                "success": result.returncode == 0,
                "output": result.stdout[:5000],
                "error": result.stderr[:2000],
            }
        except subprocess.TimeoutExpired:
            return {"success": False, "error": "Timeout", "output": ""}

Choosing the right level

Stacking all three levels on a simple FAQ chatbot is costly and unnecessary, while relying only on the first level for an agent that executes code amounts to protecting nothing. The table below provides a starting point; adjust it based on the sensitivity of the accessible data, which often matters more than the agent’s sophistication.

Scenario Recommended level
Simple Q&A chatbot Permissions (Level 1)
Agent with API access Permissions + Network (Levels 1-2)
Agent executing code Full Docker (Level 3)
Multi-user agent Docker + session isolation

Whatever level you choose, test it as you would test a feature. A sandbox that you install and then forget silently degrades over time—an added dependency, a volume mounted “temporarily,” a domain opened for troubleshooting. Re-run path traversal and forbidden domain tests with every agent update: a sandbox bypass is not a minor anomaly, it’s a critical incident.

Key takeaways

  • Sandboxing is essential as soon as an agent has access to tools or executes code
  • Three levels of isolation: permissions, network, container—combine based on risk
  • The principle of least privilege applies to every resource: files, network, commands
  • Docker with --network=none and --read-only provides the most robust isolation
  • Isolation must be tested regularly—a sandbox bypass is a critical incident