Skip to main content

Sandboxing Agents: Containment and Environment Isolation

An agent running code in your production environment is an attack surface and a liability. Without sandboxing, an agent bug could delete production data, exfiltrate secrets, or exhaust system resources. Sandboxing isolates the agent: it gets a limited filesystem, no network access, capped memory, and no ability to affect the host. This article covers sandboxing strategies from lightweight process isolation to full virtual machines.

The Threat Model

What can go wrong?

  1. Resource exhaustion: Agent spawns a runaway process that consumes all CPU/memory.
  2. Unauthorized access: Agent reads files it shouldn't (secrets, other users' data).
  3. Exfiltration: Agent sends data to external servers.
  4. Persistence: Agent modifies system files, installs backdoors.
  5. Privilege escalation: Agent exploits OS to gain root access.

Sandboxing mitigates each through layers.

Layer 1: Process-Level Isolation (Lightweight)

At minimum, run agents in a separate process with resource quotas (covered in article 5). This is fast but weak:

import resource
import subprocess

def run_agent_process(agent_code: str, timeout: int = 30) -> dict:
"""
Run agent in a subprocess with resource limits.
Weak isolation, but fast.
"""

def set_limits():
# Memory limit (500 MB)
resource.setrlimit(resource.RLIMIT_AS, (500*1024*1024, 500*1024*1024))
# CPU time limit (2x the wall-clock timeout)
resource.setrlimit(resource.RLIMIT_CPU, (timeout*2, timeout*2))
# File size limit (1 GB)
resource.setrlimit(resource.RLIMIT_FSIZE, (1024*1024*1024, 1024*1024*1024))
# Open file descriptors (100)
resource.setrlimit(resource.RLIMIT_NOFILE, (100, 100))

proc = subprocess.Popen(
["python", "-c", agent_code],
preexec_fn=set_limits,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True
)

try:
stdout, stderr = proc.communicate(timeout=timeout)
except subprocess.TimeoutExpired:
proc.kill()
stdout, stderr = "", f"Timed out after {timeout}s"

return {
"stdout": stdout,
"stderr": stderr,
"exit_code": proc.returncode
}

Pros: Fast, minimal overhead.
Cons: Weak isolation; agent can still read most files, make network requests.

Run agents in isolated Docker containers. Each agent gets:

  • Isolated filesystem: Only /app and /tmp visible.
  • No network: Cannot make outbound requests.
  • Capped resources: Memory, CPU, disk quotas.
  • Ephemeral: Container deleted after execution (no persistence).
import docker
import uuid
import time

class ContainerizedAgent:
"""Run agents in Docker containers for strong isolation."""

def __init__(self, image: str = "python:3.11-slim"):
self.client = docker.from_env()
self.image = image

def run_agent(self, agent_code: str,
timeout: int = 30,
memory_limit: str = "512m") -> dict:
"""
Execute agent code in an isolated container.
"""

# Unique container name
container_id = str(uuid.uuid4())[:8]

try:
container = self.client.containers.run(
self.image,
["python", "-c", agent_code],
name=f"agent-{container_id}",
detach=True,

# Resource limits
mem_limit=memory_limit, # 512 MB max
cpuset_cpus="0", # Single CPU

# Filesystem isolation
read_only=True, # Filesystem read-only
tmpfs={"/tmp": "size=100m"}, # Temp dir (in-memory)

# Network isolation
network_disabled=True, # No network

# Security
user="nobody", # Non-root user
cap_drop=["ALL"], # Drop all capabilities
security_opt=["no-new-privileges:true"],

# Cleanup
auto_remove=False # We'll remove manually
)

# Wait for completion with timeout
try:
exit_code = container.wait(timeout=timeout)["StatusCode"]
logs = container.logs().decode('utf-8')
except docker.errors.APIError:
# Timeout
container.kill()
container.wait()
logs = ""
exit_code = 124 # Timeout exit code

return {
"success": exit_code == 0,
"exit_code": exit_code,
"output": logs,
"timed_out": exit_code == 124
}

except Exception as e:
return {
"success": False,
"error": str(e),
"exit_code": -1
}

finally:
# Cleanup: remove container
try:
container.remove(force=True)
except:
pass

Pros:

  • Strong isolation: agent cannot see host filesystem.
  • No network access by default.
  • Automatic cleanup (ephemeral).
  • Easy to reproduce (containers are identical).

Cons:

  • Slower startup (1–2s overhead per container).
  • Docker daemon required.
  • More complex operations (image management).

Layer 3: Virtual Machine Isolation (Stronghold)

For maximum security (e.g., untrusted user code), run agents in lightweight VMs using KVM or Firecracker:

import subprocess
import time

class FirecrackerAgent:
"""
Run agents in Firecracker microVMs for maximum isolation.
Firecracker: lightweight VMs optimized for function execution.
"""

def __init__(self, vm_image: str):
self.vm_image = vm_image # Path to rootfs

def run_agent(self, agent_code: str, timeout: int = 30) -> dict:
"""
Execute agent in a Firecracker VM.
"""

# Write agent code to temp file (will be passed to VM)
code_file = f"/tmp/agent-{uuid.uuid4()}.py"
with open(code_file, 'w') as f:
f.write(agent_code)

# Firecracker CLI (simplified; real version is more complex)
cmd = [
"firecracker",
"--config-file", self._make_firecracker_config(code_file),
]

try:
result = subprocess.run(
cmd,
capture_output=True,
timeout=timeout + 5,
text=True
)

return {
"success": result.returncode == 0,
"exit_code": result.returncode,
"output": result.stdout,
"timed_out": False
}
except subprocess.TimeoutExpired:
return {
"success": False,
"error": "VM execution timed out",
"timed_out": True,
"exit_code": -1
}
finally:
# Cleanup
try:
os.remove(code_file)
except:
pass

def _make_firecracker_config(self, code_file: str) -> dict:
"""Generate Firecracker configuration."""
return {
"vm_config": {
"vcpu_count": 1,
"memory_size_mib": 256,
"ht_enabled": False
},
"kernel": {"kernel_image_path": "/vmlinuz"},
"rootfs_file": self.vm_image,
"network_interfaces": [] # No network
}

Pros:

  • Maximum isolation: agent runs in separate OS kernel.
  • Cannot escape VM (escape requires kernel exploit).
  • Can run any OS (Linux, Windows, etc.).

Cons:

  • Slow startup (5–10s per VM).
  • Higher memory overhead (256 MB–1 GB per VM).
  • Complex setup (custom images, kernel configuration).

Strategy: Multi-Layer Sandbox Stack

In production, combine layers:

Layer 1: Process-level quotas (CPU, memory, file descriptors)

Layer 2: Docker container (filesystem, network isolation)

Layer 3: Read-only mounts (agent cannot write to production code)

Layer 4: SELinux/AppArmor labels (mandatory access control)

Layer 5: Network policies (no egress except to allowlist)

A real production setup:

def run_agent_production(agent_code: str,
agent_inputs: dict,
timeout: int = 30) -> dict:
"""
Production agent execution: max isolation + auditability.
"""

# Step 1: Validate inputs (prevent injection)
for key, value in agent_inputs.items():
if not isinstance(value, (str, int, float, bool, type(None))):
return {"error": "Invalid input type"}

# Step 2: Create isolated container
container_config = {
"image": "agent-sandbox:latest",
"memory_limit": "512m",
"cpu_count": 1,
"network_disabled": True,
"read_only_filesystem": True,
"tmpfs_size": "100m",
"environment_variables": agent_inputs,
"audit_log": f"/var/log/agent-{uuid.uuid4()}.log"
}

# Step 3: Run in container
executor = ContainerizedAgent(image=container_config["image"])
result = executor.run_agent(
agent_code,
timeout=timeout,
memory_limit=container_config["memory_limit"]
)

# Step 4: Log for audit trail
with open(container_config["audit_log"], 'a') as f:
f.write(json.dumps({
"timestamp": time.time(),
"agent_code_hash": hashlib.sha256(agent_code.encode()).hexdigest(),
"exit_code": result["exit_code"],
"timed_out": result.get("timed_out", False),
"output_size": len(result.get("output", ""))
}) + '\n')

return result

Allowlisting vs. Denylisting

Use allowlists (whitelist approach): only allow what agents must do.

class SandboxPolicy:
"""Define what agents can do."""

ALLOWED_COMMANDS = [
"python",
"pytest",
"black",
"pylint"
]

ALLOWED_FILES = [
"/app/src/", # Code to modify
"/app/tests/", # Tests
"/tmp/", # Temp space
]

BLOCKED_PATTERNS = [
"rm -rf",
"/etc/passwd",
"curl",
"wget",
">& /dev/tcp/" # Network tricks
]

@staticmethod
def validate_command(cmd: str) -> bool:
"""Check if command is allowed."""
base_cmd = cmd.split()[0]
if base_cmd not in SandboxPolicy.ALLOWED_COMMANDS:
return False

for blocked in SandboxPolicy.BLOCKED_PATTERNS:
if blocked in cmd:
return False

return True

Key Takeaways

  • Process isolation: Fast but weak; use resource limits.
  • Container isolation: Strong isolation with reasonable overhead; recommended for production.
  • VM isolation: Maximum security; use for untrusted code.
  • Multi-layer: Combine process, container, and network policies for defense-in-depth.
  • Allowlisting: Define what agents can do (whitelist), not what they cannot.
  • Audit logging: Log every agent execution for compliance and debugging.

Frequently Asked Questions

What if an agent needs to run a command not in the allowlist?

Either add it to the allowlist (after review), or escalate the task to a human. Never allow agents to execute arbitrary commands.

Can agents read my source code from within a container?

No—read-only mount prevents modification. Readable but not writable. If you need stronger privacy, encrypt source code or use a different approach (agent doesn't see source, only API specs).

What's the cost of containerized execution?

Docker startup is ~200–500 ms per container. For an agent that runs 50 commands in a session, amortized cost is ~10 ms per command (acceptable). For large batch jobs, consider persistent agents within a single container.

How do I debug an agent that fails in sandbox?

Recreate the container locally with the same image and run the agent code manually. Containers are reproducible—if it fails in production, it fails identically locally.

Further Reading