How to safely run untrusted AI-agent code on GCP
The moment your product lets an LLM agent write and execute code — a code interpreter, a data-analysis tool, an autonomous task runner — you are running untrusted code on your infrastructure. The model is not malicious, but its output is attacker-influenceable through prompt injection, and it has no concept of your security boundary. This is a defense-in-depth problem, and on GCP the defaults are dangerously permissive. Here is the threat model and the layered fix.
The threat model
Assume the code that runs is adversarial. Concretely, three things go wrong:
- Ambient credential theft. Every GCP VM and GKE node exposes a metadata server at
169.254.169.254that hands out OAuth access tokens for the attached service account to anything that can make an HTTP request to it — no password, no extra auth. Agent code that curlshttp://169.254.169.254/computeMetadata/v1/instance/service-accounts/default/tokenwalks away with your cloud credentials. - Sandbox escape. A normal container shares the host kernel. A kernel exploit, or a misconfiguration like
privileged: trueor a mounted Docker socket, turns "code ran in a container" into "code owns the node." - Data exfiltration. Even without escaping, code with open network egress can POST your data — secrets it found, customer records it was handed — to an attacker endpoint. Unrestricted outbound is a leak channel.
Defense 1 — Close the metadata credential path (do this first)
This is the highest-value single control, and the correct mechanism on GKE is Workload Identity — not a blanket firewall rule. Here is the important nuance: with Workload Identity enabled, the in-cluster GKE metadata server intercepts requests to 169.254.169.254 and only ever returns a token for the pod's own bound Google service account. It refuses to hand out the node's powerful default service-account credentials. So enabling Workload Identity is what actually neutralizes the credential-theft path; the metadata address stays reachable precisely because it is now the governed, least-privilege token broker.
# Enable on the cluster, then bind a K8s SA to a minimal Google SA:
gcloud container clusters update CLUSTER \
--workload-pool=PROJECT_ID.svc.id.goog
gcloud iam service-accounts add-iam-policy-binding \
agent-task@PROJECT_ID.iam.gserviceaccount.com \
--role roles/iam.workloadIdentityUser \
--member "serviceAccount:PROJECT_ID.svc.id.goog[agent-sandbox/agent-ksa]"
Do not simply blanket-block egress to 169.254.169.254 when you rely on Workload Identity — that same address is how the pod obtains its own least-privilege token, so blocking it breaks legitimate auth. The exception is a task that needs zero Google Cloud credentials: for that workload, additionally deny egress to the metadata IP so it can't reach the server at all.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: deny-metadata, namespace: agent-sandbox}
spec:
# apply ONLY to no-cloud-access tasks, not Workload-Identity pods
podSelector: {matchLabels: {needs-gcp-creds: "false"}}
policyTypes: [Egress]
egress:
- to:
- ipBlock:
cidr: 0.0.0.0/0
except: [169.254.169.254/32] # block the metadata server
On plain Compute Engine there is no Workload Identity broker, so the metadata server always returns the instance's own service-account token on the v1 path — note that disabling the legacy v0.1/v1beta1 endpoints does not close that v1 token path. The hardening there is to attach a minimal (or no) service account to the instance so the token that leaks is nearly powerless, and for untrusted code to block egress to 169.254.169.254 at the firewall or host level so the token endpoint is simply unreachable. The principle is the same: untrusted code must never be able to obtain a token for anything more powerful than its own task identity.
Defense 2 — A real isolation boundary: gVisor or Cloud Run
Because a container shares the host kernel, use a stronger boundary for genuinely untrusted code. Two GCP-native options:
- GKE Sandbox (gVisor). gVisor interposes a user-space kernel between the workload and the host, intercepting syscalls so a kernel-level exploit hits the sandbox, not the node. Create a sandboxed node pool, then opt a pod in with a runtime class:
spec: runtimeClassName: gvisor nodeSelector: sandbox.gke.io/runtime: gvisor - Cloud Run. Each container instance runs in a gVisor-based sandbox with no persistent host to escape onto. By default an instance can serve many concurrent requests, so for strict one-task-per-instance isolation set maximum concurrency to 1 — then a single task has the instance to itself. Give the service a minimal service account, restrict VPC egress, and let it scale to zero between tasks. Good when each agent task is a short, stateless unit of work.
Whichever you pick, disallow the classic escape hatches: never privileged: true, never mount the Docker/containerd socket, and never mount host paths into the sandbox.
And to size the compute behind it, the free GPU Idle-Cost Calculator →
Defense 3 — Per-task ephemeral service accounts, least privilege
The default Compute Engine service account is broadly privileged and attached to nodes automatically — never let untrusted code run as it. Instead, mint a dedicated service account per task type with only the IAM roles that task genuinely needs (a single bucket, a single dataset), bound to the pod through Workload Identity. Keep credentials short-lived and per-task so a stolen token expires fast and unlocks almost nothing. The test to apply: if this exact code were fully compromised, what could the identity it runs as touch? If the answer is more than the task requires, tighten the IAM binding.
Defense 4 — Egress controls
Workload Identity closes the credential path; egress controls close the exfiltration path. Default-deny outbound and allow only what the task needs. In practice that means a NetworkPolicy that denies egress except to a known allowlist, running the workload in a private cluster (no public node IPs) with tightly scoped Cloud NAT or none at all, and, for a strong perimeter around your data services, VPC Service Controls so tokens can't be used to pull data out to another project even if they leak. If the task legitimately needs the internet, proxy it through an allowlisting egress gateway rather than opening 0.0.0.0/0.
Defense 5 — Harden the pod: seccomp, no-new-privileges, limits, timeouts
Assume everything above can fail and make the container itself hostile terrain. A hardened securityContext plus resource caps and a hard deadline:
securityContext:
runAsNonRoot: true
allowPrivilegeEscalation: false # no-new-privileges
readOnlyRootFilesystem: true
capabilities: {drop: ["ALL"]}
seccompProfile: {type: RuntimeDefault}
resources:
limits: {cpu: "1", memory: 1Gi} # cap the blast radius
# on the Pod spec:
activeDeadlineSeconds: 300 # kill runaway tasks
allowPrivilegeEscalation: false sets no-new-privileges so a setuid binary can't gain rights; seccompProfile: RuntimeDefault filters dangerous syscalls; dropping all capabilities and a read-only root filesystem remove the easy footholds. Resource limits stop a fork-bomb or crypto-miner from taking the node, and activeDeadlineSeconds guarantees the task can't run forever. Mount scratch space as a size-limited emptyDir so writes go somewhere ephemeral and bounded.
Putting the layers together
| Layer | Stops | Mechanism on GCP |
|---|---|---|
| Metadata path | Credential theft | Workload Identity (deny 169.254.169.254 only for no-cred tasks) |
| gVisor / Cloud Run | Kernel escape | GKE Sandbox runtimeClass / Cloud Run isolation |
| Ephemeral least-priv SA | Lateral movement | Per-task SA, minimal IAM, short-lived |
| Egress controls | Data exfiltration | Default-deny NetworkPolicy, private cluster, VPC-SC |
| Pod hardening | Privilege gain, runaways | seccomp, drop caps, limits, deadline |
No single layer is sufficient — the point of defense in depth is that an agent has to defeat all of them, and the first two (Workload Identity closing the credential path and a real gVisor sandbox) already remove the two attacks that turn "the model wrote bad code" into "the attacker owns our project." This is the same governance discipline the wider agentic-AI wave demands: treat model output as untrusted input, and give it the least power that still lets it do its job.
Related reading
Run the compute behind it well: production vLLM on Kubernetes, why GPU clusters sit idle, and reduce LLM inference cost.