What changes when the code is infrastructure

  • The source diff does not predict the effect. Declarative code is evaluated through a dependency graph, so a one-line change can replace a database and a two-hundred-line refactor can change nothing.
  • The plan is the review artefact. Terraform will tell you, before you commit to anything, exactly which resources it intends to create, update, replace or destroy — in JSON, with a fixed vocabulary.
  • Reviewing the plan beats reviewing the HCL. The HCL shows intent; the plan shows consequence. Only one of those is what production experiences.
  • The agent never holds apply credentials. Propose, plan, evaluate, review, apply — and the agent's authority ends at the first of those five.
  • Guardrails go in layers, and every layer misses something. Read-only credentials, a plan-only stage, static analysis, plan-diff policy, deny-lists and human approval each catch a different class of mistake.

Nearly everything written about coding agents assumes application code: a failing test is the worst outcome, a revert is always available, and the feedback loop runs in seconds. Infrastructure inverts all three assumptions. An apply that goes wrong takes a service down in Mumbai or in London; reverting the commit does not bring back a deleted volume; and the loop between proposing a change and learning whether it worked is measured in minutes.

That is the case against. The case for is narrower and, in practice, decisive. Infrastructure-as-code is the only common category of software where you can render the exact effect of a change before making it, in a machine-readable format, for free, on every pull request. Application code has no such command. If you are going to let an agent write code that a machine will then review, this is the ground where the machine review can actually be rigorous.

Dimension Application code Infrastructure code What it means for the agent
Execution model Imperative; control flow readable top to bottom Declarative desired state; order derived from a dependency graph The agent cannot reason its way to the effect. It has to run a plan.
Source of truth The repository The repository, a state file, and the live cloud — which can disagree A correct-looking change can still produce a wrong plan against stale state
Idempotence Re-running tests is free and side-effect-free Apply mutates the world; some mutations are irreversible There is no "try it and see" mode. Trying it is the risk.
Failure mode A red build A deleted database, a widened security group, an unexpected invoice Blast radius, not test coverage, is the unit of risk
Rollback Revert the commit Reverting may recreate the resource but not its contents Prevention has to carry the weight that remediation carries elsewhere
Review artefact A diff of source, and whatever CI reports A diff of source and a typed, machine-readable plan The gate can be automated far more precisely than for application code

The loop: propose, plan, classify, review the diff, apply

The workflow is five steps and the agent participates in exactly one of them. It writes HCL and opens a pull request. Everything after that is pipeline.

# The agent's output is a branch. It does not run any of the following.
terraform init  -input=false -backend-config=env/dev.hcl
terraform plan  -input=false -lock-timeout=5m -out=tfplan.binary

# The saved plan file is opaque. Render it into the reviewable artifact:
terraform show -json tfplan.binary > tfplan.json

# Cheap first pass — does this plan remove anything at all?
jq -r '.resource_changes[]
       | select(.change.actions | any(. == "delete"))
       | [(.change.actions | join("+")), .type, .address] | @tsv' tfplan.json

Two flags in there earn their place. -input=false makes the command fail rather than block on a prompt, which is what you want in CI. -lock-timeout=5m stops a concurrent run turning into an immediate error when two pull requests touch the same state. And -out matters more than it looks: without a saved plan file there is nothing to convert to JSON, and without JSON there is nothing to evaluate policy against. A pipeline that runs terraform plan without -out has thrown away the artefact this entire approach depends on.

The terraform show -json step is the hinge. It produces an object whose format_version has been "1.0" since Terraform 1.1, with a resource_changes array at the top level. Each entry names a resource by address, type, name and mode ("managed" or "data"), and carries a change object whose actions array is drawn from a closed vocabulary. That closed vocabulary is what makes automated classification possible rather than heuristic.

Recommended

Make the plan artefact the thing a human reads, and put it in the pull request as a comment. A reviewer who is shown "3 to add, 1 to change, 1 to destroy — module.data.aws_db_instance.primary" will stop. The same reviewer shown a one-line change to a variable default will approve it in four seconds, because it genuinely looks harmless. The information asymmetry is the whole problem, and rendering the plan is the whole fix.

Reading the plan JSON: catching the dangerous change automatically

Terraform emits exactly one of seven actions arrays per resource change. Knowing all seven, and what each implies, is most of the work.

change.actions Meaning Risk Gate
["no-op"]Nothing changes for this resourceNoneAutomatic
["read"]A data source will be read during applyNoneAutomatic
["create"]A new object will be createdLow, but it has a priceAutomatic below a cost threshold; review above it
["update"]Modified in place, object preservedLow — unless the attribute is a security or retention controlAutomatic unless a sensitive attribute path moves
["delete", "create"]Destroy then create: a replacement, with downtimeHigh; catastrophic for anything statefulTwo-person review; policy denial for protected types
["create", "delete"]Create then destroy — create_before_destroy = trueMedium: less downtime, still a destroyReview, with the same type checks applied
["delete"]The object is removed and not replacedHighestPolicy denial for protected types; two-person review otherwise

Two sibling fields turn an alarm into an explanation. change.replace_paths is an array of attribute paths that forced a replacement, so you can print which attribute did it. And action_reason, which sits on the resource-change entry rather than inside change, carries values such as replace_because_cannot_update, replace_because_tainted, replace_by_request, delete_because_no_resource_config and delete_because_index_change-style variants. A reviewer told "this is a replacement because identifier cannot be updated in place" needs no further investigation. A reviewer told "1 to destroy" starts an archaeology project.

The classifier below is deliberately small. It is not trying to be a security product; it is trying to be a gate that a team can read in one sitting and therefore trust.

#!/usr/bin/env python3
"""classify_plan.py — turn `terraform show -json` output into a verdict.

    terraform plan -input=false -out=tfplan.binary
    terraform show -json tfplan.binary > tfplan.json
    python3 classify_plan.py tfplan.json

Exit codes:  0 = may auto-apply    1 = needs human approval    2 = blocked
"""
import json
import sys

# Types holding data you cannot re-create from the repository. Keep this list
# explicit and reviewed; an implicit rule is a rule nobody owns.
STATEFUL_TYPES = {
    "aws_db_instance", "aws_rds_cluster", "aws_dynamodb_table",
    "aws_s3_bucket", "aws_efs_file_system", "aws_elasticache_cluster",
    "azurerm_storage_account", "azurerm_postgresql_flexible_server",
    "google_sql_database_instance", "google_storage_bucket",
    "kubernetes_persistent_volume_claim",
}

# Attributes whose value moving is a security or retention decision,
# not a routine update.
SENSITIVE_ATTRS = {
    "deletion_protection", "skip_final_snapshot", "force_destroy",
    "backup_retention_period", "publicly_accessible",
    "cidr_blocks", "cidr_ipv4", "policy", "assume_role_policy",
}

ALLOW, REVIEW, BLOCK = "allow", "review", "block"
RANK = {ALLOW: 0, REVIEW: 1, BLOCK: 2}


def escalate(current: str, proposed: str) -> str:
    return proposed if RANK[proposed] > RANK[current] else current


def action_kind(actions: list) -> str:
    """Collapse a `change.actions` array into one label.

    Terraform emits exactly one of: ["no-op"], ["read"], ["create"],
    ["update"], ["delete"], ["delete","create"], ["create","delete"].
    The two-element forms are replacements; the order tells you whether
    create_before_destroy is set on the resource.
    """
    if actions == ["delete", "create"]:
        return "replace"
    if actions == ["create", "delete"]:
        return "replace_create_first"
    if actions == ["delete"]:
        return "delete"
    if actions == ["create"]:
        return "create"
    if actions == ["update"]:
        return "update"
    return "noop"                       # ["no-op"] and ["read"]


def moved_sensitive_attrs(change: dict) -> list:
    """Sensitive top-level attributes whose value changes, or becomes unknown."""
    before = change.get("before") or {}
    after = change.get("after") or {}
    unknown = change.get("after_unknown") or {}
    moved = []
    for key in SENSITIVE_ATTRS:
        if unknown.get(key):                      # computed at apply time
            moved.append(key)
        elif (key in before or key in after) and before.get(key) != after.get(key):
            moved.append(key)
    return sorted(moved)


def classify(plan: dict):
    verdict, findings = ALLOW, []

    for rc in plan.get("resource_changes", []):
        if rc.get("mode") != "managed":
            continue                              # data sources only read
        change = rc.get("change", {})
        kind = action_kind(change.get("actions", []))
        address, rtype = rc["address"], rc["type"]

        if kind in ("delete", "replace", "replace_create_first"):
            paths = change.get("replace_paths") or []
            why = rc.get("action_reason") or "no reason given"
            detail = f"{kind} of {address} ({why}"
            detail += f", forced by {paths})" if paths else ")"
            if rtype in STATEFUL_TYPES:
                findings.append("BLOCK  stateful " + detail)
                verdict = escalate(verdict, BLOCK)
            else:
                findings.append("REVIEW " + detail)
                verdict = escalate(verdict, REVIEW)

        elif kind in ("create", "update"):
            moved = moved_sensitive_attrs(change)
            if moved:
                findings.append(f"REVIEW {kind} of {address} touches "
                                + ", ".join(moved))
                verdict = escalate(verdict, REVIEW)

    return verdict, findings


def main() -> int:
    with open(sys.argv[1]) as fh:
        plan = json.load(fh)

    # Fail closed if the schema is not the one this script was written against.
    major = str(plan.get("format_version", "")).split(".")[0]
    if major != "1":
        print(f"unexpected format_version {plan.get('format_version')!r} — "
              "re-check the schema before trusting this gate", file=sys.stderr)
        return 2

    verdict, findings = classify(plan)
    for line in findings:
        print(line)
    print(f"verdict: {verdict} ({len(findings)} finding(s))")
    return RANK[verdict]


if __name__ == "__main__":
    raise SystemExit(main())

Note the failure direction. If the format_version is not what the script expects, it returns 2 and blocks — because a gate that silently degrades to "everything is fine" the day a schema changes is worse than no gate. The same instinct explains the explicit STATEFUL_TYPES set: a list somebody has to add to when the stack grows is a list somebody reads, and the review that adds aws_neptune_cluster to it is a five-minute conversation about what "stateful" means in your organisation.

A worked example: the one-line diff that would have deleted the database

A platform team inside a Bengaluru global capability centre decided to standardise resource naming on canonical region codes: mumbai becomes ap-south-1, london becomes eu-west-2. It is a tidy-up, it is boring, it is exactly the kind of mechanical repository-wide rename an agent does well. The agent was pointed at the infrastructure repository and produced a clean pull request touching a few dozen files.

The diff on the file that mattered was one line: a locals value changed from "payments-prod-mumbai" to "payments-prod-ap-south-1". Any reviewer would approve it. The plan disagreed.

// tfplan.json (abridged — real output carries before_sensitive,
// after_sensitive and the full attribute maps)
{
  "format_version": "1.0",
  "resource_changes": [
    {
      "address": "module.data.aws_db_instance.primary",
      "module_address": "module.data",
      "mode": "managed",
      "type": "aws_db_instance",
      "name": "primary",
      "provider_name": "registry.terraform.io/hashicorp/aws",
      "change": {
        "actions": ["delete", "create"],
        "before": { "identifier": "payments-prod-mumbai",
                    "deletion_protection": true },
        "after":  { "identifier": "payments-prod-ap-south-1",
                    "deletion_protection": true },
        "after_unknown": { "id": true, "endpoint": true, "address": true },
        "replace_paths": [["identifier"]]
      },
      "action_reason": "replace_because_cannot_update"
    }
  ]
}

The identifier attribute on aws_db_instance forces replacement — a long-standing behaviour of the AWS provider, and the reason has nothing to do with the agent. Terraform cannot rename an RDS instance in place through this resource, so a name change becomes destroy-then-create. The ["delete", "create"] ordering says the destroy happens first: no create-before-destroy, no overlap, no second copy of the data.

Four separate layers had a chance to catch this, and it is worth being precise about which one actually did.

  • Human review of the HCL: missed it. The diff was one string literal. Nothing about it suggests a database.
  • Static analysis (Trivy's config scanner, Checkov, tflint): missed it. These evaluate configuration for insecure or deprecated patterns. The configuration here is entirely secure and entirely correct. It just also destroys production.
  • The plan-diff policy: caught it. A Conftest rule denying any delete action against a protected resource type fired on the first evaluation, named the address, and printed replace_because_cannot_update alongside replace_paths. The pipeline failed before a human was asked for anything.
  • Provider-level deletion protection: would have caught it — badly. deletion_protection = true is set, so AWS would have refused the delete. But that refusal arrives mid-apply, after Terraform has already made other changes, leaving a half-applied plan and a state file that needs care. Correct outcome, terrible location.
Watch out

A lifecycle { prevent_destroy = true } block would have been better still: it makes terraform plan itself fail, so the problem surfaces at the cheapest possible moment. But it only protects a resource while that block remains in the configuration, and it does not override terraform destroy. It is a seatbelt, not a wall — and an agent that is allowed to edit lifecycle blocks can remove it. Put resource-type deny-lists on the lifecycle path itself.

Every article here is written by a Verified Builder. Want your name on the next one?

AI Tech Connect lists AI engineers, founders and researchers across India and the UK — and the people hiring browse it to find them. Adding your profile is free.

Become a Verified Builder →

Guardrails in layers, and what each one misses

No single control is sufficient, and the useful way to think about a stack of them is not "how much safety does this add" but "what class of mistake does this specifically fail to catch".

Layer What it catches What it misses
Read-only credentials for the agent Any attempt to change infrastructure outside the pipeline Everything about whether the proposed change is sensible
Plan-only CI stage Syntax errors, provider errors, unresolvable references, plans that will not compute A plan that computes perfectly and is catastrophic
Static analysis — Trivy config, Checkov, tflint Insecure defaults, missing encryption, public buckets, deprecated syntax, unpinned providers Intent. A change can be flawlessly secure and still delete production.
Plan-diff policy — Conftest/OPA, Sentinel Deletes and replacements of protected types, IAM widening, tag violations, oversized plans Resource types nobody has added to the list yet
Deny-lists on paths and resource types Agents editing state backends, organisation policies, DNS zones, lifecycle blocks The same effect reached indirectly through a module input
Environment scoping — agent opens PRs against dev only The entire class of "the agent went straight at production" Drift that makes the dev plan unrepresentative of the prod plan
Human approval on destructive actions Everything the classifier flags, in principle Approval fatigue — the reviewer who clears forty low-risk plans a day
prevent_destroy and provider deletion protection The destroy itself, at the last possible moment Nothing, but it fails loudly mid-run, which is an expensive place to learn

Policy as code, in the form the plan gives you

Conftest evaluates Rego against structured data, which is precisely what the plan JSON is. As of OPA 1.0 the if and contains keywords are mandatory in rule declarations; import rego.v1 is a no-op on 1.x but keeps the file working on older toolchains, so it costs nothing to leave in.

# policy/terraform_plan.rego
# conftest test --policy policy/ tfplan.json
package main

import rego.v1

protected_types := {
    "aws_db_instance",
    "aws_rds_cluster",
    "aws_s3_bucket",
    "google_sql_database_instance",
    "azurerm_postgresql_flexible_server",
}

# Types in this stack that support tags. Keep the list explicit — a rule that
# fires on IAM roles and route tables trains reviewers to ignore it.
taggable_types := {
    "aws_instance", "aws_db_instance", "aws_s3_bucket",
    "aws_lb", "aws_ecs_service", "azurerm_storage_account",
}

required_tags := {"owner", "cost-centre", "data-classification"}

# Rule 1 — never destroy or replace a protected resource type.
deny contains msg if {
    rc := input.resource_changes[_]
    rc.mode == "managed"
    rc.type in protected_types
    "delete" in rc.change.actions
    msg := sprintf("destructive action %v on protected %s (%v)",
                   [rc.change.actions,
                    rc.address,
                    object.get(rc, "action_reason", "no reason given")])
}

# Rule 2 — no ingress rule may open a port to the whole internet.
deny contains msg if {
    rc := input.resource_changes[_]
    rc.type == "aws_vpc_security_group_ingress_rule"
    rc.change.after.cidr_ipv4 == "0.0.0.0/0"
    msg := sprintf("%s would allow ingress from 0.0.0.0/0 on port %v",
                   [rc.address, object.get(rc.change.after, "from_port", "any")])
}

# Rule 3 — every taggable resource created must carry the ownership tags the
# finance and data-residency reports are built on.
deny contains msg if {
    rc := input.resource_changes[_]
    rc.type in taggable_types
    "create" in rc.change.actions
    tags := object.get(rc.change.after, "tags", {})
    missing := required_tags - object.keys(tags)
    count(missing) > 0
    msg := sprintf("%s is missing required tags: %v", [rc.address, missing])
}

# Warn — a plan big enough that a human reviewer will skim it.
warn contains msg if {
    n := count([rc |
        rc := input.resource_changes[_]
        rc.change.actions != ["no-op"]
    ])
    n > 25
    msg := sprintf("plan touches %d resources; split the change", [n])
}

Rule 3 is the one that quietly pays for itself in a dual-market estate. Tag hygiene is how a Chennai delivery team and a London product team share an account without arguing about the invoice — and data-classification is how you keep a data-residency commitment enforceable rather than aspirational, whether the constraint comes from India's DPDP framework or from a UK public-sector contract that names the region a dataset may live in. A tagging rule evaluated on the plan catches the omission before the resource exists, which is the only time fixing it is free.

If you run HCP Terraform or Terraform Enterprise, Sentinel gives you the same evaluation with three enforcement levels — advisory, which warns; soft-mandatory, which a privileged user can override with the override recorded in the audit log; and hard-mandatory, which nothing can override. Map your risk tiers onto those directly: warnings advisory, reviewable risk soft-mandatory, protected-type destruction hard-mandatory.

The pipeline that holds the credentials

# .github/workflows/terraform-plan.yml
# The agent opens the PR. This workflow is the only thing that runs terraform,
# and the role it assumes cannot mutate anything.
name: terraform-plan

on:
  pull_request:
    paths: ["infra/**"]

permissions:
  contents: read
  id-token: write        # OIDC only — no long-lived cloud keys anywhere
  pull-requests: write   # to post the plan summary as a comment

jobs:
  plan:
    runs-on: ubuntu-latest
    environment: dev                     # dev credentials, dev state, dev only
    defaults:
      run:
        working-directory: infra/envs/dev
    steps:
      - uses: actions/checkout@v4

      - name: Assume the READ-ONLY plan role
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::111122223333:role/terraform-plan-readonly
          aws-region: ap-south-1         # Mumbai; eu-west-2 for the London stack
          role-duration-seconds: 900

      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_wrapper: false

      - run: terraform init -input=false -backend-config=env/dev.hcl

      # -detailed-exitcode: 0 = no changes, 1 = error, 2 = changes present.
      - name: Plan
        id: plan
        run: |
          set +e
          terraform plan -input=false -lock-timeout=5m \
                         -out=tfplan.binary -detailed-exitcode
          code=$?
          set -e
          if [ "$code" -eq 1 ]; then exit 1; fi
          echo "has_changes=$([ "$code" -eq 2 ] && echo true || echo false)" \
            >> "$GITHUB_OUTPUT"

      - name: Render the plan as JSON
        run: terraform show -json tfplan.binary > tfplan.json

      - name: Policy gate
        run: conftest test --policy ../../../policy tfplan.json

      - name: Risk classifier
        run: python3 ../../../tools/classify_plan.py tfplan.json

      - uses: actions/upload-artifact@v4
        with:
          name: tfplan-dev
          path: infra/envs/dev/tfplan.json
          retention-days: 1
        # Both tfplan.binary and tfplan.json can contain values Terraform
        # marks sensitive. Treat this artifact as a secret: private repo,
        # short retention, never echoed into a public build log.

The read-only role is the load-bearing part, and it is worth being pedantic about what "read-only" means. A plan needs to describe resources, read data sources and read the state backend; it does not need to create, modify, delete, or write to the state bucket. Grant exactly that, assume it through OIDC with a fifteen-minute session, and there is no static credential for anything to exfiltrate. The apply role lives in a separate protected environment with required reviewers and is never reachable from a pull-request-triggered workflow. Our guide to least-privilege credentials for AI agents covers the general shape; infrastructure is where getting it wrong is least forgiving.

Avoid

Giving the agent apply credentials "just for dev, so it can iterate faster". Two things go wrong. Dev accounts are rarely as isolated as people believe — shared DNS zones, shared registries, peered networks and cross-account roles all leak outward. And once the agent can apply in one environment, the loop that produced a working change in dev is the loop somebody will copy for prod at 18:00 on a Friday, because it demonstrably worked.

From a verified Builder

"The change we made that mattered was not a model change or a prompt change. We stopped asking reviewers to read the agent's Terraform and started posting the plan summary as the first comment on every pull request. Approval time went up by about a minute and the number of surprises went to zero, because people were finally reading the thing that describes what happens."

— Oliver, Verified Builder · London, United Kingdom

Context engineering for an infrastructure repository

An agent that does not know your module conventions will invent some, and inventing conventions in an IaC repository produces resources that look fine individually and are unmaintainable collectively. The fix is the same as elsewhere — a durable instructions file at the root of the repository — but the contents are specific enough to be worth listing.

An AGENTS.md or CLAUDE.md for infrastructure should state: the module layout and where a new resource belongs; the naming convention, with one worked example rather than a rule expressed in prose; the mandatory tag set and what each tag means; which provider versions are pinned and where the pin lives; how environments are separated, whether by workspace or by directory; and — most valuable of all — an explicit list of things the agent must never touch. State backend configuration, lifecycle blocks, IAM trust policies, DNS zones and anything under a prod/ path make a good starting list. Our guide to AGENTS.md and CLAUDE.md files that actually steer agents covers how to keep such a file from rotting into decoration.

Scope matters at least as much as content. Pointing an agent at a whole infrastructure monorepo is the most common avoidable mistake: it will find seven ways of doing the same thing, average them, and produce something that matches none of your conventions. Point it at one module directory plus the two or three modules that directory consumes. A smaller context here is not a compromise for cost reasons — it is what makes the output consistent, because a directory with one prevailing pattern teaches that pattern unambiguously.

Pro tip

For any refactor that should not change infrastructure — extracting a module, renaming resources with moved blocks, reorganising files — state the expected plan in the task and assert it in CI. terraform plan -detailed-exitcode returns 0 for an empty diff, 2 when changes are present and 1 on error, so "this refactor must produce exit code 0" is a one-line, machine-checkable acceptance criterion. It is the closest thing infrastructure has to a unit test, and agents are noticeably better at tasks with a checkable definition of done.

Where agents earn their keep, and where they must not go

The pattern in the table below is consistent: agents do well where a correct answer is verifiable from the plan, and badly where the consequences of a change are invisible to it.

Task Suitability Required guardrail
Applying a tagging policy across hundreds of resourcesHighAssert the plan contains updates only, no replacements
Provider major-version upgrade and deprecation fixesHighPlan-only CI, replacement detection, lowest environment first
Extracting a hand-rolled stack into a reusable moduleHighmoved blocks reviewed by hand; assert an empty diff
Writing variable validation, descriptions and module docsHighOrdinary code review; there is no plan risk at all
Translating a stack to a second region or providerMediumNew state only; never against an existing environment
Adding a resource to an established patternMediumPolicy check on IAM, network and retention attributes; cost estimate
Authoring an IAM policy from a descriptionLowHuman authorship. The agent may critique, not write.
Initial network topology — CIDR plan, peering, egressLowHuman design; the agent implements an agreed diagram
Instance sizing and cost-sensitive capacity decisionsLowHuman, with cost estimation in the loop
terraform state mv, state rm, importDo notOut of scope entirely — no guardrail makes this acceptable
Changing a resource carrying prevent_destroyDo notPolicy denial, not a review request

State manipulation deserves the emphasis. terraform state mv and terraform state rm change Terraform's belief about the world without changing the world, and a mistake produces a state file that disagrees with reality in a way no plan will reveal — the next plan simply proposes to create something that already exists, or to destroy something it has forgotten it owns. Since Terraform 1.1, moved blocks handle in-state refactors declaratively: they live in the configuration, they are visible in the pull request, they are validated during planning and they apply across every workspace. That is a task an agent can do, precisely because it produces a reviewable artefact. The imperative commands are not, and the distinction is exactly the one this whole article turns on.

The high-suitability rows have something else in common: they are large, tedious and mechanical, which is where the return on agents is genuinely highest. The techniques in our guide to migrating a legacy codebase with AI coding agents transfer almost directly to a provider upgrade spanning two hundred files. For the low-suitability rows, the honest answer is that a human should be doing the design and the agent should be implementing it — which is the argument for writing the design down first, as spec-driven development sets out.

Kubernetes, Helm and the promotion path

The discipline transfers to Kubernetes with different verbs and a weaker artefact. kubectl diff -f manifest.yaml renders the change against live cluster state, and it follows the Unix diff convention: exit code 0 means no differences, 1 means differences were printed, and anything higher is a real failure. Scripts must treat 1 as a signal, not an error — getting this wrong is the single most common bug in homegrown Kubernetes gates, because with set -e the pipeline fails on precisely the plans that have something to show. kubectl apply --dry-run=server is the complement: it sends the object through the API server's validation and admission control without persisting it, catching what client-side validation cannot, including webhook rejections and defaulting behaviour.

For Helm, the databus23/helm-diff plugin adds helm diff upgrade, which renders what a release upgrade would change before you run it. Install it with helm plugin install https://github.com/databus23/helm-diff. The output is text rather than a typed action list, so your policy layer works harder — you are pattern-matching a diff instead of parsing a closed vocabulary. Where a chart's rendered output can be captured as manifests, running the same policy engine over those manifests is usually the better path.

Environment promotion is the last structural guardrail, and the rule is short: the agent may only ever open a pull request against the lowest environment. Dev to staging to production is a promotion path for a change that has already been planned, applied and observed at least once. An agent that can open a pull request against a production directory has removed the only control that does not depend on a reviewer being alert at the time.

Drift complicates this honestly. Terraform's plan JSON exposes a resource_drift array alongside resource_changes, describing differences it found between prior state and reality during refresh. If production has drifted — a console change during an incident, a manual scaling decision, a change made by another tool — the dev plan is not a preview of the prod plan, and an agent-authored change validated in dev can behave differently one environment up. Run a scheduled plan against every environment and alert on non-empty drift. It is a ten-line cron job and it is the difference between a promotion path that means something and one that is theatre.

Common pitfalls

Five failure modes account for most of the trouble teams hit in the first quarter of pointing agents at infrastructure.

  • Reviewing the HCL and calling it review. The most consequential mistake and the easiest to fix. If the plan is not rendered into the pull request, the review is being performed on the wrong artefact. Post the summary line — added, changed, destroyed — as a comment, and make destroyed non-zero a required conversation.
  • Approval fatigue. A gate that fires on every plan trains reviewers to approve reflexively, at which point it is worse than nothing because it manufactures a false record of scrutiny. Tune the classifier so that a routine plan passes silently and a genuinely risky one is rare enough to be startling. Our guide to AI code review and CI quality gates that cut noise, not corners makes the same argument for application code, and the arithmetic is identical here.
  • Treating the plan as a secret-free artefact. Both the saved plan file and its JSON rendering can contain values Terraform marks sensitive — database passwords supplied as variables, generated keys, connection strings. Never print either into a public build log, never attach them to a public artefact, and set short retention where you must store them.
  • Blast radius nobody has bounded. A single change can touch hundreds of resources and still pass every type-based rule. Cap the number of non-no-op resource changes a single automated plan may contain, warn above it and require a split, as rule 4 in the Rego above does. This is the infrastructure instance of a general principle set out in agents that fail safe: blast radius, kill switches and bounded harm.
  • Assuming static analysis covers destruction. Checkov, tflint and Trivy's configuration scanner — which absorbed tfsec's checks, with tfsec itself receiving no new rules since its final release in May 2025 — are genuinely valuable, and they answer a different question. They ask "is this configuration insecure?" They do not ask "does applying this delete something irreplaceable?" Only the plan answers that, and only a rule written against the plan enforces it.

The through-line is worth restating, because it survives whichever tools you are using in eighteen months' time. Infrastructure code is where an agent can do the most damage and where machine review can be the most rigorous, and those two facts have the same cause: the effect of the change is computable in advance. Application code will get better tooling for this eventually. Infrastructure already has it, in every repository, on every pull request, for free. The teams that get value out of agents here are simply the ones who stopped treating terraform plan as noise to scroll past and started treating it as the artefact under review.

Sources