devops

Build a Terraform Plan Review Agent: Catch Risky Changes in CI Before Merge

Wire an LLM agent into CI that reads your terraform plan JSON, flags destroys and IAM widening, comments on the PR, and blocks merge on high-risk changes.

July 24, 2026·8 min read·
#ai#terraform#devops#cicd#security

The review nobody reads carefully enough

A terraform apply in CI is one of the highest-blast-radius actions your pipeline can take, and the human safeguard in front of it is usually a reviewer skimming a 400-line plan on a Friday afternoon. The dangerous lines — a destroy on a stateful resource, a security group opening 0.0.0.0/0, an IAM policy quietly widening to "*" — hide in the noise of tag changes and tag re-orders. This is exactly the kind of narrow, high-signal judgment an LLM agent is good at, if you scope it correctly.

This guide builds a Terraform plan review agent that runs in CI: it parses the machine-readable plan, applies deterministic rules for the changes you never want to miss, asks an LLM to reason about the rest, posts a structured review comment on the pull request, and sets a non-zero exit code that blocks merge on high-risk changes. It never touches your infrastructure — it reads a plan and returns findings. That read-only boundary is the whole point, and it's the same design discipline behind a safe Kubernetes MCP server: give the agent a bounded, structured surface, not a shell.

Read the plan as JSON, never as text

The first mistake teams make is piping terraform plan's human-formatted output into a prompt. That output is lossy, colorized, and ambiguous. Terraform emits a stable, structured plan you can parse instead:

terraform plan -out=tfplan.binary
terraform show -json tfplan.binary > plan.json

The resource_changes array in plan.json is the ground truth. Each entry has a change.actions field — ["create"], ["update"], ["delete"], or ["delete", "create"] for a replacement — plus before and after objects. You extract intent from this deterministically, before any model is involved:

import json

def load_changes(path="plan.json"):
    plan = json.load(open(path))
    out = []
    for rc in plan.get("resource_changes", []):
        actions = rc["change"]["actions"]
        if actions == ["no-op"]:
            continue
        out.append({
            "address": rc["address"],            # aws_db_instance.primary
            "type": rc["type"],                  # aws_db_instance
            "actions": actions,
            "before": rc["change"]["before"],
            "after": rc["change"]["after"],
        })
    return out

Deterministic rules first, LLM second

Do not ask the model to catch the things you can catch with code. A delete on a database is not a judgment call — it's a hard rule. Encode the non-negotiables as deterministic checks so they can never be missed by a model having a bad day. This is the same layering as policy-as-code with Kyverno: the deterministic gate is the fence, the LLM is the extra pair of eyes behind it.

STATEFUL = {"aws_db_instance", "aws_rds_cluster", "aws_s3_bucket",
            "aws_dynamodb_table", "aws_ebs_volume", "aws_efs_file_system"}

def rule_findings(changes):
    findings = []
    for c in changes:
        acts = c["actions"]
        # Destroy or replace of anything that holds data.
        if c["type"] in STATEFUL and ("delete" in acts):
            findings.append({"severity": "high", "address": c["address"],
                "rule": "stateful-destroy",
                "note": f"{acts} on stateful resource — data loss risk"})
        # Security group opened to the world.
        if c["type"] == "aws_security_group_rule":
            cidrs = (c["after"] or {}).get("cidr_blocks") or []
            if "0.0.0.0/0" in cidrs:
                findings.append({"severity": "high", "address": c["address"],
                    "rule": "world-open-ingress",
                    "note": "ingress from 0.0.0.0/0"})
        # IAM policy widening to wildcard actions.
        if c["type"] in {"aws_iam_policy", "aws_iam_role_policy"}:
            doc = json.dumps((c["after"] or {}).get("policy", ""))
            if '"Action": "*"' in doc or '"*:*"' in doc:
                findings.append({"severity": "high", "address": c["address"],
                    "rule": "iam-wildcard", "note": "wildcard IAM action"})
    return findings

These rules are boring on purpose. They catch the incidents that make it into postmortems, and they run in milliseconds with zero token cost. The LLM's job is everything else: the subtle stuff that doesn't fit a regex.

The agent: structured findings via tool use

Now the LLM. The trick that makes it CI-safe is to never accept free-form prose. Force the model to return findings through a tool schema, so the output is validated JSON you can act on — the same constraint that makes any ops agent trustworthy, covered in depth in the agent harness as infrastructure. Using the Anthropic Python SDK with Claude Sonnet — the right price/latency tier for a per-PR reviewer:

import anthropic

REVIEW_TOOL = {
    "name": "report_findings",
    "description": "Report risky or noteworthy changes in a Terraform plan.",
    "input_schema": {
        "type": "object",
        "properties": {
            "findings": {
                "type": "array",
                "items": {
                    "type": "object",
                    "properties": {
                        "address": {"type": "string"},
                        "severity": {"enum": ["high", "medium", "low"]},
                        "note": {"type": "string",
                                 "description": "One sentence: what and why it's risky."},
                    },
                    "required": ["address", "severity", "note"],
                },
            }
        },
        "required": ["findings"],
    },
}

SYSTEM = (
    "You are a senior infrastructure reviewer. You are given a Terraform plan "
    "as JSON resource_changes. Flag ONLY changes that carry real operational or "
    "security risk: irreversible data operations, network exposure, privilege "
    "escalation, deletion of shared resources, or changes that force replacement "
    "of resources with dependents. Do NOT flag tag-only edits, description "
    "changes, or routine version bumps. Call report_findings exactly once. "
    "If nothing is risky, return an empty findings array."
)

def llm_findings(changes):
    client = anthropic.Anthropic()
    msg = client.messages.create(
        model="claude-sonnet-5",
        max_tokens=1500,
        system=SYSTEM,
        tools=[REVIEW_TOOL],
        tool_choice={"type": "tool", "name": "report_findings"},
        messages=[{"role": "user", "content": json.dumps(changes)[:60000]}],
    )
    for block in msg.content:
        if block.type == "tool_use":
            return block.input["findings"]
    return []

Two details do the heavy lifting. tool_choice forces the tool call, so the model can't wander off into a chatty paragraph. And the input slice caps context — a plan touching 300 resources shouldn't blow your token budget or your latency; if your plans are routinely that large, chunk resource_changes and review each batch, then merge findings.

Gate the merge

Combine both sources, dedupe, and turn the result into an exit code. High-severity findings fail the check; medium and low post as comments but let the human decide.

def main():
    changes = load_changes()
    findings = rule_findings(changes) + llm_findings(changes)
    # Dedupe by address, keeping the highest severity.
    rank = {"high": 3, "medium": 2, "low": 1}
    best = {}
    for f in findings:
        a = f["address"]
        if a not in best or rank[f["severity"]] > rank[best[a]["severity"]]:
            best[a] = f
    findings = sorted(best.values(), key=lambda f: -rank[f["severity"]])
    post_comment(findings)                         # your PR-comment function
    highs = [f for f in findings if f["severity"] == "high"]
    if highs:
        print(f"BLOCKED: {len(highs)} high-severity change(s)")
        raise SystemExit(1)
    print(f"OK: {len(findings)} finding(s), none blocking")

if __name__ == "__main__":
    main()

Wire it into GitHub Actions

The workflow generates the plan, runs the reviewer, and — critically — never grants the reviewer credentials to change anything. It reads state to produce a plan and writes a PR comment; that's the entire scope. If you're new to composing pipeline stages like this, the complete GitHub Actions production setup covers the mechanics.

name: terraform-plan-review
on:
  pull_request:
    paths: ["infra/**"]

permissions:
  contents: read
  pull-requests: write        # to post the review comment — nothing more

jobs:
  review:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
      - name: Plan
        working-directory: infra
        run: |
          terraform init -input=false
          terraform plan -out=tfplan.binary -input=false
          terraform show -json tfplan.binary > plan.json
      - name: AI review
        working-directory: infra
        env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: python ../scripts/tf_review.py

Because the permissions block grants no cloud credentials and the script only reads plan.json, the worst case for a compromised or hallucinating agent is a wrong comment — not a wrong apply. That containment is deliberate: the blast radius is bounded by what the job can do, not by what the prompt tells it to do.

Test the reviewer before you trust it

An agent that gates merges is itself production infrastructure, so it needs its own tests. Build a fixture set of saved plan.json files — a known database destroy, a wildcard IAM policy, a benign tag change — and assert the reviewer flags exactly the ones it should. This is the eval discipline from evals for DevOps AI agents: measure precision and recall on real plans before the agent has veto power over your pipeline. A reviewer that cries wolf on every tag edit gets ignored within a week; one that misses a destroy is worse than no reviewer at all.

Honest limits

This agent does not replace human review, and selling it as such is how you get burned. It misses novel risk the way any pattern-matcher does, it can misjudge context it can't see (that "destroy" might be an intentional migration), and its LLM half is non-deterministic — run it twice and the medium/low findings may shift. Treat it as a high-recall first pass that guarantees the deterministic rules always fire and surfaces the subtle stuff for a human to confirm. Pair it with sound Terraform hygiene — remote state, small blast-radius modules, the patterns in Terraform best practices and cost-saving Terraform patterns — and it earns its keep as the reviewer that never gets tired on a Friday. Start it in comment-only mode (no exit-code gate) for a few weeks, watch what it flags, and only give it merge-blocking power once you trust its precision.

#ai#terraform#devops#cicd#security
D
DevToCashAuthor

Senior DevOps/SRE Engineer · 10+ years · Professional Trader (IDX, Crypto, US Equities)

I write about real infrastructure patterns and trading strategies I use in production and in live markets. No courses, no affiliate hype — just documentation of what actually works.

More about me →