◉ANTIGRAVITY LABJP
Articles/Integrations
⬡ Integrations/2026-03-23Advanced

Before You Let AI Read Your terraform plan — Strip Secrets, Fix Generated Code, Leave the Verdict to a Machine

What to check before handing a terraform plan to Antigravity for review: JSON plans carry sensitive values in plaintext, a conditional in AI-written RDS code that turns encryption off in dev, a jq gate that fails the build, a CI layout that leaves no secrets behind, and S3 state locking — all from runs I actually did.

antigravity473terraformiacinfrastructureai-reviewci6

I was wiring up a flow that feeds terraform plan output to an AI reviewer when I decided to look at exactly what I was about to hand over. I ran terraform show -json and piped it through grep for a dummy password I had planted.

It matched.

In the text plan, that same value had been masked as (sensitive value). I had quietly assumed Terraform was protecting me everywhere. The JSON output turned out to be a different story.

So I rebuilt the step before the AI. Below is what I measured with Terraform 1.10.5 and the AWS provider 5.x, using dummy credentials and no connection to any cloud account — everything ran as far as plan.

Where secrets survive in a plan

I marked a variable db_password as sensitive = true and counted how often the dummy value appeared in each output.

OutputOccurrences of the dummy valueWhat you see
terraform plan (text)0(sensitive value)
terraform show (text)0Same
terraform show -json3Plaintext in after; only a flag in after_sensitive
Saved plan file (-out)Present once unzippedThe value is inside the tfplan entry of the zip
{
  "after": { "keepers": { "pw": "hunter2-secret" } },
  "after_sensitive": { "keepers": { "pw": true } }
}

after_sensitive is an annotation, not a redaction. In machine-readable output, hiding the value is left to whoever reads it. If you feed that JSON to an agent, the password lands in the conversation history.

My rule since then: redact the JSON before anything reads it.

Redact with jq

# redact.jq — mask sensitive values and drop the variable values
def mask($v; $s):
  if ($s == true) then "(sensitive)"
  elif ($s|type) == "object" and ($v|type) == "object"
    then reduce ($v|keys[]) as $k ($v; .[$k] = mask($v[$k]; $s[$k]))
  else $v end;
 
del(.variables, .prior_state, .configuration)
| .resource_changes |= map(.change.after = mask(.change.after; .change.after_sensitive))
| .planned_values.root_module.resources
    |= map(.values = mask(.values; .sensitive_values))
terraform show -json plan.bin | jq -f redact.jq > plan.redacted.json
grep -c 'hunter2' plan.redacted.json   # should be 0

On an RDS configuration with a password variable, the dummy value appeared in three places (variables, planned_values, resource_changes). After the filter it appeared zero times, and password read (sensitive).

This only protects what Terraform knows is sensitive. A forgotten sensitive = true, or a secret baked into a resource name, goes straight through. I still open plan.redacted.json myself once before pointing an agent at a new project. And never publish the saved plan file as a CI artifact; upload the redacted JSON only.

Put the rules in AGENTS.md first

Before generation, I put the constraints at the repository root — mostly things I do not want written.

# Infrastructure rules
 
- Provider: AWS (ap-northeast-1) / Terraform >= 1.10
- Encryption is always on; never switch it with a conditional
- No credentials or passwords in .tf or tfvars
- Fetch AZ names with aws_availability_zones; do not hardcode them
- Never run `terraform apply`; stop at plan
- After any change, attach the output of `terraform validate` and `terraform plan`

The last two lines matter most to me. "No apply" caps the damage of a wrong assumption at a plan, and requiring validate/plan output makes the agent show its work. Which filename a given tool reads varies, so after adding the file, ask the agent to summarize the rules it is following.

AI-written RDS code: three things I changed

I asked for a production PostgreSQL instance — Multi-AZ, encrypted, seven-day backups. terraform validate passed on the first try. I still did not adopt the code as-is.

The encryption conditional. The generated code tied both settings to the environment:

storage_encrypted = var.environment == "production"
multi_az          = var.environment == "production"

Multi-AZ only in production is a sensible cost decision. Encryption is not. With environment = "dev", my plan showed storage_encrypted as false. If you ever restore a production snapshot into dev, that is where unencrypted data appears. The fix:

storage_encrypted   = true
multi_az            = var.environment == "production"
publicly_accessible = false

(The condition ? true : false form that models like to write is redundant, since the condition is already a boolean. Harmless, but it spends the reviewer's attention.)

Hardcoded AZs. Names like ap-northeast-1a/1c/1d can fail at apply time on accounts where an AZ is unavailable, because AZ name-to-zone mapping differs per account. Fetch them instead:

data "aws_availability_zones" "available" {
  state = "available"
}
 
locals {
  azs = slice(data.aws_availability_zones.available.names, 0, 3)
}

Where the password comes from. password = var.db_password is fine, but unless you decide how that variable gets its value, it drifts toward plaintext in terraform.tfvars. Decide early: TF_VAR_db_password from the environment, or a managed secret in Secrets Manager.

Let a machine decide pass or fail

An AI reviewer is a good second pair of eyes. But "is it encrypted, is it public" must give the same answer every time, so I moved that verdict out of the AI and into an exit code.

#!/usr/bin/env bash
# scripts/policy-check.sh — fail with non-zero if a plan violates the rules
set -euo pipefail
PLAN_JSON="${1:?usage: policy-check.sh plan.json}"
 
jq -e '
  [ .resource_changes[]
    | select(.type == "aws_db_instance")
    | select(.change.after.storage_encrypted != true
             or .change.after.publicly_accessible == true) ]
  | length == 0
' "$PLAN_JSON" > /dev/null \
  || { echo "NG: unencrypted or publicly accessible RDS instance" >&2; exit 1; }
 
echo "OK"

With environment = "production" it exited 0. With the plan regenerated for dev (the conditional-encryption version), it exited 1. The trap above is caught without waiting for any model.

If your list of rules grows, a policy engine such as Sentinel is the next step, but it belongs to HCP Terraform / Terraform Enterprise. For one person or a small team, a few lines of jq that grow only when a real incident demands it are easier to maintain.

In CI

The layout I settled on: no long-lived keys (OIDC), no concurrent runs, and only the redacted JSON kept.

name: terraform-plan-review
on:
  pull_request:
    paths: ['infra/**']
 
permissions:
  id-token: write
  contents: read
  pull-requests: write
 
concurrency:
  group: tf-${{ github.ref }}
  cancel-in-progress: true
 
jobs:
  plan:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: infra/environments/dev
    steps:
      - uses: actions/checkout@v4
 
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ vars.TF_PLAN_ROLE_ARN }}   # read-only role
          aws-region: ap-northeast-1
 
      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: "1.10.5"
          terraform_wrapper: false
 
      - run: terraform init -input=false
 
      - name: plan
        run: |
          rc=0
          terraform plan -input=false -lock=false -detailed-exitcode -out=plan.bin || rc=$?
          # 0: no changes / 2: changes / 1: error
          [ "$rc" -eq 1 ] && exit 1
          terraform show -json plan.bin | jq -f ../../../scripts/redact.jq > plan.redacted.json
 
      - name: policy check
        run: ../../../scripts/policy-check.sh plan.redacted.json
 
      - uses: actions/upload-artifact@v4
        with:
          name: plan-redacted
          path: infra/environments/dev/plan.redacted.json   # never upload plan.bin

-detailed-exitcode returns 0 for no changes, 2 for changes, and 1 for errors, so the script stops only on 1. Keep apply in a separate workflow behind human approval. Handing plan.redacted.json to the agent in your IDE is the AI-review step.

State locking is moving away from DynamoDB

I used to write "S3 + DynamoDB locking" here. The current S3 backend documentation marks dynamodb_table as deprecated and says it will be removed in a future minor version; use_lockfile is the replacement.

terraform {
  backend "s3" {
    bucket       = "my-tfstate-bucket"
    key          = "dev/terraform.tfstate"
    region       = "ap-northeast-1"
    encrypt      = true
    use_lockfile = true   # defaults to false
  }
}

It needs s3:GetObject, s3:PutObject, and s3:DeleteObject on the .tflock object. During migration you can set both S3 and DynamoDB locking, so I'd switch in two steps rather than one.

Run terraform force-unlock <LOCK_ID> only after you have confirmed nobody holds the lock. With agents that may start several applies at once, unlocking at the wrong moment is how you get a double apply — which is why the concurrency block and human-approved applies are the real prevention.

Start small

The line I'm keeping: redact before the AI reads, let a machine give the verdict, and keep apply in a human's hands.

If you have a Terraform project handy, try terraform show -json plan.bin | grep -c '<a value you consider secret>' once. If the number isn't zero, you've just seen why the step before the AI is worth building.

Share

Thank You for Reading

Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

⬡ Integrations2026-11-03
Turning a Spreadsheet Chore into Apps Script: The First Request a Non-Engineer Can Hand to Antigravity
For non-engineers who want a weekly copy-and-paste routine handled by Apps Script: how to word the request to Antigravity, build in a dry run, keep the data safe, set a trigger, and sort out the usual snags.
⬡ Integrations2026-10-17
When Antigravity's Browser Agent Stops at the Login Screen: Four Things to Check
The browser agent came back with only a sign-in page. Here are four checks, built on the fact that it runs in a separate Chrome profile, to get its login state sorted.
⬡ Integrations2026-09-05
Pointing GOOGLE_GEMINI_BASE_URL at Your Own Gateway Does Not Change Who Holds the Key
I pointed the Antigravity CLI at a gateway of my own and recorded exactly which credentials travelled with it. Authorization is dropped across a cross-origin redirect, but the API key header survives every hop. Here is the measurement, and how I moved the key off my laptop.