Infrastructure Drift skill for Codex

Catch console changes before the next apply does. Codex runs refresh-only plans with detailed exit codes, turns the JSON plan into a per-attribute drift report with a bundled script, and traces each change to its author in cloud audit logs before deciding to revert, adopt into code, import or ignore it. It also covers Pulumi, CloudFormation and Argo CD, sets up read-only scheduled drift checks, and applies nothing without your approval.

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- infrastructure-drift

Installs to ~/.agents/skills/infrastructure-drift/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $infrastructure-drift, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Detects and reconciles drift between infrastructure as code and real cloud resources - Terraform and OpenTofu refresh-only plans with detailed exit codes, a bundled JSON plan drift report, Pulumi refresh previews, CloudFormation drift detection, Argo CD OutOfSync - and decides per change whether to revert, adopt into code, import, or ignore. Use when a plan shows unexpected changes nobody made in code, someone hot-fixed production in the console, a scheduled drift check is needed in CI, resources exist that no state file knows about, or before a risky apply on long-untouched stacks.

Use it when

  • A plan shows changes that nobody made in code
  • Production was hot-fixed in the console during an incident
  • You want scheduled drift detection for every IaC stack
  • Resources exist in the cloud that no state file manages

Use something else when

  • You need to move, rename or remove resources in state: use terraform-state
  • You are designing reusable modules rather than reconciling stacks: use terraform-modules

How to check the result

After reconciliation a refresh-only plan exits 0, a normal plan shows no changes, and the drift report lists a decision and actor for every previously drifted attribute.

The mistake it prevents

Running apply to make drift disappear without checking who changed what, which silently reverts an emergency fix made during an incident.

~/.agents/skills/infrastructure-drift/SKILL.md

--- name: infrastructure-drift description: Detects and reconciles drift between infrastructure as code and real cloud resources - Terraform and OpenTofu refresh-only plans with detailed exit… license: MIT ---

Done well means: drift is detected on a schedule rather than discovered during an unrelated apply, every drifted attribute gets an explicit decision (revert, adopt, or ignore with a reason), reconciliation never destroys something a person deliberately created without asking, and the code and the cloud agree afterwards.

Kinds of drift

KindExampleDetected by
Attribute driftSecurity group rule added in the console; instance resizedRefresh-only plan, pulumi refresh --preview-only, CloudFormation drift detection
Deleted outside IaCBucket removed by hand; resource in state no longer existsRefresh shows it gone; normal plan wants to create it
Unmanaged resourcesResources created by hand that no state knows aboutCloud inventory compared with state (not by plan)
Code driftMerged code never appliedNormal plan shows changes with no resource_drift
Cluster driftkubectl edit on a GitOps-managed objectArgo CD / Flux OutOfSync status

Detect (Terraform / OpenTofu)

terraform init -input=false
terraform plan -refresh-only -detailed-exitcode -out=drift.tfplan -input=false -lock=false
echo "exit=$?"            # 0 no drift, 1 error, 2 drift found
terraform show -json drift.tfplan > drift.json
python3 scripts/drift_report.py drift.json            # or --markdown for a PR/issue comment

tofu takes the same flags. Running plans needs cloud credentials and network; in a Codex sandbox, use read-only credentials and ask for approval. -lock=false is acceptable only for read-only refresh-only plans that are never applied; drop it for anything else.

scripts/drift_report.py (standard library) reads terraform show -json output, lists each drifted resource with the attributes that differ between state and reality (sensitive values masked), then lists what a normal apply would do, flagging replacements and deletions. Exit code 2 means drift or changes, 0 means clean, 1 means bad input.

Run a normal plan too: terraform plan -detailed-exitcode -out=plan.tfplan. Its resource_drift shows the same outside changes, and resource_changes shows what apply would do about them, usually reverting them.

Decide per drifted attribute

SituationDecisionAction
Unauthorised or accidental change (port 22 opened, encryption turned off)RevertNormal apply restores code values, after user confirmation
Emergency hot-fix that should stay (instance resized during incident)AdoptChange the code to match, plan shows no changes, merge
Attribute legitimately managed elsewhere (ASG desired count by autoscaler, tags added by a security tool, ECS task definition revision by deploy pipeline)Ignorelifecycle { ignore_changes = [desired_count] } with a comment explaining who owns it
Resource deleted outside IaC and still neededRecreateApply recreates it; check data loss implications first
Resource deleted outside IaC and not neededRemove from codeDelete the block (or removed block); plan shows no create
Resource exists but is unmanagedImport or documentimport block plus code, or record it as intentionally unmanaged

Never decide “revert” for a change you cannot explain. Find who made it first: CloudTrail (aws cloudtrail lookup-events --lookup-attributes AttributeKey=ResourceName,AttributeValue=<id>), GCP Cloud Audit Logs, or Azure Activity Log. A console change at 3 a.m. during an incident is probably load-bearing.

terraform apply -refresh-only updates state to match reality without changing infrastructure. It records the drift as accepted but leaves code disagreeing, so the next normal plan reverts it anyway. Use it only together with a code change, or to clear deleted-resource noise.

Reconcile safely

- [ ] 1. Run refresh-only plan; generate drift report
- [ ] 2. For each drifted resource, find the actor in audit logs
- [ ] 3. Record a decision per attribute (table above) and share it with the user
- [ ] 4. Adopt: edit code; Ignore: add ignore_changes with a comment; Import: add import blocks
- [ ] 5. Normal plan: only the intended reverts remain; no replace/delete surprises
- [ ] 6. Get explicit confirmation, then apply through the usual pipeline
- [ ] 7. Re-run refresh-only plan: exit code 0

Applying to shared or production infrastructure is a production change: show the final plan and wait for a clear yes. Never apply with -auto-approve from Codex, and never apply a plan that replaces or deletes resources the user has not specifically approved.

Adopting unmanaged resources (Terraform 1.5+ / OpenTofu):

import {
  to = aws_s3_bucket.reports
  id = "acme-reports-prod"
}
terraform plan -generate-config-out=generated.tf   # draft HCL for import blocks without resources

Clean up generated config (remove computed and default attributes, use variables) before merging. State surgery beyond imports, such as moved blocks and state rm, belongs to $terraform-state.

Scheduled detection in CI

# .github/workflows/drift.yml (excerpt)
on:
  schedule: [{ cron: "0 6 * * 1-5" }]
  workflow_dispatch: {}
permissions: { contents: read, id-token: write, issues: write }
jobs:
  drift:
    strategy:
      matrix: { stack: [network, data, app] }
    steps:
      # checkout, setup-terraform, OIDC login with a read-only role ...
      - run: terraform -chdir=stacks/${{ matrix.stack }} init -input=false
      - id: plan
        run: |
          set +e
          terraform -chdir=stacks/${{ matrix.stack }} plan -refresh-only -detailed-exitcode -out=drift.tfplan -input=false -lock=false
          echo "code=$?" >> "$GITHUB_OUTPUT"
      - if: steps.plan.outputs.code == '2'
        run: |
          terraform -chdir=stacks/${{ matrix.stack }} show -json drift.tfplan > drift.json
          # drift_report.py vendored into the repo from this skill's scripts/ folder
          python3 tools/drift_report.py drift.json --markdown > drift.md || true
          # open or update an issue with drift.md (gh issue create/edit)
      - if: steps.plan.outputs.code == '1'
        run: exit 1
  • Use a read-only role for detection; drift jobs never apply.
  • Report drift as an issue or chat alert per stack, deduplicated, with the owner.
  • Managed alternatives: HCP Terraform health assessments, Spacelift, env0 and Scalr run scheduled drift detection. The older open-source driftctl is no longer maintained.

Other tools

# Pulumi
pulumi refresh --preview-only --stack prod          # what changed outside Pulumi
pulumi preview --refresh --diff --stack prod         # what an update would do after refresh

# CloudFormation
aws cloudformation detect-stack-drift --stack-name app-prod
aws cloudformation describe-stack-drift-detection-status --stack-drift-detection-id <id>
aws cloudformation describe-stack-resource-drifts --stack-name app-prod \
  --stack-resource-drift-status-filters MODIFIED DELETED

# Argo CD
argocd app list -o wide | grep -v Synced
argocd app diff shop-prod

For GitOps, decide per app whether selfHeal: true (revert cluster edits automatically) is appropriate; see $argocd-gitops.

Finding unmanaged resources

Plans cannot see what state does not know. Compare an inventory with state:

terraform state list > managed.txt
terraform show -json | jq -r '.values.root_module | .. | .resources? // empty | .[] | .values.arn? // empty' | sort > managed_arns.txt
aws resourcegroupstaggingapi get-resources --query 'ResourceTagMappingList[].ResourceARN' --output text | tr '\t' '\n' | sort > cloud_arns.txt
comm -13 managed_arns.txt cloud_arns.txt      # in cloud, not in this state

Repeat across every state file for the account. The tagging API covers only taggable resources; AWS Config, GCP Cloud Asset Inventory and Azure Resource Graph give fuller lists. Tag everything IaC creates (managed-by = terraform, stack = network) via provider default_tags so unmanaged resources stand out.

Prevent drift

  • Console write access limited to break-glass roles, with alerts when they are used.
  • Every break-glass change gets a follow-up PR to adopt or revert it.
  • ignore_changes only with a comment naming the external owner; review them quarterly.
  • Scheduled detection on every stack, including ones nobody has touched in months.

Report template

**Stacks checked:** network, data, app (prod) on 2025-06-03
| Resource | Drift | Actor (audit log) | Decision |
|---|---|---|---|
| aws_security_group.web | +ingress 22/tcp from 0.0.0.0/0 | jdoe console, 05-28 | Revert (confirmed) |
| aws_db_instance.main | t3.medium -> r6g.large | incident INC-412 | Adopt in code |
| aws_ecs_service.api | desired_count 4 -> 9 | autoscaling | ignore_changes (owner: autoscaler) |
**Unmanaged:** 3 buckets (data team), import planned in PR #214
**After:** refresh-only plan exit 0; normal plan no changes

What the zip contains

Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Infrastructure Drift"
  short_description: "Catch console changes before the next apply does"
  default_prompt: "$infrastructure-drift Someone changed prod in the AWS console, find all the drift in our Terraform stacks"
scripts/drift_report.py148 lines, 5.8 KB
#!/usr/bin/env python3
"""Summarise drift and pending changes from a Terraform/OpenTofu JSON plan.

Usage:
    terraform plan -refresh-only -out=drift.tfplan     # or a normal plan
    terraform show -json drift.tfplan > drift.json
    python3 scripts/drift_report.py drift.json
    python3 scripts/drift_report.py drift.json --markdown > drift.md
    tofu show -json plan.out | python3 scripts/drift_report.py -

Reads `resource_drift` (changes made outside IaC, detected during refresh) and
`resource_changes` (what apply would do). For each drifted resource it lists
the attributes whose values differ. Sensitive values are masked.

Exit codes: 0 no drift and no changes, 2 drift or changes found, 1 bad input.
Standard library only.
"""
from __future__ import annotations

import argparse
import json
import sys

MAX_VALUE = 80


def flatten(value, prefix=""):
    """Yield (path, leaf) pairs for nested dicts and lists."""
    if isinstance(value, dict):
        if not value:
            yield prefix, {}
        for k, v in value.items():
            yield from flatten(v, f"{prefix}.{k}" if prefix else str(k))
    elif isinstance(value, list):
        if not value:
            yield prefix, []
        for i, v in enumerate(value):
            yield from flatten(v, f"{prefix}[{i}]")
    else:
        yield prefix, value


def is_sensitive(mask, path: str) -> bool:
    """Walk the before/after_sensitive mask along a flattened path."""
    node = mask
    for part in path.replace("[", ".[").split("."):
        if node is True:
            return True
        if not part:
            continue
        if part.startswith("[") and isinstance(node, list):
            idx = int(part[1:-1])
            node = node[idx] if idx < len(node) else False
        elif isinstance(node, dict):
            node = node.get(part, False)
        else:
            return False
    return node is True


def short(v) -> str:
    text = json.dumps(v, sort_keys=True) if not isinstance(v, str) else v
    return text if len(text) <= MAX_VALUE else text[: MAX_VALUE - 3] + "..."


def diff_attrs(change: dict) -> list[tuple[str, str, str]]:
    before = dict(flatten(change.get("before") or {}))
    after = dict(flatten(change.get("after") or {}))
    unknown = dict(flatten(change.get("after_unknown") or {}))
    sens_b = change.get("before_sensitive") or {}
    sens_a = change.get("after_sensitive") or {}
    rows = []
    for path in sorted(set(before) | set(after)):
        b, a = before.get(path), after.get(path)
        if b == a:
            continue
        if unknown.get(path) is True:
            a_txt = "(known after apply)"
        elif is_sensitive(sens_a, path):
            a_txt = "(sensitive)"
        else:
            a_txt = short(a) if path in after else "(removed)"
        b_txt = "(sensitive)" if is_sensitive(sens_b, path) else (short(b) if path in before else "(absent)")
        rows.append((path, b_txt, a_txt))
    return rows


def label(actions: list[str]) -> str:
    if actions == ["delete", "create"] or actions == ["create", "delete"]:
        return "replace"
    return "+".join(actions)


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("plan", help="output of `terraform show -json <planfile>`, or - for stdin")
    ap.add_argument("--markdown", action="store_true", help="emit a Markdown report")
    args = ap.parse_args()
    try:
        plan = json.load(sys.stdin if args.plan == "-" else open(args.plan, encoding="utf-8"))
    except (OSError, json.JSONDecodeError) as exc:
        print(f"error: cannot read plan JSON: {exc}", file=sys.stderr)
        return 1
    if "resource_changes" not in plan and "resource_drift" not in plan:
        print("error: no resource_changes or resource_drift keys; pass `terraform show -json <planfile>` output, not `terraform plan -json` logs", file=sys.stderr)
        return 1

    md = args.markdown
    drift = [d for d in plan.get("resource_drift", []) if d.get("change", {}).get("actions") != ["no-op"]]
    changes = [c for c in plan.get("resource_changes", []) if c.get("change", {}).get("actions") not in (["no-op"], ["read"])]

    print("## Drift (changed outside IaC)\n" if md else f"DRIFT: {len(drift)} resource(s) changed outside IaC")
    if not drift:
        print("None detected.\n" if md else "  none")
    for d in drift:
        act = label(d["change"]["actions"])
        gone = act == "delete"
        print(f"### `{d['address']}` ({'deleted outside IaC' if gone else act})\n" if md else f"  {d['address']}  [{'deleted outside IaC' if gone else act}]")
        if gone:
            continue
        rows = diff_attrs(d["change"])
        if md and rows:
            print("| Attribute | In state | Real |\n|---|---|---|")
        for path, b, a in rows:
            print(f"| `{path}` | `{b}` | `{a}` |" if md else f"      {path}: {b} -> {a}")
        if md:
            print()

    counts: dict[str, list[str]] = {}
    for c in changes:
        counts.setdefault(label(c["change"]["actions"]), []).append(c["address"])
    total = sum(len(v) for v in counts.values())
    print("## Planned changes\n" if md else f"\nPLANNED CHANGES: {total}")
    if not counts:
        print("None.\n" if md else "  none")
    for act in ("replace", "delete", "update", "create"):
        for addr in counts.pop(act, []):
            print(f"- **{act}** `{addr}`" if md else f"  {act:8} {addr}")
    for act, addrs in counts.items():
        for addr in addrs:
            print(f"- **{act}** `{addr}`" if md else f"  {act:8} {addr}")
    if any(label(c["change"]["actions"]) in ("replace", "delete") for c in changes):
        print("\n> Plan destroys or replaces resources. Review before any apply." if md else "\nWARNING: plan destroys or replaces resources. Review before any apply.")
    return 2 if drift or changes else 0


if __name__ == "__main__":
    sys.exit(main())

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem