AWS Builder Center
Agent Sandboxes on AWS: Implementing Programmatic Tool Calling on EKS with Fargate, gVisor, and Kata

Agent Sandboxes on AWS: Implementing Programmatic Tool Calling on EKS with Fargate, gVisor, and Kata

Building isolated code execution environments for AI agents that write and run untrusted Python with inline tool calling

Series: Agent Sandboxes on AWS (5 articles)

  1. 2
    Agent Sandboxes on AWS: Implementing Programmatic Tool Calling on EKS with Fargate, gVisor, and Kata This article
Principal Solutions Architect, Amazon Web Services
Anuj Dewangan and Michelle Ponting · July 2026

Contents

  1. The Problem: AI Agents Need to Run Untrusted Code
  2. What Makes a Good Agent Sandbox?
  3. EKS Fargate
  4. gVisor
  5. Kata Containers
  6. Example Sandbox Implementations on EKS
  7. Comparison: Fargate vs gVisor vs Kata Containers for Agent Sandboxes
  8. Defense in Depth: The Full Security Stack
  9. Conclusion

The Problem: AI Agents Need to Run Untrusted Code

The Agent Sandboxes on AWS  pillar makes the general case: whenever an AI agent runs code it didn't write — including code a model wrote for it — that code is untrusted and needs an isolated, controlled place to run. Programmatic Tool Calling (PTC) is one of those use cases, and it's the one this post builds end to end: how to implement a PTC sandbox on Amazon EKS with three isolation runtimes.
PTC is a shift in how agents use tools. Instead of the LLM deciding which tool to call next in a conversational loop, it writes code that programmatically invokes tools during execution — a pattern that is increasingly common in modern agent architectures.

Architecture: How Programmatic Tool Calling Works

In a traditional agentic tool-calling loop, the LLM and orchestrator take turns: the LLM decides which tool to call, the orchestrator executes it, the result goes back, and the cycle repeats — each call is a full LLM round-trip.
PTC inverts this. The LLM generates executable code — typically Python — that invokes tools as function calls, orchestrating them with native constructs: loops, conditionals, error handling, and data transformations. The code runs in a sandbox, and tool invocations are intercepted and routed to the real implementations by the orchestrating agent. The LLM is invoked only once — all subsequent logic happens at code execution time.
Why PTC matters: Consider an agent that needs to "check inventory across 5 warehouses, find the closest one with stock, calculate shipping cost, and place the order — but retry with the next warehouse if shipping exceeds $10." In a traditional loop, each tool call is a round-trip to the LLM — potentially 10+ invocations with complex state tracking. With PTC, the LLM writes one Python program with a for loop over warehouses, conditional logic for the threshold, and inline tool calls — a single invocation generates all the logic, and tool calls execute at runtime without returning to the LLM.
But this creates a fundamental security challenge: the code is untrusted. It's generated by an LLM, which means it could contain anything — accidental or adversarial. The code runs on your infrastructure, potentially with access to your network, your credentials, and your data. You need a sandbox.

Traditional Tool Calling vs Programmatic Tool Calling

Figure 1: Traditional tool calling requires an LLM round-trip per tool call. PTC generates code once — tool calls happen inside the code at runtime.
The left side shows the traditional pattern: the LLM decides each tool call, the orchestrator executes it, and the result returns for the next decision — three tool calls means three LLM invocations. The right side shows PTC: the LLM generates code containing all three tool calls in a loop, and the sandbox executes it, calling tools directly as it runs — the LLM is invoked only once.
This has several implications for the sandbox:
  • Bidirectional communication — it sends tool requests out and receives results back, mid-execution
  • Long-lived — it stays up for the entire code execution, which may involve multiple sequential tool calls
  • Mediated tool calls — the sandbox has no credentials or network access; the orchestrator makes every tool call

What Makes a Good Agent Sandbox?

An agent code execution sandbox must satisfy several requirements simultaneously:
  • Compute isolation — one session's code cannot affect another's. Untrusted code could contain infinite loops, memory-intensive operations, or attempts to read other processes' memory; without isolation, one bad session could crash the sandbox for everyone
  • Network isolation — code cannot reach the internet, other AWS services, or other resources. With network access it could scan your VPC, call AWS APIs using the host's credentials, exfiltrate data, or attack neighboring sandboxes
  • Minimal attack surface — the environment contains only what's needed to run the code. Shells, package managers, and extra libraries all expand the attack surface; a stripped-down environment limits privilege escalation, payload download, and lateral movement if the application-level restrictions are bypassed
  • Controlled communication — the sandbox has no direct access to external services. All tool calls are mediated by the orchestrating agent, giving you a single enforcement point for authorization, rate limiting, input validation, and audit logging before any tool runs
  • Resource limits — bounded CPU, memory, and execution time prevent a fork bomb or runaway allocation from starving other sessions or driving up costs
  • Ephemeral, single-use lifecycle — created per-session, used once, then destroyed and never reused across a trust boundary (terminate-never-reuse). One session's variables, temp files, or runtime state must never leak to the next
  • Fast startup — ready in seconds, not minutes. PTC is interactive; slow provisioning makes the agent unusable for real-time workflows
This post explores three approaches to building such sandboxes on Amazon EKS: EKS Fargate (AWS-managed, serverless task-level isolation), gVisor (user-space kernel on EKS managed node groups), and Kata Containers with Cloud Hypervisor (microVM-per-pod on EKS managed node groups).

EKS Fargate

EKS Fargate is a serverless compute engine for Kubernetes. Running a pod on Fargate provisions a dedicated, isolated environment for it. Each Fargate pod gets:
  • Its own Linux kernel (not shared with any other pod)
  • Its own network namespace with a dedicated ENI
  • Its own memory allocation (not overcommitted)
  • Hardware-enforced isolation — AWS manages the isolation mechanism entirely
Figure 2: Fargate isolation on EKS — each pod runs in its own dedicated environment with a separate kernel and ENI

gVisor

gVisor is an application kernel written in Go that implements much of the Linux system call interface in user space. When a container runs under gVisor, its syscalls are intercepted by gVisor's Sentry component before reaching the host kernel. The container sees a Linux kernel (reporting version 4.4.0), but it's gVisor's reimplementation.
This is a security boundary: even if the code exploits a kernel vulnerability, it's exploiting gVisor's user-space implementation, not the host kernel.
Figure 3: gVisor isolation on EKS EC2 — each pod runs under a gVisor Sentry that intercepts syscalls before they reach the shared host kernel

Kata Containers

Kata Containers is an open-source project that runs OCI-compatible containers inside lightweight virtual machines. Each container (or pod, in Kubernetes) gets its own VM with its own guest kernel — providing the same hardware-enforced isolation boundary as a traditional VM, but with a container-like workflow and startup time.
The key component is the Virtual Machine Monitor (VMM) — the process that creates and manages the VM. Kata Containers supports multiple VMMs, but this implementation uses Cloud Hypervisor, a modern, Rust-based VMM designed for cloud-native workloads. Cloud Hypervisor is minimal by design: it supports only the virtio device model, has no legacy device emulation, and benefits from Rust's memory safety guarantees — reducing the attack surface of the VMM itself.
When a pod is scheduled with runtimeClassName: kata-clh, the Kata runtime:
  1. Creates a lightweight VM using Cloud Hypervisor
  2. Boots a minimal guest kernel inside the VM
  3. Starts the container workload (driver.py + tcp_wrapper.py) inside the VM
  4. The container sees a standard Linux environment, but it's running inside its own VM with its own kernel
This provides a clear security boundary: even if untrusted code exploits a kernel vulnerability, it's exploiting the guest kernel inside the VM — not the host kernel. An attacker would need to escape the VM (a hypervisor exploit) to reach the host.
Figure 4: Kata Containers + Cloud Hypervisor isolation on EKS EC2 — each pod runs in its own KVM-backed VM with a dedicated guest kernel and kata-agent

Example Sandbox Implementations on EKS

Let's make the PTC pattern concrete with a reference implementation on Amazon EKS — using Kubernetes Jobs for per-session isolation, VPC networking for security boundaries, and three compute options (Fargate, EC2 with gVisor, and EC2 with Kata Containers) for runtime isolation. The design covers four areas: the orchestrator-to-sandbox lifecycle, the sandbox container (code executor, TCP bridge, and image), the communication protocol, and the EKS deployment architecture.

Orchestrator-to-Sandbox Lifecycle

At a high level, the orchestrator uses the Kubernetes API to create a per-session sandbox, connects to it over TCP, relays tool calls during code execution, and cleans up when done:
Figure 5: Orchestrator-to-sandbox lifecycle — the orchestrator creates a K8s Job, connects via TCP, relays tool calls, and cleans up
The orchestrator sits outside the cluster (or in a separate namespace) and communicates with the sandbox pod exclusively over TCP. The Kubernetes API is used only for lifecycle management — creating and deleting Jobs, polling for pod readiness. All code execution and tool call relay happens over an NDJSON TCP connection. The following sections detail each component of this architecture.

The NDJSON Protocol

The orchestrator and sandbox communicate using NDJSON (Newline Delimited JSON) — each message is one JSON object on a single line, terminated by \n. It's streaming-friendly and works over both stdio and TCP.
Every message from the sandbox includes a "type" field for routing:
  • "type": "tool_call" — the code is requesting a tool invocation; "payload" holds the tool name and arguments
  • "type": "result" — execution completed; includes "status" (completed or error), "stdout", and "stderr"
  • "type": "error" — a driver-level error (invalid input, serialization failure)
Messages from the orchestrator are either code payloads ({"code": "..."}) or tool results (arbitrary JSON). Each line is independently parseable, making the protocol resilient to partial reads and easy to debug.

EKS Network Architecture

The sandbox pods must run in private subnets with no route to the internet — no NAT gateway, no internet gateway. The only external connectivity is through VPC endpoints for the AWS services required to operate the cluster and pull container images.
Figure 6: VPC network architecture — sandbox pods in isolated private subnets with VPC endpoints for ECR, EKS, STS, and S3
The required VPC endpoints and their purposes:
  • ECR API (com.amazonaws.region.ecr.api) — interface endpoint for ECR authentication and image manifest resolution
  • ECR DKR (com.amazonaws.region.ecr.dkr) — interface endpoint for Docker image layer pulls
  • S3 Gateway (com.amazonaws.region.s3) — gateway endpoint for fetching image layers from the ECR-managed S3 bucket. The endpoint policy should be restricted to only the ECR layer bucket ARN, preventing the sandbox from accessing any other S3 resources
  • EKS API (com.amazonaws.region.eks) — interface endpoint for kubelet-to-control-plane communication
  • STS (com.amazonaws.region.sts) — interface endpoint for IAM role credential exchange (required by the VPC CNI and node registration)
Security groups restrict the VPC endpoints to the cluster's security group, so even escaped code can't reach them from an arbitrary identity. The sandbox pods have no IAM credentials — the service account token isn't mounted, and the pod's security group allows only inbound TCP 8080 from the orchestrator.

The Sandbox Container

The design principle is simple: the code executor only knows about stdin and stdout. It has no network access, no credentials, and no awareness of the outside world. A separate component bridges the gap between the executor's stdio and the orchestrator's TCP connection.

The Code Executor (driver.py)

The code executor is a Python process that reads code from stdin, executes it, and writes results to stdout in an infinite loop. It has no network stack — no sockets, HTTP, or DNS — its only interface is the stdin/stdout pipe.
To enable tool calling, the executor exposes a call_tool() function, and the LLM is prompted to use it for tool invocations — for example, call_tool({"tool": "check_inventory", "args": {"warehouse_id": "W-123"}}). When called, it:
  1. Serializes the tool request as JSON and writes it to stdout, which the orchestrator reads via the TCP bridge
  2. Blocks on stdin until the orchestrator executes the tool and writes the result back as a JSON line
  3. Parses the response and returns it to the calling code
From the code's perspective, call_tool() is a synchronous call; from the system's, it's a stdio message exchange — the executor pauses, the orchestrator handles the call, and the executor resumes when the result arrives. The executor also enforces application-level restrictions: restricted builtins (no os, subprocess, or socket), a limited set of importable modules, and captured stdout/stderr so executed code can't interfere with the protocol.
Here is an annotated reference implementation:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
"""
Code executor — reads NDJSON from stdin, executes Python, writes results to stdout.
The only external interface is call_tool(), which communicates over stdio.
"""

import sys, json, builtins, traceback, io
from contextlib import redirect_stdout, redirect_stderr

# Restricted builtins — no os, subprocess, socket, or system access
ALLOWED_MODULES = {'json', 'math', 'random', 'datetime', 'string', 're',
'collections', 'itertools', 'functools'}

def _restricted_import(name, *args, **kwargs):
if name in ALLOWED_MODULES:
return __import__(name, *args, **kwargs)
raise ImportError(f"Import of '{name}' is not allowed in the sandbox")

SAFE_BUILTINS = {name: getattr(builtins, name) for name in (
'bool','int','float','str','bytes','list','tuple','set','dict',
'range','enumerate','zip','map','filter','sorted','len','sum',
'min','max','abs','round','all','any','next','iter','isinstance',
'print','type','repr','format','Exception','ValueError','TypeError',
) if hasattr(builtins, name)}
SAFE_BUILTINS.update({'None': None, 'True': True, 'False': False,
'__import__': _restricted_import})

def _make_call_tool(stdin, stdout):
"""Create a call_tool() bound to real stdin/stdout (not redirected)."""
def call_tool(payload):
# Write tool request as NDJSON to stdout
stdout.write(json.dumps({"type": "tool_call", "payload": payload}) + "\n")
stdout.flush()
# Block until orchestrator sends the tool result
line = stdin.readline()
if not line:
return None
try:
return json.loads(line)
except json.JSONDecodeError:
return None
return call_tool

def execute(code, exec_globals):
"""Execute code with captured stdout/stderr."""
stdout_capture, stderr_capture = io.StringIO(), io.StringIO()
try:
with redirect_stdout(stdout_capture), redirect_stderr(stderr_capture):
exec(code, exec_globals)
return {"type": "result", "status": "completed",
"stdout": stdout_capture.getvalue(),
"stderr": stderr_capture.getvalue()}
except Exception as e:
return {"type": "result", "status": "error",
"error": f"{type(e).__name__}: {e}",
"stdout": stdout_capture.getvalue(),
"stderr": stderr_capture.getvalue()}

def main():
real_stdin, real_stdout = sys.stdin, sys.stdout
call_tool = _make_call_tool(real_stdin, real_stdout)

for line in real_stdin: # Read NDJSON lines forever
line = line.strip()
if not line:
continue
try:
message = json.loads(line)
except json.JSONDecodeError as e:
real_stdout.write(json.dumps({"type": "error", "error": str(e)}) + "\n")
real_stdout.flush()
continue

code = message.get("code")
if not code:
continue

result = execute(code, {"__builtins__": SAFE_BUILTINS, "call_tool": call_tool})
real_stdout.write(json.dumps(result) + "\n")
real_stdout.flush()

if __name__ == "__main__":
main()

The TCP Bridge (tcp_wrapper.py)

The executor speaks stdio, but the orchestrator reaches it over the network (the sandbox runs in a separate container, pod, or VM). The TCP wrapper bridges the gap: it listens on a port, accepts connections, and pipes data bidirectionally between the socket and the executor's stdin/stdout. Each connection spawns a new executor subprocess with two pipes:
  • Socket → executor stdin — data from the orchestrator flows in
  • Executor stdout → socket — data from the executor flows back
This separation is intentional: the executor has no network access at all — it only reads and writes file descriptors — so even if compromised, it can't open network connections.
Here is an annotated reference implementation:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
"""
TCP wrapper — bridges a TCP socket to driver.py's stdin/stdout.
Listens on a port, spawns a driver.py subprocess per connection.
"""

import sys, socket, subprocess, threading

DRIVER_PATH = "/app/driver.py"

def pipe(src, dst):
"""Copy lines from src to dst until EOF."""
try:
for line in src:
dst.write(line)
dst.flush()
except (BrokenPipeError, OSError):
pass

def handle_client(conn, addr):
"""Handle a single client connection by piping to/from driver.py."""
proc = subprocess.Popen(
["python3", DRIVER_PATH],
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, text=True,
)
sock_in = conn.makefile("r")
sock_out = conn.makefile("w")

# socket -> driver stdin
t_in = threading.Thread(target=pipe, args=(sock_in, proc.stdin), daemon=True)
# driver stdout -> socket
t_out = threading.Thread(target=pipe, args=(proc.stdout, sock_out), daemon=True)
t_in.start()
t_out.start()

proc.wait() # Wait for driver to exit
try:
conn.shutdown(socket.SHUT_RDWR)
except OSError:
pass
conn.close()

def main():
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8080
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("0.0.0.0", port))
srv.listen(5)

while True:
conn, addr = srv.accept()
threading.Thread(target=handle_client, args=(conn, addr), daemon=True).start()

if __name__ == "__main__":
main()

The Container

Both components are packaged in a minimal container image — the smallest possible with the least attack surface: no shell, no package manager, no system utilities. If untrusted code escapes the application-level sandbox, there's nothing useful to exploit.
Here is an example Dockerfile using a multi-stage build with a distroless base image:
1
2
3
4
5
6
7
8
9
10
# Example Dockerfile — multi-stage build for a minimal sandbox container
FROM python:3.13-slim AS build
COPY driver.py tcp_wrapper.py /app/

FROM gcr.io/distroless/python3-debian12
COPY --from=build /app /app
WORKDIR /app
USER 65534
EXPOSE 8080
CMD ["tcp_wrapper.py", "8080"]
Key decisions in this Dockerfile:
  • Non-root user — USER 65534 (the nobody user) ensures the process cannot write to system directories or escalate privileges
  • Minimal contents — only two files (driver.py and tcp_wrapper.py) are included. No requirements file, no pip install, no additional dependencies
The resulting image is ~20MB compressed. The minimal footprint reduces the attack surface and speeds up image pulls in environments without internet access (via ECR PrivateLink).

Per-Session Kubernetes Jobs

Each user session gets its own Kubernetes Job, created via the Batch API with the sandbox image and a set of security constraints. Here is an example Job spec:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# Example K8s Job spec — ephemeral, hardened sandbox with auto-cleanup and resource limits
apiVersion: batch/v1
kind: Job
metadata:
name: sandbox-<session-id>
namespace: sandbox
spec:
ttlSecondsAfterFinished: 30 # Auto-delete 30s after completion
activeDeadlineSeconds: 120 # Hard kill after 2 minutes
backoffLimit: 0 # No retries
template:
spec:
automountServiceAccountToken: false
restartPolicy: Never
containers:
- name: driver
image: <ecr-repo>/sandbox-driver:latest
ports:
- containerPort: 8080
resources:
requests: { cpu: 250m, memory: 256Mi }
limits: { cpu: 500m, memory: 512Mi }
securityContext:
runAsNonRoot: true
runAsUser: 65534
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
Key properties of this Job spec:
  • Ephemeral — ttlSecondsAfterFinished: 30 ensures the Job and its pod are garbage-collected shortly after completion, leaving no persistent state
  • Time-bounded — activeDeadlineSeconds: 120 kills the pod if code execution runs too long, preventing resource exhaustion
  • Hardened — no service account token, non-root user, all Linux capabilities dropped, no privilege escalation
  • Resource-limited — CPU and memory requests/limits prevent a single session from starving other workloads

Three Execution Environments

The Job spec above is runtime-agnostic — it works on any EKS compute. But the isolation guarantees depend entirely on what runs the pod: same container image, same driver.py, same NDJSON protocol, but fundamentally different security boundaries underneath.
In the following sections, we explore three approaches:
  • EKS Fargate — each pod runs in a dedicated, AWS-managed environment with its own kernel: serverless task-level isolation, zero node management
  • gVisor via EKS managed node groups — each pod runs under a user-space kernel that intercepts syscalls: strong isolation, sub-5-second startup, full NetworkPolicy enforcement
  • Kata Containers with Cloud Hypervisor via EKS managed node groups — each pod runs in a lightweight microVM with its own kernel: hardware-enforced isolation, NetworkPolicy enforcement, full syscall compatibility

Sandbox on EKS Fargate

To deploy a sandbox on Fargate, create a Fargate Profile matching the sandbox namespace, then the orchestrator would submit a standard Kubernetes Job — no runtime class or tolerations needed:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# Fargate Profile — any pod in this namespace runs in Fargate isolation
aws eks create-fargate-profile \
--cluster-name my-cluster \
--fargate-profile-name sandbox-profile \
--pod-execution-role-arn arn:aws:iam::<account>:role/eks-fargate-role \
--subnets subnet-<private-a> subnet-<private-b> \
--selectors '[{"namespace": "sandbox-fargate"}]'

---
# Job spec — Fargate Profile matches the namespace automatically
apiVersion: batch/v1
kind: Job
metadata:
name: sandbox-<session-id>
namespace: sandbox-fargate
spec:
ttlSecondsAfterFinished: 30
activeDeadlineSeconds: 120
backoffLimit: 0
template:
spec:
automountServiceAccountToken: false
restartPolicy: Never
containers:
- name: driver
image: <ecr-repo>/sandbox-driver:latest
ports: [{ containerPort: 8080 }]
resources:
requests: { cpu: 250m, memory: 256Mi }
limits: { cpu: 500m, memory: 512Mi }
securityContext:
runAsNonRoot: true
runAsUser: 65534
allowPrivilegeEscalation: false
capabilities: { drop: ["ALL"] }

Sandbox on gVisor via EKS Managed Node Groups

NetworkPolicy Enforcement

Unlike Fargate, EC2 nodes run the VPC CNI DaemonSet (aws-node). Setting ENABLE_NETWORK_POLICY=true deploys an eBPF-based Network Policy Agent that enforces Kubernetes NetworkPolicy at the pod level:
  • sandbox-deny-all blocks all ingress and egress by default
  • sandbox-allow-webapp permits only inbound TCP 8080 from the orchestrator subnet
  • Two sandbox pods on the same node cannot communicate — eBPF rules block it before packets reach the network stack
This adds a layer of pod-level network isolation over the Fargate solution, enforced at the kernel level.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# Default deny — block all ingress and egress for sandbox pods
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: sandbox-deny-all
namespace: sandbox-gvisor
spec:
podSelector:
matchLabels:
app: sandbox
policyTypes: [Ingress, Egress]

---
# Allow only the orchestrator to reach sandbox pods on TCP 8080
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: sandbox-allow-orchestrator
namespace: sandbox-gvisor
spec:
podSelector:
matchLabels:
app: sandbox
policyTypes: [Ingress]
ingress:
- from:
- ipBlock:
cidr: 172.31.64.0/20 # orchestrator subnet
ports:
- protocol: TCP
port: 8080

Warm Node Pool

EC2 managed node groups scale to zero when idle. But if a request arrives with no nodes running, the node must boot, register, and pull the image before the pod starts — 60-90 seconds of cold start. To avoid this, a low-priority "warm pool" pod keeps at least one gVisor node alive:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
apiVersion: apps/v1
kind: Deployment
metadata:
name: warm-pool
spec:
replicas: 1
template:
spec:
runtimeClassName: gvisor
priorityClassName: warm-pool # value: -1
tolerations:
- key: runtime
value: gvisor
effect: NoSchedule
containers:
- name: pause
image: ptc-sandbox-driver:latest
command: ["python3", "-c", "import time; time.sleep(999999)"]
When a real sandbox pod arrives, it either fits alongside the warm pool pod or preempts it — either way, it starts instantly on the warm node.
To deploy a sandbox on gVisor, the orchestrator would submit a Job with runtimeClassName: gvisor and a toleration for the gVisor node taint:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# Job spec — runtimeClassName and toleration target gVisor nodes
apiVersion: batch/v1
kind: Job
metadata:
name: sandbox-<session-id>
namespace: sandbox-gvisor
spec:
ttlSecondsAfterFinished: 30
activeDeadlineSeconds: 120
backoffLimit: 0
template:
spec:
runtimeClassName: gvisor # use gVisor instead of default runc
automountServiceAccountToken: false
restartPolicy: Never
tolerations:
- key: runtime
value: gvisor
effect: NoSchedule
containers:
- name: driver
image: <ecr-repo>/sandbox-driver:latest
ports: [{ containerPort: 8080 }]
resources:
requests: { cpu: 250m, memory: 256Mi }
limits: { cpu: 500m, memory: 512Mi }
securityContext:
runAsNonRoot: true
runAsUser: 65534
allowPrivilegeEscalation: false
capabilities: { drop: ["ALL"] }

Sandbox on Kata Containers via EKS EC2

NetworkPolicy and Warm Node Pool

Both work exactly as in the gVisor section: apply the same sandbox-deny-all and sandbox-allow-orchestrator NetworkPolicies (in the sandbox-kata namespace), and run the same low-priority warm-pool pod — just with runtimeClassName: kata-clh and a runtime=kata toleration — to avoid the 60-90 second cold start.

Deploying a Kata Sandbox

Deploying on Kata uses the same Job spec as gVisor above — the same container image, driver.py, and NDJSON protocol — with runtimeClassName: kata-clh (instead of gvisor), the toleration value changed from gvisor to kata, and the sandbox-kata namespace.

Sandbox Lifecycle on EKS

The runtime sections above handle isolation, not lifecycle. The warm-pool Deployments shown earlier are the minimal version — just keeping a node hot. Production lifecycle (atomic checkout, per-session leases, and terminate-never-reuse) is better handled by the open-source kubernetes-sigs/agent-sandbox  operator, which provides SandboxTemplate, SandboxWarmPool, and SandboxClaim CRDs and is runtime-agnostic across Fargate, gVisor, and Kata. A cold Job start can take ~45 seconds (node scale-up plus image pull) versus a sub-second checkout from a warm pool. See the companion post, Serverless AI agent sandboxes on EKS Fargate with Kubernetes warm pools , for the full walkthrough.

Comparison: Fargate vs gVisor vs Kata Containers for Agent Sandboxes

AspectFargategVisorKata Containers
Isolation ModelAWS-managed task-level isolation — each pod runs in a dedicated environment with its own kernel, managed entirely by AWSUser-space kernel — gVisor's Sentry intercepts syscalls in a Go process before they reach the shared host kernelMicroVM — each pod runs inside a lightweight VM (Cloud Hypervisor) with its own guest kernel, isolated at the hypervisor level
Kernel SharingNo — each pod has its own kernelYes — pods share the host kernel, but syscalls are filtered through gVisorNo — each pod has its own guest kernel inside the VM
Attack SurfaceMinimal — each pod is fully isolated by AWS; the isolation mechanism is AWS-managed and not exposed to the userReduced — attacker must escape gVisor's syscall filter (Go memory safety helps)Minimal — attacker must escape the VM (hypervisor exploit required). Cloud Hypervisor is written in Rust with a minimal device model
EKS IntegrationNative via Fargate Profile — no node managementCustom AMI + RuntimeClass + managed node groupCustom AMI + RuntimeClass + managed node group
Node ManagementNone — fully serverlessEC2 managed node group (AMI updates, scaling config)EC2 managed node group (AMI updates, Kata/Cloud Hypervisor updates)
NetworkPolicyNot enforced (Fargate doesn't run CNI DaemonSet)Enforced via VPC CNI eBPF agent — pod-level isolationEnforced via VPC CNI eBPF agent — pod-level isolation
Pod-to-Pod IsolationInherent — separate Fargate environments can't share memory or kernel state. SG self-reference allows network path.eBPF NetworkPolicy blocks inter-pod traffic even on the same nodeeBPF NetworkPolicy blocks inter-pod traffic + separate VMs provide kernel-level isolation between pods on the same node
Cold Start~30-45 seconds (environment provision + ENI + image pull)~3 seconds on warm node (pre-allocated ENI, cached image)~5 seconds on warm node (VM boot + kernel init + container start)
Syscall CompatibilityFull — runs a real Linux kernel~380 of ~450+ syscalls implemented — some edge cases may failFull — runs a real Linux guest kernel inside the VM
Performance OverheadMinimal — near-native execution inside the Fargate environment5-15% overhead on syscall-heavy workloads (each syscall goes through gVisor)Minimal — near-native execution inside the VM; slight overhead from virtio device layer
Memory OverheadHigher — each Fargate pod has its own kernel and dedicated resourcesLower — gVisor Sentry is ~15MB per sandboxHigher — each VM has its own kernel memory (~30-50MB per pod)
Container ImageDistroless, 20MBSame distroless, 20MBSame distroless, 20MB
Operational ComplexityLow — Fargate is fully managedMedium — custom AMI, node group, gVisor updatesMedium-High — custom AMI, node group, Kata + Cloud Hypervisor updates
Best ForStrongest out-of-the-box isolation, infrequent workloads, zero node managementLow-latency requirements, high-frequency workloads, need for NetworkPolicy enforcementStrongest self-managed isolation with full syscall compatibility, NetworkPolicy enforcement, and full transparency

Defense in Depth: The Full Security Stack

No single runtime is sufficient. All three approaches implement multiple overlapping security layers:
LayerControl
ApplicationRestricted Python builtins, blocked imports (no os/subprocess/socket), call_tool() as the only external interface
ContainerDistroless image (no shell), non-root user (65534), drop ALL capabilities, no privilege escalation, no service account token
RuntimeFargate task-level isolation, gVisor user-space kernel, or Kata Containers microVM
NetworkPrivate subnets with no internet, security groups restricting inbound to port 8080 from orchestrator only, NetworkPolicy (gVisor, Kata)
InfrastructureECR via PrivateLink (no public internet for image pulls), S3 gateway restricted to ECR layer bucket
LifecycleTTL auto-cleanup (30s after completion), activeDeadline hard kill (120s), ResourceQuota limits (50 pods, 25 CPU, 25Gi memory)
CommunicationAll tool calls relayed through the orchestrator — sandbox never directly contacts AWS services or the internet
If an attacker compromises one layer, the next contains the blast radius. The Python sandbox stops most attacks; if bypassed, the distroless container has nothing to exploit; if the container is escaped, the runtime boundary — Fargate's managed environment, gVisor's syscall filter, or Kata's microVM — is the next line of defense; and if all of that fails, network isolation ensures the sandbox can't reach anything useful.

Conclusion

Programmatic Tool Calling gives AI agents the full expressiveness of a programming language for task execution — but it requires running untrusted, LLM-generated code on your infrastructure, which demands a serious approach to sandboxing.
Fargate, gVisor, and Kata Containers each provide strong isolation with different tradeoffs: Fargate offers the simplest operations with AWS-managed isolation, gVisor the fastest startup with pod-level network enforcement, and Kata the strongest self-managed isolation with full syscall compatibility and transparency.
The choice depends on your priorities: minimize operational burden with Fargate, get sub-second startup with gVisor on EC2, or get hardware-enforced isolation with full control and NetworkPolicy on Kata. In all cases, defense in depth — from Python builtins to container hardening to runtime isolation to network segmentation — is what makes the architecture trustworthy.

Series: Agent Sandboxes on AWS (5 articles)

  1. 2
    Agent Sandboxes on AWS: Implementing Programmatic Tool Calling on EKS with Fargate, gVisor, and Kata This article
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article