| name | tf-best-practices |
| description | Best-practice authoring guidance AND a read-only policy gate for AWS Terraform generated by a migration skill. Load during any phase that writes a terraform/ directory — first as the "what to emit" posture rules + security-baseline spec, then after writing as the deterministic policy verdict. Read-only: it reports whether the generated Terraform passes; it never edits .tf files, never touches .phase-status.json, and never decides phase completion. Complements (does not replace) terraform fmt/init/validate. |
tf-best-practices — Generated-IaC posture rules + read-only policy gate
A shared authoring guide and verdict producer, not a workflow. It answers two
questions for a phase that generates AWS Terraform:
- Before writing — "what security posture must the generated
terraform/ follow?"
(the posture rules + the baseline.tf account-hardening spec)
- After writing — "does the generated
terraform/ pass policy?" (a deterministic,
read-only verdict + a machine-readable report)
Routing — load the part that matches your context
This skill is entered at two touchpoints in the caller's Generate flow, with the caller's
own terraform-authoring work in between. The caller states which touchpoint it is at when it
loads this skill, and reads the corresponding part:
| Caller context | Load | Why |
|---|
About to author terraform/ (before writing) | Part 1 → references/security-posture-rules.md | The "what to emit" AWS authoring rules (gate-enforced + authoring-only + compliance-conditional). |
terraform/ written, ready to validate (after writing) | Part 2 → references/terraform-validation.md + run the gate script | The fmt → init → validate → policy protocol and the read-only verdict. |
Everything this skill states is source-cloud-agnostic (pure AWS Terraform). Any GCP/Heroku
detection or artifact reading is the caller's job; where a rule needs a caller-known fact (e.g.
declared compliance frameworks), the caller passes it as a caller-context signal — see
references/security-posture-rules.md § Caller-context signals.
Boundary (read this first)
This unit is a verdict producer, never a mutator. Its entire write surface is the
JSON verdict it is asked to emit. Specifically it MUST NOT:
- edit, format, or rewrite any
.tf file (the caller owns remediation),
- read or write
.phase-status.json or any run-state file (interpreter-owned),
- decide whether a phase may complete, or prompt the user (caller policy).
The caller (a migration skill's Generate phase) owns: the fix-and-retry loop that edits
the .tf it generated, terraform fmt auto-apply, the retry/skip/abort prompt, the
Phase Completion gate, and every .phase-status.json write. See the consuming skill's
generate phase for how the verdict feeds those decisions.
Consumers (v1): gcp-to-aws only. The contract is source-agnostic and designed to
be adopted by heroku-to-aws later, but that wiring is intentionally out of scope for now.
Part 1 — Authoring posture (load before writing terraform/)
Emit generated Terraform that satisfies the posture in
references/security-posture-rules.md.
These are the "what good AWS Terraform looks like" rules. Following them makes the Part 2 gate
pass by construction. This unit does not read the caller's artifacts — it consumes only
caller-context signals the caller passes in.
Scope. security-posture-rules.md covers, in three tiers:
- Gate-enforced (Part 2 verifies statically): ALB TLS, no-public-database, RDS +
ElastiCache encryption-at-rest, no-public-DB-port ingress, no-public admin/datastore-port
ingress, no-wildcard-IAM.
- Authoring-only (not gate-checkable, still required):
deletion_protection,
master-password-via-Secrets-Manager, S3 hardening, Fargate/EKS/ECR settings, private-subnet
placement, backups, baseline monitoring.
- Compliance-conditional (emitted when the caller declares
soc2/pci/hipaa/fedramp):
VPC flow logs, S3 access logging, secret rotation, customer-managed KMS.
Still the caller's own generation concern (candidates to migrate here later): the
account-hardening baseline.tf layer (CloudTrail, GuardDuty, Config, Security Hub).
Part 2 — Policy gate (run after writing terraform/)
Run the read-only checker against the generated directory. Resolve the script path relative to
the plugin root ($PLUGIN_ROOT/skills/tf-best-practices/scripts/...), the same convention the
plugin uses for its other scripts:
python3 "$PLUGIN_ROOT/skills/tf-best-practices/scripts/validate-terraform-policy.py" "$TERRAFORM_DIR" --json "$VERDICT_PATH"
$TERRAFORM_DIR — required, caller-supplied: the generated terraform/ directory
(e.g. $MIGRATION_DIR/terraform). This skill never defaults or discovers it — the caller
always passes the path it wrote Terraform to.
--json $VERDICT_PATH — optional; writes a machine-readable verdict the caller can merge
into its own validation-report.json.
The policy check is one stage of a larger validation flow (fmt → init → validate → policy).
The full protocol — including offline-fallback behavior and how the policy verdict maps into a
validation-report.json — is documented in
references/terraform-validation.md. That protocol is
descriptive: the caller owns the fmt/init/validate execution, the fix-and-retry loop, and
the report write; this unit contributes only the read-only policy stage + verdict shape.
Exit codes → caller action
| Exit | stdout | Meaning | Caller does |
|---|
0 | POLICY_OK | posture satisfied | proceed |
1 | POLICY_FAIL | violations present | read violations[], edit the named .tf sites, re-run (caller's retry budget) |
2 | (usage error) | bad path / IO | surface to user; do not treat as pass |
Verdict shape (--json)
{
"check": "policy",
"policy_status": "POLICY_OK | POLICY_FAIL",
"violations": [
{
"check": "policy",
"rule": "alb_https_listener | alb_http_redirect | no_tf_files",
"file": "compute.tf",
"line": 7,
"severity": "error",
"summary": "human-readable violation",
"fix_hint": "concrete remediation the caller can apply"
}
]
}
Each violations[] entry is actionable evidence — file + line + fix_hint tell the
caller exactly what to edit. The caller applies the edit; this unit only reports.
Policy rules enforced today
Every rule is fail-open on ambiguity — it fires only on unambiguous, in-block literal
evidence, so a valid stack is never falsely blocked (a POLICY_FAIL is a hard completion gate
for the caller, so a false positive would block a real migration).
Internet-facing ALB TLS posture (an ALB is internet-facing when internal is absent,
false, or variable-driven — fail-safe):
alb_https_listener — must have an HTTPS listener on 443 with certificate_arn and a
forward action.
alb_http_redirect — an HTTP :80 listener must redirect to HTTPS, never forward
to targets. Internal ALBs (internal = true) are exempt.
Managed database exposure & encryption (aws_db_instance, aws_rds_cluster):
rds_not_public — must not set publicly_accessible = true (absent/variable → fail-open).
rds_encryption_at_rest — must set storage_encrypted = true; missing or literal false
fires (RDS defaults to unencrypted), variable-driven fails open. S3 is not checked (default
SSE-S3 since Jan 2023).
ElastiCache encryption (aws_elasticache_replication_group):
elasticache_encryption_at_rest — must set at_rest_encryption_enabled = true; missing
or literal false fires, variable-driven fails open. aws_elasticache_cluster (Memcached)
not checked.
Security group ingress:
db_sg_no_public_ingress — an inline aws_security_group ingress covering 5432/3306
must not allow 0.0.0.0/0.
sg_no_public_admin_ingress — an inline ingress must not open a curated never-public
admin/datastore port (22, 3389, 6379, 11211, 27017, 9200/9300, 5601) to
0.0.0.0/0. Web (80/443) and app/game ports are not flagged; DB ports are handled by the
rule above. Both: separate aws_security_group_rule / aws_vpc_security_group_ingress_rule
resources fail open (not correlated).
IAM least-privilege (aws_iam_policy, aws_iam_role_policy, aws_iam_group_policy,
aws_iam_user_policy):
no_wildcard_iam — an Allow statement must not use Action/Resource "*".
aws_iam_policy_document data sources and assume-role trust policies fail open.
The checker is a zero-dependency static HCL reader (no terraform init, no provider
download) — it runs even when the registry is unreachable. It uses brace-depth matching
for nested blocks, so a valid HTTPS listener written with a nested forward { ... }
block is not a false failure.
Fixtures (also the checker's regression suite)
fixtures/terraform-policy/ holds intentionally-shaped Terraform used by
scripts/test_validate_terraform_policy.py:
bad-http-forward/ — internet-facing ALB that forwards plaintext HTTP → MUST POLICY_FAIL.
internal-alb-only/ — internal ALB on HTTP → MUST POLICY_OK (HTTP allowed internally).
good-https-redirect/ — the correct pattern → POLICY_OK.
These are deliberately non-compliant test data (never deployed). They are excluded from the
repo-wide checkov scan via .checkov.yaml skip-path; do not "harden" them — doing so
breaks the tests that assert the failure paths.
Verification
uv run --python 3.12 --with pytest python -m pytest scripts/test_validate_terraform_policy.py -q