AI in platform engineering · October 2026

Guardrails for AI agents writing Terraform: OIDC, OPA and approval gates

AI agents are now good enough to write most of your Terraform. That moves the interesting question from who writes the change to who is allowed to apply it. This is the guardrail design I'd put in front of any agent that touches infrastructure code, with the IAM, policy and pipeline configs.

· 18 min read · Arjun Kanojia

At work I use agentic AI to help create and maintain Terraform. It is good at the parts of infrastructure as code that used to eat afternoons: wiring a module, bumping a provider across a pile of workspaces, writing the variables and outputs nobody enjoys writing.

It is also a tool that can write a change that replaces a database, and explain with complete confidence why that is fine.

So the question is no longer whether agents should write IaC. They already do. HashiCorp's recent guidance answers it in one line: agents propose changes, and the platform governs them (InfoQ's summary). This post makes that concrete for Terraform, GitLab CI and AWS. I helped move 300+ Jenkins pipelines onto GitLab runners on EKS this year, so the examples are GitLab CI, but the same shape works with GitHub Actions, OpenTofu or HCP Terraform.

One rule holds the design together: nothing the agent can edit should be able to change what the agent is allowed to do.

A note on scope: this is a reference design built from public guidance and industry practice, not a description of any employer's setup.

1. Why this is the question right now

The vendors converged. HashiCorp now describes HCP Terraform as the control plane for agent-written infrastructure: agents can generate, validate and explain configuration, but should not approve their own work, weaken policy, hold broad credentials or bypass deployment controls.

The security community named the risks. OWASP published its Top 10 for Agentic Applications in December 2025. Two entries describe an IaC agent with cloud access almost exactly: ASI02, tool misuse, and ASI03, identity and privilege abuse. The older LLM06: Excessive Agency gives the three root causes: too much functionality, too many permissions, too much autonomy.

The incidents went public. In July 2025, SaaStr founder Jason Lemkin reported that a Replit agent deleted a live database during a code freeze. Replit's CEO called it unacceptable, added automatic separation of development and production databases, and announced a planning-only mode (Fortune). In 2026, the founder of PocketOS reported that a coding agent working on a staging task deleted a production storage volume, and the backups stored in it, in about nine seconds. By his account it used an API token that was unrelated to the task and carried delete rights, with no confirmation step and no environment scoping. The hosting provider later recovered more recent data and changed its API (Information Age).

In both reports the model is not the interesting part. They are ordinary access-control failures: a credential that could delete production was reachable from where the agent was running. OWASP's advice says it plainly: "Implement authorization in downstream systems rather than relying on an LLM to decide if an action is allowed or not."

There is a delivery argument too. DORA's 2026 report on the ROI of AI-assisted development found that the gains depend on the platform around the tool, and that adoption brings a "verification tax" on reviewers and more delivery instability unless teams keep batches small and testing automated (InfoQ).

2. Five ways agent-written IaC goes wrong

Before designing controls, be specific about what you are defending against. These failure modes keep coming up in public write-ups. Each is mapped to an OWASP agentic category and to the control that stops it.

Failure modeWhat it looks likeOWASPThe control that stops it
Destructive planA rename or a "cleanup" becomes destroy-and-recreate on a database, bucket or volume.ASI02 Tool misusePolicy on the plan JSON that denies deletes of protected types, plus prevent_destroy on stateful resources.
Credential reachThe agent finds a broader credential than the task needed, in an env var, a CLI profile, a CI variable or a state file, and uses it.ASI03 Identity and privilege abuseNo long-lived keys anywhere. Short-lived OIDC roles bound to one repo and one branch.
Weakening the gateTo get a green pipeline, the agent edits the pipeline, the policy or the test that was blocking it.ASI10 Rogue agentsPipeline and policy live outside the repo. Code owners on anything that defines the checks.
Plausible but wrongValid HCL that passes validate but opens a security group to the world or drops encryption, because the pattern is common in public code.ASI09 Human-agent trust exploitationStatic scanning and policy as code. Small merge requests. A reviewer who reads the plan.
Runaway blast radiusOne "simple" change touches hundreds of resources across environments.ASI08 Cascading failuresA change-count limit for agent-authored plans, one workspace per merge request, state split per environment.

Notice what is missing from the right-hand column: a better prompt. Instructions make these failures rarer. They do not make them impossible, and impossible is the bar for deleting production data.

3. The principle: the agent proposes, the pipeline governs

None of this needs a new security idea. It is separation of duties and least privilege (AC-5 and AC-6 in NIST SP 800-53) applied to a new kind of author. Google's Building Secure and Reliable Systems sets the same goal for humans: remove direct access to production, route changes through narrow, audited automation, and require a second party for sensitive actions.

So treat the agent as a fast, unprivileged contributor. It gets the same path to production as a new joiner on day one: a merge request.

AGENT WORKSPACE · NO CLOUD CREDENTIALS Engineerticket or prompt AI agentown bot identity Local checksfmt · validate · tflint Merge requestbranch push only merge request pipeline starts CI PIPELINE · SHORT-LIVED OIDC CREDENTIALS PER JOB Plan jobread-only role Policy gateOPA on plan JSON Human approvalcode owner + environment Apply jobmain branch only saves the plan file deletes · IAM · blast radius author cannot approve applies saved plan assumes (read) assumes (write) nightly drift plan reuses the read-only role AWS ACCOUNT · TRUST POLICIES THE AGENT CANNOT EDIT tf-plan-readonlytrust: any branch State + live resourcesS3 state with lockfile · EKS · RDS · network tf-applytrust: ref main only blue = read path · apricot = write path
Fig 1. Three trust zones. The agent can only reach the first. The credentials live in the third, where nothing in the repo can change who may use them. Swipe sideways to see it all.

The design has four layers, and each one assumes the layer before it has already failed:

  1. Workspace. What the agent can run and read.
  2. Identity. Which credentials exist, and what is able to assume them.
  3. Policy. What a plan is allowed to contain.
  4. Approval. Who says yes, and what exactly they are saying yes to.

Only layers 2 to 4 are security boundaries. Layer 1 is a seatbelt: worth wearing, but it is not what stops the car.

4. Layer 1: a workspace with nothing worth stealing

The workspace is wherever the agent runs: a laptop, a dev container or a cloud sandbox. Give it context, not credentials.

Give it context

  • Your modules and conventions. A rules file in the repo that says which internal modules to use, how workspaces are laid out and what is off limits.
  • Current provider docs. HashiCorp's Terraform MCP server gives an agent live registry documentation and module inputs, so it is not relying on the provider schema in its training data.
  • Cheap, offline checks. terraform fmt, validate, tflint and terraform test with mocked providers all run without cloud credentials.

Deny the dangerous commands

Most coding agents support allow and deny lists for shell commands. This is the shape for Claude Code, checked in as .claude/settings.json. Other agents have an equivalent.

{
  "permissions": {
    "allow": [
      "Bash(terraform fmt *)",
      "Bash(terraform validate *)",
      "Bash(terraform init -backend=false *)",
      "Bash(terraform test *)",
      "Bash(tflint *)"
    ],
    "deny": [
      "Bash(terraform apply *)",
      "Bash(terraform destroy *)",
      "Bash(terraform state *)",
      "Bash(terraform import *)",
      "Bash(aws *)",
      "Read(**/*.tfstate)",
      "Read(**/*.tfvars)",
      "Read(**/.env)"
    ]
  }
}

Deny rules win over allow rules, so the agent can format and validate but cannot apply, touch state, call the AWS CLI or read state files, which hold secrets in cleartext.

Then assume the deny list fails

A command filter is pattern matching on text. The Claude Code documentation is honest about this: a deny rule covers the invocation the agent usually produces and "isn't a security boundary around the program". Wrap the same command in bash -c and the pattern no longer matches.

So the real control in this layer is absence:

  • No cloud credentials in the environment. No ~/.aws profile, no exported keys, no instance role with write access. On a developer laptop, that means a container or sandbox without the developer's own AWS session mounted in.
  • Its own identity in Git. A bot account or project access token that can push branches and open merge requests, and nothing else. It cannot push to main, it is on no approver list, and everything it does is attributable.
  • Fetched content is data. READMEs, issues and module docs can carry instructions aimed at the agent. The defence against prompt injection is structural: an agent with no credentials has nothing worth hijacking.

5. Layer 2: credentials the agent cannot widen

This layer does the heavy lifting. The AWS Well-Architected security pillar says to use temporary credentials for machine identities (SEC02-BP02), and CI systems have a clean way to do that: OIDC federation. The job gets a signed token from GitLab, and AWS STS swaps it for short-lived keys only if the role's trust policy matches the claims in that token.

CI job GitLab (OIDC issuer) AWS STS AWS APIs 1. ask for an ID token (aud) 2. signed JWT, sub = repo + ref 3. AssumeRoleWithWebIdentity(role ARN, JWT) 4. fetch signing keys, verify JWT 6. temporary keys, expire within the hour 7. terraform plan or apply, signed with the temporary keys 5. trust policy check aud and sub must match no match: AccessDenied
Fig 2. OIDC federation between GitLab CI and AWS. There is no stored secret to leak, rotate or find on disk. The decision in step 5 is made by AWS, using a policy the repository cannot edit. Swipe sideways to see it all.

The claim that matters is sub. GitLab builds it as project_path:<group>/<project>:ref_type:<type>:ref:<name> (GitLab docs). That gives you a way to say, in IAM, that a role can only be used by one repository on one branch. Use it to split the pipeline into two roles.

resource "aws_iam_openid_connect_provider" "gitlab" {
  url            = "https://gitlab.com" # or your self-managed GitLab URL
  client_id_list = ["sts.amazonaws.com"]
}

locals {
  repo = "project_path:platform/infra-live"
  trust = {
    # any branch of this one repo may plan
    plan = {
      test = "StringLike"
      sub  = "${local.repo}:ref_type:branch:ref:*"
    }
    # only the protected main branch may apply
    apply = {
      test = "StringEquals"
      sub  = "${local.repo}:ref_type:branch:ref:main"
    }
  }
}

data "aws_iam_policy_document" "trust" {
  for_each = local.trust

  statement {
    actions = ["sts:AssumeRoleWithWebIdentity"]
    principals {
      type        = "Federated"
      identifiers = [aws_iam_openid_connect_provider.gitlab.arn]
    }
    condition {
      test     = "StringEquals"
      variable = "gitlab.com:aud"
      values   = ["sts.amazonaws.com"]
    }
    condition {
      test     = each.value.test
      variable = "gitlab.com:sub"
      values   = [each.value.sub]
    }
  }
}

resource "aws_iam_role" "plan" {
  name                 = "tf-plan-readonly"
  assume_role_policy   = data.aws_iam_policy_document.trust["plan"].json
  max_session_duration = 3600
}

resource "aws_iam_role_policy_attachment" "plan_read" {
  role       = aws_iam_role.plan.name
  policy_arn = "arn:aws:iam::aws:policy/ReadOnlyAccess"
}

resource "aws_iam_role_policy" "plan_no_data" {
  name = "deny-data-reads"
  role = aws_iam_role.plan.id
  policy = jsonencode({
    Version = "2012-10-17"
    # ReadOnlyAccess still allows s3:GetObject, so deny data reads explicitly
    Statement = [{
      Effect      = "Deny"
      Action      = ["s3:GetObject", "secretsmanager:GetSecretValue"]
      NotResource = "arn:aws:s3:::example-tf-state/*"
    }]
  })
}

resource "aws_iam_role" "apply" {
  name                 = "tf-apply"
  assume_role_policy   = data.aws_iam_policy_document.trust["apply"].json
  permissions_boundary = aws_iam_policy.tf_boundary.arn
  max_session_duration = 3600
}
RoleWho can assume itWhat it can doWhy
tf-plan-readonlyAny branch pipeline in the infrastructure repoRead APIs and read state. No writes, with explicit denies on secrets and object data.An agent's merge request needs a real plan to be reviewable.
tf-applyOnly pipelines on protected mainCreate, change and delete, inside a permissions boundaryA reviewed merge is the only route to a change.
Break-glassNamed humans, through SSO with MFA, with an alert on every useAdminIncidents happen. The emergency path should exist, be loud, and be tested.

That split is the core of the design. An agent can edit .gitlab-ci.yml on its branch and point the plan job at tf-apply. STS will read a token whose sub ends in the agent's branch name, compare it with a trust policy that only accepts ref:main, and refuse. The control sits in AWS, where the agent cannot reach it.

It only holds if main is protected: no direct pushes, and merges need approval. The trust policy is exactly as strong as your branch protection.

Three things people get wrong

  • A plan is not harmless. terraform plan runs provider code and reads data sources, and the external data source runs a local program. A plan job executes whatever the branch says, with the plan role's credentials. That is why the role has to be read-only.
  • Read-only is broader than it sounds. The AWS managed ReadOnlyAccess policy can read S3 objects, which is data, not configuration. Deny data reads explicitly, as in the snippet.
  • State is sensitive. The plan role has to read state, and state can contain secrets. Keep one state per workspace and environment, and encrypt the bucket with a KMS key. The Terraform docs give the same warning about saved plan files: treat them as sensitive artifacts.

6. Layer 3: policy on the plan, not the prompt

The plan is the only statement of what will actually happen. terraform show -json turns a saved plan into a documented JSON format, with one entry per resource under resource_changes and a list of actions for each. A replacement shows up as ["delete", "create"], so a policy that looks for delete catches both destroys and replacements.

This is a Rego policy for Conftest, which wraps Open Policy Agent. It encodes three of the controls from section 2.

package main

import rego.v1

# Types that must never be deleted or replaced without a human-authored change
protected_types := {
  "aws_db_instance", "aws_s3_bucket", "aws_kms_key",
  "aws_ebs_volume", "aws_efs_file_system", "aws_route53_zone",
}

# Type prefixes an agent may not touch at all
human_only_prefixes := ["aws_iam_", "aws_organizations_", "aws_kms_"]

max_agent_changes := 25

changes := [rc |
  some rc in input.resource_changes
  rc.mode == "managed"
  rc.change.actions != ["no-op"]
]

# "delete" is present for both a destroy and a replace
deny contains msg if {
  some rc in changes
  "delete" in rc.change.actions
  rc.type in protected_types
  msg := sprintf(
    "%s: delete or replace of %s needs a human-authored change",
    [rc.address, rc.type],
  )
}

deny contains msg if {
  input.meta.agent_authored
  some rc in changes
  some prefix in human_only_prefixes
  startswith(rc.type, prefix)
  msg := sprintf(
    "%s: agent-authored changes may not touch %s",
    [rc.address, rc.type],
  )
}

deny contains msg if {
  input.meta.agent_authored
  count(changes) > max_agent_changes
  msg := sprintf(
    "plan changes %d resources; the agent limit is %d",
    [count(changes), max_agent_changes],
  )
}

The input.meta.agent_authored flag is not part of the plan. The pipeline adds it in the next section: it is true when the agent's bot account pushed the branch. OPA's own Terraform tutorial goes a step further and weights each resource type, so the limit is a blast-radius score, not a count.

Where the policy lives matters more than what it says

  • Outside the repo it judges. If the policy sits next to the Terraform, the cheapest way to pass the check is to edit it, and an agent told to get the pipeline green can take that route. Publish the bundle from a separate repository with its own owners, and pull it by version.
  • On every plan, human or agent. The baseline rules apply to everyone. The agent only gets extra ones.
  • Before the plan as well. A policy on plan output runs after the plan has executed. Catch external data sources and local-exec provisioners with a static check of the HCL first. Conftest can parse HCL directly, and scanners such as tflint, Trivy or Checkov cover common misconfigurations at the same stage.

One limit: values only known after apply are not in the plan JSON, so no plan-time policy sees everything. Policy narrows what a human has to check. It does not replace the human.

7. Layer 4: approval that means something

This pipeline ties the layers together. Merge request pipelines run plan and policy with the read-only role. The pipeline on main plans again, runs the same policy, then waits for a person before applying the exact plan that was checked.

stages: [plan, policy, apply]

default:
  # terraform, conftest and jq in one image; pin it by digest in real use
  image: registry.example.com/platform/terraform-ci:2026.10

.aws_oidc:
  id_tokens:
    AWS_ID_TOKEN:
      aud: sts.amazonaws.com
  before_script:
    - umask 077 && echo "$AWS_ID_TOKEN" > /tmp/oidc-token
    - export AWS_WEB_IDENTITY_TOKEN_FILE=/tmp/oidc-token
    - export AWS_ROLE_SESSION_NAME="gitlab-${CI_PROJECT_ID}-${CI_PIPELINE_ID}"

.mr_and_main:
  rules:
    - if: $CI_PIPELINE_SOURCE == "schedule"
      when: never
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

plan:
  stage: plan
  extends: [.aws_oidc, .mr_and_main]
  variables:
    AWS_ROLE_ARN: arn:aws:iam::111122223333:role/tf-plan-readonly
  script:
    - terraform init -input=false -lockfile=readonly
    - terraform plan -input=false -lock=false -out=tfplan
    - terraform show -json tfplan > tfplan.json
  artifacts:
    paths: [tfplan, tfplan.json]
    access: developer          # plan files can hold secrets in cleartext
    expire_in: 1 day

policy:
  stage: policy
  extends: .mr_and_main
  needs: [plan]
  script:
    - |
      AGENT=false
      if [ "$GITLAB_USER_LOGIN" = "infra-agent" ]; then AGENT=true; fi
      jq --argjson agent "$AGENT" '. + {meta: {agent_authored: $agent}}' \
        tfplan.json > input.json
    # the policy bundle lives outside this repo and is pulled by version
    - conftest pull registry.example.com/platform/tf-policy:1.8.0
    - conftest test input.json

apply:
  stage: apply
  extends: .aws_oidc
  needs: [plan, policy]
  variables:
    AWS_ROLE_ARN: arn:aws:iam::111122223333:role/tf-apply
  rules:
    - if: $CI_PIPELINE_SOURCE == "schedule"
      when: never
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
      when: manual
  environment: production        # protected, with deployment approvals
  resource_group: tf-production  # one apply at a time
  script:
    - terraform init -input=false -lockfile=readonly
    - terraform apply -input=false tfplan   # the reviewed plan, not a fresh one
  • terraform apply tfplan applies the saved plan, the same file the policy job read and the approver looked at. If state has moved since, Terraform rejects the plan as stale.
  • -lock=false on plan is there because a read-only role cannot write the lock file (this assumes the S3 backend's native use_lockfile locking). The apply job takes the lock, and resource_group makes sure only one apply runs at a time.
  • environment: production points at a GitLab protected environment with deployment approvals. By default, the person who triggered the pipeline cannot approve their own deployment.
  • -lockfile=readonly stops a pipeline from silently accepting new provider versions or checksums. Provider upgrades become a reviewed change of their own.

Settings that make the approval real

On the GitLab side I'd switch these on (most need Premium or Ultimate):

  • Approval settings: prevent approval by the merge request creator, prevent approvals by users who add commits, and remove all approvals when commits are added.
  • Code owners for .gitlab-ci.yml, IAM, network and state configuration, so a change to the guardrails themselves needs the platform team.
  • "Pipelines must succeed", so a failed policy job blocks the merge.
  • The pipeline definition included from a separate project, so the jobs above cannot be rewritten from a feature branch.

Who is the author when a human prompts an agent?

My rule: the person who asked for the change is its author, and cannot be its approver. Two humans see every production change. The agent is neither of them.

The second human should review the plan, not just the diff. A good agent-written merge request says what the plan adds, changes and destroys, and calls out every replacement. Keep changes small enough that reading the plan takes minutes. That is the DORA small-batch advice again, and it matters more when the author can produce a thousand lines before you finish your coffee.

Approval fatigue is a failure mode too. If every agent change needs the same two senior engineers, they will start approving on trust. Tiers put human attention where the blast radius is.

8. How much autonomy for which change

Not every change deserves the same gate. Classify by what the plan does and where, not by who wrote it or how confident the agent sounds.

Agent-proposed plan Deletes or replaces statefulor protected resources? Touches IAM, KMS, the network edgeor the pipeline itself? Production workspace? Additive only, and underthe blast-radius limit? nononono yesyesyesyes TIER 3 · HUMAN-AUTHORED The agent may draft and explain. A named engineer owns the change. Two approvals and a change window. databases · IAM · KMS · edge · pipeline TIER 2 · AGENT PROPOSES A human approves the plan. Apply from main. TIER 1 · AUTO-APPLY IN NON-PROD Policy pass is the approval. Review after. Otherwise: treat as tier 2 Tier 0 (docs, formatting, tests) never changes a plan.
Fig 3. Autonomy follows blast radius. Every question here can be answered by the policy job from the plan JSON and the workspace name, so the tier is computed, not self-declared. Swipe sideways to see it all.
TierExample changesWhat the agent may doGate
0. Draft onlyDocs, formatting, variable descriptions, testsOpen a merge requestNormal code review. Policy confirms the plan has no changes.
1. Auto-apply in non-prodAdditive resources in dev or sandbox, under the change limitPropose, and the pipeline applies after a policy passPolicy is the approval. Humans review a daily digest afterwards.
2. Agent proposesAny production change, in-place updates, module upgradesPropose onlyCode owner approval on the merge request, then deployment approval on the plan.
3. Human-authoredDeleting or replacing stateful resources. IAM, KMS, organisation policies, the network edge. The pipeline and the policy.Draft and explainA named engineer owns it. Two approvals and a change window.

Start every repository at tier 2. Open tier 1 once you have data, and the data you want is a DORA metric you probably already track: change failure rate, split by agent-authored and human-authored changes. If the agent's number is no worse over a few months, widen its lane.

9. Drift detection as the safety net

Guardrails fail quietly. Someone fixes something in the console during an incident, a credential leaks, or an apply half-finishes. Drift detection is how you find out.

At Avizva we ran a Terraform module registry for 5+ client environments, with remote state, locking and a monthly drift check. With agents producing more changes more often, I'd run it nightly. It is one more job in the same pipeline, on a schedule, using the read-only role.

drift:
  stage: plan
  extends: .aws_oidc
  variables:
    AWS_ROLE_ARN: arn:aws:iam::111122223333:role/tf-plan-readonly
  rules:
    - if: $CI_PIPELINE_SOURCE == "schedule"
  script:
    - terraform init -input=false -lockfile=readonly
    # exit 0 = in sync, 1 = error, 2 = code and reality disagree
    - terraform plan -input=false -lock=false -detailed-exitcode

The -detailed-exitcode flag gives three outcomes: 0 for no changes, 1 for an error and 2 for a non-empty diff. On main, a 2 means code and reality disagree, and the failed scheduled pipeline is your alert. In practice it points at one of three things:

  • An out-of-band change. Something wrote to the account outside the pipeline. Pair the drift job with a CloudTrail alert on any production write that did not come from a tf-apply session or the break-glass role.
  • A partial apply. An apply failed halfway and nobody followed up.
  • Merged but not applied. A change is sitting on main waiting for a deployment approval that nobody gave.

An agent is well suited to triaging drift: read the plan, explain what changed and open a merge request that adopts or reverts it. In production that is still a tier 2 change. Nothing should auto-correct drift there, because drift during an incident is often a human doing the right thing.

10. Rollout plan and checklist

You do not need all of this on day one. This is the order I'd do it in, biggest risk reduction first.

PhaseWhat to doDone when
Week 1: identityRemove static AWS keys from CI variables and from any environment an agent runs in. Create the OIDC provider and the plan and apply roles. Protect main.A branch pipeline that tries to assume tf-apply gets AccessDenied. Test it on purpose.
Week 2: pipelinePlan, policy, manual apply of the saved plan. Protected environment with deployment approvals. One apply at a time.There is no route to an apply except a merge to main plus an approval.
Week 3: policyStart with three rules: no deletes of protected types, no IAM changes from agents, a change-count limit. Move the policy to its own repository.A deliberately bad merge request is blocked.
Week 4: workspaceBot identity for the agent, a deny list, a sandbox with no credentials, a rules file with your module conventions.The agent can open a merge request and can do nothing else.
Week 5 onwards: tiers and driftNightly drift job. Label agent-authored merge requests. Track change failure rate by author type.You have the numbers to decide on tier 1.

The checklist

  • No long-lived cloud keys in the agent's environment, in CI variables or in the repo.
  • The apply role's trust policy is pinned to protected main, and a test from a branch proves it.
  • The plan role is read-only, with explicit denies for secrets and object data.
  • Policy runs on the plan JSON in every pipeline, and the policy code lives outside the repo.
  • The pipeline definition and the code owners file cannot change without the platform team.
  • Authors cannot approve, committers cannot approve, and approvals reset on new commits.
  • Apply uses the saved plan, and applies are serialised.
  • prevent_destroy is set on stateful resources as the last line of defence.
  • Drift detection runs nightly, and out-of-band writes raise an alert.
  • A break-glass role exists, needs MFA, alerts on use and has been tested.
  • Agent-authored changes are labelled and measured.

The short version

An agent that writes Terraform is a contributor, not an operator. Give it context instead of credentials, make the pipeline the only thing that can touch the cloud, let a policy read every plan before a person does, and keep the decision about who may apply somewhere the repository cannot reach.

If you only do one thing this week, do the first row of the rollout table: split plan and apply into two roles, pin the apply role to main, and delete the static keys. Most of the stories that make the news stop being possible at that point.

← All posts