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​