Reviewing the Terraform Your Coding Agent Wrote
A Terraform change can parse and plan successfully while granting too much access or replacing a database. Agent-written changes need the same review of intent and effective behavior as any infrastructure change. Start with the plan, then inspect the policy decisions hidden in the diff.
This post covers where those decisions hide and how to make the pipeline catch the worst of them before a human has to.
Review the plan, not the HCL
Reviewing the HCL tells you what the author meant. The plan tells you what will actually happen. With agent-written changes, those differ more often than you’d expect: a renamed resource that Terraform will destroy and recreate, a for_each key change that replaces every instance, a default that picks up a different value in production.
Make the plan the review artifact:
terraform plan -out=tfplan
terraform show -no-color tfplan > plan.txt # for humans, posted to the PR
terraform show -json tfplan > plan.json # for policy checksThen check the plan summary before you look at any HCL. Anything marked must be replaced or destroy on a stateful resource (a database, a bucket, a DynamoDB table, a KMS key) is a stop-and-ask, whatever the diff looks like. For resources that must never be destroyed by accident, lifecycle { prevent_destroy = true } blocks many planned destructions while the configuration remains. Removing the resource block also removes this lifecycle protection; review deletions and use service-level deletion protection where available.
Where the risk sits in agent-written Terraform
Four places account for most of it.
IAM policy documents. This is the most important by far. Agents reach for Action = "service:*" and Resource = "*" because it makes the first apply succeed. Look at every aws_iam_policy, aws_iam_role_policy and aws_iam_policy_document in the diff, and give iam:PassRole the most attention. A separate post explains how broad PassRole can enable escalation when service trust and the other required permissions also permit it.
Network exposure. Security groups with 0.0.0.0/0 ingress “for testing”, publicly_accessible = true on RDS, and S3 buckets without a public access block. These are easy to grep for and easy to miss in a long diff.
Hardcoded identifiers. An agent that can’t find a reference will paste an ARN, account ID or AMI ID it saw somewhere in the context. That works in one environment and quietly points at the wrong one elsewhere. Every literal ARN in the diff should have a reason.
State and provider changes. A new backend block, a changed state key or a new provider alias can move where Terraform thinks resources live. Treat any change to backend or provider configuration as its own review.
Make the pipeline catch the worst ones
Humans are bad at spotting the one missing Condition block in a long diff. Machines are good at it. Conftest runs OPA Rego policies against the plan JSON. Here is an intentionally limited Rego example for known policy strings and literal action spellings. It catches a missing service-condition key and literal Action: "*"; it does not validate condition values or prove least privilege:
# policy/iam.rego
package main
import rego.v1
policy_types := {"aws_iam_policy", "aws_iam_role_policy", "aws_iam_user_policy", "aws_iam_group_policy"}
as_array(x) := x if is_array(x)
as_array(x) := [x] if not is_array(x)
# Every statement in every IAM policy the plan will create or update.
statements contains {"addr": rc.address, "s": s} if {
some rc in input.resource_changes
rc.type in policy_types
is_string(rc.change.after.policy)
doc := json.unmarshal(rc.change.after.policy)
some s in as_array(doc.Statement)
}
passes_roles(s) if "iam:PassRole" in as_array(s.Action)
passes_roles(s) if "iam:*" in as_array(s.Action)
passes_roles(s) if "*" in as_array(s.Action)
has_service_condition(s) if s.Condition.StringEquals["iam:PassedToService"]
has_service_condition(s) if s.Condition.StringLike["iam:PassedToService"]
deny contains msg if {
some item in statements
item.s.Effect == "Allow"
passes_roles(item.s)
not has_service_condition(item.s)
msg := sprintf("%s: allows iam:PassRole without an iam:PassedToService condition", [item.addr])
}
deny contains msg if {
some item in statements
item.s.Effect == "Allow"
"*" in as_array(item.s.Action)
msg := sprintf("%s: allows Action \"*\"", [item.addr])
}Run it in CI after the plan:
conftest test plan.json --policy policy/Write policies like this for the handful of mistakes you never want merged, not for style. A policy suite that fails on things people routinely override becomes a suite people learn to bypass.
Know what the policy can’t see
If a policy document references a resource that doesn’t exist yet (a role ARN created in the same apply, say), its value is unknown at plan time and appears as after_unknown instead of a string. The rule above skips those statements rather than guessing. Treat skipped or unknown policies as an explicit incomplete review, not a pass. aws_iam_policy_document values can also remain unknown. Applied state JSON has a different structure from plan JSON, so the rule above cannot be reused unchanged against it. For a production gate, add fixtures for unknown values, nested inline policies, wildcard/case variants, NotAction, and empty or wildcard condition values, then adapt the parser to the input format.
Off-the-shelf scanners such as Checkov and Trivy’s misconfiguration scanning cover hundreds of general rules (public buckets, unencrypted volumes, open security groups). Use one of them for breadth and a few rules of your own for the mistakes specific to your agents.
Give the agent the rules before it writes the code
Catching a mistake in CI is cheaper than catching it in production. Not making it in the first place is cheaper still. Instructions can explain why PassRole needs specific roles and service conditions. They do not guarantee that an agent will follow them or that a broad policy will apply successfully.
Put the rules where the agent will read them. That can be a short section in AGENTS.md, or a skill that loads only when the agent is writing IAM. Keep the Conftest policy and the written guidance consistent, so the agent is taught the same rules CI enforces.
Where this stops
Policy-as-code checks structure, not intent. A policy can be perfectly scoped to the wrong bucket. The plan review, where a person asks “should this thing have this access at all?”, still has to happen. The pipeline’s job is to make sure that person spends their attention on that question, not on hunting for a missing Condition block.