Skip to main content

Sandboxing Agent Environments

Reading time: 28 min | Relevance: Platform Engineers, ML Infrastructure, Agent Safety Teams


The Unrestricted Agent Problem

An agent with unrestricted shell access can do anything: rm -rf /, exfiltrate all data to a remote server, install a backdoor, call every external API without limit, or simply fork-bomb the host into unusability.

The sandbox is not about trusting or distrusting the agent. It is about a safety engineering principle that applies to every complex system: contain the blast radius of any failure, regardless of its cause. The agent might be confused by a prompt injection attack, executing code it retrieved from a malicious website, or simply hallucinating a dangerous command. None of these failure modes are speculative - all have occurred in deployed systems.

The sandbox contains potential damage regardless of what the agent decides to do. And it does so independently of any other safety layer - guards, filters, policies. Defense in depth means the sandbox is the last line of defense that cannot be social-engineered.


:::tip 🎮 Interactive Playground Visualize this concept: Try the Agent Sandboxing demo on the EngineersOfAI Playground - no code required. :::

Why This Exists

Early computer security established the principle of least privilege: every component should have only the minimum access it needs to accomplish its function. Unix file permissions, user namespaces, and chroot jails were all implementations of this principle.

The container revolution (Docker 2013, Kubernetes 2014) made process isolation operationally simple. Security researchers documented countless container escape vulnerabilities - CVE-2019-5736 (runc), CVE-2020-15257 (containerd) - pushing the industry toward stronger isolation primitives.

Firecracker (Amazon 2018), developed for AWS Lambda, pioneered microVM isolation: hardware-level separation with container-class startup times (125ms). This made per-task VM isolation feasible for the first time.

For agentic AI specifically, the challenge is that code-executing agents are the most powerful and most dangerous class of agents. When an LLM generates code and immediately executes it, every safety property of the LLM collapses into the safety properties of the execution environment. A perfect LLM running in an unrestricted environment is more dangerous than a mediocre LLM running in a tight sandbox.


Sandboxing Levels

From weakest to strongest, each level adds isolation at the cost of complexity and startup time:

The right level depends on what the agent can do. For a coding agent executing arbitrary user-provided or LLM-generated code, anything below Level 2 is insufficient. For agents executing financial transactions or managing production infrastructure, Level 3 or 4 is appropriate.


What to Restrict

A sandboxed agent environment should restrict every dimension of the execution environment:

DimensionWhat to RestrictHow
FilesystemWhich paths are readable/writableMount namespace, read-only root FS, tmpfs for /tmp
NetworkWhich hosts/IPs can be reachedNetwork namespace, egress allowlist, iptables
SyscallsWhich kernel calls are allowedseccomp-bpf filter
ProcessesWhether new processes can be spawnedno-new-privileges, pid namespace
CapabilitiesLinux kernel capabilitiesDrop all except needed
ResourcesCPU, memory, disk, timecgroups v2
DevicesWhich devices are accessibleDevice cgroup, read-only /dev

Level 1: Process Isolation with seccomp

The minimum viable sandbox for a subprocess executing agent-generated code:

import subprocess
import json
import os
import resource
import signal
from typing import Optional


# Minimal seccomp profile in JSON format (for use with libseccomp or Docker)
# This allowlist blocks most dangerous syscalls while permitting basic execution
MINIMAL_SECCOMP_PROFILE = {
"defaultAction": "SCMP_ACT_ERRNO",
"architectures": ["SCMP_ARCH_X86_64", "SCMP_ARCH_AARCH64"],
"syscalls": [
{
"names": [
# Core execution
"read", "write", "open", "openat", "close", "fstat", "lstat", "stat",
"mmap", "mprotect", "munmap", "brk", "rt_sigaction", "rt_sigprocmask",
"rt_sigreturn", "ioctl", "pread64", "pwrite64", "readv", "writev",
"access", "pipe", "select", "sched_yield", "mremap", "msync",
"mincore", "madvise", "dup", "dup2", "pause", "nanosleep", "getitimer",
"alarm", "setitimer", "getpid", "sendfile", "socket", "connect",
"accept", "sendto", "recvfrom", "sendmsg", "recvmsg", "shutdown",
"bind", "listen", "getsockname", "getpeername", "socketpair",
"setsockopt", "getsockopt", "clone", "fork", "vfork", "execve",
"exit", "wait4", "kill", "uname", "fcntl", "flock", "fsync",
"fdatasync", "truncate", "ftruncate", "getdents", "getcwd", "chdir",
"rename", "mkdir", "rmdir", "creat", "link", "unlink", "symlink",
"readlink", "chmod", "fchmod", "chown", "fchown", "lchown", "umask",
"gettimeofday", "getrlimit", "getrusage", "sysinfo", "times",
"getuid", "syslog", "getgid", "setuid", "setgid", "geteuid",
"getegid", "setpgid", "getppid", "getpgrp", "setsid", "setreuid",
"setregid", "getgroups", "setgroups", "setresuid", "getresuid",
"setresgid", "getresgid", "getpgid", "setfsuid", "setfsgid",
"getsid", "capget", "capset", "rt_sigsuspend", "sigaltstack",
"utime", "mknod", "uselib", "personality", "ustat", "statfs",
"fstatfs", "sysfs", "getpriority", "setpriority", "sched_setparam",
"sched_getparam", "sched_setscheduler", "sched_getscheduler",
"sched_get_priority_max", "sched_get_priority_min",
"sched_rr_get_interval", "mlock", "munlock", "mlockall", "munlockall",
"vhangup", "modify_ldt", "pivot_root", "prctl", "arch_prctl",
"adjtimex", "setrlimit", "chroot", "sync", "acct", "settimeofday",
"getrandom", "memfd_create", "copy_file_range", "preadv2", "pwritev2",
"futex", "set_tid_address", "set_robust_list", "get_robust_list",
"clock_gettime", "clock_getres", "clock_nanosleep", "exit_group",
"epoll_create", "epoll_ctl", "epoll_wait", "epoll_create1",
"eventfd", "eventfd2", "timerfd_create", "timerfd_settime",
"timerfd_gettime", "signalfd", "signalfd4", "inotify_init",
"inotify_add_watch", "inotify_rm_watch", "inotify_init1",
"pipe2", "pselect6", "ppoll", "splice", "tee", "vmsplice",
"move_pages", "utimensat", "epoll_pwait", "newfstatat",
"unlinkat", "renameat", "linkat", "symlinkat", "readlinkat",
"fchmodat", "faccessat", "paccept", "accept4", "recvmmsg",
"sendmmsg", "socket", "socketpair", "setsockopt", "getsockopt",
"connect", "getdents64", "wait4", "waitpid", "waitid",
"openat2", "faccessat2",
],
"action": "SCMP_ACT_ALLOW",
},
# Block dangerous syscalls explicitly
{
"names": [
"ptrace", # Process inspection / debugging
"process_vm_readv", # Cross-process memory read
"process_vm_writev", # Cross-process memory write
"kexec_load", # Load new kernel
"perf_event_open", # Performance monitoring (info leak)
"bpf", # eBPF programs
"userfaultfd", # Page fault handler (exploitation primitive)
],
"action": "SCMP_ACT_ERRNO",
}
]
}


class ProcessSandbox:
"""
Minimal process-level sandbox for executing untrusted code snippets.
Uses resource limits and subprocess isolation - not sufficient for
high-risk code execution, but suitable for low-risk compute tasks.
"""

def __init__(
self,
max_memory_mb: int = 512,
max_cpu_seconds: int = 30,
max_output_bytes: int = 1_048_576, # 1 MB
):
self.max_memory_mb = max_memory_mb
self.max_cpu_seconds = max_cpu_seconds
self.max_output_bytes = max_output_bytes

def run_python(self, code: str, stdin: str = "") -> dict:
"""
Execute Python code in a subprocess with resource limits.
Returns {"stdout": str, "stderr": str, "returncode": int, "timed_out": bool}
"""
def preexec():
# Set resource limits before exec
memory_bytes = self.max_memory_mb * 1024 * 1024
resource.setrlimit(resource.RLIMIT_AS, (memory_bytes, memory_bytes))
resource.setrlimit(
resource.RLIMIT_CPU,
(self.max_cpu_seconds, self.max_cpu_seconds)
)
# Prevent core dumps
resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
# No new file descriptors beyond reasonable count
resource.setrlimit(resource.RLIMIT_NOFILE, (64, 64))
os.setsid()

try:
proc = subprocess.Popen(
["python3", "-c", code],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
preexec_fn=preexec,
)

try:
stdout, stderr = proc.communicate(
input=stdin.encode(),
timeout=self.max_cpu_seconds + 5,
)
except subprocess.TimeoutExpired:
proc.kill()
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
return {
"stdout": "",
"stderr": "Execution timed out",
"returncode": -1,
"timed_out": True,
}

return {
"stdout": stdout[:self.max_output_bytes].decode("utf-8", errors="replace"),
"stderr": stderr[:self.max_output_bytes].decode("utf-8", errors="replace"),
"returncode": proc.returncode,
"timed_out": False,
}

except Exception as e:
return {"stdout": "", "stderr": str(e), "returncode": -1, "timed_out": False}

Level 2: Docker Container Sandboxing

Docker provides much stronger isolation: separate filesystem namespace, network namespace, PID namespace, user namespace. Combined with security hardening options, this is appropriate for production coding agents:

import docker
import tarfile
import io
import tempfile
import os
from dataclasses import dataclass


@dataclass
class ContainerConfig:
"""Security-hardened Docker container configuration for agent execution."""

image: str = "python:3.12-slim"
memory_limit: str = "512m"
cpu_quota: int = 50000 # 50% of one CPU core (100000 = 1 core)
timeout_seconds: int = 30
network_disabled: bool = True # Start with no network; enable per allowlist
read_only_root: bool = True # Root filesystem is read-only
allowed_egress_hosts: list[str] = None # None = no egress; [] = no egress; [host,...] = allowlisted

# Capabilities to drop (drop all, then add back only what's needed)
drop_capabilities: list[str] = None

def __post_init__(self):
if self.drop_capabilities is None:
# Drop ALL capabilities - agent code should not need any
self.drop_capabilities = [
"ALL" # Docker syntax: drop all, add back nothing
]


class DockerCodeSandbox:
"""
Production Docker-based sandbox for agent code execution.

Security properties:
- Read-only root filesystem (writes only to tmpfs mounts)
- No network by default (opt-in egress allowlist)
- All Linux capabilities dropped
- No-new-privileges prevents capability escalation
- Non-root user inside container
- CPU and memory cgroup limits
- Per-execution ephemeral containers (no state leakage between tasks)
- Seccomp profile applied

Usage:
sandbox = DockerCodeSandbox()
result = sandbox.execute_python(code="print('hello')", files={"data.txt": "content"})
"""

DOCKERFILE_TEMPLATE = """
FROM python:3.12-slim

# Create non-root user
RUN groupadd -r sandboxuser && useradd -r -g sandboxuser -d /workspace sandboxuser

# Install common packages agents might need
RUN pip install --no-cache-dir requests pandas numpy scipy matplotlib 2>/dev/null || true

# Set up workspace
RUN mkdir -p /workspace && chown sandboxuser:sandboxuser /workspace

# Switch to non-root user
USER sandboxuser
WORKDIR /workspace
"""

def __init__(self, config: ContainerConfig = None):
self.config = config or ContainerConfig()
self.client = docker.from_env()
self._ensure_image()

def _ensure_image(self):
"""Build the sandboxed execution image if not present."""
image_tag = "agent-sandbox:latest"
try:
self.client.images.get(image_tag)
self.sandbox_image = image_tag
except docker.errors.ImageNotFound:
print("Building sandbox image...")
dockerfile_bytes = self.DOCKERFILE_TEMPLATE.encode()
image, _ = self.client.images.build(
fileobj=io.BytesIO(dockerfile_bytes),
tag=image_tag,
rm=True,
)
self.sandbox_image = image_tag

def execute_python(
self,
code: str,
files: dict[str, str] = None,
env_vars: dict[str, str] = None,
timeout: int = None,
) -> dict:
"""
Execute Python code in an ephemeral hardened container.

Args:
code: Python source code to execute
files: Additional files to inject into /workspace {filename: content}
env_vars: Environment variables to inject (use for secrets - not command args)
timeout: Override default timeout in seconds

Returns:
{"stdout": str, "stderr": str, "exit_code": int, "files": dict}
"""
timeout = timeout or self.config.timeout_seconds
files = files or {}
env_vars = env_vars or {}

container = None
try:
# Build the tar archive to inject into the container
tar_buffer = self._build_tar(code, files)

# Container run options - all security hardening applied here
run_options = {
"image": self.sandbox_image,
"command": ["python3", "/workspace/main.py"],
"detach": True,
"remove": False, # Remove manually after collecting output
"mem_limit": self.config.memory_limit,
"cpu_quota": self.config.cpu_quota,
"cpu_period": 100000,
"network_disabled": self.config.network_disabled,
"read_only": self.config.read_only_root,
"tmpfs": {
"/workspace": "exec,size=100m", # Writable workspace in tmpfs
"/tmp": "exec,size=64m", # Writable /tmp in tmpfs
},
"security_opt": [
"no-new-privileges:true",
f"seccomp:{json.dumps(MINIMAL_SECCOMP_PROFILE)}",
],
"cap_drop": self.config.drop_capabilities,
"environment": env_vars,
"user": "sandboxuser",
"working_dir": "/workspace",
}

# If egress is allowed, configure network with iptables instead of disabling
if self.config.allowed_egress_hosts:
run_options["network_disabled"] = False
# Note: in production, use a network namespace with iptables egress rules
# This is a placeholder - actual egress filtering requires host-level iptables

container = self.client.containers.run(**run_options)

# Inject files into the container
container.put_archive("/workspace", tar_buffer.getvalue())
container.start()

# Wait for completion with timeout
try:
result = container.wait(timeout=timeout)
exit_code = result.get("StatusCode", -1)
timed_out = False
except Exception:
container.kill()
exit_code = -1
timed_out = True

stdout = container.logs(stdout=True, stderr=False).decode("utf-8", errors="replace")
stderr = container.logs(stdout=False, stderr=True).decode("utf-8", errors="replace")

# Collect output files from /workspace
output_files = {}
try:
bits, _ = container.get_archive("/workspace/output")
output_files = self._extract_tar(bits)
except Exception:
pass # No output directory is fine

return {
"stdout": stdout[:1_048_576],
"stderr": stderr[:65536],
"exit_code": exit_code,
"timed_out": timed_out,
"output_files": output_files,
}

finally:
if container:
try:
container.remove(force=True)
except Exception:
pass

def _build_tar(self, code: str, files: dict[str, str]) -> io.BytesIO:
"""Build a tar archive containing main.py and any additional files."""
buffer = io.BytesIO()
with tarfile.open(fileobj=buffer, mode="w") as tar:
def add_file(name: str, content: str):
encoded = content.encode("utf-8")
info = tarfile.TarInfo(name=name)
info.size = len(encoded)
tar.addfile(info, io.BytesIO(encoded))

add_file("main.py", code)
for filename, content in files.items():
add_file(filename, content)

buffer.seek(0)
return buffer

def _extract_tar(self, tar_stream) -> dict[str, str]:
"""Extract files from a tar stream returned by container.get_archive."""
files = {}
buffer = io.BytesIO(b"".join(tar_stream))
with tarfile.open(fileobj=buffer, mode="r") as tar:
for member in tar.getmembers():
if member.isfile():
f = tar.extractfile(member)
if f:
files[member.name] = f.read().decode("utf-8", errors="replace")
return files

Network Policy: Egress Allowlisting

The default should be no network access. If the agent needs to call specific APIs, allowlist exactly those domains:

import subprocess
import ipaddress
import socket
from typing import Optional


class NetworkPolicyManager:
"""
Manages network policy for sandboxed agent containers.

Strategy: deny all egress by default using iptables rules applied to
the container's network namespace. Allowlist specific domains/IPs as needed.

In production, use Kubernetes NetworkPolicy or AWS Security Groups for
infrastructure-level enforcement instead of iptables directly.
"""

def __init__(self, container_id: str):
self.container_id = container_id
self.allowed_hosts: set[str] = set()
self.allowed_ips: set[str] = set()

def allow_domain(self, domain: str):
"""
Allowlist a domain for egress.
Resolves to IP(s) and adds iptables rules in container namespace.
"""
try:
ips = socket.getaddrinfo(domain, None)
for info in ips:
ip = info[4][0]
if ip not in self.allowed_ips:
self.allowed_ips.add(ip)
self._add_egress_rule(ip)
except socket.gaierror as e:
raise ValueError(f"Cannot resolve domain {domain}: {e}")

self.allowed_hosts.add(domain)

def _add_egress_rule(self, ip: str):
"""Add iptables rule in container network namespace to allow egress to IP."""
# In production: use nsenter to enter container netns and apply iptables rule
# nsenter --net=/proc/{pid}/ns/net iptables -A OUTPUT -d {ip} -j ACCEPT
cmd = [
"nsenter", f"--net=/proc/{self._get_container_pid()}/ns/net",
"iptables", "-A", "OUTPUT", "-d", ip, "-j", "ACCEPT"
]
subprocess.run(cmd, check=True, capture_output=True)

def apply_default_deny(self):
"""Block all non-allowlisted egress traffic."""
pid = self._get_container_pid()
cmds = [
# Allow loopback
["nsenter", f"--net=/proc/{pid}/ns/net", "iptables", "-A", "OUTPUT", "-o", "lo", "-j", "ACCEPT"],
# Allow established connections (responses to allowed requests)
["nsenter", f"--net=/proc/{pid}/ns/net", "iptables", "-A", "OUTPUT", "-m", "state", "--state", "ESTABLISHED,RELATED", "-j", "ACCEPT"],
# Drop everything else
["nsenter", f"--net=/proc/{pid}/ns/net", "iptables", "-P", "OUTPUT", "DROP"],
]
for cmd in cmds:
subprocess.run(cmd, check=True, capture_output=True)

def _get_container_pid(self) -> int:
"""Get the PID of the container's init process."""
import docker
client = docker.from_env()
container = client.containers.get(self.container_id)
return container.attrs["State"]["Pid"]

def get_policy_summary(self) -> dict:
return {
"allowed_hosts": list(self.allowed_hosts),
"allowed_ips": list(self.allowed_ips),
"default_egress": "DENY",
}

E2B: Cloud-Native Code Execution Sandboxes

E2B (e2b.dev) provides managed Firecracker microVM sandboxes. This is the highest isolation level with production-friendly developer experience:

# pip install e2b-code-interpreter

import asyncio
from typing import Optional


class E2BSandbox:
"""
Cloud-native code execution using E2B Firecracker microVMs.

Properties:
- Hardware-level isolation (Firecracker hypervisor)
- ~200ms cold start (warm pool available)
- Full Python environment with pip
- Persistent filesystem within a session
- Automatic teardown after timeout
- No infrastructure management required

E2B sandboxes are ephemeral by design: each task gets a fresh VM,
preventing any state leakage between executions.
"""

def __init__(self, api_key: str, timeout_seconds: int = 60):
self.api_key = api_key
self.timeout_seconds = timeout_seconds

async def execute_python(
self,
code: str,
files: dict[str, str] = None,
env_vars: dict[str, str] = None,
install_packages: list[str] = None,
) -> dict:
"""
Execute Python code in a fresh E2B Firecracker sandbox.

Lifecycle: create → inject files → (optional pip install) → run code → destroy
"""
# Import here to make E2B optional dependency
from e2b_code_interpreter import AsyncSandbox

async with AsyncSandbox(api_key=self.api_key) as sbx:
# Set environment variables
if env_vars:
for key, value in env_vars.items():
await sbx.commands.run(f"export {key}='{value}'")

# Inject files into sandbox filesystem
if files:
for filename, content in files.items():
await sbx.filesystem.write(f"/home/user/{filename}", content)

# Install required packages
if install_packages:
packages = " ".join(install_packages)
install_result = await sbx.commands.run(
f"pip install {packages} --quiet",
timeout=60,
)
if install_result.exit_code != 0:
return {
"success": False,
"error": f"Package installation failed: {install_result.stderr}",
"stdout": "",
"stderr": install_result.stderr,
}

# Execute the code
execution = await sbx.notebook.exec_cell(code, timeout=self.timeout_seconds)

return {
"success": not execution.error,
"stdout": "\n".join([r.text for r in execution.results if r.text]),
"stderr": execution.error.traceback if execution.error else "",
"output_files": await self._collect_output_files(sbx),
"error": str(execution.error) if execution.error else None,
}

async def _collect_output_files(self, sbx) -> dict[str, bytes]:
"""Collect any files written to /home/user/output/ during execution."""
files = {}
try:
entries = await sbx.filesystem.list("/home/user/output")
for entry in entries:
if not entry.is_dir:
content = await sbx.filesystem.read_bytes(f"/home/user/output/{entry.name}")
files[entry.name] = content
except Exception:
pass
return files


class AgentWithE2BSandbox:
"""
Coding agent that executes all generated code in E2B sandboxes.
Demonstrates the integration pattern: LLM generates code → sandbox executes it.
"""

def __init__(self, e2b_api_key: str, anthropic_api_key: str = None):
import anthropic
self.e2b = E2BSandbox(api_key=e2b_api_key)
self.client = anthropic.Anthropic(api_key=anthropic_api_key)

async def solve_coding_task(self, task: str) -> str:
"""
Run a coding task: LLM writes code, sandbox executes, LLM interprets results.
All execution is sandboxed in Firecracker microVMs.
"""
tools = [
{
"name": "execute_python",
"description": (
"Execute Python code in a secure sandbox. "
"The sandbox has internet access to pypi.org only. "
"Write output to /home/user/output/ for file results."
),
"input_schema": {
"type": "object",
"properties": {
"code": {"type": "string", "description": "Python code to execute"},
"packages": {
"type": "array",
"items": {"type": "string"},
"description": "pip packages to install before execution",
},
},
"required": ["code"],
},
}
]

messages = [{"role": "user", "content": task}]
print(f"[E2B Agent] Starting task: {task[:80]}")

for step in range(10):
response = self.client.messages.create(
model="claude-opus-4-6",
max_tokens=8192,
system=(
"You are a coding agent. Write Python code to solve tasks. "
"All code runs in a secure sandbox. You can install pip packages. "
"Print results - they will be returned to you. "
"Be efficient: prefer single comprehensive scripts over multiple steps."
),
tools=tools,
messages=messages,
)

messages.append({"role": "assistant", "content": response.content})

if response.stop_reason == "end_turn":
# Extract final text response
for block in response.content:
if hasattr(block, "text"):
return block.text
return "Task completed."

tool_results = []
for block in response.content:
if block.type != "tool_use":
continue

print(f" [Step {step+1}] Executing code in E2B sandbox...")
result = await self.e2b.execute_python(
code=block.input["code"],
install_packages=block.input.get("packages", []),
)

output = result["stdout"]
if result.get("error"):
output += f"\nERROR: {result['error']}\n{result['stderr']}"

print(f" [Step {step+1}] Exit: {'success' if result['success'] else 'error'}")

tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": output[:8000] or "(no output)",
})

messages.append({"role": "user", "content": tool_results})

return "Task exceeded maximum steps."

Sandbox Layers and Escape Prevention

Known sandbox escape vectors and mitigations:

Escape VectorMechanismMitigation
Kernel exploitCVE in kernel syscall handlerVM isolation; keep host kernel patched
Privileged container--privileged flag enabledNever use --privileged; drop ALL capabilities
Docker socket mount/var/run/docker.sock exposedNever mount Docker socket into container
Host network--network hostAlways use isolated network namespace
Sensitive env varsenv dump exposes secretsInject secrets via vault, not env; clear after use
/proc/sched_debugCPU timing side-channelMount /proc read-only; filter sensitive paths
eBPF programsbpf syscall for kernel code executionBlock bpf in seccomp profile
userfaultfd exploitationUser-controlled page fault handlingBlock userfaultfd in seccomp
Volume mount exploitationWritable host volume pathsRead-only root; only tmpfs mounts

Secrets Injection

Agents often need API keys. Injecting secrets safely into sandboxes:

import os
import tempfile
from contextlib import contextmanager


class SecretInjector:
"""
Injects secrets into sandboxed environments without exposing them in:
- Command arguments (visible in /proc/*/cmdline)
- Dockerfile layers (visible in docker history)
- Container environment dumps (visible in docker inspect)
- Log files (if logging is misconfigured)
"""

def __init__(self, vault_client=None):
"""
vault_client: HashiCorp Vault client, AWS Secrets Manager, or similar.
None means secrets come from local environment (dev only).
"""
self.vault_client = vault_client

@contextmanager
def inject_secrets_as_files(
self,
secret_names: list[str],
mount_path: str = "/run/secrets",
):
"""
Context manager that creates a tmpfs directory with secret files.
The tmpfs is in-memory: never touches disk.
Secrets are deleted when the context exits.

Usage:
with injector.inject_secrets_as_files(["OPENAI_API_KEY"]) as secrets_path:
container_run(volumes={secrets_path: "/run/secrets"})
# Inside container: open("/run/secrets/OPENAI_API_KEY").read()
"""
tmpdir = tempfile.mkdtemp(prefix="agent-secrets-")
try:
for secret_name in secret_names:
value = self._fetch_secret(secret_name)
secret_path = os.path.join(tmpdir, secret_name)
with open(secret_path, "w") as f:
f.write(value)
os.chmod(secret_path, 0o400) # Read-only, owner only

yield tmpdir
finally:
# Securely delete secret files
for secret_name in secret_names:
secret_path = os.path.join(tmpdir, secret_name)
if os.path.exists(secret_path):
with open(secret_path, "wb") as f:
f.write(b"\x00" * os.path.getsize(secret_path))
os.unlink(secret_path)
os.rmdir(tmpdir)

def _fetch_secret(self, name: str) -> str:
if self.vault_client:
return self.vault_client.secrets.kv.read_secret(path=name)["data"]["data"]["value"]
# Fallback: local environment (development only)
value = os.environ.get(name)
if not value:
raise ValueError(f"Secret '{name}' not found in environment or vault")
return value

Monitoring Inside the Sandbox

Detecting anomalous behavior requires observability inside the execution environment:

import threading
import psutil
import time
from dataclasses import dataclass


@dataclass
class ExecutionMetrics:
"""Runtime metrics collected during sandboxed code execution."""
max_memory_mb: float = 0.0
avg_cpu_percent: float = 0.0
network_bytes_sent: int = 0
network_bytes_recv: int = 0
files_opened: int = 0
syscall_count: int = 0
suspicious_patterns: list[str] = None

def __post_init__(self):
if self.suspicious_patterns is None:
self.suspicious_patterns = []


class SandboxMonitor:
"""
Collects runtime metrics from a sandboxed subprocess.
Detects anomalous patterns that may indicate exploitation attempts.

Anomaly signals:
- Rapid memory growth (possible memory exhaustion attack)
- High network activity (possible data exfiltration)
- Unusual syscall patterns (if strace is available)
- Process forking (possible fork bomb)
"""

ANOMALY_THRESHOLDS = {
"memory_growth_rate_mb_per_sec": 50, # Flag if growing > 50 MB/s
"network_bytes_per_sec": 1_000_000, # Flag if sending > 1 MB/s
"child_process_count": 10, # Flag if spawning > 10 children
}

def __init__(self, pid: int, interval_seconds: float = 0.5):
self.pid = pid
self.interval = interval_seconds
self.metrics = ExecutionMetrics()
self._running = False
self._thread: Optional[threading.Thread] = None

def start(self):
self._running = True
self._thread = threading.Thread(target=self._collect_loop, daemon=True)
self._thread.start()

def stop(self) -> ExecutionMetrics:
self._running = False
if self._thread:
self._thread.join(timeout=2)
return self.metrics

def _collect_loop(self):
try:
proc = psutil.Process(self.pid)
cpu_samples = []
prev_net = psutil.net_io_counters()
prev_mem = 0.0
prev_time = time.time()

while self._running and proc.is_running():
# Memory
mem_mb = proc.memory_info().rss / 1_048_576
self.metrics.max_memory_mb = max(self.metrics.max_memory_mb, mem_mb)

# CPU
cpu = proc.cpu_percent(interval=None)
cpu_samples.append(cpu)

# Network
curr_net = psutil.net_io_counters()
self.metrics.network_bytes_sent += curr_net.bytes_sent - prev_net.bytes_sent
self.metrics.network_bytes_recv += curr_net.bytes_recv - prev_net.bytes_recv
prev_net = curr_net

# Anomaly detection
now = time.time()
elapsed = now - prev_time
if elapsed > 0:
growth_rate = (mem_mb - prev_mem) / elapsed
if growth_rate > self.ANOMALY_THRESHOLDS["memory_growth_rate_mb_per_sec"]:
self.metrics.suspicious_patterns.append(
f"Rapid memory growth: {growth_rate:.1f} MB/s"
)

net_rate = (
(curr_net.bytes_sent - prev_net.bytes_sent) / elapsed
)
if net_rate > self.ANOMALY_THRESHOLDS["network_bytes_per_sec"]:
self.metrics.suspicious_patterns.append(
f"High network egress: {net_rate/1000:.1f} KB/s"
)

prev_mem = mem_mb
prev_time = now

# Child process count
try:
children = proc.children(recursive=True)
if len(children) > self.ANOMALY_THRESHOLDS["child_process_count"]:
self.metrics.suspicious_patterns.append(
f"Excessive child processes: {len(children)}"
)
except Exception:
pass

time.sleep(self.interval)

if cpu_samples:
self.metrics.avg_cpu_percent = sum(cpu_samples) / len(cpu_samples)

except (psutil.NoSuchProcess, psutil.AccessDenied):
pass # Process ended

Production Notes

:::danger Never Mount the Docker Socket Mounting /var/run/docker.sock into a container gives the agent full Docker daemon access - equivalent to root on the host. It is the most common container escape vector in production systems. :::

:::warning Read-Only Root FS May Break Your Image When using read_only=True in Docker, any library or tool that writes to the filesystem at runtime will fail. Common culprits: pip (writes to site-packages), Python's bytecode compilation (writes .pyc files), and many logging libraries. Test your image with read-only FS before deploying. :::

:::tip Prefer Cloud Sandboxes for Maximum Isolation For coding agents executing user-provided or LLM-generated code, E2B or Modal (both Firecracker-based) provide the strongest practical isolation with the least operational overhead. The cost is 0.010.01–0.05 per sandbox execution - negligible compared to the LLM API costs of the same task. :::

Container security checklist:

[ ] Non-root user (USER directive in Dockerfile)
[ ] Read-only root filesystem
[ ] tmpfs for /tmp and any writable paths
[ ] Drop ALL capabilities (cap_drop: ALL)
[ ] no-new-privileges:true in security_opt
[ ] Seccomp profile applied (drop at minimum: ptrace, bpf, userfaultfd, kexec)
[ ] Network disabled OR egress allowlist only
[ ] Memory limit set (not unlimited)
[ ] CPU quota set
[ ] Execution timeout enforced
[ ] Docker socket NOT mounted
[ ] Secrets injected as files or env vars (not command args)
[ ] Anomaly monitoring active during execution
[ ] Container removed after execution (no state persistence)

Interview Questions

Q: Why is sandboxing important even when you trust the LLM's safety training?

A: Sandboxing is not about trusting or distrusting the LLM - it is about defense in depth. An LLM with perfect safety training can still be confused by prompt injection attacks, hallucinate dangerous commands when uncertain, or be manipulated by malicious content retrieved from external sources during a task. The sandbox contains blast radius regardless of what caused the failure. It is the same reason production databases have user-level permissions even when all the application code is assumed to be correct.

Q: What is the difference between container isolation and VM isolation, and when do you need VM isolation?

A: Container isolation uses Linux namespaces and cgroups to separate processes on the same kernel. All containers share the host kernel - a kernel exploit in any container can potentially escape. VM isolation runs a separate kernel in a hypervisor, providing hardware-level separation. A guest VM kernel exploit cannot reach the host. VM isolation is appropriate when: (1) you are executing code from untrusted or unknown sources; (2) you need to provide compliance guarantees (SOC2, PCI-DSS often require VM-level isolation for multi-tenant workloads); (3) you are operating in a high-stakes domain where a container escape would be catastrophic. For most coding agents, container isolation with strong hardening (dropped capabilities, seccomp, non-root) is sufficient.

Q: What is seccomp-bpf and what does it protect against?

A: seccomp (secure computing mode) filters the system calls a process is allowed to make. The -bpf variant uses Berkeley Packet Filter programs to implement complex per-syscall policies. It protects against exploitation of kernel vulnerabilities - an attacker who achieves code execution inside your container still needs syscalls to do useful damage. By blocking ptrace (prevents debugging other processes), bpf (prevents loading kernel eBPF programs), userfaultfd (an exploitation primitive), and kexec_load (prevents loading a new kernel), you significantly raise the bar for container escapes. Modern exploit chains often require multiple syscalls - seccomp breaks the chain at the first blocked call.

Q: How do you inject API keys into a sandboxed agent environment without exposing them?

A: The safest approach is file-based injection using tmpfs: create an in-memory temporary directory (tmpfs, so it never touches disk), write secret values to files in that directory with mode 0400, mount it read-only into the container at a known path like /run/secrets/, and delete the files when the container exits. Avoid: (1) command arguments - visible in /proc/*/cmdline; (2) Dockerfile ENV layers - visible in docker history; (3) environment variables via docker inspect - accessible to any process with Docker API access. In production, use a secrets manager (HashiCorp Vault, AWS Secrets Manager) to fetch secrets at runtime rather than storing them in any configuration file.

Q: What monitoring should you run inside a sandboxed code execution environment?

A: At minimum: (1) memory usage over time - detect rapid growth indicating a potential memory exhaustion attack; (2) network I/O - detect exfiltration attempts if egress is unexpectedly permitted; (3) process tree size - detect fork bombs; (4) CPU usage - detect crypto miners or infinite loops; (5) file open count - detect attempts to read large numbers of files. In production with strace or eBPF (on the host, not inside the sandbox), you can also capture the syscall pattern to detect anomalous behavior at the kernel level. The key design principle: monitoring happens outside the sandbox, not inside - code running inside the sandbox cannot be trusted to report its own behavior accurately.

Q: What are the most common sandbox escape vectors in Docker and how do you prevent each?

A: The most exploited vectors: (1) Docker socket mounting - --mount /var/run/docker.sock gives full Docker daemon control equivalent to host root. Prevention: never mount the socket. (2) Privileged mode - --privileged removes nearly all isolation. Prevention: never use. (3) Host network namespace - --network host removes network isolation. Prevention: always use isolated network namespace. (4) Kernel exploits - CVEs in kernel syscall handlers allow escaping namespace isolation. Prevention: use VM-level isolation for highest-risk workloads; keep host kernel patched; minimize attack surface with seccomp. (5) Sensitive path exposure - mounting sensitive host directories. Prevention: define explicit volume mounts, use read-only root FS, only mount tmpfs. (6) Capability abuse - any retained Linux capability can potentially be exploited. Prevention: drop ALL capabilities and add back only the specific ones needed.

© 2026 EngineersOfAI. All rights reserved.