Terraform when the agent does the typing
Most of the Terraform I ship now is written by a coding agent. This is the template repo that made that safe: read-only credentials for the agent, a plan gate that refuses deletes, hooks that stop it editing its own guardrails, and a CI that applies the exact plan a human approved.
At some point last year the ratio flipped. More of the Terraform going into our AWS accounts was typed by a coding agent than by a person. The HCL was fine. What worried me was everything around the HCL: the agent had the same AWS profile I did, the guardrails were a paragraph in a markdown file, and terraform apply was one confident tool call away.
So I built a template repo. It is what every new Terraform project at Storm Reply UK starts from now, and it works with both Codex and Claude Code. This post is a tour of the parts that turned out to matter, and the one principle underneath all of them.
The principle is in the house rules file, and it is the line I would keep if I had to throw the rest away:
Anything that must be enforced lives in make, pre-commit, CI or IAM, never in agent config. Agent config is a thin convenience layer.
Everything below is an application of that sentence.
The ladder
The template's overview doc has a table that ranks every safety control from strongest to weakest. It is the mental model for the whole repo, and it is worth internalising before reading any of the code.
The bottom rung is the agent's instruction file. It is called coaching, not enforcement, and the repo treats it that way. The top rung is that the agent's AWS credentials cannot apply anything. If every layer between them fails, an apply still dies at the AWS API rather than at a regex.
Rung one: the agent gets a read-only role
Each environment account is bootstrapped once with a CloudFormation stack, the only thing in the repo that is deliberately not Terraform, because it has to exist before Terraform has anywhere to put state. It creates the state bucket, a GitHub OIDC provider, an admin apply role that only CI can assume, a permissions boundary that every role Terraform creates must carry, and a read-only plan role.
The plan role is the one the agent gets. It is pinned for the whole session in the harness settings, so every shell command the agent runs inherits it:
{
"env": {
"AWS_PROFILE": "acme-dev-plan"
},
"sandbox": {
"enabled": true,
"allowUnsandboxedCommands": false
}
}
The same profile is set in the Codex config, because the two harnesses do not share settings and the repo has to work in both. With that in place the agent can terraform plan, inspect the account through the AWS MCP server, and price a change through the pricing server. It cannot apply, cannot touch state, and cannot assume anything else.
One consequence I did not expect. The state bucket policy denies writes to everyone except the apply role, which means a local plan cannot acquire the S3 lock either. So make plan runs with -lock=false, and the Makefile explains why in a comment directly above the target. A plan never writes state, so the lock buys nothing there. It took me a day to work that out the first time and I would rather you did not spend the same day.
The plan gate
Between the plan and the human sits scripts/check_plan.py. It reads the JSON plan and does two jobs. The first is the destroy gate. Any delete or replace fails make plan unless ALLOW_DESTROY=1 is set, and the rules file is specific about how that flag may be set:
Never set that flag unprompted: show the human the plan's literal delete list and get explicit confirmation on that exact list first, even when the destroy was expected.
The second job is an impact classification. Red for any destroy or replace, any IAM widening (a star on a star, a star resource, a star principal, or a removed condition), or ingress opened to the world. Amber for any update, any security-relevant type, any cost-bearing create, or a target of prod. Green otherwise. Every rule has a fixture plan under scripts/tests/fixtures/, so changing a rule without a test fails make check.
That classification then feeds a skill. When make plan writes a fresh tfplan.json, a hook fires and injects a line of context telling the agent to run the review-plan skill before anyone approves anything. The skill works through a fixed checklist, intent match, replacements, IAM and network widening, cost delta, and returns one of three verdicts: pass, pass with concerns, or do not apply. The point of doing this locally rather than in CI is ordering. If the review only happens once the PR exists, the human sees the verdict at the same moment they see the change, which is too late for it to change anything.
Hooks that stop the agent editing its own guardrails
There are six hooks, registered twice, once for each harness. The one I would copy first is protect-paths.sh. It runs before any edit or write and refuses to touch the bootstrap stack, state files, the hooks directory, the harness settings and the MCP config. The agent cannot loosen the rules it runs under, however persuasive its reasoning gets.
The second is block-destructive.sh, and I like it mostly for its comment:
# Deliberately blunt: no path resolution, no variable expansion, no shell
# tokenising. A delete phrased so this misses it still meets the sandbox and
# IAM; this is the last layer, not the only one. A false block costs more
# than a miss, because an agent that sees bogus errors learns to ignore the
# hook.
It blocks exactly one thing: a recursive force delete whose arguments name the repo root or .git, the single destructive act the sandbox cannot prevent because the project directory has to be writable. It does not try to be clever, because a hook that fires on legitimate commands teaches the agent that hooks are noise. Every hook has a test next to it and make check runs them all.
The loop
This is how a change moves from a sentence to prod. Most of the human's time goes into the two interviews and the two approvals.
New infrastructure goes through the new-module skill. It searches the public terraform-aws-modules registry first, because a thin wrapper over a maintained module beats a bespoke one almost every time. Then it runs a design interview covering sourcing, blast radius, interface, security and cost, waits for the human to approve the design, pins the upstream module to a commit SHA, and writes an ADR in the same PR. A new directory under modules/ without a matching ADR fails make check.
Plans on PRs run under the read-only role with no environment binding, so they never wait on an approval. The workflow re-plans when the allow-destroy label is added or removed, not only on pushes, and a separate job rewrites the PR description with deep links to each environment's plan. Description rather than comment, because comments scroll away and the description is what the reviewer reads.
The deploy applies the plan artifact, not a fresh plan. This is the CI detail I care about most. On merge, each environment's job plans under the read-only role, uploads the plan binary, and only then does an apply job start. The apply job is bound to a GitHub environment, which is the only way to mint an OIDC token the admin role's trust policy accepts, and it is also what forces required reviewers on test and prod before any credentials exist. It downloads the saved artifact and runs terraform apply tfplan. If state moved underneath it, Terraform rejects the stale plan outright instead of quietly applying a different diff. The plan the reviewer approved is the plan that runs.
apply:
needs: plan
if: needs.plan.outputs.changes == 'true'
environment: ${{ inputs.environment }}
steps:
- uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
name: tfplan-${{ inputs.environment }}
- run: terraform apply -input=false tfplan
Break glass is two verbs. The ops workflow can force-unlock state or apply -replace one address. It shares the deploy concurrency group so it can never race an apply, and it sits behind the same environment gate. There is no free-text Terraform runner. Arbitrary commands under admin credentials go through a PR.
Drift runs weekly under the read-only role. Exit code two opens or updates an issue labelled drift with the plan summary. Exit code zero closes it. Exit code one is a real failure and fails the job.
Tests without credentials
Native terraform test with a mocked provider runs on every PR and locally with no AWS access at all. The interesting trick is that a check block's data source can be overridden, so you can test the check itself:
mock_provider "aws" {}
override_data {
target = data.aws_budgets_budget.this
values = {
tags = { project = "stouk-example", environment = "dev" }
}
}
run "budget_name_carries_project_and_environment" {
command = plan
assert {
condition = aws_budgets_budget.this.name == "${var.project}-${var.environment}-monthly-budget"
error_message = "Budget name must be prefixed with <project>-<environment>-"
}
}
The check being tested is the one permanent check block in the composition. It looks up the deployed budget by name and asserts that the provider's default_tags made it onto the resource. Checks are warn-only by design, so it can never block a plan, but the weekly drift run reports the failure for free if someone edits providers.tf and drops a tag. The lookup uses the name prefix from locals rather than the resource's own attribute on purpose. On the very first plan the attribute is not known until apply and the check would error.
Small things that paid for themselves
- One toolchain file.
mise.tomlpins Terraform, tflint, terraform-docs, checkov, the AWS CLI and gh.mise installgives an identical set locally and in CI, andmake checkrefuses to run if mise is missing rather than silently using whatever is on the path. make locklocks four platforms. darwin and linux, arm64 and amd64. CI runs a single platform and would otherwise rewrite the lockfile on every run.- All checkov skips live in one file, each with a reason and an ADR pointer. The file says why in its first comment. Passing
--skip-checkon the command line replaces that list instead of merging with it, and the first time that bit us it took an afternoon to notice. - Renovate is self-hosted in a workflow, not the GitHub app, so it travels with the repo when a project is handed to a client org. Custom regex managers keep the SHA pins and their trailing version comments in step. A cache-warming workflow runs an hour before it on Sundays so Monday's PRs do not each reinstall the toolchain.
- Skills live once. Both harnesses read the same
.agents/skills/directory; the Claude Code directory holds symlinks. Config that cannot be shared is duplicated on purpose, because an instruction budget is a real thing and neither harness should read the other's. - Bugs in the template go upstream. A defect found in a hook, a script or a workflow gets filed on the template's own board, never patched locally, so the fix reaches every project spawned from it.
What I would tell you to copy
Give the agent credentials that cannot apply, then stop worrying about the prompt. Make the destroy gate mechanical and make the human confirm a literal list. Stop the agent editing the files that constrain it. Apply the artifact you reviewed. And rank your controls in a table, strongest first, so that when someone proposes a new rule in the markdown file you can point at the bottom row and ask what it would actually stop.
The instruction file still matters. It is where the posture lives, where the interviews are described, and where the agent learns what the team considers a small PR. It is just not where the safety is.