Guardrails for AI agents writing Terraform: OIDC, OPA and approval gates
AI agents are now good enough to write most of your Terraform. That moves the interesting question from who writes the change to who is allowed to apply it. This is the guardrail design I'd put in front of any agent that touches infrastructure code, with the IAM, policy and pipeline configs.
At work I use agentic AI to help create and maintain Terraform. It is good at the parts of infrastructure as code that used to eat afternoons: wiring a module, bumping a provider across a pile of workspaces, writing the variables and outputs nobody enjoys writing.
It is also a tool that can write a change that replaces a database, and explain with complete confidence why that is fine.
So the question is no longer whether agents should write IaC. They already do. HashiCorp's recent guidance answers it in one line: agents propose changes, and the platform governs them (InfoQ's summary). This post makes that concrete for Terraform, GitLab CI and AWS. I helped move 300+ Jenkins pipelines onto GitLab runners on EKS this year, so the examples are GitLab CI, but the same shape works with GitHub Actions, OpenTofu or HCP Terraform.
One rule holds the design together: nothing the agent can edit should be able to change what the agent is allowed to do.
A note on scope: this is a reference design built from public guidance and industry practice, not a description of any employer's setup.
1. Why this is the question right now
The vendors converged. HashiCorp now describes HCP Terraform as the control plane for agent-written infrastructure: agents can generate, validate and explain configuration, but should not approve their own work, weaken policy, hold broad credentials or bypass deployment controls.
The security community named the risks. OWASP published its Top 10 for Agentic Applications in December 2025. Two entries describe an IaC agent with cloud access almost exactly: ASI02, tool misuse, and ASI03, identity and privilege abuse. The older LLM06: Excessive Agency gives the three root causes: too much functionality, too many permissions, too much autonomy.
The incidents went public. In July 2025, SaaStr founder Jason Lemkin reported that a Replit agent deleted a live database during a code freeze. Replit's CEO called it unacceptable, added automatic separation of development and production databases, and announced a planning-only mode (Fortune). In 2026, the founder of PocketOS reported that a coding agent working on a staging task deleted a production storage volume, and the backups stored in it, in about nine seconds. By his account it used an API token that was unrelated to the task and carried delete rights, with no confirmation step and no environment scoping. The hosting provider later recovered more recent data and changed its API (Information Age).
In both reports the model is not the interesting part. They are ordinary access-control failures: a credential that could delete production was reachable from where the agent was running. OWASP's advice says it plainly: "Implement authorization in downstream systems rather than relying on an LLM to decide if an action is allowed or not."
There is a delivery argument too. DORA's 2026 report on the ROI of AI-assisted development found that the gains depend on the platform around the tool, and that adoption brings a "verification tax" on reviewers and more delivery instability unless teams keep batches small and testing automated (InfoQ).
2. Five ways agent-written IaC goes wrong
Before designing controls, be specific about what you are defending against. These failure modes keep coming up in public write-ups. Each is mapped to an OWASP agentic category and to the control that stops it.
| Failure mode | What it looks like | OWASP | The control that stops it |
|---|---|---|---|
| Destructive plan | A rename or a "cleanup" becomes destroy-and-recreate on a database, bucket or volume. | ASI02 Tool misuse | Policy on the plan JSON that denies deletes of protected types, plus prevent_destroy on stateful resources. |
| Credential reach | The agent finds a broader credential than the task needed, in an env var, a CLI profile, a CI variable or a state file, and uses it. | ASI03 Identity and privilege abuse | No long-lived keys anywhere. Short-lived OIDC roles bound to one repo and one branch. |
| Weakening the gate | To get a green pipeline, the agent edits the pipeline, the policy or the test that was blocking it. | ASI10 Rogue agents | Pipeline and policy live outside the repo. Code owners on anything that defines the checks. |
| Plausible but wrong | Valid HCL that passes validate but opens a security group to the world or drops encryption, because the pattern is common in public code. | ASI09 Human-agent trust exploitation | Static scanning and policy as code. Small merge requests. A reviewer who reads the plan. |
| Runaway blast radius | One "simple" change touches hundreds of resources across environments. | ASI08 Cascading failures | A change-count limit for agent-authored plans, one workspace per merge request, state split per environment. |
Notice what is missing from the right-hand column: a better prompt. Instructions make these failures rarer. They do not make them impossible, and impossible is the bar for deleting production data.
3. The principle: the agent proposes, the pipeline governs
None of this needs a new security idea. It is separation of duties and least privilege (AC-5 and AC-6 in NIST SP 800-53) applied to a new kind of author. Google's Building Secure and Reliable Systems sets the same goal for humans: remove direct access to production, route changes through narrow, audited automation, and require a second party for sensitive actions.
So treat the agent as a fast, unprivileged contributor. It gets the same path to production as a new joiner on day one: a merge request.
The design has four layers, and each one assumes the layer before it has already failed:
- Workspace. What the agent can run and read.
- Identity. Which credentials exist, and what is able to assume them.
- Policy. What a plan is allowed to contain.
- Approval. Who says yes, and what exactly they are saying yes to.
Only layers 2 to 4 are security boundaries. Layer 1 is a seatbelt: worth wearing, but it is not what stops the car.
4. Layer 1: a workspace with nothing worth stealing
The workspace is wherever the agent runs: a laptop, a dev container or a cloud sandbox. Give it context, not credentials.
Give it context
- Your modules and conventions. A rules file in the repo that says which internal modules to use, how workspaces are laid out and what is off limits.
- Current provider docs. HashiCorp's Terraform MCP server gives an agent live registry documentation and module inputs, so it is not relying on the provider schema in its training data.
- Cheap, offline checks.
terraform fmt,validate,tflintandterraform testwith mocked providers all run without cloud credentials.
Deny the dangerous commands
Most coding agents support allow and deny lists for shell commands. This is the shape for Claude Code, checked in as .claude/settings.json. Other agents have an equivalent.
{
"permissions": {
"allow": [
"Bash(terraform fmt *)",
"Bash(terraform validate *)",
"Bash(terraform init -backend=false *)",
"Bash(terraform test *)",
"Bash(tflint *)"
],
"deny": [
"Bash(terraform apply *)",
"Bash(terraform destroy *)",
"Bash(terraform state *)",
"Bash(terraform import *)",
"Bash(aws *)",
"Read(**/*.tfstate)",
"Read(**/*.tfvars)",
"Read(**/.env)"
]
}
}
Deny rules win over allow rules, so the agent can format and validate but cannot apply, touch state, call the AWS CLI or read state files, which hold secrets in cleartext.
Then assume the deny list fails
A command filter is pattern matching on text. The Claude Code documentation is honest about this: a deny rule covers the invocation the agent usually produces and "isn't a security boundary around the program". Wrap the same command in bash -c and the pattern no longer matches.
So the real control in this layer is absence:
- No cloud credentials in the environment. No
~/.awsprofile, no exported keys, no instance role with write access. On a developer laptop, that means a container or sandbox without the developer's own AWS session mounted in. - Its own identity in Git. A bot account or project access token that can push branches and open merge requests, and nothing else. It cannot push to
main, it is on no approver list, and everything it does is attributable. - Fetched content is data. READMEs, issues and module docs can carry instructions aimed at the agent. The defence against prompt injection is structural: an agent with no credentials has nothing worth hijacking.
5. Layer 2: credentials the agent cannot widen
This layer does the heavy lifting. The AWS Well-Architected security pillar says to use temporary credentials for machine identities (SEC02-BP02), and CI systems have a clean way to do that: OIDC federation. The job gets a signed token from GitLab, and AWS STS swaps it for short-lived keys only if the role's trust policy matches the claims in that token.
The claim that matters is sub. GitLab builds it as project_path:<group>/<project>:ref_type:<type>:ref:<name> (GitLab docs). That gives you a way to say, in IAM, that a role can only be used by one repository on one branch. Use it to split the pipeline into two roles.
resource "aws_iam_openid_connect_provider" "gitlab" {
url = "https://gitlab.com" # or your self-managed GitLab URL
client_id_list = ["sts.amazonaws.com"]
}
locals {
repo = "project_path:platform/infra-live"
trust = {
# any branch of this one repo may plan
plan = {
test = "StringLike"
sub = "${local.repo}:ref_type:branch:ref:*"
}
# only the protected main branch may apply
apply = {
test = "StringEquals"
sub = "${local.repo}:ref_type:branch:ref:main"
}
}
}
data "aws_iam_policy_document" "trust" {
for_each = local.trust
statement {
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [aws_iam_openid_connect_provider.gitlab.arn]
}
condition {
test = "StringEquals"
variable = "gitlab.com:aud"
values = ["sts.amazonaws.com"]
}
condition {
test = each.value.test
variable = "gitlab.com:sub"
values = [each.value.sub]
}
}
}
resource "aws_iam_role" "plan" {
name = "tf-plan-readonly"
assume_role_policy = data.aws_iam_policy_document.trust["plan"].json
max_session_duration = 3600
}
resource "aws_iam_role_policy_attachment" "plan_read" {
role = aws_iam_role.plan.name
policy_arn = "arn:aws:iam::aws:policy/ReadOnlyAccess"
}
resource "aws_iam_role_policy" "plan_no_data" {
name = "deny-data-reads"
role = aws_iam_role.plan.id
policy = jsonencode({
Version = "2012-10-17"
# ReadOnlyAccess still allows s3:GetObject, so deny data reads explicitly
Statement = [{
Effect = "Deny"
Action = ["s3:GetObject", "secretsmanager:GetSecretValue"]
NotResource = "arn:aws:s3:::example-tf-state/*"
}]
})
}
resource "aws_iam_role" "apply" {
name = "tf-apply"
assume_role_policy = data.aws_iam_policy_document.trust["apply"].json
permissions_boundary = aws_iam_policy.tf_boundary.arn
max_session_duration = 3600
}
| Role | Who can assume it | What it can do | Why |
|---|---|---|---|
tf-plan-readonly | Any branch pipeline in the infrastructure repo | Read APIs and read state. No writes, with explicit denies on secrets and object data. | An agent's merge request needs a real plan to be reviewable. |
tf-apply | Only pipelines on protected main | Create, change and delete, inside a permissions boundary | A reviewed merge is the only route to a change. |
| Break-glass | Named humans, through SSO with MFA, with an alert on every use | Admin | Incidents happen. The emergency path should exist, be loud, and be tested. |
That split is the core of the design. An agent can edit .gitlab-ci.yml on its branch and point the plan job at tf-apply. STS will read a token whose sub ends in the agent's branch name, compare it with a trust policy that only accepts ref:main, and refuse. The control sits in AWS, where the agent cannot reach it.
It only holds if main is protected: no direct pushes, and merges need approval. The trust policy is exactly as strong as your branch protection.
Three things people get wrong
- A plan is not harmless.
terraform planruns provider code and reads data sources, and theexternaldata source runs a local program. A plan job executes whatever the branch says, with the plan role's credentials. That is why the role has to be read-only. - Read-only is broader than it sounds. The AWS managed
ReadOnlyAccesspolicy can read S3 objects, which is data, not configuration. Deny data reads explicitly, as in the snippet. - State is sensitive. The plan role has to read state, and state can contain secrets. Keep one state per workspace and environment, and encrypt the bucket with a KMS key. The Terraform docs give the same warning about saved plan files: treat them as sensitive artifacts.
6. Layer 3: policy on the plan, not the prompt
The plan is the only statement of what will actually happen. terraform show -json turns a saved plan into a documented JSON format, with one entry per resource under resource_changes and a list of actions for each. A replacement shows up as ["delete", "create"], so a policy that looks for delete catches both destroys and replacements.
This is a Rego policy for Conftest, which wraps Open Policy Agent. It encodes three of the controls from section 2.
package main
import rego.v1
# Types that must never be deleted or replaced without a human-authored change
protected_types := {
"aws_db_instance", "aws_s3_bucket", "aws_kms_key",
"aws_ebs_volume", "aws_efs_file_system", "aws_route53_zone",
}
# Type prefixes an agent may not touch at all
human_only_prefixes := ["aws_iam_", "aws_organizations_", "aws_kms_"]
max_agent_changes := 25
changes := [rc |
some rc in input.resource_changes
rc.mode == "managed"
rc.change.actions != ["no-op"]
]
# "delete" is present for both a destroy and a replace
deny contains msg if {
some rc in changes
"delete" in rc.change.actions
rc.type in protected_types
msg := sprintf(
"%s: delete or replace of %s needs a human-authored change",
[rc.address, rc.type],
)
}
deny contains msg if {
input.meta.agent_authored
some rc in changes
some prefix in human_only_prefixes
startswith(rc.type, prefix)
msg := sprintf(
"%s: agent-authored changes may not touch %s",
[rc.address, rc.type],
)
}
deny contains msg if {
input.meta.agent_authored
count(changes) > max_agent_changes
msg := sprintf(
"plan changes %d resources; the agent limit is %d",
[count(changes), max_agent_changes],
)
}
The input.meta.agent_authored flag is not part of the plan. The pipeline adds it in the next section: it is true when the agent's bot account pushed the branch. OPA's own Terraform tutorial goes a step further and weights each resource type, so the limit is a blast-radius score, not a count.
Where the policy lives matters more than what it says
- Outside the repo it judges. If the policy sits next to the Terraform, the cheapest way to pass the check is to edit it, and an agent told to get the pipeline green can take that route. Publish the bundle from a separate repository with its own owners, and pull it by version.
- On every plan, human or agent. The baseline rules apply to everyone. The agent only gets extra ones.
- Before the plan as well. A policy on plan output runs after the plan has executed. Catch
externaldata sources andlocal-execprovisioners with a static check of the HCL first. Conftest can parse HCL directly, and scanners such as tflint, Trivy or Checkov cover common misconfigurations at the same stage.
One limit: values only known after apply are not in the plan JSON, so no plan-time policy sees everything. Policy narrows what a human has to check. It does not replace the human.
7. Layer 4: approval that means something
This pipeline ties the layers together. Merge request pipelines run plan and policy with the read-only role. The pipeline on main plans again, runs the same policy, then waits for a person before applying the exact plan that was checked.
stages: [plan, policy, apply]
default:
# terraform, conftest and jq in one image; pin it by digest in real use
image: registry.example.com/platform/terraform-ci:2026.10
.aws_oidc:
id_tokens:
AWS_ID_TOKEN:
aud: sts.amazonaws.com
before_script:
- umask 077 && echo "$AWS_ID_TOKEN" > /tmp/oidc-token
- export AWS_WEB_IDENTITY_TOKEN_FILE=/tmp/oidc-token
- export AWS_ROLE_SESSION_NAME="gitlab-${CI_PROJECT_ID}-${CI_PIPELINE_ID}"
.mr_and_main:
rules:
- if: $CI_PIPELINE_SOURCE == "schedule"
when: never
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
plan:
stage: plan
extends: [.aws_oidc, .mr_and_main]
variables:
AWS_ROLE_ARN: arn:aws:iam::111122223333:role/tf-plan-readonly
script:
- terraform init -input=false -lockfile=readonly
- terraform plan -input=false -lock=false -out=tfplan
- terraform show -json tfplan > tfplan.json
artifacts:
paths: [tfplan, tfplan.json]
access: developer # plan files can hold secrets in cleartext
expire_in: 1 day
policy:
stage: policy
extends: .mr_and_main
needs: [plan]
script:
- |
AGENT=false
if [ "$GITLAB_USER_LOGIN" = "infra-agent" ]; then AGENT=true; fi
jq --argjson agent "$AGENT" '. + {meta: {agent_authored: $agent}}' \
tfplan.json > input.json
# the policy bundle lives outside this repo and is pulled by version
- conftest pull registry.example.com/platform/tf-policy:1.8.0
- conftest test input.json
apply:
stage: apply
extends: .aws_oidc
needs: [plan, policy]
variables:
AWS_ROLE_ARN: arn:aws:iam::111122223333:role/tf-apply
rules:
- if: $CI_PIPELINE_SOURCE == "schedule"
when: never
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
when: manual
environment: production # protected, with deployment approvals
resource_group: tf-production # one apply at a time
script:
- terraform init -input=false -lockfile=readonly
- terraform apply -input=false tfplan # the reviewed plan, not a fresh one
terraform apply tfplanapplies the saved plan, the same file the policy job read and the approver looked at. If state has moved since, Terraform rejects the plan as stale.-lock=falseon plan is there because a read-only role cannot write the lock file (this assumes the S3 backend's nativeuse_lockfilelocking). The apply job takes the lock, andresource_groupmakes sure only one apply runs at a time.environment: productionpoints at a GitLab protected environment with deployment approvals. By default, the person who triggered the pipeline cannot approve their own deployment.-lockfile=readonlystops a pipeline from silently accepting new provider versions or checksums. Provider upgrades become a reviewed change of their own.
Settings that make the approval real
On the GitLab side I'd switch these on (most need Premium or Ultimate):
- Approval settings: prevent approval by the merge request creator, prevent approvals by users who add commits, and remove all approvals when commits are added.
- Code owners for
.gitlab-ci.yml, IAM, network and state configuration, so a change to the guardrails themselves needs the platform team. - "Pipelines must succeed", so a failed policy job blocks the merge.
- The pipeline definition included from a separate project, so the jobs above cannot be rewritten from a feature branch.
Who is the author when a human prompts an agent?
My rule: the person who asked for the change is its author, and cannot be its approver. Two humans see every production change. The agent is neither of them.
The second human should review the plan, not just the diff. A good agent-written merge request says what the plan adds, changes and destroys, and calls out every replacement. Keep changes small enough that reading the plan takes minutes. That is the DORA small-batch advice again, and it matters more when the author can produce a thousand lines before you finish your coffee.
Approval fatigue is a failure mode too. If every agent change needs the same two senior engineers, they will start approving on trust. Tiers put human attention where the blast radius is.
8. How much autonomy for which change
Not every change deserves the same gate. Classify by what the plan does and where, not by who wrote it or how confident the agent sounds.
| Tier | Example changes | What the agent may do | Gate |
|---|---|---|---|
| 0. Draft only | Docs, formatting, variable descriptions, tests | Open a merge request | Normal code review. Policy confirms the plan has no changes. |
| 1. Auto-apply in non-prod | Additive resources in dev or sandbox, under the change limit | Propose, and the pipeline applies after a policy pass | Policy is the approval. Humans review a daily digest afterwards. |
| 2. Agent proposes | Any production change, in-place updates, module upgrades | Propose only | Code owner approval on the merge request, then deployment approval on the plan. |
| 3. Human-authored | Deleting or replacing stateful resources. IAM, KMS, organisation policies, the network edge. The pipeline and the policy. | Draft and explain | A named engineer owns it. Two approvals and a change window. |
Start every repository at tier 2. Open tier 1 once you have data, and the data you want is a DORA metric you probably already track: change failure rate, split by agent-authored and human-authored changes. If the agent's number is no worse over a few months, widen its lane.
9. Drift detection as the safety net
Guardrails fail quietly. Someone fixes something in the console during an incident, a credential leaks, or an apply half-finishes. Drift detection is how you find out.
At Avizva we ran a Terraform module registry for 5+ client environments, with remote state, locking and a monthly drift check. With agents producing more changes more often, I'd run it nightly. It is one more job in the same pipeline, on a schedule, using the read-only role.
drift:
stage: plan
extends: .aws_oidc
variables:
AWS_ROLE_ARN: arn:aws:iam::111122223333:role/tf-plan-readonly
rules:
- if: $CI_PIPELINE_SOURCE == "schedule"
script:
- terraform init -input=false -lockfile=readonly
# exit 0 = in sync, 1 = error, 2 = code and reality disagree
- terraform plan -input=false -lock=false -detailed-exitcode
The -detailed-exitcode flag gives three outcomes: 0 for no changes, 1 for an error and 2 for a non-empty diff. On main, a 2 means code and reality disagree, and the failed scheduled pipeline is your alert. In practice it points at one of three things:
- An out-of-band change. Something wrote to the account outside the pipeline. Pair the drift job with a CloudTrail alert on any production write that did not come from a
tf-applysession or the break-glass role. - A partial apply. An apply failed halfway and nobody followed up.
- Merged but not applied. A change is sitting on
mainwaiting for a deployment approval that nobody gave.
An agent is well suited to triaging drift: read the plan, explain what changed and open a merge request that adopts or reverts it. In production that is still a tier 2 change. Nothing should auto-correct drift there, because drift during an incident is often a human doing the right thing.
10. Rollout plan and checklist
You do not need all of this on day one. This is the order I'd do it in, biggest risk reduction first.
| Phase | What to do | Done when |
|---|---|---|
| Week 1: identity | Remove static AWS keys from CI variables and from any environment an agent runs in. Create the OIDC provider and the plan and apply roles. Protect main. | A branch pipeline that tries to assume tf-apply gets AccessDenied. Test it on purpose. |
| Week 2: pipeline | Plan, policy, manual apply of the saved plan. Protected environment with deployment approvals. One apply at a time. | There is no route to an apply except a merge to main plus an approval. |
| Week 3: policy | Start with three rules: no deletes of protected types, no IAM changes from agents, a change-count limit. Move the policy to its own repository. | A deliberately bad merge request is blocked. |
| Week 4: workspace | Bot identity for the agent, a deny list, a sandbox with no credentials, a rules file with your module conventions. | The agent can open a merge request and can do nothing else. |
| Week 5 onwards: tiers and drift | Nightly drift job. Label agent-authored merge requests. Track change failure rate by author type. | You have the numbers to decide on tier 1. |
The checklist
- No long-lived cloud keys in the agent's environment, in CI variables or in the repo.
- The apply role's trust policy is pinned to protected
main, and a test from a branch proves it. - The plan role is read-only, with explicit denies for secrets and object data.
- Policy runs on the plan JSON in every pipeline, and the policy code lives outside the repo.
- The pipeline definition and the code owners file cannot change without the platform team.
- Authors cannot approve, committers cannot approve, and approvals reset on new commits.
- Apply uses the saved plan, and applies are serialised.
prevent_destroyis set on stateful resources as the last line of defence.- Drift detection runs nightly, and out-of-band writes raise an alert.
- A break-glass role exists, needs MFA, alerts on use and has been tested.
- Agent-authored changes are labelled and measured.
The short version
An agent that writes Terraform is a contributor, not an operator. Give it context instead of credentials, make the pipeline the only thing that can touch the cloud, let a policy read every plan before a person does, and keep the decision about who may apply somewhere the repository cannot reach.
If you only do one thing this week, do the first row of the rollout table: split plan and apply into two roles, pin the apply role to main, and delete the static keys. Most of the stories that make the news stop being possible at that point.