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.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- infrastructure-drift Installs to ~/.agents/skills/infrastructure-drift/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install infrastructure-drift The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project infrastructure-drift Installs to .agents/skills/infrastructure-drift/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/infrastructure-drift.zipunzip -o infrastructure-drift.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) infrastructure-drift PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
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.
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
| Kind | Example | Detected by |
|---|---|---|
| Attribute drift | Security group rule added in the console; instance resized | Refresh-only plan, pulumi refresh --preview-only, CloudFormation drift detection |
| Deleted outside IaC | Bucket removed by hand; resource in state no longer exists | Refresh shows it gone; normal plan wants to create it |
| Unmanaged resources | Resources created by hand that no state knows about | Cloud inventory compared with state (not by plan) |
| Code drift | Merged code never applied | Normal plan shows changes with no resource_drift |
| Cluster drift | kubectl edit on a GitOps-managed object | Argo 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
| Situation | Decision | Action |
|---|---|---|
| Unauthorised or accidental change (port 22 opened, encryption turned off) | Revert | Normal apply restores code values, after user confirmation |
| Emergency hot-fix that should stay (instance resized during incident) | Adopt | Change 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) | Ignore | lifecycle { ignore_changes = [desired_count] } with a comment explaining who owns it |
| Resource deleted outside IaC and still needed | Recreate | Apply recreates it; check data loss implications first |
| Resource deleted outside IaC and not needed | Remove from code | Delete the block (or removed block); plan shows no create |
| Resource exists but is unmanaged | Import or document | import 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_changesonly 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.yaml
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.py
#!/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.