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.
| Output | Occurrences of the dummy value | What you see |
|---|---|---|
terraform plan (text) | 0 | (sensitive value) |
terraform show (text) | 0 | Same |
terraform show -json | 3 | Plaintext in after; only a flag in after_sensitive |
Saved plan file (-out) | Present once unzipped | The 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 0On 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.